diff --git a/crates/interchange/src/fcp7.rs b/crates/interchange/src/fcp7.rs index 962508a5..e0e9075d 100644 --- a/crates/interchange/src/fcp7.rs +++ b/crates/interchange/src/fcp7.rs @@ -517,6 +517,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)) { @@ -1212,7 +1220,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}"); +}