#!/usr/bin/env bash
#
# Build the ffmpeg the desktop installer ships: audio decoding and nothing
# else, LGPL-2.1-or-later, one self-contained program per platform.
# https://linear.app/mixready/issue/MIX-11
#
#   scripts/build_ffmpeg.sh TARGET               build into desktop/dist-ffmpeg/TARGET/
#   scripts/build_ffmpeg.sh --key TARGET         the cache key: version and configure line
#   scripts/build_ffmpeg.sh --configure TARGET   print the configure line and exit
#   scripts/build_ffmpeg.sh --host-target        the TARGET this machine runs, or nothing
#
# TARGET is one of the three installers release.yml builds:
#
#   win-x64    cross-compiled on Linux with mingw-w64   (apt: mingw-w64 nasm)
#   mac-arm64  on an Apple silicon Mac, with Xcode's clang
#   mac-x64    on an Intel Mac, with Xcode's clang      (brew: nasm)
#
# Windows is cross-compiled rather than built under MSYS2 on the Windows runner
# for three reasons, in order of weight. A Linux minute costs half a Windows
# minute against the Actions allowance. FFmpeg's configure is a shell script
# that forks thousands of times, which runs in about a minute on Linux and in
# several under MSYS2's fork emulation, before a line compiles. And the
# toolchain comes from the runner image's distro packages, fixed for the
# image's life, where MSYS2's are rolling. Most Windows FFmpeg builds in
# circulation are made this way (https://github.com/BtbN/FFmpeg-Builds).
#
# What the desktop does with the result is in mixready/ffmpeg_decode.py:
# every .m4a (AAC or ALAC), and the MP3s libsndfile cannot finish. PyAV would
# do the same job and cannot ship, because its wheels carry a GPL FFmpeg.
#
# **Nothing GPL, nothing nonfree.** There is no --enable-gpl and no
# --enable-nonfree below and there must never be one: either would make the
# program GPL (or unredistributable), and it ships inside a proprietary app.
# scripts/check_ffmpeg_build.py reads the licence back out of the built binary,
# and scripts/build_backend.sh runs that check again on the copy it freezes.

set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

# The official release tarball, pinned by version and by checksum.
#
# ffmpeg.org publishes PGP signatures and no checksums, so this SHA-256 was
# taken from two independent pins of the same file, which agree: Homebrew's
# formula (https://github.com/Homebrew/homebrew-core/blob/master/Formula/f/ffmpeg.rb)
# and the FFmpeg that PyAV builds its wheels from
# (https://github.com/PyAV-Org/pyav-ffmpeg/blob/main/scripts/pkg.py), both
# read 2026-09-27. A tarball that does not match is refused, not rebuilt.
FFMPEG_VERSION="9.0.2"
FFMPEG_URL="https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.xz"
FFMPEG_SHA256="8c3850283eb25fa026482078a04051e0be17347b09ef81a0849bec15a96e002e"

# Bump when the build changes in a way the configure line does not show — a
# new file in the output, a different strip — so the cache key moves with it.
BUILD_REV=1

# The oldest macOS the binary runs on. Apple silicon starts at 11, and the
# frozen sidecar's own floor is no lower.
MACOS_MIN="11.0"

# The PCM a WAV or an AIFF(-C) can carry, by name rather than `pcm_*`, which
# would also pull in Blu-ray, DVD and a dozen other broadcast variants.
PCM_DECODERS="pcm_s16le,pcm_s16be,pcm_s24le,pcm_s24be,pcm_s32le,pcm_s32be"
PCM_DECODERS="$PCM_DECODERS,pcm_f32le,pcm_f32be,pcm_f64le,pcm_f64be,pcm_u8,pcm_s8"
PCM_DECODERS="$PCM_DECODERS,pcm_alaw,pcm_mulaw"

log() { echo "[build-ffmpeg] $*"; }
die() { echo "ABORT: $*" >&2; exit 1; }

