Generate a video thumbnail when a file lands in S3

Generate a thumbnail for every video that lands in S3. Our test clip's first frame was almost black, and a 320 px JPEG of a later frame came to 5,970 bytes.

Share

Every video your app accepts needs a picture to show before anyone presses play. The usual first attempt is one FFmpeg command wired to the upload, and the usual first bug report is a grid of black squares.

Short version. Take the frame from a few seconds in, not from the start, because frame one of a lot of video is dark. On our test clip it was a 5,620-byte JPEG of near darkness. Size the image for the slot it fills, where a 320 px JPEG was 8.6 times smaller than the full frame. Then trigger it from an S3 event, key each job on the object so a repeated event can’t double it, and deliver the JPEG to a prefix the trigger isn’t watching.

S3 bucketuploads/clip.mp4eventsmall functionone HTTPS callPOSTJOBffmpeg -ss 3 -i source -frames:v 1delivered to the destinationS3 bucketthumbs/clip.jpg, name from your templatewebhookyour appjob.delivery_succeeded webhookThe function never touches the video. It sends a key and a destination, and the event says where the JPEG landed.
The function that reacts to the upload never downloads the video. It names the object and the destination, and a webhook reports where the JPEG landed.

Why video thumbnails come out black

The command everyone starts with takes the first frame:

Terminal window
ffmpeg -i input.mp4 -frames:v 1 -q:v 3 thumb.jpg

That works on a screen recording and fails on anything with an intro. Our measurement clip, the same 1280x720 sample every benchmark on this site uses, fades in from black. One frame per second for its first five seconds shows it:

Five frames of the test clip side by side, one per second. The first is almost entirely black, the second shows faint pink clouds through a dark wash, and the last three show bright pink clouds, a pale sky and green trees.
The first second of the clip is a fade. A thumbnail taken from it shows the fade.

The first frame came out as a 5,620-byte JPEG. The frame at 3 seconds, same quality setting, was 51,307 bytes. That ratio is a useful tell on its own: there is almost nothing in a black frame to encode, so an unusually small thumbnail is worth a second look before you publish it.

The first frame of the test clip. It is almost completely black, with the outline of trees barely visible at the bottom right.
Frame one, 5,620 bytes.
The test clip at 3 seconds. Tall pink clouds fill a pale blue sky above a line of green trees and a distant hill.
The same clip at 3 seconds, 51,307 bytes.

Where -ss goes, before or after -i

The fix is a seek, and FFmpeg accepts it in two places. The documentation draws the difference plainly. As an input option, before -i, it “seeks in this input file to position”, jumping to the nearest seek point. As an output option, after -i, it “decodes but discards input until the timestamps reach position”.

Terminal window
# Input seek: jump close to 3 s, then decode forward to it
ffmpeg -ss 3 -i input.mp4 -frames:v 1 -q:v 3 thumb.jpg
# Output seek: decode everything from the start, keep frames from 3 s on
ffmpeg -i input.mp4 -ss 3 -frames:v 1 -q:v 3 thumb.jpg

On our clip the two produced the same 51,307-byte file, identical to the byte. When transcoding, FFmpeg decodes from the seek point to the exact timestamp by default, so the input seek lands on the same frame. The difference is the work it takes to get there, and on a 5-second clip there is no work to save. On an hour-long upload where you want a frame from minute 20, the output seek decodes twenty minutes of video it throws away. Put -ss before -i.

Let FFmpeg pick the frame

A fixed timestamp is a guess. The thumbnail filter makes an informed one: it reads batches of consecutive frames and keeps “the most representative frame in a given sequence”. On our clip it chose a different, busier frame than the 3-second grab, and that frame encoded to 68,269 bytes.

The frame the thumbnail filter chose. Pink clouds in a pale sky with a large evergreen tree and dense green foliage in the lower right, and a hill line along the bottom.
The thumbnail filter's pick, 68,269 bytes.

The catch is the batch. It holds 100 frames by default, and with -frames:v 1 the output is the pick from the first batch alone. At 24 frames per second that is the first 4.2 seconds, which on a video with a longer intro is still intro. Seek first, then let the filter choose within a window you control:

Terminal window
# Skip the first 5 s, then pick the best of the next 120 frames (5 s at 24 fps)
ffmpeg -ss 5 -i input.mp4 -vf thumbnail=n=120 -frames:v 1 -q:v 3 thumb.jpg

