The Horse in Motion and FFmpeg Gotchas - Part 2 Serverless on AWS Lambda

python
aws
A hands-on guide to deploying a Python-based FFmpeg workflow on AWS Lambda using SAM, covering layer creation, binary selection, and executing subprocesses in a serverless environment.
Published

July 30, 2025

Introduction

In Part 1 of this series, we successfully used FFmpeg to create the classic “Horse in Motion” video from a sequence of still images. We did it all within the comfortable and feature-rich environment of a Google Colab notebook. But personal notebooks are for development and experimentation. To build a real-world application, we need to run our code in a scalable, automated environment.

Welcome to Part 2, where we take our show on the road—to the cloud! Our goal is to run the exact same process inside an AWS Lambda function.

This move introduces a whole new set of “gotchas.” We can’t just apt-get install ffmpeg. Lambda functions run in a tightly controlled environment. We need to package our own FFmpeg binary, deal with file system limitations, and ensure our chosen build has the features we need.

In this post, we’ll walk through:

  • Setting up a basic serverless application with the AWS SAM CLI.
  • The challenge of finding the right static FFmpeg build.
  • Creating an AWS Lambda Layer to make FFmpeg available to our function.
  • Modifying our code to execute FFmpeg, download images, and save the final video to an S3 bucket.

Let’s dive in and see what it takes to get our horse running in the serverless world.

The Scaffolding: A Basic SAM Application

Before we even think about FFmpeg, let’s build the basic structure of our serverless application. We’ll use the AWS Serverless Application Model (SAM), a framework that makes defining and deploying serverless applications much easier. Think of it as a blueprint for our Lambda function and its related resources, like S3 buckets and permissions.

Our first step is to create a simple Lambda function that can write a file to an S3 bucket. This proves that our basic plumbing (permissions, environment variables, S3 access) is working correctly.

Here’s our initial template.yaml file:

Show the code
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >
  ffmpeg-lambda-demo
  A simple Lambda function to process video with FFmpeg.

Globals:
  Function:
    Timeout: 60 # Set a longer timeout for video processing
    MemorySize: 512 # Provide enough memory

Resources:
  # The S3 bucket where our output videos will be stored
  OutputBucket:
    Type: AWS::S3::Bucket

  # The Lambda function that will run our code
  FFmpegFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.lambda_handler
      Runtime: python3.13
      Architectures:
        - x86_64

      # Pass the bucket name to the function as an environment variable
      Environment:
        Variables:
          OUTPUT_BUCKET: !Ref OutputBucket

      # Give the function permission to write to the S3 bucket
      Policies:
        - S3WritePolicy:
            BucketName: !Ref OutputBucket

Outputs:
  OutputBucketName:
    Description: "Name of the S3 bucket for output videos"
    Value: !Ref OutputBucket

And here is the corresponding Python code in src/app.py. For now, it just creates a simple text file and uploads it to S3.

Show the code
# src/app.py
import os
import boto3

# Get the S3 bucket name from the environment variables
OUTPUT_BUCKET = os.environ.get("OUTPUT_BUCKET")
s3_client = boto3.client("s3")


def lambda_handler(event, context):
    """
    A simple handler to test writing a file to S3.
    """
    try:
        file_content = "Hello from Lambda! The connection to S3 is working."
        file_path = "/tmp/test.txt"

        # Lambda functions can only write to the /tmp directory
        with open(file_path, "w") as f:
            f.write(file_content)

        # Upload the file to our S3 bucket
        s3_client.upload_file(file_path, OUTPUT_BUCKET, "test-output.txt")

        return {
            "statusCode": 200,
            "body": "Successfully created and uploaded test.txt to S3.",
        }

    except Exception as e:
        print(e)
        raise e

Finally, our src/requirements.txt only needs boto3, which is the AWS SDK for Python.

boto3

To deploy this, you can run the standard SAM commands from your terminal:

# Build the application
sam build

# Deploy it to your AWS account with a guided process
sam deploy --guided

SAM deploy

SAM deploy

After deployment, you can test the function from the AWS Console. If it runs successfully, you’ll find a test-output.txt file in the newly created S3 bucket.

Test Lambda function

Test Lambda function

S3 bucket output file

S3 bucket output file

Now that our basic infrastructure is in place, it’s time to tackle the main challenge: getting FFmpeg to run.

Code checkpoint

All the code for this post till this point is available in the Github repo horse-in-motion-ffmpeg-gotchas-part-2 (1c09f)

The First Hurdle: Finding the Right FFmpeg Build

