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.