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 1/2] 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 From 12e7b755cc6ec1d54ded9943de16d8a90bd1ca10 Mon Sep 17 00:00:00 2001 From: dropafterfree <257463937+dropafterfree@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:03:00 +0100 Subject: [PATCH 2/2] fix(export): hold the only screen frame when a clip starts after it A static screen capture with one video frame at 0s and a container covering 20s exported clip 0-20s since the previous commit, but clip 5-15s still failed with "the screen video has no readable frame at the clip's start": seek_clip_start refused a last frame older than the 0.5s tail tolerance. When the screen container covers the clip start, hold the last frame without bound. The clip end is already clamped to that duration. The camera keeps the 0.5s margin, and a distant future frame still goes through the first-frame check. Co-Authored-By: Claude Opus 5.5 --- crates/compositor/src/timeline_walk.rs | 71 +++++++++++++++++++++----- 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/crates/compositor/src/timeline_walk.rs b/crates/compositor/src/timeline_walk.rs index 33191e9af9..e5fe15b449 100644 --- a/crates/compositor/src/timeline_walk.rs +++ b/crates/compositor/src/timeline_walk.rs @@ -277,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 @@ -453,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); @@ -468,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); } @@ -573,7 +594,31 @@ unsafe fn walk_timeline( #[cfg(test)] mod tests { - use super::{cover_container_duration, 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é.