#!/usr/bin/env bash
# Build + install ffmpeg. Four callers:
# * Linux manylinux_2_28 container (RHEL 8 baseline) — RHEL 8's
# repos don't ship ffmpeg, source-build is required.
# * macOS x86_64 Rosetta build — brew bottles target macOS 14+,
# which fails our 10.15 deployment-target floor, so we
# source-build with MACOSX_DEPLOYMENT_TARGET=10.15 in env. clang
# reads $MACOSX_DEPLOYMENT_TARGET and stamps LC_BUILD_VERSION
# minos on every produced dylib.
# * macOS arm64 native build — brew's ffmpeg formula is a GPL
# build (--enable-gpl plus x264/x265/SvtAv1Enc/lame/rubberband),
# so bundling its dylibs made the macos-arm64 prebuilt archive
# GPL-encumbered for a library that only ever decodes. Source-
# building here keeps every shipped lane on the same LGPL-2.1,
# decoder-only ffmpeg (and drops the encoder chain from the
# bundle entirely).
# * Windows MSYS2 {UCRT64,CLANGARM64} — for exactly the same
# licensing reason as macOS arm64: MSYS2's mingw-w64-*-ffmpeg is
# a `--enable-gpl --enable-version3` build carrying x264, x265,
# SvtAv1Enc, lame, rubberband and friends, and bundle-dll swept
# that whole encoder tree into the shipped .zip. Source-building
# here puts Windows on the identical LGPL-2.1 decode-only surface
# as the other three platforms.
#
# Windows/MSYS2 notes (they surprise people coming from the POSIX
# lanes; each is asserted below rather than assumed):
# * ffmpeg's mingw32 target sets shlibdir=bindir and SLIBPREF="",
# so the shared objects install as $PREFIX/bin/avcodec-62.dll,
# NOT $PREFIX/lib/libavcodec.so.62. The import libraries
# ($PREFIX/lib/libavcodec.dll.a) are what the linker consumes.
# Anything downstream that globs $PREFIX/lib for shared objects
# will come up empty on Windows — look in $PREFIX/bin.
# * `uname -m` lies on MSYS2: msys2-runtime is an x86_64 Cygwin
# fork even when it is running under emulation on a Windows-on-ARM
# host, so it reports x86_64 on the CLANGARM64 lane whose clang
# emits aarch64. MSYSTEM_CARCH is the honest signal and ffmpeg's
# own configure prefers it (`arch_default="$MSYSTEM_CARCH"`).
#
# Config is intentionally minimal: shared libs, no executables, no
# devices, only the codecs notcurses might encounter when decoding
# user-provided images. Keeping the surface tight controls bundle
# size (full ffmpeg adds ~80 MB; this targets ~25 MB).
#
# LICENSING IS LOAD-BEARING: no --enable-gpl, no --enable-nonfree, no
# encoders. These dylibs ship inside a prebuilt archive for an
# Artistic-2.0 distribution, so a GPL ffmpeg would relicense the
# archive out from under users. The assertion after ./configure below
# fails the build if the reported license is anything other than
# "LGPL version 2.1 or later" — if it ever fires, the question is a
# licensing decision, not a build flag.
#
# Honours $PREFIX (default /usr/local) so the install can target a
# workspace-relative cache dir, letting actions/cache persist the
# build between runs.
set -euxo pipefail
# 8.1.2 — newest point release on the 8.1 branch. ffmpeg 9.0 is out
# but we deliberately stay a series behind: 8.1 is the branch the
# distros and brew are shipping, so it's the one with the most
# eyeballs on it, and a decode-only surface gains nothing from a
# brand-new major. Revisit when 9.x has a couple of point releases.
#
# Fetched from ffmpeg's own GitHub mirror rather than ffmpeg.org:
# the first r10 CI dispatch saw connection timeouts/resets against
# ffmpeg.org from multiple GitHub-hosted runners (reproduced locally
# too — not a one-off blip), and ffmpeg.org carries no CDN fronting,
# so a single upstream host being flaky takes every lane down with
# it. GitHub's tag archive is a legitimate substitute here (unlike a
# bare source snapshot from most projects) because ffmpeg checks
# `configure` into git — it's not an autotools output that only a
# `make dist` tarball would carry. TAG uses ffmpeg's own release-tag
# convention (`n` + version, e.g. `n8.1.2`); VERSION stays bare
# because it also names this script's install layout below and is
# what t/17 cross-checks resources/third-party.json against.
VERSION='8.1.2'
TAG="n${VERSION}"
URL="https://github.com/FFmpeg/FFmpeg/archive/refs/tags/${TAG}.tar.gz"
PREFIX="${PREFIX:-/usr/local}"
mkdir -p "$PREFIX"
# Make sure pkg-config finds the source-built codec libs we depend
# on (libdav1d, libvpx, libopus, libdeflate). Their .pc files were
# installed by their respective build scripts into $PREFIX/lib/pkgconfig.
# Defensive export — build-linux-glibc.sh already exports this in
# its env, but the macOS workflow step invokes us directly without
# setting it.
export PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig:${PKG_CONFIG_PATH:-}"
# iconv is the one dependency PKG_CONFIG_PATH cannot steer: upstream
# GNU libiconv ships no .pc file, and ffmpeg probes it with a bare
# compile+link (`check_func_headers iconv.h iconv || check_lib iconv
# iconv.h iconv -liconv`) against whatever -I/-L are already in force.
# So when a libiconv has been source-built into this prefix — which
# only happens on the Windows lanes, where build-libiconv.sh runs
# first — hand configure the include and library dirs explicitly.
# -I/-L entries are searched ahead of the compiler's own defaults, so
# ours wins over the msystem's identically-named pacman copy, which
# stays installed because it is in the toolchain's package closure.
#
# Conditional, and keyed on the header actually being present, so the
# Linux and macOS lanes get a configure line byte-identical to the one
# they have always run: they have iconv in libc, and nothing installs
# an iconv.h into their prefix.
iconv_flags=()
if [[ -f "$PREFIX/include/iconv.h" ]]; then
iconv_flags+=(
"--extra-cflags=-I$PREFIX/include"
"--extra-ldflags=-L$PREFIX/lib"
)
fi
# Portable parallelism: GNU nproc on Linux, sysctl on macOS.
JOBS="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)"
# nasm is needed for x86 SIMD — caller MUST pre-install it before
# invoking this script, because the codec libs we now also build
# (libdav1d, libvpx) need nasm too and the caller orchestrates the
# order. Concretely:
# * manylinux_2_28 x86_64: build-linux-glibc.sh `dnf install nasm`
# in its system-deps step.
# * macOS x86_64: _build-macos.yml's "Install build deps" step
# runs `arch -x86_64 /usr/local/bin/brew install nasm`.
# * Windows UCRT64: _build-windows.yml's pacman list carries
# mingw-w64-ucrt-x86_64-nasm.
# Bail out loudly if an x86 caller skipped that step.
#
# The requirement is x86-only: nasm assembles x86/x86_64 SIMD and
# nothing else. On aarch64/arm64 (macOS arm64 native, manylinux
# aarch64, MSYS2 CLANGARM64) ffmpeg, dav1d and libvpx all use
# ARM-native inline/GAS assembly the C compiler handles, so demanding
# nasm there would make the arm64 lanes install a package they can't
# use.
#
# Arch detection precedence, and why it isn't just `uname -m`:
# * $MSYSTEM_CARCH first. MSYS2 exports it (x86_64 under UCRT64,
# aarch64 under CLANGARM64) and it tracks the *toolchain*, which
# is what decides whether ffmpeg emits x86 asm. `uname -m` under
# MSYS2 reports the arch of msys2-runtime — an x86_64 Cygwin fork
# that runs emulated on Windows-on-ARM — so on the CLANGARM64
# lane it says x86_64 while clang targets aarch64. Trusting it
# there would demand a nasm the build never invokes. ffmpeg's own
# configure uses exactly this precedence, so our gate and its asm
# selection can't disagree.
# * `uname -m` otherwise, which is correct everywhere else —
# including under `arch -x86_64` on Apple Silicon, where it
# reports x86_64, which is exactly the Rosetta case we DO want
# gated.
BUILD_ARCH="${MSYSTEM_CARCH:-$(uname -m)}"
case "$BUILD_ARCH" in
x86_64|amd64|i[3-6]86)
if ! command -v nasm >/dev/null 2>&1; then
echo "❌ nasm not on PATH (required for x86 SIMD)." >&2
echo " Caller must install nasm before invoking build-ffmpeg.sh:" >&2
echo " * manylinux: dnf install -y nasm" >&2
echo " * macOS: arch -x86_64 /usr/local/bin/brew install nasm" >&2
echo " * MSYS2: pacman -S mingw-w64-ucrt-x86_64-nasm" >&2
exit 1
fi
;;
*)
echo "Non-x86 build arch ($BUILD_ARCH) — skipping the nasm requirement." >&2
;;
esac
cd /tmp
curl -fSL --retry 5 --retry-delay 10 -o ffmpeg.tar.gz "$URL"
tar -xzf ffmpeg.tar.gz
# GitHub's tag-archive top-level dir is `-`, so
# `FFmpeg-n8.1.2` here — not `ffmpeg-${VERSION}` the way ffmpeg.org's
# own release tarball was named.
cd "FFmpeg-${TAG}"
# Configure flags:
# --disable-{static,doc,debug} → smaller, faster build
# --enable-shared → bundle-able .so files
# --disable-programs → no ffmpeg/ffprobe binaries
# --disable-{decoder,encoder,muxer,demuxer,parser,bsf,protocol,filter}
# → start from empty, enable only what we need
# --enable-decoder=... → image + audio for notcurses' use cases
# --enable-{demuxer,protocol}=... → matching containers + file:/data:/pipe:
# --enable-zlib → PNG via zlib
# --enable-libxml2 omitted → DASH manifest support unneeded
# --enable-pic → required for shared lib on x86_64
# Accelerated codec libs the caller is expected to have already
# source-built into $PREFIX:
# * libdav1d → ~10× faster AV1 decode (the big win).
# * libvpx → VP8/9 decode (marginal win, but matches what
# package-managed lanes ship).
# * libopus → Opus audio decode (marginal win, parity).
# --enable-libfoo enables the libfoo-dispatched codecs; the matching
# --enable-decoder=libdav1d / libvpx_vp9 / libopus selects the libfoo
# decoder over the internal one when codec_id matches at runtime.
#
# Component-name note (ffmpeg 8.x re-verified, 2026-08): every name
# in the allowlists below was checked against `./configure
# --list-{decoders,demuxers,parsers,protocols}` for 8.1.2. ffmpeg's
# configure treats an unmatched --enable-decoder=X as a WARNING, not
# an error ("Option --enable-decoder=tga did not match anything"), so
# a wrong name silently ships a bundle missing that decoder. One such
# typo was live here: the Truevision TGA decoder is called `targa`,
# never `tga` (`tga` is only ever the file extension), so TGA images
# were never actually decodable despite being listed. Fixed below.
# If you add a name, confirm it against --list-decoders first and
# watch the configure output for "did not match anything".
#
# --disable-lzma / --disable-bzlib / --disable-libxml2: these are
# all auto-enabled by ffmpeg's configure if their .pc files are
# visible via pkg-config. Our PKG_CONFIG_PATH prepends $PREFIX/
# lib/pkgconfig but doesn't block the default fallback paths
# (/usr/local/lib/pkgconfig on Intel-mac brew, /usr/lib/pkgconfig
# on Linux), so on a runner with brew xz installed, ffmpeg would
# transparently link against /usr/local/Cellar/xz/.../liblzma.5.dylib
# — a brew bottle targeting macOS 14+, which fails our 10.15
# deployment-target audit. Explicitly disable to force ffmpeg to
# ignore these even when found. We don't need any of them for the
# image/video formats notcurses cares about: lzma is for rare
# matroska variants, bzip2 for similarly rare cases, libxml2 for
# DASH/manifest demuxers.
#
# --disable-xlib / --disable-libxcb / --disable-sdl2 /
# --disable-vulkan / --disable-libdrm: same class of problem, found
# when the macOS arm64 lane moved to source-build. These are all
# [autodetect] and all serve components we've already switched off
# (x11grab, the sdl2 outdev, Vulkan/DRM hwaccel), but autodetection
# still puts them on the LINK line: on a host with brew's libxcb
# installed, libavcodec came out with LC_LOAD_DYLIBs on
# /opt/homebrew/opt/libx11, libxcb, libxau and libxdmcp. dylibbundler
# would then drag the whole X11 stack into the archive we ship. The
# bundle is supposed to be a function of these scripts, not of what
# happens to be installed on the runner — pin them off explicitly.
# Apple's own frameworks (VideoToolbox, AudioToolbox, CoreImage,
# AppKit, AVFoundation, SecureTransport) are deliberately left on:
# they live under /System, so they add no file to the bundle, and
# VideoToolbox buys hardware-accelerated h264/hevc decode for free.
#
# configure's output is tee'd to $configure_log so the two assertions
# below can read it back: an unmatched component name and a non-LGPL
# license line are both "build succeeded, artefact is wrong" failures
# that no later step would notice. `set -o pipefail` (top of file)
# keeps configure's own exit status authoritative through the tee.
configure_log="/tmp/ffmpeg-${VERSION}-configure.log"
./configure \
--prefix="$PREFIX" \
--libdir="$PREFIX/lib" \
--pkg-config-flags=--static \
--disable-static \
--enable-shared \
--enable-pic \
--disable-doc \
--disable-debug \
--disable-programs \
--disable-everything \
--disable-lzma \
--disable-bzlib \
--disable-libxml2 \
--disable-xlib \
--disable-libxcb \
--disable-sdl2 \
--disable-vulkan \
--disable-libdrm \
--enable-avformat \
--enable-avcodec \
--enable-avutil \
--enable-avdevice \
--enable-swscale \
--enable-swresample \
--enable-zlib \
--enable-libdav1d \
--enable-libvpx \
--enable-libopus \
--enable-decoder=png,mjpeg,jpegls,jpeg2000,bmp,gif,webp,tiff,targa,pcx,pbm,pgm,ppm,pam \
--enable-decoder=mp3,aac,vorbis,flac,opus,pcm_s16le,pcm_s16be,pcm_u8 \
--enable-decoder=h264,hevc,vp8,vp9,av1,mpeg4,theora \
--enable-decoder=libdav1d,libvpx_vp8,libvpx_vp9,libopus \
--enable-demuxer=image2,mjpeg,gif,mov,matroska,mp3,wav,ogg,flac,aac \
--enable-parser=png,mjpeg,h264,hevc,vp8,vp9,av1,mpegaudio,aac \
--enable-protocol=file,pipe,data \
${iconv_flags[@]+"${iconv_flags[@]}"} 2>&1 | tee "$configure_log"
# Assertion 1: every --enable-{decoder,demuxer,parser,protocol} name
# matched a real component. ffmpeg only warns on a miss, so without
# this a renamed component (they do get renamed across majors) ships
# a bundle that quietly can't decode that format.
if grep -q 'did not match anything' "$configure_log"; then
echo "❌ ffmpeg configure ignored one or more component names:" >&2
grep 'did not match anything' "$configure_log" >&2
echo " Check the name against ./configure --list-decoders etc." >&2
exit 1
fi
# Assertion 2: the build is LGPL. Every lane ships these dylibs
# inside our prebuilt archive, and Notcurses::Native is Artistic-2.0
# — a GPL ffmpeg (--enable-gpl, or a nonfree codec) would relicense
# the whole archive out from under users. If this ever fires, the
# question is a licensing decision, not a build flag.
if ! grep -q '^License: LGPL version 2.1 or later' "$configure_log"; then
echo "❌ ffmpeg configure did not report an LGPL-2.1+ license:" >&2
grep -i '^License:' "$configure_log" >&2 || echo " (no License: line at all)" >&2
exit 1
fi
# Assertion 3 (MSYS2 only): configure resolved the same architecture
# the toolchain actually targets. ffmpeg reads $MSYSTEM_CARCH for its
# arch default, so this can only diverge if MSYS2 stopped exporting it
# (configure would silently fall back to `uname -m` = x86_64 and try
# to assemble x86 SIMD with an aarch64 clang) or if a caller passed a
# conflicting --arch. Both are "configure succeeded, artefact is for
# the wrong CPU" failures that nothing downstream would notice until a
# user's LoadLibrary fails.
if [[ -n "${MSYSTEM_CARCH:-}" ]]; then
# Not the configure log's `ARCH` line: ffmpeg normalises that to the
# architecture FAMILY — an x86_64 build prints `ARCH x86`, the same
# as i686 — so comparing it against MSYSTEM_CARCH can never pass on
# UCRT64 (first r10 dispatch proved it). config.h records the exact
# architecture as macros, so ask that instead.
case "$MSYSTEM_CARCH" in
x86_64) want_macro='ARCH_X86_64' ;;
aarch64) want_macro='ARCH_AARCH64' ;;
*)
echo "❌ unrecognised MSYSTEM_CARCH='$MSYSTEM_CARCH' — teach this" >&2
echo " assertion its config.h macro before building for it." >&2
exit 1
;;
esac
if ! grep -q "^#define ${want_macro} 1\$" config.h; then
echo "❌ ffmpeg's config.h does not define ${want_macro}=1, but" >&2
echo " MSYSTEM_CARCH='$MSYSTEM_CARCH'. The produced DLLs would" >&2
echo " not match the MSYS2 environment's toolchain." >&2
grep '^#define ARCH_' config.h | grep ' 1$' >&2 || true
exit 1
fi
fi
make -j"$JOBS"
make install
# Assertion 4 (MSYS2 only): the install landed in Windows shape.
# ffmpeg's `mingw32` target block is what sets shlibdir=bindir,
# SLIBPREF="" and SLIBSUF=".dll"; if configure had failed to normalise
# `uname -s` (MINGW64_NT-…) onto that target it would happily produce
# ELF-shaped libavcodec.so.62 files in $PREFIX/lib and every later
# step — bundle-dll's DLL glob, the codec probe's avcodec-*.dll
# lookup — would report a confusing "missing" rather than "wrong
# target OS". Assert the shape directly instead of parsing configure's
# output for a target-os line it never prints.
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*)
dll_count=$(find "$PREFIX/bin" -maxdepth 1 -name 'avcodec-*.dll' -type f 2>/dev/null | wc -l)
if (( dll_count == 0 )); then
echo "❌ No \$PREFIX/bin/avcodec-*.dll after install — ffmpeg did not" >&2
echo " build for target-os=mingw32. Contents of \$PREFIX:" >&2
ls -la "$PREFIX/bin" "$PREFIX/lib" >&2 || true
exit 1
fi
if [[ ! -f "$PREFIX/lib/libavcodec.dll.a" ]]; then
echo "❌ Missing \$PREFIX/lib/libavcodec.dll.a import library —" >&2
echo " nothing downstream can link against this ffmpeg." >&2
ls -la "$PREFIX/lib" >&2 || true
exit 1
fi
;;
esac
# ldconfig only matters on Linux when PREFIX=/usr/local (the linker's
# default search path). For workspace-prefix installs OR macOS,
# the caller is expected to set LD_LIBRARY_PATH / DYLD_LIBRARY_PATH /
# PKG_CONFIG_PATH to find these.
if [[ "$PREFIX" == "/usr/local" ]] && command -v ldconfig >/dev/null 2>&1; then
ldconfig
fi
PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig:${PKG_CONFIG_PATH:-}" \
pkg-config --modversion libavcodec libavformat libavutil libswscale libswresample
cd /
rm -rf "/tmp/FFmpeg-${TAG}" /tmp/ffmpeg.tar.gz "$configure_log"