# Sets CONFIGURE to the exact arguments for TARGET. One place, because the
# cache key, the manifest and the build must all read the same line.
configure_args() {
    CONFIGURE=(
        # Start from nothing and name every component: a default build
        # carries hundreds of codecs, a network stack and every hardware API
        # its host happens to have, none of which decoding a DJ's files needs.
        --disable-everything
        # No external library found on the build machine may slip in: zlib,
        # iconv, SDL, the platform's media frameworks. Threads are on that
        # list too, which is why each target enables its own below — the
        # ffmpeg program will not build without them.
        --disable-autodetect
        --disable-network
        --disable-doc
        --disable-debug
        --disable-ffplay
        --disable-ffprobe
        --disable-avdevice
        --disable-swscale
        --enable-static
        --disable-shared
        --enable-swresample
        --enable-protocol=file,pipe
        --enable-demuxer=mp3,mov,aac,flac,wav,aiff,ogg
        --enable-parser=mpegaudio,aac,flac,vorbis,opus
        --enable-decoder="mp3float,aac,alac,flac,vorbis,opus,$PCM_DECODERS"
        # atrim is how the command line cuts at a seek point to the sample;
        # aresample and aformat convert to float32; anull is the default
        # graph. mixready/ffmpeg_decode.py asks for exactly these.
        --enable-filter=aresample,aformat,anull,atrim
        --enable-encoder=pcm_f32le
        --enable-muxer=wav
    )
    case "$1" in
        win-x64)
            CONFIGURE+=(
                --target-os=mingw32 --arch=x86_64 --enable-cross-compile
                --cross-prefix=x86_64-w64-mingw32-
                --enable-w32threads
                # libgcc and the mingw runtime linked in, so the .exe needs
                # nothing beside it that Windows does not already have.
                --extra-ldflags=-static
            )
            ;;
        mac-arm64|mac-x64)
            CONFIGURE+=(
                --enable-pthreads
                "--extra-cflags=-mmacosx-version-min=$MACOS_MIN"
                "--extra-ldflags=-mmacosx-version-min=$MACOS_MIN"
            )
            ;;
        *)
            die "unknown target '$1' — win-x64, mac-arm64 or mac-x64"
            ;;
    esac
}

sha256_of() {
    if command -v sha256sum >/dev/null 2>&1; then
        sha256sum "$1" | cut -d' ' -f1
    else
        shasum -a 256 "$1" | cut -d' ' -f1
    fi
}

cache_key() {
    configure_args "$1"
    local digest
    digest="$(printf '%s\n' "$FFMPEG_VERSION" "$FFMPEG_SHA256" "rev $BUILD_REV" "${CONFIGURE[@]}" \
        | { if command -v sha256sum >/dev/null 2>&1; then sha256sum; else shasum -a 256; fi; } \
        | cut -c1-16)"
    echo "ffmpeg-${FFMPEG_VERSION}-$1-${digest}"
}

host_target() {
    case "$(uname -s)-$(uname -m)" in
        Darwin-arm64) echo "mac-arm64" ;;
        Darwin-x86_64) echo "mac-x64" ;;
        MINGW*-*|MSYS*-*|CYGWIN*-*) echo "win-x64" ;;
        *) echo "" ;;
    esac
}

json_str() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; }

case "${1:-}" in
    --key)
        [[ -n "${2:-}" ]] || die "--key needs a TARGET"
        cache_key "$2"
        exit 0
        ;;
    --configure)
        [[ -n "${2:-}" ]] || die "--configure needs a TARGET"
        configure_args "$2"
        echo "${CONFIGURE[*]}"
        exit 0
        ;;
    --host-target)
        host_target
        exit 0
        ;;
    ""|-h|--help)
        sed -n '2,14p' "${BASH_SOURCE[0]}"
        exit 2
        ;;
esac

TARGET="$1"
configure_args "$TARGET"

# Each target is built where it runs, the Windows one excepted. Refusing the
# wrong host here is cheaper than finding an Intel binary in the arm64 app,
# which the arch gate in build_desktop.sh would only catch after a freeze.
case "$TARGET" in
    win-x64)
        [[ "$(uname -s)" == "Linux" ]] || die "win-x64 is cross-compiled on Linux (see the header)."
        command -v x86_64-w64-mingw32-gcc >/dev/null || die "no mingw-w64: apt-get install mingw-w64 nasm"
        command -v nasm >/dev/null || die "no nasm: apt-get install nasm"
        ;;
    mac-arm64|mac-x64)
        [[ "$(host_target)" == "$TARGET" ]] || die "$TARGET must be built on that Mac; this is $(uname -s) $(uname -m)."
        if [[ "$TARGET" == "mac-x64" ]]; then
            command -v nasm >/dev/null || die "no nasm: brew install nasm"
        fi
        ;;
esac

WORK="$ROOT/desktop/build-ffmpeg/$TARGET"
OUT="$ROOT/desktop/dist-ffmpeg/$TARGET"
EXE="ffmpeg"
[[ "$TARGET" == "win-x64" ]] && EXE="ffmpeg.exe"
JOBS="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)"

rm -rf "$WORK" "$OUT"
mkdir -p "$WORK" "$OUT"

