Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ from inside Align, behind the same system authorization prompt.
## Sources and verification

`align-source-<platform>.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.

Expand Down
20 changes: 8 additions & 12 deletions .github/workflows/align-rs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand All @@ -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
Expand All @@ -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
Expand Down
81 changes: 81 additions & 0 deletions crates/align-decode/src/apple.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProbeReport, DecodeError> {
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() };
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -548,6 +562,14 @@ impl MediaBackend for AppleNativeBackend {
duration_seconds: f64,
source: AudioAnalysisSource,
) -> Result<(f64, Vec<f32>), 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(
Expand Down Expand Up @@ -614,6 +636,14 @@ impl MediaBackend for AppleNativeBackend {
range: Option<(f64, Option<f64>)>,
consume: &mut dyn FnMut(crate::backend::NativeBlock) -> Result<(), DecodeError>,
) -> Result<f64, DecodeError> {
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;
Expand All @@ -636,6 +666,14 @@ impl MediaBackend for AppleNativeBackend {
range: Option<(f64, Option<f64>)>,
consume: &mut dyn FnMut(crate::backend::NativeBlockI32) -> Result<(), DecodeError>,
) -> Result<f64, DecodeError> {
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;
Expand Down Expand Up @@ -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");
Expand Down
56 changes: 39 additions & 17 deletions crates/align-decode/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,20 +129,6 @@ struct FingerprintRun {
warnings: Vec<SyncWarning>,
}

/// 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<ClipId, align_core::SearchAccuracy>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand All @@ -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,
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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
Expand Down
20 changes: 13 additions & 7 deletions docs/distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions script/build-ffmpeg-minimal.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading