fix(media): use LGPL FFmpeg build

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Atish Patel
2026-07-16 14:12:20 -05:00
co-authored by Codex
parent fc5c963347
commit c9fe0b7538
11 changed files with 202 additions and 40 deletions
+7 -1
View File
@@ -99,7 +99,13 @@ jobs:
- name: Install media privacy tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends ffmpeg libimage-exiftool-perl
sudo apt-get install -y --no-install-recommends \
build-essential ffmpeg libimage-exiftool-perl libopenh264-dev \
nasm pkg-config xz-utils zlib1g-dev
FFMPEG_PREFIX="$RUNNER_TEMP/buzz-ffmpeg" ./scripts/build-ffmpeg-lgpl.sh
echo "BUZZ_FFMPEG_PATH=$RUNNER_TEMP/buzz-ffmpeg/bin/ffmpeg" >> "$GITHUB_ENV"
echo "BUZZ_FFPROBE_PATH=$RUNNER_TEMP/buzz-ffmpeg/bin/ffprobe" >> "$GITHUB_ENV"
echo "BUZZ_FIXTURE_FFMPEG_PATH=/usr/bin/ffmpeg" >> "$GITHUB_ENV"
- name: Unit tests
run: just test-unit
- name: Media compliance tests
+10 -6
View File
@@ -44,7 +44,7 @@ unacceptable behavior to **conduct@buzz-relay.org**.
| pnpm | 10+ | Required for desktop app commands and `just ci` |
| Flutter | 3.41+ | Required for mobile app — install via [flutter.dev](https://docs.flutter.dev/get-started/install) |
| Docker | 24+ | For Postgres, Redis, MinIO |
| FFmpeg + ffprobe | 6+ | Media sanitization; pinned by Hermit |
| FFmpeg + ffprobe | 6+ | Media sanitization; install with your OS package manager |
| ExifTool | 12.70+ | Image metadata removal and verification; install with your OS package manager |
| `just` | latest | Task runner — `cargo install just` |
| `lefthook` | latest | Optional; run `lefthook install` for local Git hooks |
@@ -92,11 +92,15 @@ Adminer on `:8082`, Keycloak on `:8180` for local OAuth/OIDC testing, MinIO on
pending database migrations.
The relay validates its privacy toolchain during startup and fails closed when
FFmpeg, ffprobe, ExifTool, or a required codec is unavailable. Hermit supplies
FFmpeg and ffprobe. Install ExifTool separately (for example,
`brew install exiftool` on macOS or `apt install libimage-exiftool-perl` on
Debian/Ubuntu). Override binary locations with `BUZZ_EXIFTOOL_PATH`,
`BUZZ_FFMPEG_PATH`, and `BUZZ_FFPROBE_PATH` when needed.
FFmpeg, ffprobe, ExifTool, or a required codec is unavailable. Install the
media tools separately for local development (for example,
`brew install ffmpeg exiftool` on macOS or
`apt install ffmpeg libimage-exiftool-perl` on Debian/Ubuntu). Buzz rejects
FFmpeg builds configured with `--enable-nonfree`. The published relay image
instead builds an LGPL FFmpeg/ffprobe toolchain with BSD-licensed OpenH264 from
source and includes the corresponding source archive and build configuration.
Override binary locations with `BUZZ_EXIFTOOL_PATH`, `BUZZ_FFMPEG_PATH`, and
`BUZZ_FFPROBE_PATH` when needed.
### Running the Relay and Desktop App
+29 -2
View File
@@ -109,7 +109,31 @@ RUN pnpm install --frozen-lockfile --filter buzz-web
COPY web/ web/
RUN pnpm -C web build
# ─── Stage 5: runtime ───────────────────────────────────────────────────────
# ─── Stage 5: LGPL media toolchain ─────────────────────────────────────────
# Debian's ffmpeg package enables GPL components such as libx264. Build the two
# standalone tools used by the relay from upstream source instead, with GPL and
# non-redistributable components disabled. OpenH264 is BSD-licensed and keeps
# the relay's canonical H.264/AAC MP4 output without pulling libx264 into the
# published image. The exact corresponding source and build configuration are
# retained under /opt/ffmpeg/share/source in the runtime image.
FROM debian:${DEBIAN_VERSION}-slim AS media-tools-builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
curl \
libopenh264-dev \
nasm \
pkg-config \
xz-utils \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
COPY scripts/build-ffmpeg-lgpl.sh /usr/local/bin/build-ffmpeg-lgpl
RUN /usr/local/bin/build-ffmpeg-lgpl
# ─── Stage 6: runtime ───────────────────────────────────────────────────────
FROM debian:${DEBIAN_VERSION}-slim AS runtime
# OCI annotations: required for GHCR to auto-link the image to this repo and
@@ -126,9 +150,9 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
ffmpeg \
git \
libimage-exiftool-perl \
libopenh264-7 \
openssl \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 1000 buzz \
@@ -139,6 +163,9 @@ COPY --from=builder /build/target/release/buzz-relay /usr/local/bin/buzz-rela
COPY --from=builder /build/target/release/buzz-admin /usr/local/bin/buzz-admin
COPY --from=builder /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay
COPY --from=web-builder /build/web/dist /srv/buzz/web
COPY --from=media-tools-builder /opt/ffmpeg /opt/ffmpeg
ENV PATH="/opt/ffmpeg/bin:${PATH}"
# The invite landing page is always served from the bundled web UI. Repository
# browser routes require the separate BUZZ_SERVE_GIT_WEB_GUI=true opt-in.
-1
View File
@@ -1 +0,0 @@
hermit
-1
View File
@@ -1 +0,0 @@
hermit
-1
View File
@@ -1 +0,0 @@
.ffmpeg-6.1.1.pkg
-1
View File
@@ -1 +0,0 @@
.ffprobe-6.1.1.pkg
+87 -11
View File
@@ -36,6 +36,9 @@ pub struct ToolVersions {
pub ffmpeg: String,
/// Full first version line reported by ffprobe.
pub ffprobe: String,
/// H.264 encoder selected from the verified FFmpeg capabilities.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub video_encoder: Option<String>,
}
/// Return the startup-verified sanitizer versions for private audit records.
@@ -89,6 +92,8 @@ pub async fn validate_toolchain(config: &MediaConfig) -> Result<(), MediaError>
let exiftool = successful_version(&config.exiftool_path, "-ver").await?;
let ffmpeg = successful_version(&config.ffmpeg_path, "-version").await?;
let ffprobe = successful_version(&config.ffprobe_path, "-version").await?;
reject_nonredistributable_build(&config.ffmpeg_path).await?;
reject_nonredistributable_build(&config.ffprobe_path).await?;
let encoders = run_tool(
&config.ffmpeg_path,
&["-hide_banner", "-encoders"],
@@ -96,14 +101,8 @@ pub async fn validate_toolchain(config: &MediaConfig) -> Result<(), MediaError>
)
.await?;
let encoders = String::from_utf8_lossy(&encoders.stdout);
for required in [
"libx264",
"aac",
"libaom-av1",
"libvpx-vp9",
"libopus",
"libvorbis",
] {
let video_encoder = select_video_encoder(&encoders).ok_or(MediaError::ToolUnavailable)?;
for required in ["aac"] {
if !encoders.contains(required) {
return Err(MediaError::ToolUnavailable);
}
@@ -136,10 +135,50 @@ pub async fn validate_toolchain(config: &MediaConfig) -> Result<(), MediaError>
exiftool,
ffmpeg,
ffprobe,
video_encoder: Some(video_encoder.to_string()),
});
Ok(())
}
async fn reject_nonredistributable_build(program: &str) -> Result<(), MediaError> {
let output = run_tool(
program,
&["-hide_banner", "-buildconf"],
Duration::from_secs(15),
)
.await?;
if !output.status.success() {
return Err(MediaError::ToolUnavailable);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if build_is_nonredistributable(&stdout) || build_is_nonredistributable(&stderr) {
return Err(MediaError::ToolUnavailable);
}
Ok(())
}
fn build_is_nonredistributable(configuration: &str) -> bool {
configuration
.split_ascii_whitespace()
.any(|argument| argument == "--enable-nonfree")
}
fn select_video_encoder(encoders: &str) -> Option<&'static str> {
["libopenh264", "libx264"].into_iter().find(|encoder| {
encoders
.split_ascii_whitespace()
.any(|word| word == *encoder)
})
}
fn video_encoder() -> Result<&'static str, MediaError> {
TOOL_VERSIONS
.get()
.and_then(|versions| versions.video_encoder.as_deref())
.ok_or(MediaError::ToolUnavailable)
}
async fn successful_version(program: &str, arg: &str) -> Result<String, MediaError> {
let output = run_tool(program, &[arg], Duration::from_secs(15)).await?;
if !output.status.success() {
@@ -467,9 +506,25 @@ async fn sanitize_video(
if can_copy {
args.extend(strings(&["-c:v", "copy", "-c:a", "copy"]));
} else {
args.extend(strings(&[
"-c:v", "libx264", "-preset", "medium", "-crf", "20", "-c:a", "aac", "-b:a", "192k",
]));
match video_encoder()? {
"libopenh264" => args.extend(strings(&[
"-c:v",
"libopenh264",
"-b:v",
"4M",
"-maxrate",
"4M",
"-bufsize",
"8M",
"-pix_fmt",
"yuv420p",
])),
"libx264" => args.extend(strings(&[
"-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p",
])),
_ => return Err(MediaError::ToolUnavailable),
}
args.extend(strings(&["-c:a", "aac", "-b:a", "192k"]));
}
args.extend(strings(&[
"-movflags",
@@ -1036,6 +1091,27 @@ mod tests {
assert!(!is_structural_tag("title"));
}
#[test]
fn nonredistributable_ffmpeg_builds_are_rejected() {
assert!(build_is_nonredistributable(
"configuration: --enable-gpl --enable-nonfree --enable-libx264"
));
assert!(!build_is_nonredistributable(
"configuration: --disable-autodetect --enable-libopenh264 --enable-shared"
));
}
#[test]
fn openh264_is_preferred_without_requiring_it_from_operators() {
let both = " V....D libx264 H.264 / AVC\n V....D libopenh264 OpenH264 H.264";
assert_eq!(select_video_encoder(both), Some("libopenh264"));
assert_eq!(
select_video_encoder(" V....D libx264 H.264 / AVC"),
Some("libx264")
);
assert_eq!(select_video_encoder(" A..... aac AAC"), None);
}
#[test]
fn video_and_animation_limits_fail_closed() {
let mut probe = MediaProbe {
+5 -3
View File
@@ -1,9 +1,11 @@
# Media compliance fixtures
The compliance test builds deterministic, synthetic fixtures at runtime so
codec/container coverage follows the exact FFmpeg and ExifTool binaries shipped
with the relay. Coordinates are fictional test data (`41.8781, -87.6298`) and
no fixture contains a real person or device identifier.
codec/container coverage follows the configured FFmpeg and ExifTool binaries.
The published relay image builds its FFmpeg/ffprobe executables from the pinned
source and configuration in `scripts/build-ffmpeg-lgpl.sh`. Coordinates are
fictional test data (`41.8781, -87.6298`) and no fixture contains a real person
or device identifier.
`tiny.heic.b64` is the sole prebuilt input because FFmpeg does not provide a
portable HEIC muxer. It is a 64×64 synthetic application icon converted by
+6 -13
View File
@@ -1,4 +1,4 @@
use std::path::{Path, PathBuf};
use std::path::Path;
use std::process::Command;
use base64::Engine;
@@ -7,15 +7,6 @@ use buzz_media::MediaConfig;
use sha2::{Digest, Sha256};
fn config() -> MediaConfig {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let hermit = |name: &str| {
let path = root.join("bin").join(name);
if path.exists() {
path.to_string_lossy().into_owned()
} else {
name.to_string()
}
};
MediaConfig {
s3_endpoint: String::new(),
s3_access_key: String::new(),
@@ -30,8 +21,8 @@ fn config() -> MediaConfig {
public_base_url: "http://localhost:3000/media".to_string(),
exiftool_path: std::env::var("BUZZ_EXIFTOOL_PATH")
.unwrap_or_else(|_| "exiftool".to_string()),
ffmpeg_path: std::env::var("BUZZ_FFMPEG_PATH").unwrap_or_else(|_| hermit("ffmpeg")),
ffprobe_path: std::env::var("BUZZ_FFPROBE_PATH").unwrap_or_else(|_| hermit("ffprobe")),
ffmpeg_path: std::env::var("BUZZ_FFMPEG_PATH").unwrap_or_else(|_| "ffmpeg".to_string()),
ffprobe_path: std::env::var("BUZZ_FFPROBE_PATH").unwrap_or_else(|_| "ffprobe".to_string()),
image_process_timeout_secs: 120,
av_process_timeout_secs: 600,
upload_records_enabled: false,
@@ -59,7 +50,9 @@ fn path(path: &Path) -> &str {
fn ffmpeg(config: &MediaConfig, args: &[&str]) {
let mut full = vec!["-nostdin", "-v", "error", "-y"];
full.extend_from_slice(args);
run(&config.ffmpeg_path, &full);
let generator =
std::env::var("BUZZ_FIXTURE_FFMPEG_PATH").unwrap_or_else(|_| config.ffmpeg_path.clone());
run(&generator, &full);
}
fn add_private_metadata(config: &MediaConfig, fixture: &Path) {
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
# Build the relay's FFmpeg/ffprobe toolchain without GPL or non-redistributable
# components. The only external codec enabled here is BSD-licensed OpenH264;
# all other media support comes from FFmpeg's LGPL implementation.
set -eu
: "${FFMPEG_VERSION:=7.1.5}"
: "${FFMPEG_SHA256:=de668509caf9e35e3cd162473441fdb29538c6d96ed080292b3cf9e6fc5d558f}"
: "${FFMPEG_PREFIX:=/opt/ffmpeg}"
archive="/tmp/ffmpeg-${FFMPEG_VERSION}.tar.xz"
source_dir="/tmp/ffmpeg-${FFMPEG_VERSION}"
curl --fail --location --silent --show-error \
"https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.xz" \
--output "${archive}"
echo "${FFMPEG_SHA256} ${archive}" | sha256sum --check --strict
tar --extract --file "${archive}" --directory /tmp
cd "${source_dir}"
./configure \
--prefix="${FFMPEG_PREFIX}" \
--disable-autodetect \
--disable-debug \
--disable-doc \
--disable-ffplay \
--disable-network \
--disable-static \
--enable-libopenh264 \
--enable-shared \
--extra-ldflags="-Wl,-rpath,${FFMPEG_PREFIX}/lib" \
--enable-zlib
make --jobs="$(getconf _NPROCESSORS_ONLN)"
make install
license_dir="${FFMPEG_PREFIX}/share/licenses/ffmpeg"
openh264_license_dir="${FFMPEG_PREFIX}/share/licenses/openh264"
source_archive_dir="${FFMPEG_PREFIX}/share/source/ffmpeg"
mkdir -p "${license_dir}" "${openh264_license_dir}" "${source_archive_dir}"
cp COPYING.LGPLv2.1 LICENSE.md "${license_dir}/"
cp /usr/share/doc/libopenh264-dev/copyright "${openh264_license_dir}/"
cp "${archive}" "${source_archive_dir}/"
"${FFMPEG_PREFIX}/bin/ffmpeg" -hide_banner -buildconf \
> "${source_archive_dir}/build-configuration.txt" 2>&1
if grep -q -- '--enable-gpl\|--enable-nonfree' \
"${source_archive_dir}/build-configuration.txt"; then
echo "Refusing to package a GPL or non-redistributable FFmpeg build" >&2
exit 1
fi
"${FFMPEG_PREFIX}/bin/ffmpeg" -hide_banner -encoders 2>&1 \
| grep -q 'libopenh264'
"${FFMPEG_PREFIX}/bin/ffprobe" -hide_banner -version >/dev/null