Stream video thumbnails with cURL and FFmpeg pipes
Extracting thumbnails from remote videos can require downloading large files. For files whose metadata and requested frames are near the start, a byte-range request can reduce that download. This guide shows the pipe technique and a Bash helper that falls back to a complete, seekable download when a prefix is insufficient. Byte ranges alone cannot guarantee a usable thumbnail.
Understand HTTP range requests with cURL
An HTTP range request lets a client fetch a byte slice of a resource rather than the full object.
Servers can advertise support with Accept-Ranges: bytes, but the response to an actual range GET
is what matters.
First, make sure the origin supports ranges:
curl -fsSL --range 0-0 --max-filesize 1 -D - -o /dev/null https://example.com/video.mp4
Look for a final 206 Partial Content response and a matching Content-Range. The size limit stops
an ignored range from downloading a large file during this probe. To fetch the first 1 MiB, use
cURL’s --range (-r) flag instead of manually setting the header:
set -euo pipefail
mkdir video-prefix
curl -fsSL -r 0-1048575 --max-filesize 1048576 https://example.com/video.mp4 -o video-prefix/head.mp4
Range endpoints are inclusive. A server may ignore the range and return 200 OK with the full body;
cURL does not automatically turn that into a partial download.
Pipe partial video data into FFmpeg
FFmpeg can read from standard input (pipe:0) and usually probes the container automatically. Use
-f only when you know the format. MP4 input must support sequential reading, typically with its
moov metadata at the start (“faststart”); a pipe cannot seek back to media data after reading
metadata at the end. The following pipe examples assume this layout and enough bytes to decode the
requested frame. Run each example with Bash in a new output directory:
set -euo pipefail
mkdir first-thumbnail
curl -fsSL https://example.com/video.mp4 | \
ffmpeg -nostdin -n -hide_banner -loglevel error \
-f mp4 -i pipe:0 \
-ss 00:00:10 -frames:v 1 -update 1 -f image2 first-thumbnail/thumbnail.jpg
This decodes forward to ten seconds and writes a JPEG without saving the input video. FFmpeg can
close the pipe as soon as it has the frame, causing cURL to report a write error (23). With
pipefail, that makes the pipeline fail even if an image was produced. The automation helper below
downloads into a temporary file to avoid this ambiguity and permit seeking and retries.
Calculate an appropriate byte range
How many bytes you need depends on bitrate, timestamp, and a buffer for container metadata and the keyframe nearest your seek position. A quick back-of-the-envelope formula is:
bytes ≈ seconds × bitrate(B/s) + buffer
With bitrate in bytes per second (1 Mb/s ≈ 125,000 B/s) and a 1 MiB buffer:
# Thumbnail at 10 s from a 5 Mb/s H.264 MP4 stream
BITRATE_BPS=$((5 * 125000)) # 625,000 B/s
SEEK_SECONDS=10
BUFFER=$((1 * 1024 * 1024)) # 1,048,576 B
BYTES_NEEDED=$((SEEK_SECONDS * BITRATE_BPS + BUFFER))
# Bytes_needed = 7,298,576
This is only an estimate: variable bitrate and metadata at the end can invalidate it. After running the calculation above, request that prefix as follows:
set -euo pipefail
mkdir estimated-thumbnail
curl -fsSL -r "0-$((BYTES_NEEDED - 1))" --max-filesize "$BYTES_NEEDED" https://example.com/video.mp4 | \
ffmpeg -nostdin -n -hide_banner -loglevel error -f mp4 -i pipe:0 \
-ss 00:00:10 -frames:v 1 -update 1 -f image2 estimated-thumbnail/thumb.jpg
Automate the workflow with a bash helper
Save this as thumbnail.sh and run it with Bash. It accepts a timestamp in HH:MM:SS[.ms] format
and an estimated whole-number bitrate from 1 to 999 Mb/s. The helper retries downloads into a
temporary file, attempts the prefix first, then downloads the complete video if necessary. That
fallback can consume the full file’s bandwidth and disk space.
#!/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
# Convert timestamp → seconds
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"; rmdir "$WORK_DIR"' EXIT
make_thumbnail() {
rm -f -- "$WORK_DIR/thumb.jpg"
ffmpeg -nostdin -y -hide_banner -loglevel error -xerror \
-ss "$TIMESTAMP" -i "$WORK_DIR/input.mp4" -map 0:v:0 \
-frames:v 1 -q:v 2 -c:v mjpeg -update 1 -f image2 "$WORK_DIR/thumb.jpg" &&
[[ -s "$WORK_DIR/thumb.jpg" ]]
}
if curl -fsSL --retry 3 --proto '=http,https' --proto-redir '=http,https' \
--range "0-$((BYTES - 1))" --max-filesize "$BYTES" \
-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 -fsSL --retry 3 --proto '=http,https' --proto-redir '=http,https' \
-o "$WORK_DIR/input.mp4" -- "$VIDEO_URL"
if ! make_thumbnail; then
echo "No thumbnail decoded at $TIMESTAMP" >&2
exit 1
fi
fi
# Refuse to replace an output created by another process during the download.
(set -o noclobber; cat "$WORK_DIR/thumb.jpg" > "$OUT")
echo "Thumbnail saved to $OUT"
Handle different container formats
Not every file can be decoded from a prefix. These illustrative range sizes are not guaranteed to contain the required headers and frames; use the helper’s complete-download fallback for automation.
MP4
Only use a prefix when the moov atom precedes the media data. A larger prefix does not solve
metadata at the end unless it includes the entire file:
set -euo pipefail
mkdir mp4-thumbnail
curl -fsSL -r 0-5242879 https://example.com/video.mp4 | \
ffmpeg -nostdin -n -hide_banner -loglevel error -f mp4 -i pipe:0 -ss 00:00:05 \
-frames:v 1 -update 1 -f image2 mp4-thumbnail/thumb.jpg
WebM
WebM headers are lighter, but keyframes can be spaced widely, so a larger slice helps:
set -euo pipefail
mkdir webm-thumbnail
curl -fsSL -r 0-10485759 https://example.com/video.webm | \
ffmpeg -nostdin -n -hide_banner -loglevel error -f webm -i pipe:0 -ss 00:00:05 \
-frames:v 1 -update 1 -f image2 webm-thumbnail/thumb.jpg
MKV
Matroska supports seeking, but a pipe still requires sequential decoding. The prefix must contain the track headers and enough clusters to reach the requested frame:
set -euo pipefail
mkdir mkv-thumbnail
curl -fsSL -r 0-15728639 https://example.com/video.mkv | \
ffmpeg -nostdin -n -hide_banner -loglevel error -f matroska -i pipe:0 -ss 00:00:05 \
-frames:v 1 -update 1 -f image2 mkv-thumbnail/thumb.jpg
Performance best practices
- A complete download can be quicker than several failed prefix requests; measure with your files.
- Choose prefix sizes using the actual bitrate and container layout, rather than resolution alone.
- When you need multiple thumbnails from one file, FFmpeg’s
selectfilter can pull several frames in one pass:
set -euo pipefail
mkdir selected-thumbnails
curl -fsSL -r 0-15728639 https://example.com/video.mp4 | \
ffmpeg -nostdin -n -hide_banner -loglevel error -f mp4 -i pipe:0 \
-vf "select=eq(n\,150)+eq(n\,300)+eq(n\,450)" -fps_mode vfr -f image2 selected-thumbnails/thumb_%02d.jpg
Real-world use cases
Video preview grids
set -euo pipefail
mkdir preview-grid
curl -fsSL -r 0-20971519 https://example.com/video.mp4 | \
ffmpeg -nostdin -n -hide_banner -loglevel error -f mp4 -i pipe:0 \
-vf "select='not(mod(n,300))',scale=160:90,tile=4x3" \
-frames:v 1 -update 1 -f image2 preview-grid/preview.jpg
On-demand thumbnails for streaming platforms
An unencrypted, independently decodable MPEG-TS HLS segment can be read directly. Fragmented MP4 segments used by DASH and some HLS streams also need their initialization segment; for those, give FFmpeg the manifest URL instead of piping an isolated media segment.
set -euo pipefail
mkdir live-thumbnail
curl -fsSL https://example.com/live/segment-123.ts | \
ffmpeg -nostdin -n -hide_banner -loglevel error -f mpegts -i pipe:0 \
-frames:v 1 -update 1 -f image2 live-thumbnail/live_thumb.jpg
Parallel batch processing
GNU Parallel can run the helper for several URLs. Each job gets a distinct output filename:
parallel --halt soon,fail=1 -j 4 bash ./thumbnail.sh {} 00:00:10 5 thumb_{#}.jpg ::: \
https://cdn.example.com/a.mp4 \
https://cdn.example.com/b.mp4 \
https://cdn.example.com/c.mp4
Troubleshoot common issues
- Missing or incomplete images: Use the complete-download fallback and check that the timestamp is inside the video’s duration. FFmpeg can exit successfully without producing a frame when the requested timestamp is beyond the end.
- Slow downloads: Use a nearby CDN and measure appropriate ranges.
--compressednegotiates compression of the response body, not the headers, and generally does not help compressed video. - FFmpeg cannot detect the format: Check for an HTTP error body or missing metadata. Forcing
-f mp4cannot repair a truncated or non-seekable input.
Wrap-up
Byte-range requests can reduce thumbnail download costs when the file layout permits it. Check download and decoder failures, verify that an image was produced, and retain a seekable fallback.
If you’d prefer an off-the-shelf service, check out Transloadit’s /video/thumbs Robot. It handles format detection, retries, and scaling, so you can focus on building your product.
