Skip to content

feat: install FFmpeg with one command, from a pinned LGPL build - #118

Merged
abrichr merged 2 commits into
mainfrom
feat/ffmpeg-provision
Aug 28, 2026
Merged

feat: install FFmpeg with one command, from a pinned LGPL build#118
abrichr merged 2 commits into
mainfrom
feat/ffmpeg-provision

Conversation

@abrichr

@abrichr abrichr commented Aug 28, 2026

Copy link
Copy Markdown
Member

What this fixes

Recording video needs an FFmpeg executable. When one was missing, the error
listed four mechanisms and left you to pick. Now it names one command:

capture install-ffmpeg

That downloads a single pinned archive for your platform, checks its SHA-256
against a digest compiled into the package, and installs it under your user
data directory. --dry-run prints the URL, digest, and destination without
fetching. capture uninstall-ffmpeg removes it.

What it does not change

The wheel and the source distribution still carry zero FFmpeg bytes, and
Capture downloads nothing unless you run that command. Bundling FFmpeg in an
MIT package would relicense the package, which is why the friction existed.

The build, and why it is LGPL

The archives come from the openadapt-desktop ffmpeg-runtime-v8.1.2-r1
release, which openadapt-desktop builds from the upstream FFmpeg 8.1.2 tarball
(ffmpeg-8.1.2.tar.xz, sha256 464beb5e...b524c, signed by
FCF986EA15E6E293A5644F10B4322F04D67658D8). The configure arguments are
recorded inside each archive and include --disable-gpl, --disable-nonfree,
and --disable-version3.

FFmpeg's own LICENSE.md is the source for the licence claim: most files are
LGPL v2.1 or later, and of the optional GPL parts it says "None of these parts
are used by default, you have to explicitly pass --enable-gpl to configure to
activate them." So the pinned build is LGPL-2.1-or-later. That licence text is
installed beside the binaries, and the install receipt records the archive, its
digest, and the matching upstream source tarball.

Reusing openadapt-desktop's release matters: Desktop already builds, signs, and
publishes these exact artifacts and its Rust provisioner already installs them.
A second downloader with its own pin would eventually disagree with the first.

Digest ordering

  1. Stream the archive to a temporary file under a byte cap, hashing as it goes.
  2. Compare against the pinned digest. On a mismatch, stop. Nothing has been
    opened, extracted, made executable, or run.
  3. Open the zip, refuse any member under bin/ that is not pinned, and extract
    each pinned member at mode 0600, digest-checked as it is written.
  4. Only after every digest matches, set the executable bit.
  5. Promote the staged directory and write the receipt.
  6. Run a real encode-and-decode probe. That is the first execution.

tests/test_ffmpeg_provision.py::test_every_digest_is_checked_before_anything_becomes_executable
records extraction and chmod events and asserts the last verification
precedes the first executable bit. Reordering the two loops fails it.

The downloader also refuses a non-HTTPS URL, a redirect to an unpinned host, an
oversized body, an oversized member, and an archive whose bin/ carries
anything unpinned.

Precedence

Unchanged, with the installed runtime appended last:

  1. Recorder(ffmpeg_path=...) or OPENADAPT_FFMPEG_PATH
  2. OPENADAPT_DESKTOP_FFMPEG_PATH
  3. Desktop's ffmpeg.json provision manifest
  4. PATH
  5. The runtime capture install-ffmpeg installed

Installing never displaces an FFmpeg you already chose. When something earlier
wins, the command says so and tells you how to prefer the installed one.

Release gate

scripts/verify_distribution.py compared filenames, so a renamed binary would
have passed. It now checks, for every member: executable magic (ELF, Mach-O,
PE), container magic (zip, xz, gzip, bzip2, zstd, ar), and FFmpeg build strings
ffmpeg version, libavcodec, --enable-gpl and the rest. Those last two
categories apply to members that are not text, where text means UTF-8 with no
NUL byte. Documentation that names FFmpeg passes; a binary renamed .py does
not. Dropping a real bin/ffmpeg into the built wheel is caught under its own
name and after renaming to codec_helper.bin.

The gate already runs in test.yml's package-contract job on every pull
request and in both release.yml publication paths.

scripts/check_ffmpeg_pin.py is new and reads the live release assets: archive
digest, every member digest, the bin/ allowlist, and the configure arguments
recorded in the archive. The LGPL claim is verified against the artifact rather
than asserted. .github/workflows/ffmpeg-pin.yml runs it weekly and on any
pull request touching the pin, alongside an install-and-encode smoke test on
Linux, macOS, and Windows.