In a standard Linux environment, installing FFmpeg is as simple as sudo apt-get install ffmpeg. In AWS Lambda, we don’t have that luxury. We need a static binary — a single, self-contained executable file that we can package with our code. This binary needs to have all its dependencies compiled into it, so it can run anywhere without needing external libraries.

Attempt #1: The John Van Sickle Build

A very popular and reliable source for static FFmpeg builds is John Van Sickle’s website. These builds are fantastic and widely used. Let’s download one and see if it fits our needs.

We’ll grab the amd64 build, as our Lambda function is configured for the x86_64 architecture.

# Download and extract the build
!wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
--2025-07-30 12:18:11--  https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
Resolving johnvansickle.com (johnvansickle.com)... 107.180.57.212
Connecting to johnvansickle.com (johnvansickle.com)|107.180.57.212|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 41888096 (40M) [application/x-xz]
Saving to: ‘ffmpeg-release-amd64-static.tar.xz’

ffmpeg-release-amd6 100%[===================>]  39.95M  11.9MB/s    in 3.4s    

2025-07-30 12:18:15 (11.9 MB/s) - ‘ffmpeg-release-amd64-static.tar.xz’ saved [41888096/41888096]
!tar -xf ffmpeg-release-amd64-static.tar.xz

Now, let’s inspect this build, just like we did in Part 1. We need two things:

  1. An H.264 encoder (like libx264).
  2. The drawtext filter for adding our title.

First, let’s check for libx264 by listing the available codecs.

!/content/ffmpeg-7.0.2-amd64-static/ffmpeg -codecs | grep libx264
ffmpeg version 7.0.2-static https://johnvansickle.com/ffmpeg/  Copyright (c) 2000-2024 the FFmpeg developers
  built with gcc 8 (Debian 8.3.0-6)
  configuration: --enable-gpl --enable-version3 --enable-static --disable-debug --disable-ffplay --disable-indev=sndio --disable-outdev=sndio --cc=gcc --enable-fontconfig --enable-frei0r --enable-gnutls --enable-gmp --enable-libgme --enable-gray --enable-libaom --enable-libfribidi --enable-libass --enable-libvmaf --enable-libfreetype --enable-libmp3lame --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenjpeg --enable-librubberband --enable-libsoxr --enable-libspeex --enable-libsrt --enable-libvorbis --enable-libopus --enable-libtheora --enable-libvidstab --enable-libvo-amrwbenc --enable-libvpx --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxml2 --enable-libdav1d --enable-libxvid --enable-libzvbi --enable-libzimg
  libavutil      59.  8.100 / 59.  8.100
  libavcodec     61.  3.100 / 61.  3.100
  libavformat    61.  1.100 / 61.  1.100
  libavdevice    61.  1.100 / 61.  1.100
  libavfilter    10.  1.100 / 10.  1.100
  libswscale      8.  1.100 /  8.  1.100
  libswresample   5.  1.100 /  5.  1.100
  libpostproc    58.  1.100 / 58.  1.100
 DEV.LS h264                 H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 (decoders: h264 h264_v4l2m2m) (encoders: libx264 libx264rgb h264_v4l2m2m)

Great news! We see (encoders: libx264 ...), so our preferred encoder is available.

Now for the critical test: does this build support the drawtext filter? As we learned, this filter depends on the libfreetype library being enabled during compilation (check the ffmpeg drawtext docs). Let’s check the build configuration.

!/content/ffmpeg-7.0.2-amd64-static/ffmpeg -buildconf
ffmpeg version 7.0.2-static https://johnvansickle.com/ffmpeg/  Copyright (c) 2000-2024 the FFmpeg developers
  built with gcc 8 (Debian 8.3.0-6)
  configuration: --enable-gpl --enable-version3 --enable-static --disable-debug --disable-ffplay --disable-indev=sndio --disable-outdev=sndio --cc=gcc --enable-fontconfig --enable-frei0r --enable-gnutls --enable-gmp --enable-libgme --enable-gray --enable-libaom --enable-libfribidi --enable-libass --enable-libvmaf --enable-libfreetype --enable-libmp3lame --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenjpeg --enable-librubberband --enable-libsoxr --enable-libspeex --enable-libsrt --enable-libvorbis --enable-libopus --enable-libtheora --enable-libvidstab --enable-libvo-amrwbenc --enable-libvpx --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxml2 --enable-libdav1d --enable-libxvid --enable-libzvbi --enable-libzimg
  libavutil      59.  8.100 / 59.  8.100
  libavcodec     61.  3.100 / 61.  3.100
  libavformat    61.  1.100 / 61.  1.100
  libavdevice    61.  1.100 / 61.  1.100
  libavfilter    10.  1.100 / 10.  1.100
  libswscale      8.  1.100 /  8.  1.100
  libswresample   5.  1.100 /  5.  1.100
  libpostproc    58.  1.100 / 58.  1.100

  configuration:
    --enable-gpl
    --enable-version3
    --enable-static
    --disable-debug
    --disable-ffplay
    --disable-indev=sndio
    --disable-outdev=sndio
    --cc=gcc
    --enable-fontconfig
    --enable-frei0r
    --enable-gnutls
    --enable-gmp
    --enable-libgme
    --enable-gray
    --enable-libaom
    --enable-libfribidi
    --enable-libass
    --enable-libvmaf
    --enable-libfreetype
    --enable-libmp3lame
    --enable-libopencore-amrnb
    --enable-libopencore-amrwb
    --enable-libopenjpeg
    --enable-librubberband
    --enable-libsoxr
    --enable-libspeex
    --enable-libsrt
    --enable-libvorbis
    --enable-libopus
    --enable-libtheora
    --enable-libvidstab
    --enable-libvo-amrwbenc
    --enable-libvpx
    --enable-libwebp
    --enable-libx264
    --enable-libx265
    --enable-libxml2
    --enable-libdav1d
    --enable-libxvid
    --enable-libzvbi
    --enable-libzimg

If you scan through the long list of flags in the output, you’ll see something encouraging:

--enable-libfribidi
--enable-libfreetype

It looks like we’re in luck! --enable-libfreetype is present, which is the primary dependency for the drawtext filter. So, our filter should be available, right?

Let’s confirm by explicitly searching for drawtext in the list of available filters.

!/content/ffmpeg-7.0.2-amd64-static/ffmpeg -filters | grep drawtext
ffmpeg version 7.0.2-static https://johnvansickle.com/ffmpeg/  Copyright (c) 2000-2024 the FFmpeg developers
  built with gcc 8 (Debian 8.3.0-6)
  configuration: --enable-gpl --enable-version3 --enable-static --disable-debug --disable-ffplay --disable-indev=sndio --disable-outdev=sndio --cc=gcc --enable-fontconfig --enable-frei0r --enable-gnutls --enable-gmp --enable-libgme --enable-gray --enable-libaom --enable-libfribidi --enable-libass --enable-libvmaf --enable-libfreetype --enable-libmp3lame --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenjpeg --enable-librubberband --enable-libsoxr --enable-libspeex --enable-libsrt --enable-libvorbis --enable-libopus --enable-libtheora --enable-libvidstab --enable-libvo-amrwbenc --enable-libvpx --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxml2 --enable-libdav1d --enable-libxvid --enable-libzvbi --enable-libzimg
  libavutil      59.  8.100 / 59.  8.100
  libavcodec     61.  3.100 / 61.  3.100
  libavformat    61.  1.100 / 61.  1.100
  libavdevice    61.  1.100 / 61.  1.100
  libavfilter    10.  1.100 / 10.  1.100
  libswscale      8.  1.100 /  8.  1.100
  libswresample   5.  1.100 /  5.  1.100
  libpostproc    58.  1.100 / 58.  1.100

This is puzzling. The output is empty. Even though the libfreetype dependency is enabled, the drawtext filter itself is not included in this build. This is a perfect example of a deep FFmpeg gotcha: a build can have the libraries needed for a feature, but the feature itself might still be disabled through other configuration flags during the compile process (like --disable-filters=drawtext). The reasons can vary, from reducing binary size to avoiding potential licensing conflicts.

So, despite getting our hopes up, this build won’t work for us. The key takeaway remains the same: you must verify that the specific codec and filter you need are present in your chosen build.

It’s time to continue our search for a different static build that has both an H.264 encoder and the drawtext filter enabled.

Attempt #2: A Build That Strikes the Right Balance

Our search for the perfect FFmpeg binary leads us to another highly-regarded source: the automated builds from GitHub user BtbN, which are linked from the official FFmpeg download page. These builds come in various flavors, so we have a better chance of finding one that fits our specific needs.

We’ll try the ffmpeg-n7.1-latest-linux64-lgpl-7.1.tar.xz version.