TARBALL="$WORK/ffmpeg-${FFMPEG_VERSION}.tar.xz"
log "fetching $FFMPEG_URL"
curl -fsSL --retry 3 --retry-delay 5 -o "$TARBALL" "$FFMPEG_URL"
got="$(sha256_of "$TARBALL")"
if [[ "$got" != "$FFMPEG_SHA256" ]]; then
    die "ffmpeg-${FFMPEG_VERSION}.tar.xz has SHA-256 $got, not the pinned $FFMPEG_SHA256."
fi
log "source verified: sha256 $got"

tar -xJf "$TARBALL" -C "$WORK"
SRC="$WORK/ffmpeg-${FFMPEG_VERSION}"

log "configuring for $TARGET"
( cd "$SRC" && ./configure "${CONFIGURE[@]}" ) >"$WORK/configure.log" 2>&1 || {
    tail -40 "$WORK/configure.log" >&2
    die "configure failed (log above)."
}

# configure only WARNS about a component name it does not know, and then
# builds without it: a decoder renamed upstream would ship a program that
# cannot open the format, reported nowhere until a DJ's file fails.
if grep -q "did not match anything" "$WORK/configure.log"; then
    grep "did not match anything" "$WORK/configure.log" >&2
    die "a component in the configure line no longer exists in FFmpeg $FFMPEG_VERSION."
fi
# The licence configure settled on, and the program actually being built —
# with no threads it silently drops ffmpeg and builds only the libraries. Read
# from the files the build compiles against rather than from configure's
# report, whose layout is not an interface.
grep -q '^#define FFMPEG_LICENSE "LGPL version 2.1 or later"' "$SRC/config.h" \
    || die "configure did not settle on LGPL 2.1 or later: $(grep FFMPEG_LICENSE "$SRC/config.h" || echo 'no licence line')"
grep -q '^CONFIG_FFMPEG=yes' "$SRC/ffbuild/config.mak" \
    || die "configure is not building the ffmpeg program (see $WORK/configure.log)."
log "configured: LGPL version 2.1 or later"

log "compiling with $JOBS jobs"
make -C "$SRC" -j"$JOBS" >"$WORK/make.log" 2>&1 || {
    tail -40 "$WORK/make.log" >&2
    die "make failed (log above)."
}
[[ -f "$SRC/$EXE" ]] || die "make finished without producing $EXE."

# What the program links against. `-static` and --disable-autodetect should
# leave only the operating system's own libraries; anything else would have to
# ship beside the binary, and the installer carries nothing that would.
case "$TARGET" in
    win-x64)
        deps="$(x86_64-w64-mingw32-objdump -p "$SRC/$EXE" | sed -n 's/^[[:space:]]*DLL Name: //p')"
        if grep -qiE '^(lib|av|sw|zlib|postproc)' <<<"$deps"; then
            die "ffmpeg.exe needs a DLL Windows does not have: $(echo "$deps" | tr '\n' ' ')"
        fi
        ;;
    mac-*)
        deps="$(otool -L "$SRC/$EXE" | tail -n +2 | awk '{print $1}')"
        if grep -vE '^(/usr/lib/|/System/Library/)' <<<"$deps" | grep -q .; then
            die "ffmpeg links a library macOS does not have: $(echo "$deps" | tr '\n' ' ')"
        fi
        ;;
esac
log "links only the system's own libraries: $(echo "$deps" | tr '\n' ' ')"

cp "$SRC/$EXE" "$OUT/$EXE"
# The licence the binary is under, and FFmpeg's own statement of which parts
# carry which licence. scripts/generate_notices.py puts both in the installer's
# third-party notices.
cp "$SRC/COPYING.LGPLv2.1" "$SRC/LICENSE.md" "$OUT/"

compiler="$( (cd "$SRC" && sed -n 's/^CC=//p' ffbuild/config.mak | head -1) || true)"
compiler_version="$( ($compiler --version 2>/dev/null || true) | head -1)"
cat >"$OUT/manifest.json" <<JSON
{
  "name": "ffmpeg",
  "version": "$FFMPEG_VERSION",
  "target": "$TARGET",
  "licence": "LGPL-2.1-or-later",
  "source": {
    "url": "$FFMPEG_URL",
    "sha256": "$FFMPEG_SHA256"
  },
  "configure": "$(json_str "${CONFIGURE[*]}")",
  "build_rev": $BUILD_REV,
  "binary": "$EXE",
  "binary_sha256": "$(sha256_of "$OUT/$EXE")",
  "compiler": "$(json_str "$compiler_version")"
}
JSON

log "checking the licence the binary reports, and that it decodes"
"${PYTHON:-python3}" "$ROOT/scripts/check_ffmpeg_build.py" "$OUT"

log "built $OUT/$EXE ($(du -h "$OUT/$EXE" | cut -f1))"
