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.
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.
Why video thumbnails come out black
The command everyone starts with takes the first frame:
ffmpeg -i input.mp4 -frames:v 1 -q:v 3 thumb.jpgThat 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:

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.


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”.
# Input seek: jump close to 3 s, then decode forward to itffmpeg -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 onffmpeg -i input.mp4 -ss 3 -frames:v 1 -q:v 3 thumb.jpgOn 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 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:
# 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.jpgHere are all six variants, measured on the Rendobar API against the same source:
| Variant | Encode | Size | Cost |
|---|---|---|---|
| first frame, no -ss | 104 ms fastest | 5.5 KB | $0.0016 |
| -ss before -i | 137 ms | 50.1 KB | $0.0017 |
| -ss after -i | 144 ms | 50.1 KB | $0.0016 |
| thumbnail filter | 227 ms | 66.7 KB | $0.0018 |
| 320 px JPEG | 131 ms | 5.8 KB | $0.0016 |
| contact sheet, 5 frames | 142 ms | 23.2 KB | $0.0017 |
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.
| Variant | Dimensions | Bytes |
|---|---|---|
| Full frame at 3 s | 1280x720 | 51,307 |
| Scaled to 320 px wide | 320x180 | 5,970 |
| Contact sheet, 5 frames | 1600x180 | 23,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:
ffmpeg -ss 3 -i input.mp4 -frames:v 1 -vf scale=320:-2 -q:v 3 thumb.jpgThe 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:
ffmpeg -i input.mp4 -vf "fps=1,scale=320:-2,tile=5x1" -frames:v 1 -q:v 3 sheet.jpgRun 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.
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.
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.