!wget https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n7.1-latest-linux64-lgpl-7.1.tar.xz
--2025-07-30 12:47:03--  https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n7.1-latest-linux64-lgpl-7.1.tar.xz
Resolving github.com (github.com)... 20.27.177.113
Connecting to github.com (github.com)|20.27.177.113|:443... connected.
HTTP request sent, awaiting response... 302 Found
Location: https://release-assets.githubusercontent.com/github-production-release-asset/292087234/75ce9a3f-bd4b-4ae9-bb05-385c7b8da63b?sp=r&sv=2018-11-09&sr=b&spr=https&se=2025-07-30T13%3A47%3A07Z&rscd=attachment%3B+filename%3Dffmpeg-n7.1-latest-linux64-lgpl-7.1.tar.xz&rsct=application%2Foctet-stream&skoid=96c2d410-5711-43a1-aedd-ab1947aa7ab0&sktid=398a6654-997b-47e9-b12b-9515b896b4de&skt=2025-07-30T12%3A47%3A03Z&ske=2025-07-30T13%3A47%3A07Z&sks=b&skv=2018-11-09&sig=bGeoLsQzeAvxofmrtVK39Na%2BVyj%2BO5mxz2M0FccEBOI%3D&jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmVsZWFzZS1hc3NldHMuZ2l0aHVidXNlcmNvbnRlbnQuY29tIiwia2V5Ijoia2V5MSIsImV4cCI6MTc1Mzg3OTkyNCwibmJmIjoxNzUzODc5NjI0LCJwYXRoIjoicmVsZWFzZWFzc2V0cHJvZHVjdGlvbi5ibG9iLmNvcmUud2luZG93cy5uZXQifQ.pS2FQvAc99cW6JgwYOMMf-u25upRiKdaFW3lSaJ4Wew&response-content-disposition=attachment%3B%20filename%3Dffmpeg-n7.1-latest-linux64-lgpl-7.1.tar.xz&response-content-type=application%2Foctet-stream [following]
--2025-07-30 12:47:04--  https://release-assets.githubusercontent.com/github-production-release-asset/292087234/75ce9a3f-bd4b-4ae9-bb05-385c7b8da63b?sp=r&sv=2018-11-09&sr=b&spr=https&se=2025-07-30T13%3A47%3A07Z&rscd=attachment%3B+filename%3Dffmpeg-n7.1-latest-linux64-lgpl-7.1.tar.xz&rsct=application%2Foctet-stream&skoid=96c2d410-5711-43a1-aedd-ab1947aa7ab0&sktid=398a6654-997b-47e9-b12b-9515b896b4de&skt=2025-07-30T12%3A47%3A03Z&ske=2025-07-30T13%3A47%3A07Z&sks=b&skv=2018-11-09&sig=bGeoLsQzeAvxofmrtVK39Na%2BVyj%2BO5mxz2M0FccEBOI%3D&jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmVsZWFzZS1hc3NldHMuZ2l0aHVidXNlcmNvbnRlbnQuY29tIiwia2V5Ijoia2V5MSIsImV4cCI6MTc1Mzg3OTkyNCwibmJmIjoxNzUzODc5NjI0LCJwYXRoIjoicmVsZWFzZWFzc2V0cHJvZHVjdGlvbi5ibG9iLmNvcmUud2luZG93cy5uZXQifQ.pS2FQvAc99cW6JgwYOMMf-u25upRiKdaFW3lSaJ4Wew&response-content-disposition=attachment%3B%20filename%3Dffmpeg-n7.1-latest-linux64-lgpl-7.1.tar.xz&response-content-type=application%2Foctet-stream
Resolving release-assets.githubusercontent.com (release-assets.githubusercontent.com)... 185.199.108.133, 185.199.109.133, 185.199.111.133, ...
Connecting to release-assets.githubusercontent.com (release-assets.githubusercontent.com)|185.199.108.133|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 105102440 (100M) [application/octet-stream]
Saving to: ‘ffmpeg-n7.1-latest-linux64-lgpl-7.1.tar.xz’

ffmpeg-n7.1-latest- 100%[===================>] 100.23M  32.5MB/s    in 3.1s    

2025-07-30 12:47:07 (32.5 MB/s) - ‘ffmpeg-n7.1-latest-linux64-lgpl-7.1.tar.xz’ saved [105102440/105102440]
!tar -xf ffmpeg-n7.1-latest-linux64-lgpl-7.1.tar.xz

Let’s repeat our inspection process. First, the most important question: does it have our drawtext filter?

