From 8e1fb4125855850b52a81dee0808b8e8ef1c0e8e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:20:19 +0000 Subject: [PATCH] #371: split stereo halves from FCP7 XML play on their own side Premiere writes a stereo clip to FCP7 XML as two mono clip items on two tracks (sourcetrack trackindex 1 and 2). The importer already picks channel 0 and channel 1 for them, but left both centred, and a centred one-channel clip plays on both sides. So both channels summed on L and on R: +3 dB per channel for an uncorrelated stereo effect (and its L-R doubled), +6 dB for dual-mono dialogue. Import: a clip that plays one channel of a split two-channel file is panned to that channel's side (channel 0 left, channel 1 right). An Audio Pan filter on the clip item still overrides it. Export: a one-channel clip always writes its Audio Pan, also when it is centred, so a centred Breakout to Mono round-trips centred. Measured with filmcraft-cli (import, export WAV): a split stereo file and a split dual-mono file now come out at 0.00 dB and sample-identical to the source (before: +3.01 dB and +6.02 dB per channel); a mono file on a mono track was and stays at 0 dB. Tests (crates/interchange/tests/fcp7.rs): split_stereo_halves_are_panned_to_their_side and split_stereo_half_keeps_its_audio_pan fail without the import change; centred_mono_breakout_round_trips_centred fails without the export change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012HrAqZmrrDLRpxBJatxvaJ --- crates/interchange/src/fcp7.rs | 12 +++++- crates/interchange/tests/fcp7.rs | 65 +++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/crates/interchange/src/fcp7.rs b/crates/interchange/src/fcp7.rs index d5de669d..15919e8e 100644 --- a/crates/interchange/src/fcp7.rs +++ b/crates/interchange/src/fcp7.rs @@ -516,6 +516,14 @@ impl<'a, 'i> Imp<'a, 'i, '_> { && let Ok(ch) = u16::try_from(idx - 1) { ti.source_channels = vec![ch]; + // The two halves of a split stereo file play on their own side, as the stereo clip did (#371): + // a mono clip is centred (both sides at unity), so the halves would otherwise sum on both + // sides, +3 dB for uncorrelated channels, +6 dB for dual mono. An `audiopan` filter + // (read below) still overrides this. + let stereo = matches!(&self.b.p.item(item).map(|i| &i.kind), Some(ItemKind::Media(m)) if m.info.audio().is_some_and(|a| a.channels == 2)); + if stereo && ch <= 1 { + set_param(&mut ti, "panner", "balance", Param::new(ParamValue::Float(if ch == 0 { -100.0 } else { 100.0 }))); + } } if let Some(mode) = child_text(c, "compositemode") { match BLEND_XML.iter().find(|(n, _)| n.eq_ignore_ascii_case(mode)) { @@ -1159,7 +1167,9 @@ impl Exp<'_, '_> { self.float_param(c, "volume", "level", "Level", 0.0, 3.98109, rate, move |v| db_to_gain(v + g), "level"); self.close_effect(); } - if param_modified(c, "panner", "balance") { + // A one-channel clip says where it is panned even when centred: without Audio Pan the + // importer pans the halves of a split stereo file to their sides (#371). + if param_modified(c, "panner", "balance") || c.source_channels.len() == 1 { self.open_effect("Audio Pan", "audiopan", "audiopan", "audiopan", "audio", true); self.float_param(c, "panner", "balance", "Pan", -1.0, 1.0, rate, |v| v / 100.0, "pan"); self.close_effect(); diff --git a/crates/interchange/tests/fcp7.rs b/crates/interchange/tests/fcp7.rs index 6b2a9414..aad1d5f0 100644 --- a/crates/interchange/tests/fcp7.rs +++ b/crates/interchange/tests/fcp7.rs @@ -3,7 +3,7 @@ mod support; -use filmcraft_interchange::{ExportOptions, Format, detect, export, import}; +use filmcraft_interchange::{ExportOptions, Format, Imported, detect, export, import}; use filmcraft_media::Generator; use filmcraft_project::{ItemKind, Keyframe, Label, Marker, MarkerId, MarkerKind, MediaRef, Param, ParamValue, Project, TrackKind, TransitionAlign}; use filmcraft_time::{FrameRate, Tick}; @@ -600,3 +600,66 @@ fn audio_trackindex_1_is_channel_0_only_when_the_file_is_split() { assert!(a1[1].source_channels.is_empty(), "{:?}", a1[1].source_channels); assert_eq!(seq.tracks(TrackKind::Audio)[1].items[0].source_channels, vec![1u16]); } + +/// A Premiere-style stereo file split onto two mono tracks (A1 = `trackindex` 1, A2 = 2). +fn split_stereo_doc(pan_a1: &str) -> String { + format!( + r#" + +S48{RATE} + +"# + ) +} + +fn balances(imp: &Imported) -> Vec { + let seq = imp.project.sequence(only_seq(imp)).unwrap(); + seq.tracks(TrackKind::Audio) + .iter() + .flat_map(|t| &t.items) + .map(|i| i.effect("panner").and_then(|e| e.param("balance")).and_then(|p| p.value.as_f64()).unwrap_or(0.0)) + .collect() +} + +/// #371: the halves of a split stereo file played centred, so both channels summed on both sides +/// (+3 dB for a stereo effect, +6 dB for dual-mono dialogue). They now play on their own side. +#[test] +fn split_stereo_halves_are_panned_to_their_side() { + let (imp, _) = import(split_stereo_doc("").as_bytes(), Format::Fcp7Xml, None).unwrap(); + assert_eq!(balances(&imp), vec![-100.0, 100.0]); +} + +/// An Audio Pan filter on a split half still wins. +#[test] +fn split_stereo_half_keeps_its_audio_pan() { + let pan = r#"Audio Panaudiopanaudiopanaudiopanaudio + panPan-110"#; + let (imp, _) = import(split_stereo_doc(pan).as_bytes(), Format::Fcp7Xml, None).unwrap(); + assert_eq!(balances(&imp), vec![0.0, 100.0]); +} + +/// A centred one-channel clip (Breakout to Mono) round-trips centred: export writes its Audio Pan. +#[test] +fn centred_mono_breakout_round_trips_centred() { + let r = FrameRate::FPS_23_976; + let mut p = Project::new("P"); + let a = media(&mut p, "/m/a.wav", false, true, r); + let s = sequence(&mut p, "t", r, false); + let left = clip(&mut p, s, TrackKind::Audio, 0, a, 0, 24, 0); + let right = clip(&mut p, s, TrackKind::Audio, 1, a, 0, 24, 0); + for (id, ch) in [(left, 0u16), (right, 1u16)] { + let (_, it) = p.sequence_mut(s).unwrap().find_item_mut(id).unwrap(); + it.source_channels = vec![ch]; + } + let (imp, xml, _) = roundtrip(&p, s, Format::Fcp7Xml, &ExportOptions::default()); + assert_eq!(balances(&imp), vec![0.0, 0.0], "{xml}"); +}