From b082cea889d54a90d89a90f8dafedbc97eb1c8a4 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 19 Aug 2026 21:19:01 -0500 Subject: [PATCH] Add containerized FFmpeg build producing a 16 KB-aligned GPL AAR There is no usable prebuilt FFmpeg for Android any more. arthenica/ffmpeg-kit is archived and its binaries were deleted from Maven Central, so every com.arthenica:ffmpeg-kit-* coordinate 404s and all of its release tags have zero assets. Maven Central's search index still lists the old versions, which misleads; the files behind those entries are gone. The successor, ffmpeg-kit-next, is source-only by design. Building it ourselves is the only remaining option, not a preference. ffmpeg-kit-next is Nix-only -- there is no plain android.sh, only nix-android.sh and a flake -- so the toolchain lives in a container rather than on the developer's machine. The recipe doubles as the reproducibility artifact F-Droid expects and as the GPL corresponding-source obligation. Four problems this path hits, none of them documented upstream: - The nixos/nix base image already ships bash, coreutils and git; installing them collides with the existing profile entries and fails the image build. - Upstream scripts use #!/bin/bash but the image provides only /bin/sh, so start-android.sh dies with "cannot execute: required file not found" after the entire toolchain has been built. - Gradle's AAPT2 comes from Maven as a prebuilt binary linked against FHS paths that do not exist under Nix, failing with "Daemon startup failed" after the whole native build succeeds. Nixpkgs' Android SDK ships an already-patched aapt2, so Gradle is pointed at that. - A bare '*.aar' find also collects every AAR Gradle unpacked into its own caches, so the copy is scoped to the ffmpeg-kit outputs. The NDK stays at r27d as the flake pins it. Do not "upgrade" to r28+: android/jni/Android.mk applies -Wl,-z,max-page-size=16384 manually precisely because r27 predates automatic alignment, and the result is verified 16 KB compliant as-is. Verified against the produced artifact: every .so on both ABIs reports LOAD align 0x4000, libraries are separate rather than a static monolith as the GPL relinking obligation requires, and the embedded configure line confirms --enable-gpl --enable-version3 with x264, x265, SVT-AV1, LAME, libass and the MediaCodec wrappers. Note that --enable-small and --enable-lto internalize symbols, so absence from strings output proves nothing; check the configure line instead. The 35 MB AAR itself is gitignored. F-Droid strips checked-in prebuilt native libraries, and the recipe is the artifact of record. Co-Authored-By: Claude Opus 5 (1M context) --- app/libs/README.md | 17 +++++ tools/ffmpeg/Containerfile | 33 +++++++++ tools/ffmpeg/README.md | 124 ++++++++++++++++++++++++++++++++ tools/ffmpeg/build-ffmpeg.sh | 134 +++++++++++++++++++++++++++++++++++ 4 files changed, 308 insertions(+) create mode 100644 app/libs/README.md create mode 100644 tools/ffmpeg/Containerfile create mode 100644 tools/ffmpeg/README.md create mode 100755 tools/ffmpeg/build-ffmpeg.sh diff --git a/app/libs/README.md b/app/libs/README.md new file mode 100644 index 0000000..fc50130 --- /dev/null +++ b/app/libs/README.md @@ -0,0 +1,17 @@ +# Local FFmpeg AAR + +`ffmpeg-kit-next-*.aar` is **built, not committed**. Build it with: + +```sh +cd tools/ffmpeg +podman build -t ffmpeg-kit-builder:local -f Containerfile . +mkdir -p out +podman run --name ffmpeg-build -v "$PWD/out":/work/out:Z \ + localhost/ffmpeg-kit-builder:local full +cp out/ffmpeg-kit-next-*.aar ../../app/libs/ +``` + +The `.aar` is deliberately gitignored. It is a ~35 MB binary blob, and F-Droid's build +process strips checked-in prebuilt native libraries — committing it would break the +F-Droid build and bloat the repository. The reproducible recipe in `tools/ffmpeg/` is +the artifact of record, and it also serves as the GPL corresponding-source obligation. diff --git a/tools/ffmpeg/Containerfile b/tools/ffmpeg/Containerfile new file mode 100644 index 0000000..5185f99 --- /dev/null +++ b/tools/ffmpeg/Containerfile @@ -0,0 +1,33 @@ +# Reproducible FFmpeg build environment for ffmpeg-kit-next. +# +# ffmpeg-kit-next is Nix-only: the repository ships nix-android.sh and a flake, and +# there is no plain android.sh. Rather than install Nix on a developer machine, the +# whole toolchain lives in this image. It also serves as the reproducibility artifact +# F-Droid expects. +# +# The flake pins Android NDK 27.3.13750724 (r27d) itself and applies +# -Wl,-z,max-page-size=16384 for arm64-v8a and x86_64 in android/jni/Android.mk, so +# the output is 16 KB page-size compliant without needing NDK r28+ on the host. +# +# The base image already provides git, bash, curl and tar. Compilers and build tools +# (make, cmake, autotools, pkg-config) are supplied by the flake's devShell at build +# time, so nothing further is installed here — `nix profile install` would in fact +# collide with the base image's existing profile entries. + +FROM docker.io/nixos/nix:2.35.2 + +# nix-android.sh points NIX_USER_CONF_FILES at the repo's own nix.conf, which enables +# the flakes and nix-command experimental features. Set them here too so that plain +# `nix` invocations behave the same way. +RUN mkdir -p /etc/nix && \ + printf 'experimental-features = nix-command flakes\naccept-flake-config = true\nwarn-dirty = false\nmax-jobs = auto\n' \ + >> /etc/nix/nix.conf + +# Upstream's scripts/*.sh use `#!/bin/bash`, but this image provides only /bin/sh. +RUN ln -sf /bin/sh /bin/bash + +WORKDIR /work +COPY build-ffmpeg.sh /usr/local/bin/build-ffmpeg.sh +RUN chmod +x /usr/local/bin/build-ffmpeg.sh + +ENTRYPOINT ["/usr/local/bin/build-ffmpeg.sh"] diff --git a/tools/ffmpeg/README.md b/tools/ffmpeg/README.md new file mode 100644 index 0000000..8f87392 --- /dev/null +++ b/tools/ffmpeg/README.md @@ -0,0 +1,124 @@ +# FFmpeg build + +Builds [`ffmpeg-kit-next`](https://github.com/arthenica/ffmpeg-kit-next) into an Android +AAR that the app consumes. + +## Why this exists + +`arthenica/ffmpeg-kit` — the library nearly every Android FFmpeg tutorial still points at +— was **archived**, and its binaries were **deleted from Maven Central**. Every +`com.arthenica:ffmpeg-kit-*` coordinate now returns 404, and all of its GitHub release +tags have zero attached assets. Maven Central's search index still *lists* the old +versions, which is misleading; the files behind those entries are gone. + +Its successor, `ffmpeg-kit-next`, is **source-only by design** and publishes no +prebuilt packages. So building FFmpeg ourselves is not a preference, it is the only +remaining option. + +Community forks publishing prebuilt 16 KB-aligned AARs do exist, but each fails on +license, ABI coverage, or publisher credibility — and at least one redistributes a +non-free FDK-AAC build, which FFmpeg states is *unredistributable*. + +## Why a container + +`ffmpeg-kit-next` is **Nix-only**. There is no plain `android.sh`; the repository ships +`nix-android.sh` plus a flake, and the flake pins the entire toolchain including +**Android NDK 27.3.13750724 (r27d)**. + +Rather than install Nix on a developer machine, the toolchain lives in a container +image. That keeps the host clean and doubles as the reproducibility artifact F-Droid +wants. + +Note the NDK version: **do not** "helpfully" upgrade to r28+. The flake pins r27d and +`android/jni/Android.mk` applies `-Wl,-z,max-page-size=16384` manually for `arm64-v8a` +and `x86_64` precisely because r27 predates automatic 16 KB alignment. The output is +16 KB compliant as-is. + +## Usage + +```sh +podman build -t ffmpeg-kit-builder:local -f Containerfile . + +mkdir -p out +podman run --name ffmpeg-build -v "$PWD/out":/work/out:Z \ + localhost/ffmpeg-kit-builder:local full +``` + +Two modes: + +| Mode | Libraries | Purpose | +|---|---|---| +| `spike` | minimal | Validates the toolchain end to end without waiting on x264/x265/SVT-AV1 | +| `full` | shipping set | The GPL configuration that ships | + +The run deliberately omits `--rm`: the container's writable layer retains the several +gigabytes of Nix store contents (Android SDK and NDK), so subsequent builds skip the +download. Reuse it with `podman start -a ffmpeg-build`. + +Only `arm64-v8a` and `x86_64` are built, matching the app's `abiFilters`. Dropping the +32-bit ABIs roughly halves both build time and APK size, and Play does not require them. + +## Library selection + +Flag names come from `get_library_name()` in the upstream `scripts/function.sh`. Two +that are easy to get wrong: + +- It is **`--enable-lame`**, not `--enable-libmp3lame`. +- It is **`--enable-libsvtav1`** for SVT-AV1. + +MP3 deserves a note: **Android has no MP3 encoder at any API level**. That is a platform +gap, not a Media3 limitation, so `--enable-lame` is the only way the app can output MP3. + +`--enable-android-media-codec` gives FFmpeg the `h264_mediacodec` / `hevc_mediacodec` +wrappers (added in FFmpeg 6.0). These act as a fallback-within-the-fallback: hardware +encode from the FFmpeg side when a job was routed away from Media3 for container reasons +but still wants hardware speed. + +## Licensing + +The `full` build passes `--enable-gpl` with **x264** and **x265**, which makes the +distributed binary **GPL-3.0**. This is deliberate — see [`../../LICENSES/README.md`](../../LICENSES/README.md). + +Worth recording, because it is widely misunderstood: **libass is ISC licensed, not GPL**, +so subtitle burn-in does not require the GPL flag. The only things GPL genuinely buys are +x264/x265 software encode (and with them CRF and 2-pass rate control), vidstab, and the +GPL filter set. + +Never build with `--enable-nonfree`. FFmpeg states it renders the binary +*unredistributable*. + +## Verified build output (2026-08-19, ffmpeg-kit-next v8.1.1 / FFmpeg 8.1.2) + +The `full` build produced a 35 MB AAR with 10 shared libraries per ABI for `arm64-v8a` +and `x86_64`. Confirmed against the artifact rather than assumed: + +- **16 KB page alignment**: every `.so` on both ABIs reports `LOAD align 0x4000` + (`readelf -lW`). This is the hard Play gate. +- **Separate shared libraries**, not a static monolith — which is what the LGPL/GPL + relinking obligation requires. +- **Embedded configure line** (from `strings libavutil.so`): + `--enable-gpl --enable-version3 --enable-libx264 --enable-libx265 --enable-libsvtav1 + --enable-libvpx --enable-libmp3lame --enable-libopus --enable-libdav1d --enable-libass + --enable-libfontconfig --enable-libfreetype --enable-libfribidi --enable-libharfbuzz + --enable-mediacodec --enable-jni --enable-shared --enable-small --enable-lto` +- Present and verified: `libx264` (with an x264 core banner, so genuinely linked), + `libx265`, `libsvtav1`, `libmp3lame`, `h264_mediacodec`, `hevc_mediacodec`, `libopus`, + `libdav1d`, the GIF encoder and muxer, libass internals (`ass_shaper_new`), and the + `subtitles`, `scale`, `palettegen`, `paletteuse` and `concat` filters. + +Note `--enable-version3`: combined with `--enable-gpl` this makes the binary **GPL-3.0**, +which is what `LICENSES/README.md` states. + +A caution on verifying this yourself: the build uses `--enable-small` and `--enable-lto`, +so internal symbols like `ff_libx264_encoder` do **not** appear in `strings` output. +Their absence proves nothing. Check the configure line and the registered codec *names* +instead. + +## Release checklist + +GPL-3.0 requires corresponding source alongside the binary. For each release, attach to +the GitHub Release next to the APK: + +- the exact `ffmpeg-kit-next` tag and FFmpeg version used, +- the full configure line (printed in this script's build log), and +- any patches applied. diff --git a/tools/ffmpeg/build-ffmpeg.sh b/tools/ffmpeg/build-ffmpeg.sh new file mode 100755 index 0000000..cf195a6 --- /dev/null +++ b/tools/ffmpeg/build-ffmpeg.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# +# Builds ffmpeg-kit-next into an Android AAR. Runs INSIDE the container image +# defined by the sibling Containerfile. +# +# Usage: build-ffmpeg.sh [spike|full] +# +# spike - minimal library set. Validates that the toolchain works and produces an +# AAR, without waiting on x264/x265/SVT-AV1. Use this first. +# full - the shipping configuration (GPL: x264 + x265). +# +set -euo pipefail + +# The upstream scripts use `#!/bin/bash`, but the nixos/nix image ships only /bin/sh +# (itself bash, via the Nix store). Without this, start-android.sh dies with +# "cannot execute: required file not found" AFTER the whole toolchain has been built, +# which is an expensive way to discover a missing symlink. +if [[ ! -e /bin/bash ]]; then + ln -sf "$(command -v bash)" /bin/bash +fi + +MODE="${1:-spike}" +TAG="${FFMPEG_KIT_TAG:-v8.1.1}" +SRC=/work/ffmpeg-kit-next +OUT=/work/out + +# --------------------------------------------------------------------------- +# Library selection +# --------------------------------------------------------------------------- +# Flag names come from get_library_name() in scripts/function.sh — note it is +# --enable-lame, NOT --enable-libmp3lame. +# +# android-media-codec gives FFmpeg the h264_mediacodec / hevc_mediacodec wrappers. +# Those are the fallback-within-the-fallback: hardware encode from the FFmpeg side +# when a job has been routed away from Media3 for container reasons but still wants +# hardware encode. + +COMMON_LIBS=( + --enable-android-media-codec + --enable-android-zlib + --enable-lame # MP3 encode. Android has NO MP3 encoder at any API level, + # so this is the only way the app can output MP3 at all. + --enable-opus + --enable-dav1d # fast AV1 decode +) + +SUBTITLE_LIBS=( + --enable-libass # ISC licensed, NOT GPL - subtitle burn-in is LGPL-safe + --enable-fontconfig + --enable-freetype + --enable-fribidi + --enable-harfbuzz +) + +# GPL. These are the reason the shipped binary is GPL-3.0 rather than LGPL: they are +# the only route to CRF and 2-pass rate control, which no Android hardware encoder +# exposes. See LICENSES/README.md. +GPL_LIBS=( + --enable-gpl + --enable-x264 + --enable-x265 +) + +EXTRA_LIBS=( + --enable-libvpx # VP8/VP9 + --enable-libsvtav1 # fast AV1 encode +) + +case "$MODE" in + spike) LIBS=("${COMMON_LIBS[@]}") ;; + full) LIBS=("${COMMON_LIBS[@]}" "${SUBTITLE_LIBS[@]}" "${GPL_LIBS[@]}" "${EXTRA_LIBS[@]}") ;; + *) echo "unknown mode: $MODE (expected 'spike' or 'full')" >&2; exit 2 ;; +esac + +echo "==============================================" +echo " ffmpeg-kit-next build" +echo " mode : $MODE" +echo " tag : $TAG" +echo " libraries : ${LIBS[*]}" +echo " started : $(date -u +%Y-%m-%dT%H:%M:%SZ)" +echo "==============================================" + +if [[ ! -d "$SRC" ]]; then + git clone --branch "$TAG" --depth 1 \ + https://github.com/arthenica/ffmpeg-kit-next.git "$SRC" +fi + +cd "$SRC" + +# --------------------------------------------------------------------------- +# AAPT2 override +# --------------------------------------------------------------------------- +# The final step packages the .so files into an AAR with Gradle. Gradle's default +# AAPT2 comes from Maven as a prebuilt binary dynamically linked against normal FHS +# paths (/lib64/ld-linux-x86-64.so.2). Those do not exist in a Nix image, so it dies +# with "AAPT2 ... Daemon startup failed" AFTER the entire native build has succeeded. +# +# The Android SDK that Nix provides has an aapt2 that nixpkgs has already patchelf'd, +# so point Gradle at that one instead. +AAPT2="$(find /nix/store -maxdepth 6 -name aapt2 -type f 2>/dev/null | head -1)" +if [[ -n "$AAPT2" ]]; then + echo "using nix-provided aapt2: $AAPT2" + grep -v 'aapt2FromMavenOverride' android/gradle.properties > /tmp/gradle.properties.new || true + mv /tmp/gradle.properties.new android/gradle.properties + echo "android.aapt2FromMavenOverride=$AAPT2" >> android/gradle.properties +else + echo "WARNING: no nix aapt2 found; the AAR packaging step will probably fail." >&2 +fi + +# 64-bit only, matching the app's abiFilters. Dropping the 32-bit ABIs roughly halves +# build time and APK size, and Play does not require them. +# --api-level matches the app's minSdk 33 (default is 24), so the native code may use +# the newer NDK media APIs. ffmpeg-kit protocols (ffkitsaf, ffkitmem, ffkitstream) are +# left enabled: ffkitsaf is the SAF bridge that replaces the old getSafParameter trick. +# Output still stages through a real cache path rather than a SAF fd, because MP4 +# faststart needs to seek back to rewrite the moov atom. +./nix-android.sh -p android-r27d \ + --api-level=33 \ + --disable-arm-v7a \ + --disable-arm-v7a-neon \ + --disable-x86 \ + "${LIBS[@]}" + +mkdir -p "$OUT" +# Only the ffmpeg-kit AAR. A bare '*.aar' find also sweeps up every AAR that Gradle +# happens to have unpacked into its own caches (junit, espresso, tracing...), which +# is confusing noise in the output directory. +find "$SRC/android/ffmpeg-kit-next-android-lib/build/outputs/aar" \ + "$SRC/prebuilt" \ + -name 'ffmpeg-kit-next*.aar' -exec cp -v {} "$OUT/" \; 2>/dev/null + +echo "==============================================" +echo " finished : $(date -u +%Y-%m-%dT%H:%M:%SZ)" +ls -la "$OUT" || true