# The ffmpeg binary is in the 'bin' subdirectory
!/content/ffmpeg-n7.1-latest-linux64-lgpl-7.1/bin/ffmpeg -filters | grep drawtext
ffmpeg version n7.1.1-56-gc2184b65d2-20250729 Copyright (c) 2000-2025 the FFmpeg developers
  built with gcc 15.1.0 (crosstool-NG 1.27.0.42_35c1e72)
  configuration: --prefix=/ffbuild/prefix --pkg-config-flags=--static --pkg-config=pkg-config --cross-prefix=x86_64-ffbuild-linux-gnu- --arch=x86_64 --target-os=linux --enable-version3 --disable-debug --enable-iconv --enable-zlib --enable-libfribidi --enable-gmp --enable-libxml2 --enable-openssl --enable-lzma --enable-fontconfig --enable-libharfbuzz --enable-libfreetype --enable-libvorbis --enable-opencl --enable-libpulse --enable-libvmaf --enable-libxcb --enable-xlib --enable-amf --enable-libaom --enable-libaribb24 --disable-avisynth --enable-chromaprint --enable-libdav1d --disable-libdavs2 --disable-libdvdread --disable-libdvdnav --disable-libfdk-aac --enable-ffnvcodec --enable-cuda-llvm --disable-frei0r --enable-libgme --enable-libkvazaar --enable-libaribcaption --enable-libass --enable-libbluray --enable-libjxl --enable-libmp3lame --enable-libopus --enable-librist --enable-libssh --enable-libtheora --enable-libvpx --enable-libwebp --enable-libzmq --enable-lv2 --enable-libvpl --enable-openal --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenh264 --enable-libopenjpeg --enable-libopenmpt --enable-librav1e --disable-librubberband --disable-schannel --enable-sdl2 --enable-libsnappy --enable-libsoxr --enable-libsrt --enable-libsvtav1 --enable-libtwolame --enable-libuavs3d --enable-libdrm --enable-vaapi --disable-libvidstab --enable-vulkan --enable-libshaderc --enable-libplacebo --enable-libvvenc --disable-libx264 --disable-libx265 --disable-libxavs2 --disable-libxvid --enable-libzimg --enable-libzvbi --extra-cflags=-DLIBTWOLAME_STATIC --extra-cxxflags= --extra-libs='-ldl -lgomp' --extra-ldflags=-pthread --extra-ldexeflags=-pie --cc=x86_64-ffbuild-linux-gnu-gcc --cxx=x86_64-ffbuild-linux-gnu-g++ --ar=x86_64-ffbuild-linux-gnu-gcc-ar --ranlib=x86_64-ffbuild-linux-gnu-gcc-ranlib --nm=x86_64-ffbuild-linux-gnu-gcc-nm --extra-version=20250729
  libavutil      59. 39.100 / 59. 39.100
  libavcodec     61. 19.101 / 61. 19.101
  libavformat    61.  7.100 / 61.  7.100
  libavdevice    61.  3.100 / 61.  3.100
  libavfilter    10.  4.100 / 10.  4.100
  libswscale      8.  3.100 /  8.  3.100
  libswresample   5.  3.100 /  5.  3.100
 T.C drawtext          V->V       Draw text on top of video frames using libfreetype library.

Success! The drawtext filter is present and accounted for. This is a huge step forward.

Now, let’s check for our preferred H.264 video encoder, libx264.

!/content/ffmpeg-n7.1-latest-linux64-lgpl-7.1/bin/ffmpeg -codecs | grep libx264
ffmpeg version n7.1.1-56-gc2184b65d2-20250729 Copyright (c) 2000-2025 the FFmpeg developers
  built with gcc 15.1.0 (crosstool-NG 1.27.0.42_35c1e72)
  configuration: --prefix=/ffbuild/prefix --pkg-config-flags=--static --pkg-config=pkg-config --cross-prefix=x86_64-ffbuild-linux-gnu- --arch=x86_64 --target-os=linux --enable-version3 --disable-debug --enable-iconv --enable-zlib --enable-libfribidi --enable-gmp --enable-libxml2 --enable-openssl --enable-lzma --enable-fontconfig --enable-libharfbuzz --enable-libfreetype --enable-libvorbis --enable-opencl --enable-libpulse --enable-libvmaf --enable-libxcb --enable-xlib --enable-amf --enable-libaom --enable-libaribb24 --disable-avisynth --enable-chromaprint --enable-libdav1d --disable-libdavs2 --disable-libdvdread --disable-libdvdnav --disable-libfdk-aac --enable-ffnvcodec --enable-cuda-llvm --disable-frei0r --enable-libgme --enable-libkvazaar --enable-libaribcaption --enable-libass --enable-libbluray --enable-libjxl --enable-libmp3lame --enable-libopus --enable-librist --enable-libssh --enable-libtheora --enable-libvpx --enable-libwebp --enable-libzmq --enable-lv2 --enable-libvpl --enable-openal --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenh264 --enable-libopenjpeg --enable-libopenmpt --enable-librav1e --disable-librubberband --disable-schannel --enable-sdl2 --enable-libsnappy --enable-libsoxr --enable-libsrt --enable-libsvtav1 --enable-libtwolame --enable-libuavs3d --enable-libdrm --enable-vaapi --disable-libvidstab --enable-vulkan --enable-libshaderc --enable-libplacebo --enable-libvvenc --disable-libx264 --disable-libx265 --disable-libxavs2 --disable-libxvid --enable-libzimg --enable-libzvbi --extra-cflags=-DLIBTWOLAME_STATIC --extra-cxxflags= --extra-libs='-ldl -lgomp' --extra-ldflags=-pthread --extra-ldexeflags=-pie --cc=x86_64-ffbuild-linux-gnu-gcc --cxx=x86_64-ffbuild-linux-gnu-g++ --ar=x86_64-ffbuild-linux-gnu-gcc-ar --ranlib=x86_64-ffbuild-linux-gnu-gcc-ranlib --nm=x86_64-ffbuild-linux-gnu-gcc-nm --extra-version=20250729
  libavutil      59. 39.100 / 59. 39.100
  libavcodec     61. 19.101 / 61. 19.101
  libavformat    61.  7.100 / 61.  7.100
  libavdevice    61.  3.100 / 61.  3.100
  libavfilter    10.  4.100 / 10.  4.100
  libswscale      8.  3.100 /  8.  3.100
  libswresample   5.  3.100 /  5.  3.100

