AGENTS.md
August 9, 2026 · View on GitHub
Instructions for AI coding agents working in this repository.
What this repo is
A bash script, build-ffmpeg, that downloads, builds and statically links FFmpeg and ~70
of its dependencies from source, plus the Dockerfiles and CI that exercise it. There is no
application code, no test suite, and no build system of its own — the script is the
project.
The script is not one file: build-ffmpeg is a thin entry point that sources the
fragments under src/ in an explicit order. There is no assembly or codegen step —
the fragments are the source, sourced at runtime. Users always get the whole tree (release
tarball, git clone, or the Dockerfiles' COPY src).
Repository layout
| Path | What it is |
|---|---|
build-ffmpeg | Entry point only: resolves SCRIPT_DIR, checks src/ exists, sources the fragments in order. Edit it only to add, remove or reorder a fragment. |
src/ | The script. Almost every change goes here — see the fragment list below. |
web-install.sh, web-install-gpl-and-non-free.sh | One-liner installers. They resolve the latest release, download GitHub's auto-generated archive for that tag, extract it and run build-ffmpeg from it. They do not fetch anything from master. |
Dockerfile, cuda-ubuntu.dockerfile, full-static.dockerfile, export.dockerfile | Container builds, all exercised by CI. |
.github/workflows/build.yml | lint, then six full builds: build-linux, build-linux-with-system-libs, build-macos, build-docker, build-cuda-ubuntu-docker, build-full-static, then release-version-check on v* tags only. |
README.md | End-user documentation. Not contributor docs. |
.editorconfig | shfmt reads its indent keys from here. |
.gitattributes | export-ignore entries that keep repo infrastructure out of the release tarball git archive builds. Nothing the build needs may be listed there. |
packages/, workspace/, build/ | Build output. Gitignored. Never read or edit these. packages/ holds ~70 extracted upstream source trees; grepping it will bury you in unrelated code. Not to be confused with src/packages/, which is script source — the .gitignore entries are anchored (/packages) precisely so they do not swallow it. |
docs/, plans/ | Gitignored scratch notes. Not part of the project. |
When searching the repo, restrict the search to the tracked files. git ls-files is the
reliable filter; a bare grep -r . is not.
Releases vs master
master holds unreleased work; users get releases.
Releases are drafted and published by hand. Nothing is uploaded to them and nothing
needs to be: GitHub generates a source archive for every tag, so if the tag exists its
archive exists. .gitattributes export-ignore applies to those archives, which is what
keeps repo infrastructure out of what users download.
The installers resolve the newest release from the /releases/latest redirect, which
points at /releases/tag/<tag>, and then fetch /archive/refs/tags/<tag>.tar.gz. Do not
replace that with an api.github.com lookup: it is rate-limited to 60/hr per IP, breaks CI
runners behind shared NAT, and would add a jq dependency.
The release-version-check job asserts that a pushed tag matches SCRIPT_VERSION in the
tagged commit, read from src/00-header.sh (v9.0.3 requires SCRIPT_VERSION=9.0.3), so
master carrying the next
release's version is legal while a mismatched tag fails. It does not gate on the builds —
there is no artifact to withhold, so it reports in seconds instead of after an hour.
Style and tooling
- Formatting is enforced.
shfmt(v3.12.0) must produce no diff. It reads.editorconfig: 4-space indent,binary_next_line = false,switch_case_indent = false. Runshfmt -d build-ffmpeg web-install.sh web-install-gpl-and-non-free.sh src/before finishing. - ShellCheck is enforced at
--severity=style, its strictest level, pinned to v0.11.0. Run it asshellcheck -x --severity=style build-ffmpeg web-install.sh web-install-gpl-and-non-free.sh src/*.sh src/packages/*.sh. The fragments have to be named on the command line.-xfollows thesourcelines only to resolve definitions for the file being checked; it emits no diagnostics for the sourced files, so-xon the entry point alone lints the loader and nothing else. Each fragment therefore carries a# shellcheck shell=bashdirective (fragments have no shebang), and the handful of variables that are set in one fragment and read in another carry a narrow# shellcheck disable=with a reason on the same line. The# shellcheck source=directives in the entry point stay — they make-xresolve the loader's own call graph. The scripts are currently clean, so anything it reports is a regression you introduced. Prefer fixing over silencing; if a# shellcheck disable=really is warranted, give it a reason comment on the same line. - Package consistency is enforced too, in the same
lintjob, because both failures it catches are silent and only surface at download time — up to an hour into a build../build-ffmpeg --list-packages | grep MISSINGmust print nothing:download()derivesVER_<PACKAGE>from the name passed tobuild(), and when the two disagree it finds no checksum and fetches the tarball unverified instead of failing. The job also checks that every variable interpolated into adownloadURL is assigned somewhere —X265_COMMITwas not, once, and the URL collapsed toget/.tar.gz. - Target
/bin/bash3.2. macOS still ships bash 3.2, so no associative arrays (declare -Ais a fatal error there), no${var^^}, nomapfile/readarray, no**globstar. - Both GNU and BSD userland.
sed -iandtar --wildcardsbehave differently. Use theapply_inline_patchhelper instead ofsed -i. Wheresed -i.backupdoes appear it is deliberate and portable — leave it.
How the script is structured
build-ffmpeg sources these, in exactly this order. Read them in the same order:
| Fragment | What is in it |
|---|---|
src/00-header.sh | Banner comment, PROGNAME, FFMPEG_VERSION, SCRIPT_VERSION. |
src/10-versions.sh | Every VER_* version/checksum array, plus X265_COMMIT. One central table. |
src/20-globals.sh | CWD/PACKAGES/WORKSPACE/CFLAGS/LDFLAGS/…, the small predicates (version_gte, command_exists, cxx_supports_flag), Apple Silicon detection and MJOBS detection. |
src/30-helpers.sh | make_dir … download, execute, build, build_done, verify_binary_type, cleanup. |
src/40-cli.sh | usage(), the version banner, the argument loop, the preflight command_exists checks. |
src/packages/*.sh | The build_* functions, grouped by the sections the monolith already used: 10-build-tools, 20-tls, 25-cmake, 30-video, 40-audio, 50-image, 55-other, 60-text-subtitle, 70-optical, 75-zmq, 80-hwaccel. |
src/90-build-order.sh | PACKAGE_BUILD_ORDER and the dispatch loop. |
src/95-ffmpeg.sh | The FFmpeg configure/make/install, the binary verification and the install-to-system prompt. |
Rules the entry point encodes, none of them cosmetic:
- Order is load-bearing. The dispatch loop and the FFmpeg block only work once every function and variable above them exists.
- Nothing is wrapped in a subshell or a function. Fragments are sourced into the
current shell because the package functions mutate
CONFIGURE_OPTIONS,CFLAGS/LDFLAGS/CXXFLAGS,EXTRALIBS,PATHand theOPENSSL_*exports, anddownload()leaves the shell inside the extracted source directory. - The source list is explicit, never a glob. Glob order depends on
LC_COLLATE, and an in-place upgrade over an existingffmpeg-build/tree leaves renamed-away fragments behind — an explicit list makes those orphans inert. CWD=$(pwd)insrc/20-globals.shis the invocation directory and decides wherepackages/andworkspace/are created.SCRIPT_DIRin the entry point exists only to locate the fragments. Do not conflate them.- A fragment carries no shebang and is never executable on its own. It starts with a
# shellcheck shell=bashdirective instead, which is what lets it be linted on its own. - A fragment that fails to load is fatal. Each
sourceline is followed by|| fragment_failed <name>, which aborts when the file is missing or unreadable and is a no-op otherwise — a sourced file's exit status is that of its last command, which forsrc/90-build-order.shis the dispatch loop, so the status itself cannot be trusted.
The version/checksum table
Every package has one VER_<PACKAGE>=("<version>" "<sha256>") array in
src/10-versions.sh. download() derives the array name from the package name mechanically — uppercased,
every non-alphanumeric replaced by _ — so the name passed to build() and the array name
must stay in sync or the checksum silently goes unchecked. An empty checksum means
"not pinned yet" and skips verification.
The checksum is of the downloaded archive, not its contents.
Anatomy of a package function
build_foo() {
if ! $NONFREE_AND_GPL; then return; fi # optional license gate
if ! command_exists "meson"; then return; fi # optional capability gate
if build "foo" "${VER_FOO[0]}"; then
download "https://.../foo-$CURRENT_PACKAGE_VERSION.tar.gz"
execute ./configure --prefix="${WORKSPACE}" --disable-shared --enable-static
execute make -j "$MJOBS"
execute make install
build_done "foo" "$CURRENT_PACKAGE_VERSION"
fi
CONFIGURE_OPTIONS+=("--enable-libfoo") # OUTSIDE the if — see below
}
Rules this shape encodes:
build()returns non-zero whenpackages/foo.donealready records this version, so the body is skipped on a rerun. It also setsCURRENT_PACKAGE_NAME/CURRENT_PACKAGE_VERSION, whichdownload()reads to find the checksum. Always build the URL from$CURRENT_PACKAGE_VERSION, never from a literal version — that is what keeps the URL, the.donefile and the checksum consistent.CONFIGURE_OPTIONS+=(...)goes outside theif build ... fiblock. Inside it, a cached package would silently drop its--enable-flag from the FFmpeg configure line.executeruns a command, captures its output, and aborts the script on failure. Use it for every build step. Bare commands are for cheap shell plumbing (cd,mkdir) only.download()extracts the tarball and cds into it, so the function continues in the source directory. Anything that needs a different directorycds explicitly afterwards.- The package functions run in the current shell, not a subshell, on purpose: they mutate
global state that later packages and the final configure depend on —
CONFIGURE_OPTIONS,CFLAGS/LDFLAGS/CXXFLAGS,EXTRALIBS,PATH, theOPENSSL_*exports — and the working directory carries over fromdownload(). Wrapping one in a subshell changes behavior.
The gates
| Gate | Meaning |
|---|---|
if ! $NONFREE_AND_GPL; then return; fi | GPL/non-free only (--enable-gpl-and-non-free). |
if $NONFREE_AND_GPL; then return; fi | LGPL path only. Used by the gmp/nettle/gnutls chain, which is the mirror image of openssl. |
if [ -n "$LDEXEFLAGS" ]; then return; fi | Skipped in --full-static builds. Used for packages whose filters dlopen() plugins at runtime (frei0r, ladspa), which a static binary cannot do. |
if ! command_exists "x"; then return; fi | Optional on hosts lacking a tool (python3, meson, cargo, nvcc). |
if [[ ! "$OSTYPE" == "linux-gnu" ]]; then return; fi | Linux-only hardware accel. |
if [ -z "$WHISPER_BACKEND" ]; then return; fi | Opt-in: built only when --whisper=BACKEND selects one. Used by whisper.cpp, where exactly one ggml compute backend is compiled into the static binary and only the user knows which one fits the target machine. |
Packages are also skipped from outside the gates: --disable=name[,name...] removes them from
the dispatch loop in src/90-build-order.sh, so the build_ function never runs and never
appends its --enable-* flag. The protected set (build tools and the TLS stack) and the group
and dependency tables live next to that loop.
Exactly one TLS stack is built per license mode, because FFmpeg's configure refuses both at
once: --enable-gpl-and-non-free → gettext + openssl; default LGPL → gmp + nettle + gnutls.
Adding a package
- Add
VER_<NAME>=("<version>" "<sha256>")tosrc/10-versions.sh, in the section matching where it will be built. - Write
build_<name>()following the anatomy above, next to its neighbours in the matchingsrc/packages/*.shfragment. A new fragment also has to be added to the source list inbuild-ffmpeg, in the right position — it is not picked up automatically. - Add
<name>toPACKAGE_BUILD_ORDERinsrc/90-build-order.shin the position where it must be built — the array is the actual build order and dependencies are not resolved, only ordered. - If the package name contains a
-, the function name uses_(pkg-config→build_pkg_config, entrypkg_config,VER_PKG_CONFIG). The dispatch loop calls"build_${PACKAGE}"verbatim. - Get the checksum by downloading the archive and hashing it — do not invent one, and do not
leave it empty unless the URL genuinely produces unstable bytes (as
av1does; say so in a comment when you do). - Bump
SCRIPT_VERSION— but only if it is not already ahead of the latest release tag. It names the next release, not the current commit, so a whole batch of unreleased commits shares one bump. Checkgit tag | tail -1first: bumping again while master is already ahead skips a version and makes therelease-version-checkjob's tag assertion fail.
Prefer --disable-shared --enable-static and disabling docs, tests, examples and CLI tools:
everything here is a static link into one binary and nothing else consumes these installs.
Testing a change
A full build takes well over an hour, so do not casually run one.
- Syntax and lint — always, they are seconds:
bash -nonbuild-ffmpegand on every fragment you touched, thenshfmt -dandshellcheck -x --severity=styleas spelled out above. - One package — a full build leaves
packages/*.donelockfiles behind. Delete just the one you touched (rm packages/foo.done) and rerun./build-ffmpeg --build …; every other package is skipped and only yours rebuilds. This is the fast iteration loop. - From scratch —
./build-ffmpeg --cleanupwipespackages/andworkspace/. Note that this also discards every downloaded tarball. - Useful env vars —
SKIPINSTALL=yes(never touch system binaries),NUMJOBS=n,AUTOINSTALL=yes. To skip packages, use--disable=name[,name...](--disable=rav1edrops the slow Rust build). - Verifying the result —
./workspace/bin/ffmpeg -buildconfis what CI checks. Confirm that the feature you added actually shows up there; a missing dependency usually degrades silently into a dropped feature rather than a failed build.
Never run the install step, and never write outside the repo, unless explicitly asked.
Traps worth knowing
- Silent feature loss. FFmpeg's configure drops what it cannot find instead of failing.
A build can report success and be missing ffplay, a TLS backend, or the Vulkan filters.
Check
-buildconf, not the exit code. .gitis moved to.git.bakduring the FFmpeg build so FFmpeg does not describe this repository in its version string. AnEXITtrap restores it. If you find a.git.bakand no.git, a build died — move it back.- The LGPL build currently has no TLS backend.
build_gnutlsbuilds gnutls but the--enable-gnutlsline is commented out, and gnutls is skipped entirely on arm64. Both are documented in place. Do not "fix" the comment-out without addressing the arm64 asymmetry. - Cached downloads are never auto-deleted. A checksum mismatch on a cached file aborts with instructions rather than silently re-downloading.
Commits
Imperative mood, one line, no scope prefix, no trailer — match git log:
Build gperf, which fontconfig needs on Linux. Pure-formatting commits get added to
.git-blame-ignore-revs.