diff --git a/.gitignore b/.gitignore index 624d569..dbad015 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ /target/ /vendor/ /vendor.tar -.idea/ +.idea/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 17dd3db..7cb4cff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2808,7 +2808,7 @@ dependencies = [ [[package]] name = "iced_video_player" version = "0.6.0" -source = "git+https://github.com/wash2/iced_video_player.git?branch=iced-rebase#cb3635192f3dec37619855c904a168ca37a3dac3" +source = "git+https://github.com/wash2/iced_video_player.git?branch=iced-rebase#2e82b6b08d1751924b7b21b8c56683cdbc57bc33" dependencies = [ "glib", "gstreamer", diff --git a/src/main.rs b/src/main.rs index 145a7d9..1947cee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,7 +81,32 @@ fn get_framerate(video: &Video) -> Option { let caps = pad.current_caps()?; let structure = caps.structure(0)?; let framerate = structure.get::("framerate").ok()?; - Some(framerate.numer() as f64 / framerate.denom() as f64) + positive_framerate(framerate) +} + +fn positive_framerate(framerate: gst::Fraction) -> Option { + if framerate.numer() <= 0 || framerate.denom() <= 0 { + return None; + } + + let fps = framerate.numer() as f64 / framerate.denom() as f64; + fps.is_finite().then_some(fps) +} + +#[cfg(test)] +mod tests { + use super::positive_framerate; + use iced_video_player::gst; + + #[test] + fn variable_framerate_uses_fallback() { + assert_eq!(positive_framerate(gst::Fraction::new(0, 1)), None); + } + + #[test] + fn fixed_framerate_is_preserved() { + assert_eq!(positive_framerate(gst::Fraction::new(30, 1)), Some(30.0)); + } } /// Runs application with these settings diff --git a/src/video.rs b/src/video.rs index 1953fe5..9df43f2 100644 --- a/src/video.rs +++ b/src/video.rs @@ -4,6 +4,40 @@ use iced_video_player::{Video, gst, gst_app, gst_pbutils}; use cosmic::action; use cosmic::app::Task; +const SUSPICIOUS_AUDIO_GAP_DURATION: gst::ClockTime = gst::ClockTime::SECOND; + +fn is_suspicious_audio_gap(duration: Option) -> bool { + duration.is_some_and(|duration| duration >= SUSPICIOUS_AUDIO_GAP_DURATION) +} + +fn suppress_suspicious_audio_gaps(audio_filter: &gst::Element) { + let Some(src_pad) = audio_filter.static_pad("src") else { + log::warn!("audio filter has no source pad; malformed audio gaps will not be suppressed"); + return; + }; + + let _ = src_pad.add_probe(gst::PadProbeType::EVENT_DOWNSTREAM, |_, info| { + let Some(event) = info.event() else { + return gst::PadProbeReturn::Ok; + }; + let gst::EventView::Gap(gap) = event.view() else { + return gst::PadProbeReturn::Ok; + }; + let (timestamp, duration) = gap.get(); + if !is_suspicious_audio_gap(duration) { + return gst::PadProbeReturn::Ok; + } + + // Some malformed MP4 edit lists cause streamsynchronizer to emit a + // long GAP before almost every audio buffer. The next buffer's PTS is + // still continuous, so forwarding those events makes audio sinks + // repeatedly wait or resynchronise and produces choppy playback. + log::debug!("suppressing audio GAP at {timestamp} with duration {duration:?}"); + drop(info.take_event()); + gst::PadProbeReturn::Handled + }); +} + #[derive(Debug, Default)] pub struct VideoSettings { pub mute: bool, @@ -27,11 +61,14 @@ pub fn new_video( .downcast::() .map_err(|_| iced_video_player::Error::Cast) .unwrap(); - if let Ok(scaletempo) = gst::ElementFactory::make("scaletempo").build() { - pipeline.set_property("audio-filter", &scaletempo); - } else { - log::warn!("scaletempo element not available; speed changes will affect pitch"); - } + let audio_filter = gst::ElementFactory::make("scaletempo") + .build() + .unwrap_or_else(|_| { + log::warn!("scaletempo element not available; speed changes will affect pitch"); + gst::ElementFactory::make("identity").build().unwrap() + }); + suppress_suspicious_audio_gaps(&audio_filter); + pipeline.set_property("audio-filter", &audio_filter); pipeline.connect("element-setup", false, |vals| { let Ok(elem) = vals[1].get::() else { return None; @@ -84,3 +121,25 @@ pub fn new_video( } } } + +#[cfg(test)] +mod tests { + use super::is_suspicious_audio_gap; + use iced_video_player::gst; + + #[test] + fn long_audio_gap_is_suspicious() { + assert!(is_suspicious_audio_gap(Some(gst::ClockTime::SECOND))); + assert!(is_suspicious_audio_gap(Some( + gst::ClockTime::from_seconds(3) + ))); + } + + #[test] + fn short_or_unknown_audio_gap_is_preserved() { + assert!(!is_suspicious_audio_gap(Some( + gst::ClockTime::from_mseconds(999) + ))); + assert!(!is_suspicious_audio_gap(None)); + } +}