And just when we thought we were in the clear, another gotcha appears. This build has drawtext, but it does not include libx264. This is often due to licensing. libx264 is licensed under GPL, and distributing a build with it can have certain legal implications.

So, are we stuck? Not at all. This is where knowing about alternatives pays off. If we search for H.264 encoders in this build, we find another option:

!/content/ffmpeg-n7.1-latest-linux64-lgpl-7.1/bin/ffmpeg -codecs | grep h264
ffmpeg version n7.1.1-56-gc2184b65d2-20250729 Copyright (c) 2000-2025 the FFmpeg developers
  built with gcc 15.1.0 (crosstool-NG 1.27.0.42_35c1e72)
  configuration: --prefix=/ffbuild/prefix --pkg-config-flags=--static --pkg-config=pkg-config --cross-prefix=x86_64-ffbuild-linux-gnu- --arch=x86_64 --target-os=linux --enable-version3 --disable-debug --enable-iconv --enable-zlib --enable-libfribidi --enable-gmp --enable-libxml2 --enable-openssl --enable-lzma --enable-fontconfig --enable-libharfbuzz --enable-libfreetype --enable-libvorbis --enable-opencl --enable-libpulse --enable-libvmaf --enable-libxcb --enable-xlib --enable-amf --enable-libaom --enable-libaribb24 --disable-avisynth --enable-chromaprint --enable-libdav1d --disable-libdavs2 --disable-libdvdread --disable-libdvdnav --disable-libfdk-aac --enable-ffnvcodec --enable-cuda-llvm --disable-frei0r --enable-libgme --enable-libkvazaar --enable-libaribcaption --enable-libass --enable-libbluray --enable-libjxl --enable-libmp3lame --enable-libopus --enable-librist --enable-libssh --enable-libtheora --enable-libvpx --enable-libwebp --enable-libzmq --enable-lv2 --enable-libvpl --enable-openal --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenh264 --enable-libopenjpeg --enable-libopenmpt --enable-librav1e --disable-librubberband --disable-schannel --enable-sdl2 --enable-libsnappy --enable-libsoxr --enable-libsrt --enable-libsvtav1 --enable-libtwolame --enable-libuavs3d --enable-libdrm --enable-vaapi --disable-libvidstab --enable-vulkan --enable-libshaderc --enable-libplacebo --enable-libvvenc --disable-libx264 --disable-libx265 --disable-libxavs2 --disable-libxvid --enable-libzimg --enable-libzvbi --extra-cflags=-DLIBTWOLAME_STATIC --extra-cxxflags= --extra-libs='-ldl -lgomp' --extra-ldflags=-pthread --extra-ldexeflags=-pie --cc=x86_64-ffbuild-linux-gnu-gcc --cxx=x86_64-ffbuild-linux-gnu-g++ --ar=x86_64-ffbuild-linux-gnu-gcc-ar --ranlib=x86_64-ffbuild-linux-gnu-gcc-ranlib --nm=x86_64-ffbuild-linux-gnu-gcc-nm --extra-version=20250729
  libavutil      59. 39.100 / 59. 39.100
  libavcodec     61. 19.101 / 61. 19.101
  libavformat    61.  7.100 / 61.  7.100
  libavdevice    61.  3.100 / 61.  3.100
  libavfilter    10.  4.100 / 10.  4.100
  libswscale      8.  3.100 /  8.  3.100
  libswresample   5.  3.100 /  5.  3.100
 DEV.LS h264                 H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 (decoders: h264 h264_v4l2m2m h264_qsv libopenh264 h264_cuvid) (encoders: libopenh264 h264_amf h264_nvenc h264_qsv h264_v4l2m2m h264_vaapi h264_vulkan)