Here are all six variants, measured on the Rendobar API against the same source:

Video thumbnail methods
Which way of grabbing a video thumbnail is fastest, and how big is it?
Held constant: One still from the same 1280x720 source, JPEG q3 unless noted
Bar chart. Video thumbnail methods. Encode time and output size for each variant, each metric scaled to its own maximum.
VariantEncodeSizeCost
first frame, no -ss104 ms fastest5.5 KB$0.0016
-ss before -i137 ms50.1 KB$0.0017
-ss after -i144 ms50.1 KB$0.0016
thumbnail filter227 ms66.7 KB$0.0018
320 px JPEG131 ms5.8 KB$0.0016
contact sheet, 5 frames142 ms23.2 KB$0.0017
Measured 2026-09-13 on the Rendobar API. Encode time is the FFmpeg step alone, separated from download and upload. 6 of 6 runs succeeded. Sizes are exact and repeatable. Timings are a single sample and vary up to 2x run to run, so treat small differences as noise. Why.

The encode times are all under a quarter of a second, and at this size the differences between them are noise. The filter does decode more frames than a single seek, so on long files expect it to cost more time than the grab, not a different result from what the table shows about size.

Size it for where it is shown

A thumbnail usually sits in a card or a list row, not full screen. Serving the full 1280x720 frame into a 320 px slot sends bytes nobody sees.

VariantDimensionsBytes
Full frame at 3 s1280x72051,307
Scaled to 320 px wide320x1805,970
Contact sheet, 5 frames1600x18023,793

The 320 px version was 8.6 times smaller. scale=320:-2 keeps the aspect ratio and rounds the height to an even number:

Terminal window
ffmpeg -ss 3 -i input.mp4 -frames:v 1 -vf scale=320:-2 -q:v 3 thumb.jpg

The contact sheet at the top of this post is the same idea stretched out, and it makes a good hover preview. Five 320 px frames in one image came to 23,793 bytes, less than half of a single full-size frame:

Terminal window
ffmpeg -i input.mp4 -vf "fps=1,scale=320:-2,tile=5x1" -frames:v 1 -q:v 3 sheet.jpg

Run it when a video lands in S3

S3 can call you when an object is created. The documentation lists four destinations for those notifications: SNS topics, SQS queues, Lambda functions and EventBridge. Two properties of that delivery decide how the handler has to be written. Notifications “are designed to be delivered at least once”, and they arrive “typically in seconds but can sometimes take a minute or longer”.

At least once means the same upload can produce two events, so each job needs an idempotency key derived from the object itself. The key and its ETag together identify one version of one file. Hashing them keeps the result inside the 256-character limit however long the object key is.

The second rule comes straight from AWS’s own warning: a function that writes back into the bucket that triggers it can trigger itself. Scope the notification to an uploads/ prefix and deliver thumbnails somewhere else.

An S3-triggered Lambda handler. media is the connected bucket's ID.

job.ts
import { createHash } from "node:crypto";
import type { S3Event } from "aws-lambda";
import { createClient } from "@rendobar/sdk";
const rb = createClient({ apiKey: process.env.RENDOBAR_API_KEY });
export const handler = async (event: S3Event) => {
for (const record of event.Records) {
// Keys arrive URL encoded, with spaces written as "+".
const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, " "));
await rb.jobs.create({
type: "ffmpeg",
inputs: { source: `storage://media/${key}` },
params: {
command: "ffmpeg -ss 3 -i source -frames:v 1 -vf scale=320:-2 -q:v 3 out.jpg",
},
// A folder keeps the file name pattern: uploads/clip.mp4 becomes thumbs/clip.jpg
destinations: ["storage://media/thumbs"],
// The same event delivered twice finds the job the first one created.
idempotencyKey: createHash("sha256").update(`${key}:${record.s3.object.eTag}`).digest("hex"),
});
}
};

Install with npm i @rendobar/sdk. jobs.run() submits and waits, so it returns the finished job in one call.

