diff --git a/.github/release-notes.md b/.github/release-notes.md index bfbb9d1..6e25b71 100644 --- a/.github/release-notes.md +++ b/.github/release-notes.md @@ -67,7 +67,7 @@ from inside Align, behind the same system authorization prompt. ## Sources and verification `align-source-.tar.gz` contains the corresponding Align source, locked -and vendored Rust dependencies, the FFmpeg source used on Linux and Windows, and +and vendored Rust dependencies, the FFmpeg source used for the bundled tools, and the AAF component sources. License notices are included in every installer. Align is licensed under GPL-3.0-only. diff --git a/.github/workflows/align-rs.yml b/.github/workflows/align-rs.yml index 6a4cfb3..e0e8951 100644 --- a/.github/workflows/align-rs.yml +++ b/.github/workflows/align-rs.yml @@ -205,9 +205,9 @@ jobs: - name: Build AAF module run: python script/build-aaf-sidecar.py target/release --release-sources # macOS decodes and remuxes through AVFoundation and native QuickTime - # code, so its installer ships no FFmpeg. - - name: Build minimal FFmpeg sidecars (Linux) - if: runner.os == 'Linux' + # code; its FFmpeg reads only MXF, which AVFoundation cannot open. + - name: Build minimal FFmpeg sidecars (Linux, macOS) + if: runner.os != 'Windows' run: ./script/build-ffmpeg-minimal.sh target/ffmpeg-minimal - name: Build minimal FFmpeg sidecars (Windows) if: runner.os == 'Windows' @@ -227,10 +227,8 @@ jobs: fi cp -R target/release/AAF-Licenses dist-bundle/ cp LICENSE THIRD_PARTY_NOTICES.txt dist-bundle/ - if [[ "${{ runner.os }}" != "macOS" ]]; then - cp "target/ffmpeg-minimal/ffmpeg$exe" "target/ffmpeg-minimal/ffprobe$exe" dist-bundle/ - cp target/ffmpeg-minimal/FFMPEG-LICENSE.txt dist-bundle/ - fi + cp "target/ffmpeg-minimal/ffmpeg$exe" "target/ffmpeg-minimal/ffprobe$exe" dist-bundle/ + cp target/ffmpeg-minimal/FFMPEG-LICENSE.txt dist-bundle/ ls -la dist-bundle/ - name: Verify staged sidecars without MSYS2 (Windows) if: runner.os == 'Windows' @@ -241,14 +239,10 @@ jobs: - name: Prepare corresponding sources and dependency licenses shell: bash run: | - ffmpeg=() - if [[ "${{ runner.os }}" != "macOS" ]]; then - ffmpeg=(--ffmpeg target/ffmpeg-minimal/Sources) - fi python script/prepare-release-source.py target/release-sources \ --platform ${{ runner.os }} \ --licenses dist-bundle/ThirdPartyLicenses \ - "${ffmpeg[@]}" \ + --ffmpeg target/ffmpeg-minimal/Sources \ --aaf target/release/AAF-Sources \ --velopack-version ${{ env.VELOPACK_VERSION }} - uses: actions/upload-artifact@v4 @@ -266,6 +260,8 @@ jobs: "$RUNNER_TEMP/dmg-venv/bin/python" -m pip install dmgbuild==1.6.7 AAF_DIR="$PWD/target/release" \ RELEASE_LICENSES="$PWD/dist-bundle/ThirdPartyLicenses" \ + BUNDLE_FFMPEG=1 \ + FFMPEG_DIR="$PWD/target/ffmpeg-minimal" \ ALIGN_SKIP_LAUNCH_CHECK=1 \ ./script/package-macos.sh "$PWD/target/macos-package" mkdir -p installers diff --git a/crates/align-decode/src/apple.rs b/crates/align-decode/src/apple.rs index 8aa5c86..4fa1c2a 100644 --- a/crates/align-decode/src/apple.rs +++ b/crates/align-decode/src/apple.rs @@ -470,12 +470,23 @@ fn next_buffer( pub struct AppleNativeBackend; +/// AVFoundation cannot open MXF. The FFmpeg that ships with Align reads it, +/// as the portable engine does on every other platform; every other format +/// stays with AVFoundation. +fn is_mxf(path: &Path) -> bool { + path.extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("mxf")) +} + impl MediaBackend for AppleNativeBackend { fn kind(&self) -> BackendKind { BackendKind::AppleNative } fn inspect(&self, path: &Path) -> Result { + if is_mxf(path) { + return crate::portable::PortableBackend.inspect(path); + } let asset = open_asset(path)?; // SAFETY: plain-data property getter. let duration = unsafe { asset.duration() }; @@ -520,6 +531,9 @@ impl MediaBackend for AppleNativeBackend { source: AudioAnalysisSource, consume: &mut dyn FnMut(&[f32]) -> Result<(), DecodeError>, ) -> Result<(), DecodeError> { + if is_mxf(path) { + return crate::portable::PortableBackend.decode_mono_8k(path, source, consume); + } if source == AudioAnalysisSource::AllMixed { let stream_count = self.inspect(path)?.audio_streams.len(); return crate::mix::decode_all_streams_mono_8k(self, path, stream_count, consume); @@ -548,6 +562,14 @@ impl MediaBackend for AppleNativeBackend { duration_seconds: f64, source: AudioAnalysisSource, ) -> Result<(f64, Vec), DecodeError> { + if is_mxf(path) { + return crate::portable::PortableBackend.decode_window_16k( + path, + start_seconds, + duration_seconds, + source, + ); + } if source == AudioAnalysisSource::AllMixed { let stream_count = self.inspect(path)?.audio_streams.len(); return crate::mix::decode_all_streams_window_16k( @@ -614,6 +636,14 @@ impl MediaBackend for AppleNativeBackend { range: Option<(f64, Option)>, consume: &mut dyn FnMut(crate::backend::NativeBlock) -> Result<(), DecodeError>, ) -> Result { + if is_mxf(path) { + return crate::portable::PortableBackend.decode_native( + path, + stream_index, + range, + consume, + ); + } let window = range.map(|(start, duration)| (start, duration.unwrap_or(1e9))); let (session, sample_rate) = open_reader(path, stream_index, window, false)?; let mut actual = None; @@ -636,6 +666,14 @@ impl MediaBackend for AppleNativeBackend { range: Option<(f64, Option)>, consume: &mut dyn FnMut(crate::backend::NativeBlockI32) -> Result<(), DecodeError>, ) -> Result { + if is_mxf(path) { + return crate::portable::PortableBackend.decode_native_i32( + path, + stream_index, + range, + consume, + ); + } let window = range.map(|(start, duration)| (start, duration.unwrap_or(1e9))); let (session, sample_rate) = open_reader(path, stream_index, window, true)?; let mut actual = None; @@ -844,6 +882,49 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// AVFoundation cannot open MXF; the macOS engine reads it through + /// FFmpeg, with the same results as the portable engine. + #[test] + fn mxf_is_read_through_ffmpeg() { + let (Some(ffmpeg), Some(_)) = (crate::ff::ffmpeg_bin(), crate::ff::ffprobe_bin()) else { + return; + }; + let dir = fixture_dir("mxf"); + let mxf = dir.join("A001C001.MXF"); + let ok = std::process::Command::new(ffmpeg) + .args(["-v", "error", "-y", "-f", "lavfi", "-i"]) + .arg("testsrc2=size=320x240:rate=25") + .args(["-f", "lavfi", "-i", "anoisesrc=r=48000:a=0.3", "-t", "2"]) + .args(["-map", "0:v", "-map", "1:a", "-map", "1:a", "-ac", "1"]) + .args(["-c:v", "mpeg2video", "-c:a", "pcm_s24le"]) + .arg(&mxf) + .status() + .is_ok_and(|s| s.success()); + if !ok { + eprintln!("SKIP: FFmpeg without MXF support"); + return; + } + let apple = AppleNativeBackend + .inspect(&mxf) + .expect("MXF inspects on macOS"); + let portable = crate::portable::PortableBackend.inspect(&mxf).unwrap(); + assert_eq!(apple, portable); + assert_eq!(apple.audio_streams.len(), 2); + let mut samples = 0; + AppleNativeBackend + .decode_mono_8k(&mxf, AudioAnalysisSource::Automatic, &mut |block| { + samples += block.len(); + Ok(()) + }) + .unwrap(); + assert!(samples.abs_diff(16_000) < 100, "{samples} samples"); + let (_, window) = AppleNativeBackend + .decode_window_16k(&mxf, 0.5, 1.0, AudioAnalysisSource::Automatic) + .unwrap(); + assert!(window.len() >= 16_000, "{} window samples", window.len()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn inspect_matches_portable() { let dir = fixture_dir("probe"); diff --git a/crates/align-decode/src/pipeline.rs b/crates/align-decode/src/pipeline.rs index cdea230..868e995 100644 --- a/crates/align-decode/src/pipeline.rs +++ b/crates/align-decode/src/pipeline.rs @@ -129,20 +129,6 @@ struct FingerprintRun { warnings: Vec, } -/// Errors that end the whole run rather than one file's analysis: a -/// cancellation, or a missing decoder that every such file needs. -fn ends_run(error: &PipelineError) -> bool { - matches!( - error, - PipelineError::Cancelled - | PipelineError::Decode( - crate::DecodeError::Cancelled - | crate::DecodeError::FfmpegMissing(_) - | crate::DecodeError::BackendUnavailable(_) - ) - ) -} - struct FingerprintRunOptions<'a> { accuracy: align_core::SearchAccuracy, overrides: &'a HashMap, @@ -897,6 +883,23 @@ impl Pipeline { }) } + /// Errors that end the whole run rather than one file's analysis: a + /// cancellation, or a missing decoder that every such file needs. FFmpeg + /// ships with Align except on macOS, where AVFoundation decodes and FFmpeg + /// reads only MXF; there a missing FFmpeg leaves out those files alone. + fn ends_run(&self, error: &PipelineError) -> bool { + match error { + PipelineError::Cancelled + | PipelineError::Decode( + crate::DecodeError::Cancelled | crate::DecodeError::BackendUnavailable(_), + ) => true, + PipelineError::Decode(crate::DecodeError::FfmpegMissing(_)) => { + self.backend.kind() != BackendKind::AppleNative + } + _ => false, + } + } + /// Decodes one audio source to 8 kHz mono into state that `start` creates. /// When the in-process decoder (Symphonia, or AVFoundation on macOS) /// fails on the file's content, FFmpeg decodes it again into fresh state, @@ -1085,7 +1088,7 @@ impl Pipeline { // clip fails only when none of its streams can be read. match variant { Ok(variant) => variants.push(variant), - Err(error) if ends_run(&error) => return Err(error), + Err(error) if self.ends_run(&error) => return Err(error), Err(error) => { failure.get_or_insert(error); } @@ -1102,7 +1105,7 @@ impl Pipeline { align_core::par_map(clips, 4, |clip| { let outcome = analyse(clip); if let Some(p) = &progress - && !outcome.as_ref().is_err_and(ends_run) + && !outcome.as_ref().is_err_and(|error| self.ends_run(error)) { let mut event = PipelineProgress::basic( Phase::Fingerprint, @@ -1136,7 +1139,7 @@ impl Pipeline { for (clip, outcome) in clips.iter().zip(outcomes) { match outcome { Ok(variants) => analysed.push((*clip, variants)), - Err(error) if ends_run(&error) => return Err(error), + Err(error) if self.ends_run(&error) => return Err(error), Err(error) => warnings.push(SyncWarning { url: clip.url.clone(), message: format!( @@ -1990,6 +1993,25 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// FFmpeg ships with Align on Windows and Linux, so its absence is a + /// broken installation that ends the run. On macOS it reads only MXF and + /// development builds may lack it; those files are left out instead. + #[test] + fn missing_ffmpeg_ends_the_run_only_where_it_ships() { + let missing = PipelineError::Decode(crate::DecodeError::FfmpegMissing("ffmpeg".into())); + let dir = fixture_dir("ffmpeg-policy"); + let portable = Pipeline::new_in(BackendKind::Portable, Some(dir.join(".cache"))); + assert!(portable.ends_run(&missing)); + assert!(portable.ends_run(&PipelineError::Cancelled)); + #[cfg(target_os = "macos")] + { + let apple = Pipeline::new_in(BackendKind::AppleNative, Some(dir.join(".cache"))); + assert!(!apple.ends_run(&missing)); + assert!(apple.ends_run(&PipelineError::Cancelled)); + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn import_and_relink_warnings_reach_project() { // draft.warnings (import findings + relink notes) used to die in diff --git a/docs/distribution.md b/docs/distribution.md index 0359c0c..04cd761 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -37,14 +37,14 @@ named above, so keep those names and their `.sha256` files unchanged. The source archives contain the exact committed Align checkout, Cargo.lock, vendored Rust dependencies, a Cargo configuration for offline dependency -resolution, and a build manifest. Linux and Windows archives also contain the -FFmpeg source tarball used for the build, its build script and configuration; -macOS builds ship no FFmpeg. All archives contain the AAF component +resolution, and a build manifest. They also contain the FFmpeg source tarball +used for the build, its build script and configuration. All archives contain +the AAF component sources, Python's compression and crypto library sources, and the source of the pinned Velopack version. Checksums identify the included component files. Installers include LICENSE, THIRD_PARTY_NOTICES.txt, AAF-Licenses, -ThirdPartyLicenses, and on Linux and Windows FFMPEG-LICENSE.txt. The latter contains the generated +ThirdPartyLicenses, and FFMPEG-LICENSE.txt. ThirdPartyLicenses contains the generated Rust license report, upstream notice files, the Velopack license, and the build manifest. Keep source downloads available alongside the corresponding binaries. Do not replace an old release's source archive with a newer version. @@ -66,8 +66,7 @@ python script/prepare-release-source.py target/release-sources \ --velopack-version 1.2.0 ``` -Use `Windows` for that platform. For `macOS`, skip the FFmpeg build and omit -`--ffmpeg`. The source preparation script +Use `Windows` or `macOS` for those platforms. The source preparation script rejects modified tracked files and missing component sources. It uses `cargo vendor --locked` and checks dependency resolution with `cargo metadata --frozen`. Build instructions remain in [development](development.md); the @@ -88,6 +87,7 @@ The packaging scripts also run outside CI on a staged release directory (the ```sh AAF_DIR="$PWD/target/release" \ RELEASE_LICENSES="$PWD/target/release/ThirdPartyLicenses" \ +BUNDLE_FFMPEG=1 FFMPEG_DIR="$PWD/target/ffmpeg-minimal" \ ./script/package-macos.sh "$PWD/target/macos-package" ./script/create-macos-dmg.sh target/macos-package/Align.app \ target/Align-macOS-arm64.dmg @@ -103,7 +103,13 @@ Developer ID signing and Apple notarization require separate credentials. FFmpeg and ffprobe are separate executables, shipped on Linux and Windows for containers Align does not read natively (MTS, MXF, R3D, and compressed audio -such as AC-3). The build has no video decoders: video is only parsed for timing. +such as AC-3). On macOS, AVFoundation reads everything else; the macOS engine +uses the bundled tools for MXF, which AVFoundation cannot open, and analysis +uses them to retry a file AVFoundation fails on. `package-macos.sh` bundles +them with `BUNDLE_FFMPEG=1`; the build targets the app's minimum macOS (15.0), +and packaging rejects tools for another architecture or minimum version, or +linking libraries outside macOS. The build manifest's component hashes +describe the tools as built; macOS packaging re-signs its copies. The build has no video decoders: video is only parsed for timing. On Windows, the tools are linked statically against the MinGW-w64 runtime, including the winpthreads library that provides FFmpeg's clock and sleep functions, so they need only Windows system DLLs and run without MSYS2 or diff --git a/docs/formats.md b/docs/formats.md index ddec8a5..41ced18 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -10,7 +10,8 @@ named in a warning and left out of synchronization only when its media or audio still cannot be read; the other files are still synchronized. Inspect the warnings before exporting. -On macOS, the default backend uses AVFoundation. The portable backend reads +On macOS, the default backend uses AVFoundation, and reads MXF, which +AVFoundation cannot open, with the bundled FFmpeg. The portable backend reads MOV/MP4/M4V headers natively, decodes WAV, AIFF, MP3, AAC, ALAC, and PCM with Symphonia, and uses FFmpeg for other containers (MTS, MXF, R3D) and for streams Symphonia decodes incorrectly, such as HE-AAC. When Symphonia decodes, diff --git a/script/build-ffmpeg-minimal.sh b/script/build-ffmpeg-minimal.sh index e3dec26..6d4c945 100755 --- a/script/build-ffmpeg-minimal.sh +++ b/script/build-ffmpeg-minimal.sh @@ -33,6 +33,11 @@ JOBS="$(getconf _NPROCESSORS_ONLN 2>/dev/null || sysctl -n hw.logicalcpu 2>/dev/ EXE="" platform_options=() case "$(uname -s)" in + Darwin) + # The tools must run on the oldest macOS Align supports (Info.plist + # LSMinimumSystemVersion), not only on the build machine's. + export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-15.0}" + ;; MINGW*|MSYS*|CYGWIN*) EXE=".exe" platform_options=(--extra-ldflags=-static) diff --git a/script/package-macos.sh b/script/package-macos.sh index a9d60cc..6bf03d9 100755 --- a/script/package-macos.sh +++ b/script/package-macos.sh @@ -9,11 +9,13 @@ OUT_DIR="${1:-$HOME/Downloads/Align-macOS}" APP_NAME="Align" BUNDLE_ID="com.align.app" APP_VERSION="$(sed -n 's/^version = "\([^"]*\)"/\1/p' "$ROOT_DIR/Cargo.toml" | head -1)" -# AVFoundation decodes and QuickTime export/AAF metadata are native, so -# FFmpeg is opt-in (BUNDLE_FFMPEG=1) for the portable backend only. +# AVFoundation decodes and QuickTime export/AAF metadata are native. FFmpeg +# reads MXF, which AVFoundation cannot open; releases bundle it +# (BUNDLE_FFMPEG=1), development bundles skip the build by default. BUNDLE_FFMPEG="${BUNDLE_FFMPEG:-0}" FFMPEG_DIR="${FFMPEG_DIR:-}" ALIGN_SKIP_LAUNCH_CHECK="${ALIGN_SKIP_LAUNCH_CHECK:-0}" +MIN_MACOS="15.0" AAF_DIR="${AAF_DIR:-}" RELEASE_LICENSES="${RELEASE_LICENSES:-}" @@ -47,6 +49,18 @@ if [[ "$BUNDLE_FFMPEG" == "1" ]]; then for tool in ffmpeg ffprobe; do test -x "$FFMPEG_DIR/$tool" "$FFMPEG_DIR/$tool" -version >/dev/null + # Only macOS system libraries: a Homebrew or local dylib would be + # missing on users' Macs. + if otool -L "$FFMPEG_DIR/$tool" | tail -n +2 | grep -v -E '^[[:space:]]+(/usr/lib/|/System/Library/)'; then + echo "$tool links libraries outside macOS" >&2 + exit 1 + fi + # Built for arm64 and for the app's minimum macOS, like the app itself. + minos="$(vtool -show-build "$FFMPEG_DIR/$tool" | awk '$1 == "minos" { print $2; exit }')" + if [[ "$(lipo -archs "$FFMPEG_DIR/$tool")" != "arm64" || "$minos" != "$MIN_MACOS" ]]; then + echo "$tool is built for $(lipo -archs "$FFMPEG_DIR/$tool"), macOS $minos; need arm64, macOS $MIN_MACOS" >&2 + exit 1 + fi done test -f "$FFMPEG_DIR/FFMPEG-LICENSE.txt" fi @@ -122,7 +136,7 @@ cat >"$APP_CONTENTS/Info.plist" <CFBundleVersion 1 LSMinimumSystemVersion - 15.0 + $MIN_MACOS NSHighResolutionCapable @@ -138,9 +152,9 @@ Align — audio synchronization for video editing align-aaf — self-contained AAF module (no Python installation needed) LICENSE — Align license THIRD_PARTY_NOTICES.txt — third-party license notices -$([ "$BUNDLE_FFMPEG" == "1" ] && echo " ffmpeg, ffprobe — bundled media tools - (discovered beside the executables)" || echo " (FFmpeg omitted: macOS uses the Apple backend; - portable decoding requires ffmpeg/ffprobe on PATH)") +$([ "$BUNDLE_FFMPEG" == "1" ] && echo " ffmpeg, ffprobe — bundled media tools for MXF + FFMPEG-LICENSE.txt — FFmpeg license (LGPL)" || echo " (FFmpeg omitted: MXF and the portable backend + need ffmpeg/ffprobe on PATH)") CLI: ./align-cli sync /path/to/media > result.json diff --git a/script/prepare-release-source.py b/script/prepare-release-source.py index ec77b4a..cca31a2 100644 --- a/script/prepare-release-source.py +++ b/script/prepare-release-source.py @@ -21,7 +21,7 @@ def main(): parser.add_argument('output', type=Path) parser.add_argument('--platform', required=True, choices=('Linux', 'macOS', 'Windows')) parser.add_argument('--licenses', type=Path, required=True) - # macOS releases decode through AVFoundation and ship no FFmpeg. + # The FFmpeg tools' corresponding source; on macOS they read only MXF. parser.add_argument('--ffmpeg', type=Path) parser.add_argument('--aaf', type=Path, required=True) parser.add_argument('--velopack-version', required=True)