From 8d189ad984c137822512807491a389d3e481ac83 Mon Sep 17 00:00:00 2001 From: dropafterfree <257463937+dropafterfree@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:29:58 +0100 Subject: [PATCH] fix(export): bound a clip by the container duration, not a one-frame video stream A static screen capture can hold a single video frame (duration 0.067s) in a file whose audio and container run 87s. The export clamped each clip to the video stream duration, so such a clip was cut to one frame, or dropped silently when it started after 0.067s. Take the longer of the stream and container durations; the decoder already holds the last frame to the end of the clip window. Co-Authored-By: Claude Sonnet 5.5 --- crates/compositor/shim.c | 2 ++ crates/compositor/src/ffi.rs | 2 ++ crates/compositor/src/linux_decode.rs | 16 +++++---- crates/compositor/src/pipeline_macos.rs | 13 +++++-- crates/compositor/src/pipeline_windows.rs | 14 ++++++-- crates/compositor/src/timeline_walk.rs | 41 ++++++++++++++++++++++- 6 files changed, 76 insertions(+), 12 deletions(-) diff --git a/crates/compositor/shim.c b/crates/compositor/shim.c index 5f0741ed32..be7ebfeca3 100644 --- a/crates/compositor/shim.c +++ b/crates/compositor/shim.c @@ -8,6 +8,8 @@ AVStream* sn_fmt_stream(AVFormatContext* s, int i) { return s->streams[i]; } unsigned sn_fmt_nb_streams(AVFormatContext* s) { return s->nb_streams; } +// Durée du conteneur en AV_TIME_BASE (µs) ; AV_NOPTS_VALUE (négatif) si inconnue. +int64_t sn_fmt_duration(AVFormatContext* s) { return s->duration; } AVIOContext* sn_fmt_get_pb(AVFormatContext* s) { return s->pb; } void sn_fmt_set_pb(AVFormatContext* s, AVIOContext* p) { s->pb = p; } diff --git a/crates/compositor/src/ffi.rs b/crates/compositor/src/ffi.rs index 0df8963a4a..c0f8856ac2 100644 --- a/crates/compositor/src/ffi.rs +++ b/crates/compositor/src/ffi.rs @@ -42,6 +42,8 @@ extern "C" { pub fn sn_fmt_stream(s: *mut AVFormatContext, i: i32) -> *mut AVStream; /// `s->nb_streams` (cf. `shim.c`). pub fn sn_fmt_nb_streams(s: *mut AVFormatContext) -> u32; + /// `s->duration`, en µs (`AV_TIME_BASE`), négatif si inconnue (cf. `shim.c`). + pub fn sn_fmt_duration(s: *mut AVFormatContext) -> i64; /// `s->pb` (cf. `shim.c`). pub fn sn_fmt_get_pb(s: *mut AVFormatContext) -> *mut AVIOContext; /// `s->pb = p` (cf. `shim.c`). diff --git a/crates/compositor/src/linux_decode.rs b/crates/compositor/src/linux_decode.rs index 2e2433d84b..efbf96bfa6 100644 --- a/crates/compositor/src/linux_decode.rs +++ b/crates/compositor/src/linux_decode.rs @@ -34,6 +34,7 @@ use crate::ffi::{ /// crate pour tous les accesseurs du shim. extern "C" { fn sn_fmt_stream(s: *mut AVFormatContext, i: i32) -> *mut AVStream; + fn sn_fmt_duration(s: *mut AVFormatContext) -> i64; } /// `SEEK_SET` constant — la position de seek `av_seek_frame` interprète @@ -646,13 +647,14 @@ impl SwDecoder { return None; } let duration = (*stream).duration; - if duration > 0 && self.stream_timebase > 0.0 { - let s = duration as f64 * self.stream_timebase; - if s.is_finite() && s > 0.0 { - return Some(s); - } - } - None + let stream_sec = if duration > 0 && self.stream_timebase > 0.0 { + Some(duration as f64 * self.stream_timebase).filter(|s| s.is_finite() && *s > 0.0) + } else { + None + }; + // Le plus long du flux et du conteneur : un flux d'une seule frame (écran + // statique) ne doit pas raccourcir le clip (cf. `cover_container_duration`). + crate::timeline_walk::cover_container_duration(stream_sec, sn_fmt_duration(self.fmt)) } } diff --git a/crates/compositor/src/pipeline_macos.rs b/crates/compositor/src/pipeline_macos.rs index 25638157db..57cad0d810 100644 --- a/crates/compositor/src/pipeline_macos.rs +++ b/crates/compositor/src/pipeline_macos.rs @@ -630,9 +630,18 @@ impl Decoder { } } - /// Durée réellement annoncée par le flux vidéo (symétrique de - /// `pipeline_windows::Decoder::available_duration_sec`). + /// Durée disponible de la source : le plus long du flux vidéo et du conteneur (cf. + /// `cover_container_duration`). Symétrique de + /// `pipeline_windows::Decoder::available_duration_sec`. pub unsafe fn available_duration_sec(&self) -> Option { + crate::timeline_walk::cover_container_duration( + self.video_stream_duration_sec(), + crate::ffi::sn_fmt_duration(self.fmt), + ) + } + + /// Durée annoncée par le seul flux vidéo, `nb_frames / fps` en repli. + unsafe fn video_stream_duration_sec(&self) -> Option { let stream = crate::ffi::sn_fmt_stream(self.fmt, self.vidx); let duration = (*stream).duration; let tb_sec = self.tb_sec(); diff --git a/crates/compositor/src/pipeline_windows.rs b/crates/compositor/src/pipeline_windows.rs index 39ad180fab..b99293dade 100644 --- a/crates/compositor/src/pipeline_windows.rs +++ b/crates/compositor/src/pipeline_windows.rs @@ -188,6 +188,7 @@ const AVSEEK_FLAG_BACKWARD: i32 = 1; // seek vers la keyframe <= ts (macro non g extern "C" { fn sn_fmt_stream(s: *mut AVFormatContext, i: i32) -> *mut AVStream; fn sn_fmt_nb_streams(s: *mut AVFormatContext) -> u32; + fn sn_fmt_duration(s: *mut AVFormatContext) -> i64; fn sn_fmt_get_pb(s: *mut AVFormatContext) -> *mut AVIOContext; fn sn_fmt_set_pb(s: *mut AVFormatContext, p: *mut AVIOContext); } @@ -1046,9 +1047,18 @@ impl Decoder { if r.den != 0 && r.num != 0 { r.num as f64 / r.den as f64 } else { 30.0 } } - /// Durée réellement annoncée par le flux vidéo. La durée du stream est prioritaire ; - /// `nb_frames / fps` sert de repli pour les conteneurs qui omettent `duration`. + /// Durée disponible de la source : le plus long du flux vidéo et du conteneur (cf. + /// `cover_container_duration`). pub(crate) unsafe fn available_duration_sec(&self) -> Option { + crate::timeline_walk::cover_container_duration( + self.video_stream_duration_sec(), + sn_fmt_duration(self.fmt), + ) + } + + /// Durée annoncée par le seul flux vidéo. La durée du stream est prioritaire ; + /// `nb_frames / fps` sert de repli pour les conteneurs qui omettent `duration`. + unsafe fn video_stream_duration_sec(&self) -> Option { let stream = sn_fmt_stream(self.fmt, self.vidx); let duration = (*stream).duration; let tb_sec = self.tb_sec(); diff --git a/crates/compositor/src/timeline_walk.rs b/crates/compositor/src/timeline_walk.rs index e7829180cc..33191e9af9 100644 --- a/crates/compositor/src/timeline_walk.rs +++ b/crates/compositor/src/timeline_walk.rs @@ -75,6 +75,25 @@ pub(crate) fn frame_step(next: NextFrameTime, offset_sec: f64, target_sec: f64) } } +/// Durée disponible d'une source vidéo : le plus long de la durée annoncée par le flux et de +/// celle du conteneur (`container_us`, en µs, négatif si inconnue). +/// +/// Une capture d'écran statique (ScreenCaptureKit ne livre rien tant que l'écran ne change +/// pas) ne contient parfois qu'UNE frame : `duration=0.067` pour un fichier de 87 s dont la +/// piste audio, elle, couvre tout. Borner le clip à la durée du flux le raccourcissait à une +/// frame, voire l'écartait sans bruit s'il commençait après. La sémantique de hold de +/// `advance_decoder_to` tient déjà la dernière frame jusqu'au bout de la fenêtre ; la borne +/// doit donc être celle du conteneur, qui couvre aussi l'audio. +pub(crate) fn cover_container_duration(stream_sec: Option, container_us: i64) -> Option { + let container_sec = (container_us > 0) + .then(|| container_us as f64 / 1_000_000.0) + .filter(|s| s.is_finite() && *s > 0.0); + match (stream_sec, container_sec) { + (Some(stream), Some(container)) => Some(stream.max(container)), + (stream, container) => stream.or(container), + } +} + /// Avance un décodeur vers `target_source_time`, sémantique de "hold" : à l'instant t on /// affiche la DERNIÈRE frame dont le pts est ≤ t, jamais une frame dont le pts est encore à /// venir. `timeline_offset_sec` remet les pts webcam dans le référentiel écran @@ -554,7 +573,27 @@ unsafe fn walk_timeline( #[cfg(test)] mod tests { - use super::{frame_step, FrameStep, NextFrameTime, RenderedFrame}; + use super::{cover_container_duration, frame_step, FrameStep, NextFrameTime, RenderedFrame}; + + /// Le fichier signalé : une frame (0,067 s) de vidéo, 87,38 s de conteneur. Le clip ne doit + /// être ni raccourci à une frame ni écarté. + #[test] + fn a_one_frame_video_stream_is_covered_by_the_container() { + assert_eq!(cover_container_duration(Some(40.0 / 600.0), 87_380_000), Some(87.38)); + } + + #[test] + fn a_normal_file_keeps_its_stream_duration() { + assert_eq!(cover_container_duration(Some(10.0), 10_000_000), Some(10.0)); + assert_eq!(cover_container_duration(Some(10.0), 9_990_000), Some(10.0)); + } + + #[test] + fn an_unknown_side_falls_back_to_the_other() { + assert_eq!(cover_container_duration(None, 5_000_000), Some(5.0)); + assert_eq!(cover_container_duration(Some(5.0), i64::MIN), Some(5.0)); + assert_eq!(cover_container_duration(None, i64::MIN), None); + } /// La cadence de lecture, en une phrase : à 24 fps, une seconde réelle doit adopter 24 /// frames et pas une de plus. Le bug d'origine (un pas fixe de 1/60 s, une frame par