This build includes libopenh264, an open-source H.264 encoder provided by Cisco. While libx264 is often considered the highest-quality software encoder, libopenh264 is more than capable for most use cases, including ours. Its more permissive license (BSD) makes it a popular choice for distributable builds.

We have a winner! This build strikes the perfect balance for our needs:

  • It’s a static binary, perfect for AWS Lambda.
  • It includes the essential drawtext filter.
  • It provides a solid H.264 encoder, libopenh264.

Now that we’ve found our champion binary, it’s time to package it up as a Lambda Layer and put it to work.

Putting It All Together: Building the Layer and Deploying the Function

We’ve found our FFmpeg binary. Now it’s time to integrate it into our serverless application. This involves two main steps: packaging the binary into a Lambda Layer and updating our Python code to use it.

Step 1: Create the FFmpeg Lambda Layer

A Lambda Layer is a .zip file archive that can contain additional code or data. By packaging FFmpeg as a layer, we can keep it separate from our function code. This makes our deployment package smaller and our project more organized.

Lambda has a specific directory structure it expects for layers. For executables, they need to be placed in a bin directory inside the zip file.

Let’s create this structure and package our chosen FFmpeg binary:

# Create the directory structure for the layer
!mkdir -p ffmpeg-layer/bin
# Copy the FFmpeg binary we downloaded into the correct location
!cp /content/ffmpeg-n7.1-latest-linux64-lgpl-7.1/bin/ffmpeg ffmpeg-layer/bin/
# Now, create the zip archive for the layer
# Navigate into the layer directory to get the zip structure right
%cd ffmpeg-layer
/content/ffmpeg-layer
# verify that we are in the correct folder (ffmpeg-layer)
!pwd
/content/ffmpeg-layer
# Now, zip the CONTENTS of the current directory (which is 'ffmpeg-layer')
# The '*' ensures you select all files/folders directly within ffmpeg-layer
# The -D flag helps to not store directory entries (sometimes helps with cleaner zips)
!zip -r -D ../ffmpeg-layer.zip *
  adding: bin/ffmpeg (deflated 58%)

You should now have a ffmpeg-layer.zip file in your project’s root directory. This is our Lambda Layer, ready to be deployed.

Step 2: Update the SAM Template

Next, we need to tell our template.yaml file about this new layer and attach it to our function. We also need to give our function a bit more memory and a longer timeout, as video processing is resource-intensive.

Here are the key additions to template.yaml:

Show the code
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >
  ffmpeg-lambda-demo
  A simple Lambda function to process video with FFmpeg.

Globals:
  Function:
    Timeout: 90 # Increased timeout for video processing
    MemorySize: 1024 # Increased memory for FFmpeg

Resources:
  # The S3 bucket where our output videos will be stored
  OutputBucket:
    Type: AWS::S3::Bucket

    # Define the Lambda Layer
  FFmpegLayer:
    Type: AWS::Serverless::LayerVersion
    Properties:
      LayerName: ffmpeg-layer
      Description: FFmpeg static build for video processing
      ContentUri: layers/ffmpeg-layer.zip # Points to our local zip file
      CompatibleRuntimes:
        - python3.12
        - python3.13

  # The Lambda function that will run our code
  FFmpegFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.lambda_handler
      Runtime: python3.13
      Architectures:
        - x86_64

      # Pass the bucket name to the function as an environment variable
      Environment:
        Variables:
          OUTPUT_BUCKET: !Ref OutputBucket

      # Give the function permission to write to the S3 bucket
      Policies:
        - S3WritePolicy:
            BucketName: !Ref OutputBucket

      # Attach the layer to the function
      Layers:
        - !Ref FFmpegLayer

Outputs:
  OutputBucketName:
    Description: "Name of the S3 bucket for output videos"
    Value: !Ref OutputBucket

Step 3: Update the Lambda Function Code

This is the final piece of the puzzle. We need to update our src/app.py to perform the full workflow:

  1. Download the horse frames into the /tmp directory (the only writable location in a Lambda function).
  2. Use Python’s subprocess module to call the FFmpeg binary.
  3. Upload the resulting video from /tmp to our S3 bucket.
Important Note

To use the drawtext filter, FFmpeg needs a font file. Lambda environment does not have any so you need to provide it. Download a font like Liberation Sans, and place LiberationSans-Regular.ttf inside a new src/fonts/ directory. We will package this font with our function code.

Here is the final src/app.py:

Show the code
# src/app.py
import os
import boto3
import requests
import subprocess

