From 170852d8db58dec0cc42b8624345569203de0a78 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 02:00:58 +1100 Subject: [PATCH] #533: Preserve explicit export sample rates to avoid silent substitution. --- crates/engine/src/export_tests.rs | 15 ++++++++++ crates/export/src/settings.rs | 11 +------- crates/export/src/settings_tests.rs | 44 +++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 2757ebba..cf514b49 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -64,6 +64,21 @@ fn export_integer_parameters_cannot_wrap_or_overflow() { assert!(s.execute("export.resolve", json!({"bitrateKbps":8000.0, "keyframeDistance":48.0})).is_ok(), "integer-valued floats are integers"); } +#[test] +fn wav_resolve_honours_explicit_sample_rates() { + let mut s = demo(); + for rate in [4000, 192_000, 192_001, 200_000, 352_800, 384_000] { + let result = s.execute("export.resolve", json!({"format":"wav", "settings":{"audio":{"sample_rate":rate}}})).unwrap(); + assert_eq!(result["settings"]["audio"]["sample_rate"], rate); + assert_eq!(result["output"]["sampleRate"], rate); + } + let result = s.execute("export.resolve", json!({"format":"wav", "settings":{"audio":{"sample_rate":null}}})).unwrap(); + assert_eq!(result["output"]["sampleRate"], 48_000); + for rate in [0, 384_001] { + assert!(s.execute("export.resolve", json!({"format":"wav", "settings":{"audio":{"sample_rate":rate}}})).is_err()); + } +} + #[test] fn nested_camel_case_settings_are_honoured() { let mut s = demo(); diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 8e8fc7ef..a0a083cd 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -406,16 +406,7 @@ impl ExportSettings { 6.. => 6, _ => 2, }; - Resolved { - width: w, - height: h, - rate, - sample_rate: self.audio.sample_rate.filter(|r| (8000..=192_000).contains(r)).unwrap_or(seq_sr), - channels, - target_kbps: target, - max_kbps: max, - keyint, - } + Resolved { width: w, height: h, rate, sample_rate: self.audio.sample_rate.unwrap_or(seq_sr), channels, target_kbps: target, max_kbps: max, keyint } } /// The audio codec actually used. diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index b494226b..d2410ae6 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -167,6 +167,50 @@ fn wav_and_aiff_audio_only() { } } +#[test] +fn wav_export_honours_explicit_sample_rates() { + let (project, sequence, sources) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0)); + let dir = Scratch::new("wav-sample-rates"); + for rate in [1, 4000, 7350, 8000, 48_000, 192_000, 192_001, 200_000, 352_800, 384_000] { + let settings = ExportSettings { + format: Format::Wav, + path: dir.path(&format!("{rate}.wav")), + audio: AudioSettings { sample_rate: Some(rate), ..Default::default() }, + range: Some(TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND / 4))), + ..Default::default() + }; + settings.validate().unwrap(); + let resolved = settings.resolve(64, 36, FrameRate::FPS_24, 48_000); + assert_eq!(resolved.sample_rate, rate, "requested {rate} Hz"); + export(&project, sequence, &settings, &sources, &Progress::default()).unwrap(); + let bytes = std::fs::read(&settings.path).unwrap(); + assert_eq!(u32::from_le_bytes(bytes[24..28].try_into().unwrap()), rate); + assert_eq!(u32::from_le_bytes(bytes[40..44].try_into().unwrap()), rate / 4 * 4, "quarter-second stereo 16-bit PCM"); + if let Some(probe) = ffprobe_json(&["-show_streams"], &settings.path) { + assert_eq!(probe["streams"][0]["sample_rate"], rate.to_string()); + } + } + let settings = ExportSettings { format: Format::Wav, ..Default::default() }; + assert_eq!(settings.resolve(64, 36, FrameRate::FPS_24, 44_100).sample_rate, 44_100); +} + +#[test] +fn aac_export_rejects_unsupported_explicit_sample_rates() { + let (project, sequence, sources) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0)); + let dir = Scratch::new("aac-sample-rates"); + for rate in [4000, 192_001, 200_000, 384_000] { + let settings = ExportSettings { + path: dir.path(&format!("{rate}.mp4")), + audio: AudioSettings { sample_rate: Some(rate), ..Default::default() }, + range: Some(TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND / 4))), + ..Default::default() + }; + let error = export(&project, sequence, &settings, &sources, &Progress::default()).unwrap_err(); + assert!(error.to_string().contains("sample rate"), "{error}"); + assert!(!Path::new(&settings.path).exists(), "an unsupported rate must not produce a substitute file"); + } +} + #[test] fn invalid_export_allocations_are_rejected_before_rendering() { let (project, sequence, _) = matte([1.0, 0.0, 0.0, 1.0], 64, 36, None);