# Generate a video thumbnail when a file lands in S3

Canonical: https://rendobar.com/blog/video-thumbnail-s3-upload/
Author: Abdelrahman Essawy
Published: 2026-09-07
Updated: 2026-09-13

---

## Key takeaways

- Grabbing frame one is how thumbnails come out black. Our test clip fades in, and its first frame was a 5,620-byte JPEG of near darkness where the frame at 3 seconds was 51,307 bytes of picture.
- Putting -ss before or after -i changed nothing on a 5-second clip. Both produced the same 51,307-byte JPEG, byte for byte, so pick the placement for long files, where it matters.
- The thumbnail filter picks one representative frame per batch of 100, so with -frames:v 1 it only ever looks at the first batch. Seek past an intro before you let it choose.
- Size the thumbnail for its slot. A 320 px wide JPEG came to 5,970 bytes against 51,307 for the full 1280x720 frame, 8.6 times smaller.
- S3 delivers event notifications at least once, so key each job on the object and its ETag, and write thumbnails to a prefix the trigger does not watch.

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.

_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:

```bash
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:

_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.

_Frame one, 5,620 bytes._

_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".

```bash
# 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 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:

```bash
# 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:

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:

```bash
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:

```bash
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.

const rb = createClient({ apiKey: process.env.RENDOBAR_API_KEY });

      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"),
      });
    }
};`}
  curl={`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"
    }'`}
/>

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.

```ts
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](/blog/ffprobe-validate-uploads/) 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](/docs/storage) describes. For getting
the finished video itself into S3, see [save FFmpeg output to an S3
bucket](/blog/ffmpeg-output-to-s3/). For choosing between JPEG, WebP and AVIF once
the frame exists, see [AVIF vs WebP vs JPEG compared](/blog/avif-webp-jpeg-measured/).
