Frame Capture, Covers and BlurHash on AWS

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Baseline date: 2026-10-07. Covers and posters are produced from frames of the video, see video covers and thumbnails. On AWS, MediaConvert captures the frames during the transcode, and a small Lambda function computes the BlurHash when each frame lands in S3.

Frame capture with MediaConvert

A frame capture output writes still images (JPEG) from the video at a chosen rate. It is an output in a File output group, with the container RAW and the codec FRAME_CAPTURE, as in the second output group of the job on the MediaConvert page:

{
  "Name": "Covers",
  "OutputGroupSettings": {
    "Type": "FILE_GROUP_SETTINGS",
    "FileGroupSettings": { "Destination": "s3://bookshelf-media-111122223333/videos/42/covers/" }
  },
  "Outputs": [
    {
      "NameModifier": "_cover",
      "ContainerSettings": { "Container": "RAW" },
      "VideoDescription": {
        "Width": 1280, "Height": 720,
        "CodecSettings": {
          "Codec": "FRAME_CAPTURE",
          "FrameCaptureSettings": { "FramerateNumerator": 1, "FramerateDenominator": 10, "MaxCaptures": 6, "Quality": 80 }
        }
      }
    }
  ]
}

With a frame rate of 1 over 10, one frame is captured every 10 seconds, and MaxCaptures stops after six frames. Quality is the JPEG quality from 1 to 100. The files are numbered with a counter in the name (check the exact names in your bucket after the first job). The result is a set of candidate covers: show them in the uploader and let the user choose, or choose by a rule such as the second frame, as discussed in the backend page. Reference: frame capture outputs.

Frame capture grabs frames on a fixed schedule, so it does not pick the most representative frame the way FFmpeg’s thumbnail filter does, and it does not create sprite sheets. For a sprite sheet, capture frames at a regular interval (for example one every 10 seconds at 160 pixels wide), and tile them in a Lambda function with Pillow, or run FFmpeg in a container, see the pipeline page.

A BlurHash Lambda

Each cover that is written to S3 triggers a function that downloads it, computes the BlurHash and stores the result next to the image. (A tag is not an option: S3 tag values accept a limited set of characters, and BlurHash strings contain others.)

Build the layer

Pillow contains compiled code, so install its wheels for the Lambda platform (Amazon Linux, the function’s architecture) and publish them as a layer. The blurhash package (the halcy implementation) is pure Python and has no dependencies, which makes it easy to bundle. The woltapp blurhash-python package builds a C extension and has no prebuilt wheels, so it is harder to use in a layer.

mkdir -p layer/python
pip install --platform manylinux2014_x86_64 --implementation cp --python-version 3.13 \
  --only-binary=:all: --target layer/python Pillow
pip install --no-deps --target layer/python blurhash
(cd layer && zip -r ../blurhash-layer.zip python)
aws lambda publish-layer-version --layer-name blurhash-pillow \
  --zip-file fileb://blurhash-layer.zip --compatible-runtimes python3.13 --compatible-architectures x86_64

Use a Python version that Lambda currently supports and use the same one in the pip command and in the function. Reference: Lambda layers and layers for Python functions.

The function

import io
import json
from urllib.parse import unquote_plus

import blurhash
import boto3
from PIL import Image

s3 = boto3.client("s3")


def handler(event, context):
    for record in event["Records"]:
        bucket = record["s3"]["bucket"]["name"]
        key = unquote_plus(record["s3"]["object"]["key"])       # keys arrive URL-encoded
        if not key.lower().endswith(".jpg"):
            continue

        body = s3.get_object(Bucket=bucket, Key=key)["Body"].read()
        with Image.open(io.BytesIO(body)) as image:
            width, height = image.size
            small = image.convert("RGB")
            small.thumbnail((64, 64))                              # downscale before encoding
            w, h = small.size
            data = list(small.getdata())
            rows = [data[y * w:(y + 1) * w] for y in range(h)]     # [row][column] -> (r, g, b)
            hash_ = blurhash.encode(rows, components_x=4, components_y=3)

        s3.put_object(
            Bucket=bucket,
            Key=key.rsplit(".", 1)[0] + ".json",
            Body=json.dumps({"blurhash": hash_, "width": width, "height": height}).encode(),
            ContentType="application/json",
            CacheControl="public, max-age=31536000, immutable",
        )

The function is idempotent: running it twice for the same image writes the same JSON. Write the JSON file with a different suffix from the trigger’s (here .json versus .jpg), otherwise the function can trigger itself. Reference: using Lambda with Amazon S3 and the algorithm in the BlurHash page.

Wire the trigger

Give the function read access to the media bucket and write access to the same bucket (s3:GetObject and s3:PutObject on videos/*), allow S3 to invoke it, and configure the notification for the covers/ prefix:

aws lambda add-permission --function-name cover-blurhash --statement-id s3-invoke \
  --action lambda:InvokeFunction --principal s3.amazonaws.com \
  --source-arn arn:aws:s3:::bookshelf-media-111122223333 --source-account 111122223333

aws s3api put-bucket-notification-configuration --bucket bookshelf-media-111122223333 \
  --notification-configuration '{
    "LambdaFunctionConfigurations": [{
      "LambdaFunctionArn": "arn:aws:lambda:us-east-1:111122223333:function:cover-blurhash",
      "Events": ["s3:ObjectCreated:*"],
      "Filter": { "Key": { "FilterRules": [
        { "Name": "prefix", "Value": "videos/" },
        { "Name": "suffix", "Value": ".jpg" } ] } }
    }]
  }'

The Lambda creation and the S3 event wiring follow the Bookshelf thumbnailer function, see Lambda: event-driven apps. For event delivery with retries and dead-letter handling, send the S3 events to an SQS queue and have Lambda read it, see Messaging and Integration. Then the mediaId and the hash go to the database, which the media API returns to clients.