Evidence

  • All four pinned archives downloaded and hashed by hand; every archive and
    member digest matches the committed pin, and all four report the LGPL
    configuration (scripts/check_ffmpeg_pin.py, full run).
  • End-to-end install on macOS arm64: files land at 0700/0600, the receipt
    is written, and require_video_encoder() encodes and decodes with
    h264_videotoolbox into mp4.
  • With PATH emptied, resolution picks the installed runtime and reports
    source="capture install-ffmpeg". With an ffmpeg on PATH, PATH still
    wins.
  • 685 passed, 2 skipped on the full suite.

Note on imageio-ffmpeg

I considered depending on imageio-ffmpeg instead and decided against it. See
the review discussion; the short version is that it moves the same LGPL binary
into a transitive dependency where this repository controls neither the pin nor
the build configuration, and its bundled build is a different configuration
from the one Desktop already ships and probes.

🤖 Generated with Claude Code

Recording video needed an FFmpeg executable and the error told users to pick
one of four mechanisms. `capture install-ffmpeg` now gets them one.

The wheel and the sdist still carry no FFmpeg bytes, and nothing downloads
unless the operator runs that command. It fetches one archive per platform from
the openadapt-desktop ffmpeg-runtime-v8.1.2-r1 release, which openadapt-desktop
builds from the upstream FFmpeg 8.1.2 tarball with --disable-gpl,
--disable-nonfree and --disable-version3. That build is LGPL-2.1-or-later, and
FFmpeg's own LICENSE.md is installed beside the binaries.

Ordering, which the licensing and safety story depends on: the archive digest
is compared before the archive is opened, every extracted member is written at
mode 0600 and digest-checked as it is written, and only after all of them match
does anything get an executable bit. A mismatch leaves nothing installed. The
downloader refuses a non-HTTPS URL, an off-host redirect, an oversized body, an
oversized member, and any executable member under bin/ that is not pinned.

Resolution order is unchanged and the installed runtime is last, behind
OPENADAPT_FFMPEG_PATH, Recorder(ffmpeg_path=...),
OPENADAPT_DESKTOP_FFMPEG_PATH, Desktop's ffmpeg.json manifest, and PATH.
Installing never displaces an FFmpeg the operator already chose, and the
command says so when PATH wins.

verify_distribution.py checked filenames, so a renamed binary passed. It now
checks executable magic, container magic, and FFmpeg build strings for every
member that is not text, and a member counts as text only if it is UTF-8 with
no NUL byte, so documentation that names FFmpeg still passes while a renamed
binary does not. That gate already runs on every pull request and on both
release paths.

check_ffmpeg_pin.py reads the live release assets and verifies the archive
digest, every member digest, and the configure arguments recorded inside the
archive, so the LGPL claim is checked against the artifact instead of asserted.
The new workflow runs it weekly and on any pull request that touches the pin,
plus an install-and-encode smoke test on Linux, macOS, and Windows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@abrichr
abrichr force-pushed the feat/ffmpeg-provision branch from 15f43fb to 5dd93a9 Compare August 28, 2026 17:48
@abrichr
abrichr merged commit 9701237 into main Aug 28, 2026
18 checks passed
@abrichr
abrichr deleted the feat/ffmpeg-provision branch August 28, 2026 17:54
abrichr added a commit that referenced this pull request Aug 28, 2026
test_install_writes_verified_files_and_a_receipt asserted st_mode & 0o100 on
the installed ffmpeg. Windows has no execute bit: st_mode is 0o666 or 0o444
there and chmod only toggles read-only, so the assertion could never hold and
test-windows went red on main after #118.

The install-and-encode lane in ffmpeg-pin.yml already proves the installed
runtime actually runs on windows-latest, which is the property this assertion
was reaching for. Keep the mode checks on POSIX and check readability on
Windows.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
abrichr added a commit that referenced this pull request Aug 28, 2026
#118 and #119 landed after the 1.3.0 section was written, so the notes
did not mention them. #118 in particular adds a user-facing command and
a network fetch, which the notes must state.

The FFmpeg entry says plainly that neither artifact carries FFmpeg bytes
and that nothing downloads unless the operator asks. Verified against the
built wheel and source archive rather than taken from the commit message.

The version stays 1.3.0. The highest bump implied since v1.2.2 is still
minor, and 1.3.0 is already the minor.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant