Extract video thumbnails with cURL and FFmpeg
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 gives you a Bash helper that tries a prefix, then falls back to a complete, seekable download. A local moving video lets you check that the JPEG contains the requested frame.
Set up a known HTTP video
Use Linux with Bash, GNU coreutils, cURL 8.5 or later, FFmpeg with ffprobe and the libx264
encoder, and Node.js. Ubuntu 24.04 provides the shell and media tools through its bash,
coreutils, curl, and ffmpeg packages. For the local
origin and verifier, use Node.js 24.15 or later within version 24, or version 26.
The .mts files below run with Node’s native TypeScript support
and use only built-in modules. No project installation is needed. Official Node.js 26 Linux
binaries also require the libatomic runtime,
available as Ubuntu’s libatomic1 package. The example works with Ubuntu’s FFmpeg 6.1.1 and
cURL 8.5.0, as well as FFmpeg 9.0.1 and cURL 8.22.0.
Paste this into Bash from a directory where video-thumbnail-demo does not exist. It creates a
12-second, 640×360 test video at 20 frames per second and a copy with its MP4 metadata at the end.
The pattern moves; the square in the upper left is red before 1 second and blue afterward.
(
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
)
If generation fails, the block removes its incomplete videos and directory. Fix the missing tool or encoder and repeat it. An existing directory is preserved. The parentheses keep shell options and your working directory unchanged.
Save the following as video-thumbnail-demo/origin.mts. This small loopback server serves the two
files, honors prefix ranges, and deliberately ignores them at /ignore.mp4. It chooses an available
port and records the URL in origin.url. It is a fixture for this walkthrough, not a public 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 })
Automate the workflow with a bash helper
Save this as video-thumbnail-demo/thumbnail.sh. It accepts HH:MM:SS[.ms], an estimated
whole-number bitrate from 1 to 999 Mb/s, and an output path. The temporary input makes seeking and
fallback possible. A failed decoder or a seek beyond the video’s duration must not count as success
just because FFmpeg returned zero: the helper also requires a newly written, nonempty image.
#!/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"
The helper refuses an existing file or symlink before downloading and checks again at publication
with Bash’s noclobber. Choose a new filename to repeat an extraction. Temporary input and image
files are removed on exit. The final output path is used literally, including spaces and %.
Verify the requested frame
Save this as video-thumbnail-demo/verify.mts. It decodes frame index 30 of the original 20 fps video,
which starts at 1.500 seconds, and compares all three decoded JPEGs against it. The tolerance
allows JPEG compression differences; an image from the red first frame fails this comparison.
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`)
}
Save this as video-thumbnail-demo/run-demo.sh. It starts the origin, runs the helper through all
three paths, verifies the results, and stops the origin. The videos, JPEGs, and origin.log remain
for inspection; origin.url is removed when the server stops.
#!/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
From the same directory where you ran setup, execute:
bash video-thumbnail-demo/run-demo.sh
After the download and fallback messages, the verifier prints:
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
Open the JPEGs to see the blue square and moving pattern at the selected time. The first download
for fast.mp4 should return 206 and 1,298,576 bytes. The tail-metadata copy needs a full download.
The ignored range returns 200; its advertised full size exceeds the prefix cap, so cURL refuses
it and the helper retries without a range. The complete-download paths transfer the whole video,
in addition to any earlier bytes received. To rerun the demo, move its three JPEGs elsewhere first.
Understand HTTP range requests with cURL
An HTTP range request asks for a byte slice rather than the full object. Servers can advertise
Accept-Ranges: bytes, but the response to an actual range GET is what matters. Look for
206 Partial Content and a matching Content-Range in origin.log or the response headers.
cURL’s range endpoints are inclusive: 0-1048575
requests the first 1 MiB. A server may ignore the range and return 200 OK with the full body.
cURL does not automatically turn that into a partial download.
The helper prints the final HTTP status and downloaded body byte count,
excluding response headers and transport overhead. Its prefix cap uses
--max-filesize; cURL 8.4.0 added enforcement
for responses whose size is not known beforehand. The full
fallback has no size cap, so allow disk space and bandwidth for the complete input.
Calculate an appropriate byte range
How many bytes you need depends on bitrate, timestamp, container metadata, and the keyframe near
your seek position. A quick estimate is bytes ≈ seconds × bitrate(B/s) + buffer.
For example, 5 Mb/s is approximately 625,000 B/s; 10 seconds plus a 1 MiB buffer gives
7,298,576 bytes. The helper adds an extra second before applying this estimate and passes the
original fractional timestamp to FFmpeg.
This is only an estimate: variable bitrate and metadata at the end can invalidate it. The lossless demo video intentionally exceeds the supplied 1 Mb/s estimate. Its early frame fits in the prefix, while the tail-metadata file does not. A complete download can be quicker than several failed prefix requests; measure with your own files rather than assuming savings.
Pipe partial video data into FFmpeg
FFmpeg can read standard input through pipe:0, but a pipe cannot seek backward. MP4 input must
support sequential reading, usually with its moov metadata at the start. FFmpeg’s
+faststart option moves that
metadata to the front. A larger prefix does not solve metadata at the end unless it includes the
entire file. WebM and MKV also need their track headers and enough media to reach the desired frame.
You can pipe cURL into FFmpeg and place -ss after -i to decode forward to the chosen time.
FFmpeg can close the pipe once it has its frame, causing cURL to report a write error (23).
With pipefail, that makes the pipeline fail even if an image was produced. The helper downloads
to a temporary file to avoid this ambiguity. Its
-ss before -i uses input seeking with accurate
seek enabled by default during transcoding.
Troubleshoot common issues
- No thumbnail decoded: Check that the timestamp is inside the video’s duration and that the URL returns video data. FFmpeg can exit successfully without producing a frame beyond the end.
- HTTP or incomplete-transfer errors: A failed prefix triggers the full fallback. If that request fails too, the helper exits unsuccessfully without publishing a thumbnail.
- Missing metadata or damaged input: Forcing
-f mp4cannot repair a truncated file. The helper rejects decoder error diagnostics, but it does not establish that the entire source video is intact: it only decodes the frame requested. Verify the image’s content as well as its existence. - Unexpected full downloads: Check the actual range response and MP4 layout.
--compressednegotiates compression of the response body, not the headers, and generally does not help compressed video. - Output already exists: Choose a new filename. A preserved earlier JPEG is not evidence that the latest extraction succeeded.
For a hosted thumbnail workflow, see the /video/thumbs Robot.
