Video-Thumbnails mit cURL und FFmpeg extrahieren
Um Thumbnails aus entfernten Videos zu extrahieren, müssen mitunter große Dateien heruntergeladen werden. Liegen Metadaten und gewünschte Frames nahe am Dateianfang, kann eine Bytebereichsanfrage diesen Download verkleinern. Diese Anleitung liefert ein Bash-Hilfsskript, das zunächst einen Dateianfang anfordert und bei Bedarf auf einen vollständigen Download mit wahlfreiem Zugriff zurückfällt. Mit einem lokalen Video mit Bewegung prüfen Sie, ob das JPEG den gewünschten Frame enthält.
Ein bekanntes HTTP-Video einrichten
Verwenden Sie Linux mit Bash, GNU coreutils, cURL 8.5 oder neuer, FFmpeg mit ffprobe und dem Encoder
libx264 sowie Node.js. Ubuntu 24.04 stellt die Shell- und Medienwerkzeuge über die Pakete
bash, coreutils, curl und ffmpeg bereit.
Für den lokalen Ursprungsserver und das Prüfskript verwenden Sie
Node.js 24.15 oder neuer innerhalb von Version 24 oder Version 26. Die Dateien mit der Endung
.mts unten laufen mit der nativen TypeScript-Unterstützung von Node
und verwenden nur integrierte Module. Eine Projektinstallation ist nicht nötig. Offizielle
Linux-Binärdateien von Node.js 26 benötigen außerdem
die Laufzeitbibliothek libatomic,
die unter Ubuntu als Paket libatomic1 verfügbar ist. Das Beispiel funktioniert mit FFmpeg 6.1.1 und
cURL 8.5.0 aus Ubuntu sowie mit FFmpeg 9.0.1 und cURL 8.22.0.
Fügen Sie Folgendes in Bash ein, während Sie sich in einem Verzeichnis befinden, in dem
video-thumbnail-demo nicht existiert. Es erstellt ein 12 Sekunden langes Testvideo in 640×360 mit
20 Frames pro Sekunde sowie eine Kopie mit den MP4-Metadaten am Ende. Das Muster bewegt sich;
das Quadrat oben links ist vor Ablauf von 1 Sekunde rot und danach blau.
(
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
)
Falls die Erstellung fehlschlägt, entfernt der Block seine unvollständigen Videos und sein Verzeichnis. Installieren Sie das fehlende Werkzeug oder den Encoder und führen Sie den Block erneut aus. Ein vorhandenes Verzeichnis bleibt erhalten. Die Klammern sorgen dafür, dass Shell-Optionen und Ihr Arbeitsverzeichnis unverändert bleiben.
Speichern Sie Folgendes als video-thumbnail-demo/origin.mts. Dieser kleine Loopback-Server liefert die beiden Dateien
aus, berücksichtigt Bereichsanfragen für den Dateianfang und ignoriert sie unter
/ignore.mp4 absichtlich. Er wählt einen verfügbaren Port und speichert die URL in
origin.url. Er dient als Fixture für diese Anleitung, nicht als öffentlicher Server.
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 })
Den Workflow mit einem Bash-Hilfsskript automatisieren
Speichern Sie dies als video-thumbnail-demo/thumbnail.sh. Es akzeptiert HH:MM:SS[.ms], eine geschätzte
ganzzahlige Bitrate von 1 bis 999 Mb/s und einen Ausgabepfad. Die temporäre Eingabedatei ermöglicht
das Springen zur Zeitposition und den Rückfall auf den vollständigen Download. Ein Decoder-Fehler
oder ein Sprung über die Videodauer hinaus darf nicht als Erfolg gelten, nur weil FFmpeg null
zurückgegeben hat: Das Hilfsskript verlangt außerdem ein neu geschriebenes, nicht leeres Bild.
#!/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"
Das Hilfsskript lehnt eine vorhandene Datei oder einen symbolischen Link vor dem Download ab und
prüft dies beim Bereitstellen der Ausgabe mit noclobber von Bash erneut. Wählen Sie für eine erneute
Extraktion einen neuen Dateinamen. Temporäre Eingabe- und Bilddateien werden beim Beenden entfernt.
Der endgültige Ausgabepfad wird wörtlich verwendet, einschließlich Leerzeichen und %.
Den angeforderten Frame überprüfen
Speichern Sie dies als video-thumbnail-demo/verify.mts. Es decodiert den Frame mit Index 30 des ursprünglichen Videos
mit 20 fps, der bei 1,500 Sekunden beginnt, und vergleicht alle drei decodierten JPEGs damit.
Die Toleranz lässt Unterschiede durch die JPEG-Komprimierung zu; ein Bild des roten ersten Frames
besteht diesen Vergleich nicht.
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`)
}
Speichern Sie dies als video-thumbnail-demo/run-demo.sh. Es startet den Ursprungsserver, führt das Hilfsskript für alle
drei Abläufe aus, überprüft die Ergebnisse und stoppt den Ursprungsserver. Die Videos, JPEGs und
origin.log bleiben zur Kontrolle erhalten; origin.url wird beim Stoppen des Servers entfernt.
#!/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
Führen Sie im selben Verzeichnis, in dem Sie die Einrichtung ausgeführt haben, Folgendes aus:
bash video-thumbnail-demo/run-demo.sh
Nach den Meldungen zum Download und zum Rückfall auf den vollständigen Download gibt das Prüfskript Folgendes aus:
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
Öffnen Sie die JPEGs, um das blaue Quadrat und das bewegte Muster zum ausgewählten Zeitpunkt zu
sehen. Der erste Download für fast.mp4 sollte 206 und 1.298.576 Bytes zurückgeben.
Die Kopie mit Metadaten am Ende benötigt einen vollständigen Download. Bei ignorierter
Bereichsanfrage wird 200 zurückgegeben; die angegebene Gesamtgröße überschreitet das Limit für
den Dateianfang. Deshalb lehnt cURL den Download ab, und das Hilfsskript versucht es erneut ohne
Bereichsangabe. Die Abläufe mit vollständigem Download übertragen das gesamte Video zusätzlich zu
allen zuvor empfangenen Bytes. Um die Demo erneut auszuführen, verschieben Sie zunächst ihre drei
JPEGs an einen anderen Speicherort.
HTTP-Bereichsanfragen mit cURL verstehen
Eine HTTP-Bereichsanfrage fordert einen Byteabschnitt statt des gesamten Objekts an. Server können
Accept-Ranges: bytes ankündigen, doch entscheidend ist die Antwort auf eine tatsächliche GET-Bereichsanfrage.
Achten Sie auf 206 Partial Content und ein passendes Content-Range in origin.log oder den Antwortheadern.
Die Bereichsgrenzen von cURL sind inklusiv: 0-1048575
fordert das erste 1 MiB an. Ein Server kann die Bereichsangabe ignorieren und
200 OK mit dem vollständigen Antwortkörper zurückgeben. cURL macht daraus nicht automatisch einen
Teildownload.
Das Hilfsskript gibt den endgültigen HTTP-Status und die
Anzahl der heruntergeladenen Bytes des Antwortkörpers aus,
ohne Antwortheader und Transport-Overhead. Sein Limit für den Dateianfang verwendet
--max-filesize; seit cURL 8.4.0 wird dieses auch bei Antworten
durchgesetzt, deren Größe nicht vorab bekannt ist. Für den vollständigen Ersatzdownload gilt
kein Größenlimit. Planen Sie daher Speicherplatz und Bandbreite für die gesamte Eingabedatei ein.
Einen passenden Bytebereich berechnen
Wie viele Bytes Sie benötigen, hängt von der Bitrate, dem Zeitstempel, den Container-Metadaten
und dem Keyframe nahe Ihrer Zielposition ab. Eine schnelle Schätzung liefert bytes ≈ seconds × bitrate(B/s) + buffer.
Zum Beispiel entsprechen 5 Mb/s ungefähr 625.000 B/s; 10 Sekunden plus ein Puffer von 1 MiB ergeben
7.298.576 Bytes. Das Hilfsskript fügt vor Anwendung dieser Schätzung eine zusätzliche Sekunde hinzu
und übergibt den ursprünglichen Zeitstempel samt Nachkommastellen an FFmpeg.
Dies ist nur eine Schätzung: Eine variable Bitrate und Metadaten am Ende können sie unbrauchbar machen. Das verlustfreie Demovideo überschreitet absichtlich den angegebenen Schätzwert von 1 Mb/s. Sein früher Frame liegt im angeforderten Dateianfang, während die Datei mit Metadaten am Ende damit nicht auskommt. Ein vollständiger Download kann schneller sein als mehrere fehlgeschlagene Anfragen für den Dateianfang. Messen Sie mit Ihren eigenen Dateien, statt Einsparungen vorauszusetzen.
Teilweise Videodaten per Pipe an FFmpeg übergeben
FFmpeg kann die Standardeingabe mit pipe:0 lesen, doch eine Pipe erlaubt keine Rückwärtssprünge.
Die MP4-Eingabe muss sequenzielles Lesen unterstützen, üblicherweise mit ihren Metadaten
moov am Anfang. Die Option +faststart
von FFmpeg verschiebt diese Metadaten an den Anfang. Ein größerer angeforderter Dateianfang löst
das Problem mit Metadaten am Ende nur dann, wenn er die gesamte Datei umfasst. WebM und MKV benötigen
ebenfalls ihre Spurheader und genügend Mediendaten, um den gewünschten Frame zu erreichen.
Sie können cURL per Pipe an FFmpeg weiterleiten und -ss hinter -i setzen,
um vorwärts bis zum gewählten Zeitpunkt zu decodieren. FFmpeg kann die Pipe schließen, sobald es
seinen Frame hat. Dadurch meldet cURL einen Schreibfehler (23). Mit pipefail schlägt die Pipeline
dann fehl, selbst wenn ein Bild erzeugt wurde. Das Hilfsskript lädt die Daten in eine temporäre
Datei herunter, um diese Mehrdeutigkeit zu vermeiden. Seine Anordnung
-ss vor -i nutzt das Springen in der
Eingabedatei, wobei während des Transkodierens standardmäßig präzise Zeitpositionen angesteuert werden.
Häufige Probleme beheben
- Kein Thumbnail decodiert: Prüfen Sie, ob der Zeitstempel innerhalb der Videodauer liegt und die URL Videodaten zurückgibt. FFmpeg kann erfolgreich enden, ohne einen Frame jenseits des Videoendes zu erzeugen.
- HTTP-Fehler oder unvollständige Übertragung: Schlägt die Anfrage für den Dateianfang fehl, wird der vollständige Ersatzdownload ausgelöst. Schlägt auch diese Anfrage fehl, endet das Hilfsskript mit einem Fehler, ohne ein Thumbnail bereitzustellen.
- Fehlende Metadaten oder beschädigte Eingabe: Das Erzwingen von
-f mp4kann eine abgeschnittene Datei nicht reparieren. Das Hilfsskript weist Ergebnisse mit Decoder-Fehlermeldungen zurück, stellt aber nicht sicher, dass das gesamte Quellvideo intakt ist: Es decodiert nur den angeforderten Frame. Prüfen Sie neben der Existenz auch den Inhalt des Bildes. - Unerwartete vollständige Downloads: Prüfen Sie die tatsächliche Antwort auf die Bereichsanfrage
und den MP4-Aufbau.
--compressedhandelt die Komprimierung des Antwortkörpers aus, nicht der Header, und hilft bei komprimiertem Video in der Regel nicht. - Ausgabe existiert bereits: Wählen Sie einen neuen Dateinamen. Ein erhaltenes früheres JPEG ist kein Beleg dafür, dass die letzte Extraktion erfolgreich war.
Für einen gehosteten Thumbnail-Workflow nutzen Sie den Robot /video/thumbs.
