#!/usr/bin/env bash # build_ffmpeg.sh — Build a slim LGPL-only FFmpeg 7.x XCFramework for tvOS. # # Produces: # build/xcframeworks/FFmpeg.xcframework (device arm64 + simulator arm64) # with the dSYM of each slice (dSYMs/FFmpeg.framework.dSYM, FFmpeg and dav1d # with line tables) and a PrivacyInfo.xcprivacy in each framework. # Scripts/release.sh uploads the device dSYM to the symbols service. # # Requirements: # - macOS host with Xcode 15+ and Command Line Tools installed # - Homebrew: brew install nasm pkg-config # - No Rust toolchain needed for this script (libdovi is separate) # # LGPL compliance notes: # --disable-gpl --disable-nonfree ensures a pure LGPL-2.1+ build. # We dynamic-link the result as an XCFramework (not a static .a baked into # the app binary) so users can relink against a modified FFmpeg per LGPL §4. # Publish your exact source + build script and provide object files on request. # # Usage: # bash Scripts/build_ffmpeg.sh # build everything # FFMPEG_VERSION=7.1 bash Scripts/build_ffmpeg.sh set -euo pipefail FFMPEG_VERSION="${FFMPEG_VERSION:-7.1}" FFMPEG_SRC="ffmpeg-${FFMPEG_VERSION}" FFMPEG_TARBALL="${FFMPEG_SRC}.tar.bz2" FFMPEG_URL="https://ffmpeg.org/releases/${FFMPEG_TARBALL}" # dav1d (BSD-2): the AV1 decoder for files the Apple TV has no AV1 hardware for. DAV1D_VERSION="${DAV1D_VERSION:-1.5.1}" DAV1D_SRC="dav1d-${DAV1D_VERSION}" DAV1D_URL="https://downloads.videolan.org/pub/videolan/dav1d/${DAV1D_VERSION}/${DAV1D_SRC}.tar.xz" DAV1D_SHA256="401813f1f89fa8fd4295805aa5284d9aed9bc7fc1fdbe554af4292f64cbabe21" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(dirname "$SCRIPT_DIR")" # FFmpeg's build system does not tolerate spaces in paths (config.sh splits on # them when writing linker flags). Use a space-free directory under $TMPDIR for # all intermediate build artefacts; only the finished XCFramework is copied back # into the project tree. BUILD_DIR="${TMPDIR:-/tmp}" BUILD_DIR="${BUILD_DIR%/}/HDP_FFmpeg_Build" # Final destination inside the project (may contain spaces — only the copy # command runs with this path, never the FFmpeg Makefile). XCFW_DEST="${ROOT_DIR}/build/xcframeworks" TVOS_DEPLOYMENT_TARGET="18.0" SIM_DEPLOYMENT_TARGET="18.0" # ── Toolchain ──────────────────────────────────────────────────────────────── XCODE_PATH="$(xcode-select -p)" TVOS_SDK="${XCODE_PATH}/Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS.sdk" SIM_SDK="${XCODE_PATH}/Platforms/AppleTVSimulator.platform/Developer/SDKs/AppleTVSimulator.sdk" check_tools() { for tool in nasm pkg-config meson ninja; do if ! command -v "$tool" &>/dev/null; then echo "❌ Missing: $tool → brew install $tool" exit 1 fi done if [ ! -d "$TVOS_SDK" ]; then echo "❌ tvOS SDK not found at $TVOS_SDK — install the tvOS platform in Xcode." exit 1 fi } # ── Source ─────────────────────────────────────────────────────────────────── fetch_source() { mkdir -p "${BUILD_DIR}/src" cd "${BUILD_DIR}/src" if [ ! -d "${FFMPEG_SRC}" ]; then echo "▶ Downloading FFmpeg ${FFMPEG_VERSION}…" curl -LO "${FFMPEG_URL}" tar xf "${FFMPEG_TARBALL}" rm "${FFMPEG_TARBALL}" fi if [ ! -d "${DAV1D_SRC}" ]; then echo "▶ Downloading dav1d ${DAV1D_VERSION}…" curl -LO "${DAV1D_URL}" echo "${DAV1D_SHA256} ${DAV1D_SRC}.tar.xz" | shasum -a 256 -c - tar xf "${DAV1D_SRC}.tar.xz" rm "${DAV1D_SRC}.tar.xz" fi } # ── dav1d, static, into the slice's install prefix ─────────────────────────── # FFmpeg's configure finds it there through pkg-config, and merge_dylibs # force-loads its archive into FFmpeg.framework with FFmpeg's own. build_dav1d() { local SLICE="$1" SYSROOT="$2" TRIPLE="$3" local PREFIX="${BUILD_DIR}/${SLICE}/install" local CROSS="${BUILD_DIR}/${SLICE}/dav1d-cross.txt" mkdir -p "${BUILD_DIR}/${SLICE}" cat > "${CROSS}" <extradata. --enable-bsf=hevc_mp4toannexb --enable-bsf=h264_mp4toannexb --enable-bsf=extract_extradata # Encoders — TrueHD / DTS have no Apple TV passthrough path, so Path B # decodes them to PCM and re-encodes to E-AC-3 (Revision 2 audio decision), # which movenc then muxes with a dec3 box like native Dolby Digital Plus. # Both encoders are FFmpeg-native and LGPL-clean (no GPL/libfdk dependency). --enable-encoder=ac3 --enable-encoder=eac3 # Protocols — only file + custom (AVIOBridge). No network stacks from FFmpeg. --enable-protocol=file --enable-protocol=pipe --enable-protocol=custom # avio_alloc_context (our AVIOBridge) # swresample for audio decode → PCM conversion --enable-swresample # Disable bitcode (dead since Xcode 14) --disable-bzlib # avoid dependency; unused # Interlaced sources (DVD, broadcast MPEG-2 and VC-1) are deinterlaced # before they are encoded: bwdif, one frame out per frame in. --enable-filter=buffer --enable-filter=buffersink --enable-filter=bwdif ) # ── Build for one slice ─────────────────────────────────────────────────────── build_slice() { local SLICE="$1" # "device" or "simulator" local SDK="$2" local SYSROOT="$3" local DEPLOYMENT_TARGET="$4" # $5 onward reserved; --enable-simulator was invalid and is now removed local SLICE_DIR="${BUILD_DIR}/${SLICE}" mkdir -p "${SLICE_DIR}" local CC="$(xcrun -sdk "${SDK}" -find clang)" local AR="$(xcrun -sdk "${SDK}" -find ar)" # For the simulator slice we must use the explicit target triple so the # linker produces a appletvsimulator (not appletvos) Mach-O. FFmpeg has # no --enable-simulator flag; the distinction is made entirely via CFLAGS. if [[ "${SDK}" == "appletvsimulator" ]]; then local TARGET_TRIPLE="arm64-apple-tvos${DEPLOYMENT_TARGET}-simulator" else local TARGET_TRIPLE="arm64-apple-tvos${DEPLOYMENT_TARGET}" fi local CFLAGS="-arch arm64 -target ${TARGET_TRIPLE} -isysroot ${SYSROOT}" local LDFLAGS="-arch arm64 -target ${TARGET_TRIPLE} -isysroot ${SYSROOT}" rm -rf "${SLICE_DIR}/install" build_dav1d "${SLICE}" "${SYSROOT}" "${TARGET_TRIPLE}" # Only the slice's own libraries (dav1d), never the host's. export PKG_CONFIG_LIBDIR="${SLICE_DIR}/install/lib/pkgconfig" export PKG_CONFIG_PATH="${PKG_CONFIG_LIBDIR}" echo "▶ Configuring FFmpeg for ${SLICE} (${TARGET_TRIPLE})…" cd "${BUILD_DIR}/src/${FFMPEG_SRC}" ./configure \ "${COMMON_FLAGS[@]}" \ --pkg-config-flags="--static" \ --prefix="${SLICE_DIR}/install" \ --cc="${CC}" \ --ar="${AR}" \ --sysroot="${SYSROOT}" \ --extra-cflags="${CFLAGS}" \ --extra-ldflags="${LDFLAGS}" \ --enable-static \ --disable-shared \ 2>&1 | tail -20 local JOBS JOBS="$(sysctl -n hw.logicalcpu 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || echo 8)" echo "▶ Building FFmpeg for ${SLICE} (${JOBS} jobs)…" make clean make -j"${JOBS}" make install } # ── Assemble XCFramework ────────────────────────────────────────────────────── assemble_xcframework() { echo "▶ Assembling FFmpeg.xcframework…" rm -rf "${XCFW_DEST}/FFmpeg.xcframework" mkdir -p "${XCFW_DEST}" # We ship a single merged dylib per slice. In a real build you'd merge the # individual per-library dylibs (libavformat, libavcodec, libswresample, # libswscale, libavutil) into one fat dylib using `libtool -dynamic` or # leave them as separate frameworks. For clarity we build them as a # framework bundle per library and group in one XCFramework. # Merge all .dylibs for device slice merge_dylibs "device" "appletvos" # Merge all .dylibs for simulator slice merge_dylibs "simulator" "appletvsimulator" # xcodebuild also chokes on spaces in the -output path, so assemble in the # space-free BUILD_DIR first, then copy to the project tree. local XCFW_TMP="${BUILD_DIR}/FFmpeg.xcframework" rm -rf "${XCFW_TMP}" xcodebuild -create-xcframework \ -framework "${BUILD_DIR}/device/FFmpeg.framework" \ -debug-symbols "${BUILD_DIR}/device/FFmpeg.framework.dSYM" \ -framework "${BUILD_DIR}/simulator/FFmpeg.framework" \ -debug-symbols "${BUILD_DIR}/simulator/FFmpeg.framework.dSYM" \ -output "${XCFW_TMP}" # Copy the finished XCFramework into the project (spaces in path are fine here). mkdir -p "${XCFW_DEST}" rm -rf "${XCFW_DEST}/FFmpeg.xcframework" cp -R "${XCFW_TMP}" "${XCFW_DEST}/FFmpeg.xcframework" echo "✅ FFmpeg.xcframework → ${XCFW_DEST}/FFmpeg.xcframework" } # dSYM of one slice, next to the framework: ${BUILD_DIR}//FFmpeg.framework.dSYM. # Must run right after the link: the dylib's debug map points at the .a files in # ${INSTALL}/lib (FFmpeg and dav1d), and dsymutil refuses a member whose timestamp # changed since. Crash reports name images by UUID, so the dSYM must carry the # binary's UUID, or the symbols service has nothing to match. make_dsym() { local SLICE="$1" local FW_DIR="$2" local DSYM="${BUILD_DIR}/${SLICE}/FFmpeg.framework.dSYM" rm -rf "${DSYM}" xcrun dsymutil "${FW_DIR}/FFmpeg" -o "${DSYM}" 2> "${BUILD_DIR}/${SLICE}/dsymutil.log" local U_BIN U_SYM U_BIN="$(dwarfdump --uuid "${FW_DIR}/FFmpeg" | awk '{print $2}')" U_SYM="$(dwarfdump --uuid "${DSYM}" | awk '{print $2}')" if [ -z "${U_BIN}" ] || [ "${U_BIN}" != "${U_SYM}" ]; then echo "❌ ${SLICE}: dSYM UUID (${U_SYM}) does not match the binary (${U_BIN})" exit 1 fi if [ "$(du -sk "${DSYM}" | cut -f1)" -lt 1024 ]; then echo "❌ ${SLICE}: dSYM is nearly empty; no debug info reached the dylib" exit 1 fi echo "▶ ${SLICE} dSYM: ${U_SYM}" } merge_dylibs() { local SLICE="$1" local PLATFORM="$2" local INSTALL="${BUILD_DIR}/${SLICE}/install" local FW_DIR="${BUILD_DIR}/${SLICE}/FFmpeg.framework" local HEADERS_DIR="${FW_DIR}/Headers" rm -rf "${FW_DIR}" mkdir -p "${HEADERS_DIR}" mkdir -p "${FW_DIR}/Modules" # Build is static (.a). To get ONE self-contained dynamic framework binary we # -force_load every FFmpeg archive (so all their object files / symbols are # pulled into the dylib) and link FFmpeg's own external deps + the Apple # frameworks it references. NOTE: plain `clang -dynamiclib a.dylib b.dylib` # does NOT copy symbols — it yields an empty stub — which was the bug here. local FORCE_LOAD=() for a in "${INSTALL}/lib/"*.a; do [ -f "$a" ] && FORCE_LOAD+=("-Wl,-force_load,${a}") done if [ ${#FORCE_LOAD[@]} -eq 0 ]; then echo "❌ No static archives found in ${INSTALL}/lib — did 'make install' run?" exit 1 fi # External libs FFmpeg's configure determined it needs (zlib, iconv, m, …). local EXTRALIBS EXTRALIBS="$(grep -h '^EXTRALIBS=' "${BUILD_DIR}/src/${FFMPEG_SRC}/ffbuild/config.mak" 2>/dev/null | head -1 | sed 's/^EXTRALIBS=//')" # Determine the clang for this platform so the correct SDK/target is used. local CLANG CLANG="$(xcrun -sdk "${PLATFORM}" -find clang)" # Reuse the same target triple that build_slice used. if [[ "${PLATFORM}" == "appletvsimulator" ]]; then local TRIPLE="arm64-apple-tvos${TVOS_DEPLOYMENT_TARGET}-simulator" local SYSROOT="${SIM_SDK}" else local TRIPLE="arm64-apple-tvos${TVOS_DEPLOYMENT_TARGET}" local SYSROOT="${TVOS_SDK}" fi "${CLANG}" -dynamiclib \ -target "${TRIPLE}" \ -isysroot "${SYSROOT}" \ -install_name '@rpath/FFmpeg.framework/FFmpeg' \ "${FORCE_LOAD[@]}" \ ${EXTRALIBS} \ -lz -liconv \ -framework CoreFoundation -framework CoreMedia -framework CoreVideo \ -framework AudioToolbox -framework VideoToolbox \ -o "${FW_DIR}/FFmpeg" make_dsym "${SLICE}" "${FW_DIR}" # Privacy manifest: an SDK's own bundle has to declare the required-reason # APIs it calls (fstat/lstat, clock_gettime). Not the app's manifest. cp "${SCRIPT_DIR}/FFmpeg.PrivacyInfo.xcprivacy" "${FW_DIR}/PrivacyInfo.xcprivacy" plutil -lint "${FW_DIR}/PrivacyInfo.xcprivacy" >/dev/null # Copy public headers for lib in libavformat libavcodec libavfilter libavutil libswresample libswscale; do [ -d "${INSTALL}/include/${lib}" ] && \ cp -R "${INSTALL}/include/${lib}" "${HEADERS_DIR}/" done # Supported-platform string for the Info.plist (used by installd). local SUPPORTED_PLATFORM if [[ "${PLATFORM}" == "appletvsimulator" ]]; then SUPPORTED_PLATFORM="AppleTVSimulator" else SUPPORTED_PLATFORM="AppleTVOS" fi # Write the Info.plist. CFBundleExecutable is REQUIRED — without it installd # rejects the embedded framework ("missing or invalid CFBundleExecutable"). cat > "${FW_DIR}/Info.plist" < CFBundleDevelopmentRegionen CFBundleExecutableFFmpeg CFBundleIdentifierorg.ffmpeg.FFmpeg CFBundleInfoDictionaryVersion6.0 CFBundleNameFFmpeg CFBundlePackageTypeFMWK CFBundleShortVersionString${FFMPEG_VERSION} CFBundleVersion${FFMPEG_VERSION} CFBundleSupportedPlatforms${SUPPORTED_PLATFORM} MinimumOSVersion${TVOS_DEPLOYMENT_TARGET} PLIST # Module map for Clang module import. The framework has no single flat # umbrella header — its public API lives in nested directories # (Headers/libavutil/…, Headers/libavformat/…, etc.) — so we use a # DIRECTORY umbrella, which exposes every header under Headers/. # (Do NOT reference FFmpegBridge.h here: that header belongs to the separate # FFmpegBridge Swift-interop module and is not copied into this framework.) cat > "${FW_DIR}/Modules/module.modulemap" < ). exclude header "libavcodec/d3d11va.h" exclude header "libavcodec/dxva2.h" exclude header "libavcodec/qsv.h" exclude header "libavcodec/vdpau.h" exclude header "libavutil/hwcontext_cuda.h" exclude header "libavutil/hwcontext_d3d11va.h" exclude header "libavutil/hwcontext_d3d12va.h" exclude header "libavutil/hwcontext_dxva2.h" exclude header "libavutil/hwcontext_opencl.h" exclude header "libavutil/hwcontext_qsv.h" exclude header "libavutil/hwcontext_vaapi.h" exclude header "libavutil/hwcontext_vdpau.h" exclude header "libavutil/hwcontext_vulkan.h" export * module * { export * } } MODMAP } # ── Main ───────────────────────────────────────────────────────────────────── main() { echo "▶ Build dir (space-free): ${BUILD_DIR}" echo "▶ Output dest: ${XCFW_DEST}" check_tools fetch_source build_slice "device" "appletvos" "${TVOS_SDK}" "${TVOS_DEPLOYMENT_TARGET}" build_slice "simulator" "appletvsimulator" "${SIM_SDK}" "${SIM_DEPLOYMENT_TARGET}" assemble_xcframework echo "" echo "FFmpeg dSYM (device): $(dwarfdump --uuid "${BUILD_DIR}/device/FFmpeg.framework.dSYM" | awk '{print $2}')" echo " git add -f build/xcframeworks/FFmpeg.xcframework (build/ is git-ignored)" echo "" echo "Next: add build/xcframeworks/FFmpeg.xcframework to your Xcode project" echo "and set it as an Embed & Sign dependency of the Astrape target." echo "" echo "LGPL compliance checklist:" echo " ✓ --disable-gpl --disable-nonfree (pure LGPL build)" echo " ✓ Shipped as dynamic framework (relinking possible)" echo " ✓ Publish this script + exact FFmpeg source on request" echo " ✓ Provide object files on request" echo " ✓ Include LGPL text in-app (Settings → Acknowledgements)" } main "$@"