diff --git a/crates/compositor/shim.c b/crates/compositor/shim.c index 5f0741ed3..be7ebfeca 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 0df8963a4..c0f8856ac 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 2e2433d84..efbf96bfa 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 25638157d..57cad0d81 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 39ad180fa..b99293dad 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 e7829180c..e5fe15b44 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 @@ -258,24 +277,37 @@ unsafe fn catch_up(dec: &mut Decoder, target: f64, clip_start: f64) -> Result<() } /// Normal recordings can have audio up to roughly half a second longer than -/// video. Hold the last frame only in that margin: an unbounded fallback would -/// hide a genuinely truncated camera recording. +/// video. Hold the last frame of a camera only in that margin: an unbounded +/// fallback would hide a genuinely truncated camera recording. A screen whose +/// container covers the clip start is held without bound (see `walk_timeline`). const CLIP_START_TAIL_TOLERANCE_SEC: f64 = 0.5; -/// Seek a clip start within the normal tail margin. A future frame across an +/// Whether the frame a clip-start seek landed on (`current`) can stand in at +/// `target`, or `None` when only the first frame can tell. `hold_tail_sec` is how +/// far past the last frame the hold may reach. +fn accepts_clip_start(current: f64, target: f64, hold_tail_sec: f64) -> Option { + if current <= target && target - current <= hold_tail_sec + 1e-6 { + return Some(true); + } + if (target - current).abs() <= CLIP_START_TAIL_TOLERANCE_SEC + 1e-6 { + return Some(true); + } + if current <= target { + return Some(false); + } + None +} + +/// Seek a clip start within the tail margin. A future frame across an /// interior gap is invalid, but a request before the first frame of an offset /// stream retains the existing first-frame hold. -unsafe fn seek_clip_start(decoder: &mut Decoder, target: f64) -> Result { +unsafe fn seek_clip_start(decoder: &mut Decoder, target: f64, hold_tail_sec: f64) -> Result { let frame = decoder.seek_to_or_last(target)?; if frame.is_null() { return Ok(false); } - let current = decoder.cur_time_sec(); - if (target - current).abs() <= CLIP_START_TAIL_TOLERANCE_SEC + 1e-6 { - return Ok(true); - } - if current <= target { - return Ok(false); + if let Some(accepted) = accepts_clip_start(decoder.cur_time_sec(), target, hold_tail_sec) { + return Ok(accepted); } // Only a distant future result needs this extra seek. Distinguish a late // stream origin from a missing interior interval using the actual first @@ -434,7 +466,14 @@ unsafe fn walk_timeline( // un seul seek keyframe, puis chaque décodeur avance selon son propre pts jusqu'aux // temps source demandés par les spans de vitesse. - if !seek_clip_start(sdec, clip.source_start_sec) + // La fin du clip est déjà bornée par la durée disponible : si le conteneur couvre le + // début du clip, la dernière frame du flux écran peut être tenue jusque-là. + let screen_hold_tail = if screen_available_duration.is_some_and(|d| d > clip.source_start_sec) { + f64::INFINITY + } else { + CLIP_START_TAIL_TOLERANCE_SEC + }; + if !seek_clip_start(sdec, clip.source_start_sec, screen_hold_tail) .with_context(|| format!("Cannot export clip {}: could not read the screen video.", clip_index + 1))? { bail!("Cannot export clip {}: the screen video has no readable frame at the clip's start.", clip_index + 1); @@ -449,7 +488,8 @@ unsafe fn walk_timeline( } else { format!("Cannot export clip {}: the screen video has no readable frame at the clip's start.", clip_index + 1) }; - if !seek_clip_start(wdec, webcam_start).with_context(|| webcam_error.clone())? { + let webcam_hold_tail = if has_camera { CLIP_START_TAIL_TOLERANCE_SEC } else { screen_hold_tail }; + if !seek_clip_start(wdec, webcam_start, webcam_hold_tail).with_context(|| webcam_error.clone())? { bail!(webcam_error); } @@ -554,7 +594,51 @@ unsafe fn walk_timeline( #[cfg(test)] mod tests { - use super::{frame_step, FrameStep, NextFrameTime, RenderedFrame}; + use super::{ + accepts_clip_start, cover_container_duration, frame_step, FrameStep, NextFrameTime, + RenderedFrame, CLIP_START_TAIL_TOLERANCE_SEC, + }; + + /// Le fichier signalé, clip 5–15 s : la seule frame est à 0 s, le conteneur couvre le clip. + /// Elle doit être tenue, pas refusée comme une queue de flux tronquée. + #[test] + fn a_clip_after_the_only_frame_holds_it_when_the_container_covers_it() { + assert_eq!(accepts_clip_start(0.0, 5.0, f64::INFINITY), Some(true)); + } + + /// Sans conteneur pour la couvrir (webcam, durée inconnue), la queue reste bornée. + #[test] + fn a_bounded_hold_still_refuses_a_distant_last_frame() { + assert_eq!(accepts_clip_start(0.0, 5.0, CLIP_START_TAIL_TOLERANCE_SEC), Some(false)); + assert_eq!(accepts_clip_start(9.8, 10.2, CLIP_START_TAIL_TOLERANCE_SEC), Some(true)); + } + + /// Une frame FUTURE lointaine (trou intérieur ou origine tardive) ne profite pas du hold + /// illimité : c'est la première frame qui tranche. + #[test] + fn an_unbounded_hold_does_not_accept_a_distant_future_frame() { + assert_eq!(accepts_clip_start(20.0, 15.0, f64::INFINITY), None); + } + + /// 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