Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/compositor/shim.c
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand Down
2 changes: 2 additions & 0 deletions crates/compositor/src/ffi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
16 changes: 9 additions & 7 deletions crates/compositor/src/linux_decode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
}

Expand Down
13 changes: 11 additions & 2 deletions crates/compositor/src/pipeline_macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f64> {
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<f64> {
let stream = crate::ffi::sn_fmt_stream(self.fmt, self.vidx);
let duration = (*stream).duration;
let tb_sec = self.tb_sec();
Expand Down
14 changes: 12 additions & 2 deletions crates/compositor/src/pipeline_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<f64> {
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<f64> {
let stream = sn_fmt_stream(self.fmt, self.vidx);
let duration = (*stream).duration;
let tb_sec = self.tb_sec();
Expand Down
110 changes: 97 additions & 13 deletions crates/compositor/src/timeline_walk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f64>, container_us: i64) -> Option<f64> {
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),
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
/// 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
Expand Down Expand Up @@ -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<bool> {
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<bool> {
unsafe fn seek_clip_start(decoder: &mut Decoder, target: f64, hold_tail_sec: f64) -> Result<bool> {
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
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}

Expand Down Expand Up @@ -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
Expand Down
Loading