OUTPUT_BUCKET = os.environ.get("OUTPUT_BUCKET")
s3_client = boto3.client("s3")
TMP_DIR = "/tmp"


def download_frames():
    """Downloads the 15 horse frames into /tmp/video_frames"""
    frames_dir = os.path.join(TMP_DIR, "video_frames")
    os.makedirs(frames_dir, exist_ok=True)

    base_url = "https://raw.githubusercontent.com/hassaanbinaslam/myblog/5c15e72dde03112c5c8dea177bfed7c835aca399/posts/images/2025-07-28-the-horse-in-motion-ffmpeg-gotchas-part-1/video_frames"

    for i in range(1, 16):
        frame_number = str(i).zfill(2)
        image_url = f"{base_url}/frame{frame_number}.png"
        response = requests.get(image_url)
        if response.status_code == 200:
            with open(os.path.join(frames_dir, f"frame{frame_number}.png"), "wb") as f:
                f.write(response.content)

    print("All frames downloaded.")
    # print("List the files names downloaded")
    # print(os.listdir(frames_dir))


def lambda_handler(event, context):
    try:
        print("Starting video creation process...")
        download_frames()

        # Paths in the Lambda's writable /tmp directory
        input_path = os.path.join(TMP_DIR, "video_frames/frame%02d.png")
        output_path = os.path.join(TMP_DIR, "output.mp4")

        # Path to the font file packaged with our function
        font_file = "./fonts/LiberationSans-Regular.ttf"

        # When a layer is used, its contents are available in the /opt directory.
        # Our FFmpeg binary is therefore at /opt/bin/ffmpeg.
        ffmpeg_cmd = [
            "/opt/bin/ffmpeg",
            "-stream_loop",
            "-1",
            "-framerate",
            "1.5",
            "-i",
            input_path,
            "-vf",
            f"drawtext=fontfile={font_file}:text='The Horse in Motion and FFmpeg Gotchas Part 2':fontcolor=white:fontsize=13:box=1:boxcolor=black@0.8:boxborderw=5:x=(w-text_w)/2:y=(h-text_h)/2:enable='between(t,0,10)'",
            "-c:v",
            "libopenh264",  # Use the alternate H.264 encoder
            "-r",
            "30",
            "-pix_fmt",
            "yuv420p",
            "-t",
            "40",
            output_path,
        ]

        print(f"Running FFmpeg command: {' '.join(ffmpeg_cmd)}")

        # Execute the FFmpeg command
        result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True, check=True)

        print("FFmpeg stdout:", result.stdout)
        print("FFmpeg stderr:", result.stderr)

        print(f"FFmpeg command successful. Uploading {output_path} to S3.")

        s3_client.upload_file(output_path, OUTPUT_BUCKET, "horse-in-motion.mp4")

        return {
            "statusCode": 200,
            "body": "Successfully created and uploaded horse-in-motion.mp4 to S3.",
        }

    except subprocess.CalledProcessError as e:
        print("FFmpeg failed to execute.")
        print("Return code:", e.returncode)
        print("stdout:", e.stdout)
        print("stderr:", e.stderr)
        raise e
    except Exception as e:
        print(e)
        raise e

Finally, make sure your src/requirements.txt file also includes requests:

boto3
requests

Step 4: Deploy and Test

With all the pieces in place, we can deploy our application.

# Build the application, including the layer and function code
sam build

# Deploy the changes to your AWS account
sam deploy

Once the deployment is complete, navigate to the AWS Lambda console, find the function, and invoke it with a test event. Monitor the logs in CloudWatch. If everything works as expected, you will see the logs from the print statements, and a new file named horse-in-motion.mp4 will appear in your S3 bucket!

Test Lambda function

Test Lambda function

S3 bucket output file

S3 bucket output file
Code checkpoint

All the code for this post till this point is available in the Github repo horse-in-motion-ffmpeg-gotchas-part-2 (d756d)

Conclusion

Our serverless journey is complete! We successfully migrated our FFmpeg process from a local notebook to a scalable AWS Lambda function. Along the way, we navigated some of the most common real-world gotchas of working with FFmpeg in a constrained environment:

  • Finding the Right Build: We learned that not all static builds are created equal and that verifying the presence of specific codecs and filters is a critical first step.
  • Managing Dependencies: We discovered that a build might have one feature we need (drawtext) but lack another (libx264), forcing us to adapt and use alternatives like libopenh264.
  • Lambda Environment Constraints: We saw the importance of using the /tmp directory for file operations and learned how to package and access binaries and other assets using Lambda Layers.

By packaging FFmpeg as a layer, you now have a reusable, serverless video processing engine that you can use to build powerful on-demand media applications.