terminal
curl -X POST https://api.rendobar.com/jobs \
-H "Authorization: Bearer $RENDOBAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "ffmpeg",
"inputs": { "source": "storage://media/uploads/clip.mp4" },
"params": { "command": "ffmpeg -ss 3 -i source -frames:v 1 -vf scale=320:-2 -q:v 3 out.jpg" },
"destinations": ["storage://media/thumbs"],
"idempotencyKey": "SHA256_OF_KEY_AND_ETAG"
}'

Returns immediately with a job id. Poll GET /jobs/{id} or register a webhook rather than blocking on the request.

The handler does no media work at all. It never downloads the video and needs no S3 permissions of its own, so it costs one HTTPS request per upload however large the upload is. The job reads the object through a link to that one file, grabs the frame, and delivers the JPEG under thumbs/, using the connection’s file name pattern. Two uploads that share a file name in different folders don’t overwrite each other when the connection is set to keep both.

We ran each of the six commands above as a real job. Every one of them billed between $0.0016 and $0.0018.

Knowing the thumbnail exists

The job completing means the frame was grabbed. The delivery completing means the JPEG is in your bucket, and that is the event your app wants. Each bucket fires its own job.delivery_succeeded, carrying the path it wrote and a public link when the connection has a public URL configured.

import { verifyWebhook } from "@rendobar/sdk/webhooks";
export async function POST(req: Request): Promise<Response> {
const body = await req.text();
const ok = await verifyWebhook(body, req.headers, process.env.RENDOBAR_WEBHOOK_SECRET ?? "");
if (!ok) return new Response("Invalid signature", { status: 401 });
const event = JSON.parse(body);
if (event.event === "job.delivery_succeeded") {
// event.data.path is the key in your bucket, for example thumbs/clip.jpg
await saveThumbnailPath(event.data.jobId, event.data.path);
}
return new Response(null, { status: 204 });
}

If a delivery fails, job.delivery_failed names the reason and the thumbnail is still downloadable from the job, so a bucket with the wrong permissions costs you a retry, not a re-encode.

Where this stops

Every image and byte count here comes from one 5-second 720p clip that happens to fade in. Your uploads will have different first frames and different detail, so the ratios are the point, not the exact bytes.

The timings are single samples and sit well inside run-to-run noise at this size. Nothing here says the thumbnail filter is fast or slow on a long file, only that it looks at a limited batch.

A seek past the end of a short clip produces no frame. If your uploads can be shorter than your seek, read the duration first. Validating uploads with ffprobe covers that check, and it is cheaper than a failed job.

The trigger side assumes the video is in a bucket you have connected, as the storage connections guide describes. For getting the finished video itself into S3, see save FFmpeg output to an S3 bucket. For choosing between JPEG, WebP and AVIF once the frame exists, see AVIF vs WebP vs JPEG compared.

Frequently asked questions

Why is my FFmpeg video thumbnail black?

Because the first frame of many videos is black or nearly so. Intros fade in, and a command that takes frame one gets the fade. Seek into the video with -ss before grabbing the frame. On our test clip frame one was a 5,620-byte JPEG of darkness and the frame at 3 seconds was 51,307 bytes.

Should -ss go before or after -i when extracting a thumbnail?

Before, for long videos. As an input option FFmpeg seeks to the nearest seek point and decodes forward from there. As an output option it decodes and discards everything up to the timestamp. On our 5-second clip both placements produced an identical file.

What does FFmpeg's thumbnail filter do?

It reads batches of consecutive frames, 100 by default, and outputs the most representative frame of each batch. With -frames:v 1 you get the pick from the first batch only, so seek past an intro first or raise the batch size with thumbnail=n.

How do I generate a thumbnail automatically when a video is uploaded to S3?

Add an S3 event notification for new objects under an uploads prefix, call a small function from it, and have that function submit a job with the object as its input and a thumbnails prefix as the destination. Keep the destination outside the watched prefix, or every thumbnail triggers another job.

How big should a video thumbnail be?

As big as the slot it is shown in. A 320 px wide JPEG of our test frame was 5,970 bytes and the full 1280x720 frame was 51,307 bytes, so sending the full frame into a small card costs 8.6 times the bytes.

Sources

Tags #ffmpeg#thumbnails#s3#aws-lambda#storage
All posts
Share
  1. Video APIs that deliver output to your own bucket Guides for the video API
  2. Give a service S3 access without an access key Guides for the video API
  3. Add video processing to n8n Cloud Guides for the video API