Extraire des miniatures vidéo avec cURL et FFmpeg
L’extraction de miniatures à partir de vidéos distantes peut nécessiter le téléchargement de fichiers volumineux. Pour les fichiers dont les métadonnées et les images demandées se trouvent près du début, une requête de plage d’octets peut réduire ce téléchargement. Ce guide vous propose un script utilitaire Bash qui essaie un préfixe, puis se rabat sur un téléchargement complet permettant de se déplacer dans la vidéo. Une vidéo locale avec du mouvement vous permet de vérifier que le JPEG contient l’image demandée.
Configurer une vidéo HTTP de référence
Utilisez Linux avec Bash, GNU coreutils, cURL 8.5 ou version ultérieure, FFmpeg avec ffprobe et l’encodeur libx264,
ainsi que Node.js. Ubuntu 24.04 fournit les outils de shell et de traitement multimédia via ses paquets bash,
coreutils, curl et ffmpeg. Pour le serveur d’origine local
et le vérificateur, utilisez Node.js 24.15 ou version ultérieure de la branche 24, ou version 26.
Les fichiers .mts ci-dessous s’exécutent grâce à la prise en charge native de TypeScript
de Node et utilisent uniquement des modules intégrés. Aucune installation de projet n’est nécessaire.
Les binaires Linux officiels de Node.js 26 nécessitent aussi l’environnement d’exécution libatomic,
disponible dans le paquet Ubuntu libatomic1. L’exemple fonctionne avec FFmpeg 6.1.1 et
cURL 8.5.0 d’Ubuntu, ainsi qu’avec FFmpeg 9.0.1 et cURL 8.22.0.
Collez ce bloc dans Bash depuis un répertoire où video-thumbnail-demo n’existe pas. Il crée une
vidéo de test de 12 secondes, en 640×360 à 20 images par seconde, ainsi qu’une copie dont les
métadonnées MP4 se trouvent à la fin. Le motif bouge ; le carré en haut à gauche est rouge avant
1 seconde, puis bleu.
(
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 génération échoue, le bloc supprime ses vidéos incomplètes et son répertoire. Corrigez le problème d’outil ou d’encodeur manquant, puis relancez le bloc. Un répertoire existant est préservé. Les parenthèses laissent les options du shell et votre répertoire de travail inchangés.
Enregistrez le bloc suivant sous video-thumbnail-demo/origin.mts. Ce petit serveur sur l’interface de bouclage sert les deux
fichiers, respecte les plages de préfixe et les ignore délibérément à /ignore.mp4. Il choisit un port disponible
et enregistre l’URL dans origin.url. Il sert de dispositif de test pour ce guide, et non de serveur public.
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 })
Automatiser le processus avec un script utilitaire Bash
Enregistrez ce bloc sous video-thumbnail-demo/thumbnail.sh. Il accepte HH:MM:SS[.ms], un débit binaire estimé
exprimé par un nombre entier de 1 à 999 Mb/s, ainsi qu’un chemin de sortie. Le fichier d’entrée
temporaire permet de se déplacer dans la vidéo et de recourir au téléchargement complet. Un échec
du décodeur ou un positionnement au-delà de la durée de la vidéo ne doit pas être considéré comme
une réussite simplement parce que FFmpeg a renvoyé zéro : le script utilitaire exige aussi une
image nouvellement écrite et non vide.
#!/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"
Le script utilitaire refuse tout fichier ou lien symbolique existant avant le téléchargement et
vérifie à nouveau ce point lors de la publication du résultat avec noclobber de Bash. Choisissez un nouveau
nom de fichier pour répéter une extraction. Les fichiers temporaires d’entrée et d’image sont
supprimés à la sortie. Le chemin de sortie final est utilisé littéralement, y compris les espaces
et %.
Vérifier l’image demandée
Enregistrez ce bloc sous video-thumbnail-demo/verify.mts. Il décode l’image d’indice 30 de la vidéo d’origine à 20 fps,
qui commence à 1,500 seconde, et compare les trois JPEG décodés à cette image. La tolérance permet
les différences dues à la compression JPEG ; une image provenant de la première image rouge
échoue à cette comparaison.
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`)
}
Enregistrez ce bloc sous video-thumbnail-demo/run-demo.sh. Il démarre le serveur d’origine, exécute le script utilitaire dans
les trois cas, vérifie les résultats et arrête le serveur d’origine. Les vidéos, les JPEG et origin.log restent
disponibles pour inspection ; origin.url est supprimé lorsque le serveur s’arrête.
#!/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
Depuis le répertoire où vous avez effectué la configuration, exécutez :
bash video-thumbnail-demo/run-demo.sh
Après les messages de téléchargement et de recours au téléchargement complet, le vérificateur affiche :
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
Ouvrez les JPEG pour voir le carré bleu et le motif en mouvement à l’instant choisi. Le premier
téléchargement de fast.mp4 devrait renvoyer 206 et 1 298 576 octets. La copie dont les métadonnées sont à la fin
nécessite un téléchargement complet. La plage ignorée renvoie 200 ; la taille totale annoncée dépasse
la limite du préfixe, donc cURL refuse le téléchargement et le script utilitaire réessaie sans plage.
Les cas de téléchargement complet transfèrent toute la vidéo, en plus des éventuels octets déjà
reçus. Pour relancer la démonstration, déplacez d’abord ses trois JPEG ailleurs.
Comprendre les requêtes de plage HTTP avec cURL
Une requête de plage HTTP demande une portion d’octets plutôt que l’objet entier. Les serveurs
peuvent annoncer Accept-Ranges: bytes, mais c’est la réponse à une véritable requête GET de plage qui compte. Recherchez
206 Partial Content et un Content-Range correspondant dans origin.log ou les en-têtes de réponse.
Les bornes de plage de cURL sont inclusives : 0-1048575
demande 1 MiB à partir du début de l’objet. Un serveur peut ignorer la plage et renvoyer 200 OK avec le corps complet.
cURL ne transforme pas automatiquement cette réponse en téléchargement partiel.
Le script utilitaire affiche le statut HTTP final et le nombre d’octets du corps téléchargés,
à l’exclusion des en-têtes de réponse et du surcoût lié au transport. Sa limite de préfixe utilise
--max-filesize ; cURL 8.4.0 a ajouté l’application de cette limite
aux réponses dont la taille n’est pas connue à l’avance. Le téléchargement complet de secours
n’a aucune limite de taille ; prévoyez donc l’espace disque et la bande passante nécessaires pour
l’intégralité du fichier d’entrée.
Calculer une plage d’octets adaptée
Le nombre d’octets nécessaires dépend du débit binaire, de l’instant choisi, des métadonnées du
conteneur et de l’image clé proche de votre position de lecture. Une estimation rapide est bytes ≈ seconds × bitrate(B/s) + buffer.
Par exemple, 5 Mb/s correspondent à environ 625 000 B/s ; 10 secondes plus une marge de 1 MiB donnent
7 298 576 octets. Le script utilitaire ajoute une seconde supplémentaire avant d’appliquer cette
estimation et transmet à FFmpeg l’horodatage fractionnaire d’origine.
Ce n’est qu’une estimation : un débit binaire variable et des métadonnées placées à la fin peuvent l’invalider. La vidéo de démonstration sans perte dépasse volontairement l’estimation fournie de 1 Mb/s. Son image située près du début tient dans le préfixe, contrairement au fichier dont les métadonnées sont à la fin. Un téléchargement complet peut être plus rapide que plusieurs requêtes de préfixe infructueuses ; mesurez avec vos propres fichiers plutôt que de présumer des économies.
Transmettre des données vidéo partielles à FFmpeg par un tube
FFmpeg peut lire l’entrée standard via pipe:0, mais un tube ne permet pas de revenir en arrière. L’entrée MP4
doit permettre une lecture séquentielle, généralement avec ses métadonnées moov au début.
L’option +faststart de FFmpeg déplace ces
métadonnées au début. Un préfixe plus grand ne résout pas le problème des métadonnées placées à la
fin, sauf s’il inclut le fichier entier. WebM et MKV ont aussi besoin de leurs en-têtes de piste et
de suffisamment de données multimédias pour atteindre l’image souhaitée.
Vous pouvez transmettre la sortie de cURL à FFmpeg par un tube et placer -ss après -i pour décoder
vers l’avant jusqu’à l’instant choisi. FFmpeg peut fermer le tube dès qu’il a son image, ce qui
amène cURL à signaler une erreur d’écriture (23). Avec pipefail, cela fait échouer le pipeline même si une
image a été produite. Le script utilitaire télécharge dans un fichier temporaire pour éviter
cette ambiguïté. Son positionnement
-ss avant -i utilise le déplacement dans le fichier d’entrée, avec
le positionnement précis activé par défaut pendant le transcodage.
Résoudre les problèmes courants
- Aucune miniature décodée : Vérifiez que l’horodatage se situe dans la durée de la vidéo et que l’URL renvoie des données vidéo. FFmpeg peut se terminer avec succès sans produire d’image si l’instant demandé dépasse la fin.
- Erreurs HTTP ou de transfert incomplet : Un préfixe en échec déclenche le téléchargement complet de secours. Si cette requête échoue aussi, le script utilitaire se termine en échec sans publier de miniature.
- Métadonnées manquantes ou entrée endommagée : Forcer
-f mp4ne peut pas réparer un fichier tronqué. Le script utilitaire rejette les diagnostics d’erreur du décodeur, mais il ne permet pas d’établir que toute la vidéo source est intacte : il décode uniquement l’image demandée. Vérifiez le contenu de l’image ainsi que son existence. - Téléchargements complets inattendus : Vérifiez la réponse réelle à la requête de plage et la
disposition du MP4.
--compressednégocie la compression du corps de la réponse, et non des en-têtes, et n’aide généralement pas pour une vidéo compressée. - La sortie existe déjà : Choisissez un nouveau nom de fichier. Un JPEG antérieur préservé ne prouve pas que la dernière extraction a réussi.
Pour un processus hébergé de création de miniatures, consultez le Robot /video/thumbs (English).
