From 4be0ab6edd7fb3783209adaa019287f3626d962b Mon Sep 17 00:00:00 2001 From: Benjamin Freeman Date: Mon, 31 Aug 2026 18:42:27 +0200 Subject: [PATCH 1/4] fix(capture): drain the Linux decoder at EOF on the seek path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `decode_at` seeks BACKWARD to the nearest keyframe then decodes forward to the target. On `av_read_frame` EOF it broke out immediately without sending a NULL flush packet, so frames still buffered inside the decoder (decode latency / B-frame reorder) were never received. When the target lands in the short tail after the last keyframe there are too few packets to beat that latency, `found` stayed null, and the preview failed with "decode_at(frame_idx=…) : aucune frame reçue" — intermittently, when scrubbing to the end. Drain the decoder at EOF like the sequential `receive_into` path already does: send a NULL packet, pull the remaining frames into `found`, keeping the target-pts check so the last frame is still returned when scrubbing past the end. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/compositor/src/linux_decode.rs | 39 ++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/compositor/src/linux_decode.rs b/crates/compositor/src/linux_decode.rs index 1255898ee..4df47663d 100644 --- a/crates/compositor/src/linux_decode.rs +++ b/crates/compositor/src/linux_decode.rs @@ -387,8 +387,45 @@ impl SwDecoder { } let r = av_read_frame(self.fmt, pkt); if r < 0 { - // EOF ou erreur : on a épuisé le fichier sans atteindre la cible. + // EOF : le fichier est épuisé, mais le décodeur peut encore garder des + // frames en interne (latence de décodage / réordonnancement B-frames). + // Sans drain, une cible proche de la fin — le seek BACKWARD se cale sur + // la dernière keyframe et il reste alors trop peu de packets après elle + // pour vaincre la latence du décodeur — ne recevait JAMAIS de frame : + // `found` restait null et `decode_at` échouait avec « aucune frame + // reçue ». Symptôme signalé : le preview casse par intermittence quand on + // scrube jusqu'au bout. On envoie donc un packet NULL pour passer le + // décodeur en mode drain, puis on vide les frames restantes — exactement + // ce que fait `receive_into` du pompage séquentiel à l'EOF. av_packet_free(&mut pkt); + avcodec_send_packet(self.dec, ptr::null_mut()); + frame = av_frame_alloc(); + if frame.is_null() { + bail!("av_frame_alloc en drain"); + } + loop { + let recv_r = avcodec_receive_frame(self.dec, frame); + if recv_r != 0 { + // Après un packet NULL il n'y a plus d'EAGAIN : soit une frame, + // soit EOF/erreur qui termine le drain. + break; + } + if found.is_null() { + found = av_frame_alloc(); + if found.is_null() { + bail!("av_frame_alloc pour resultat"); + } + } + av_frame_unref(found); + av_frame_move_ref(found, frame); + av_frame_unref(frame); + if (*found).best_effort_timestamp as f64 * self.stream_timebase + >= target_ts_seconds + { + break; + } + } + av_frame_free(&mut frame); break 'outer; } if (*pkt).stream_index != self.stream_idx { From 4522539303768ce9f52745e3924fa04ad38203a7 Mon Sep 17 00:00:00 2001 From: Benjamin Freeman Date: Mon, 31 Aug 2026 19:04:49 +0200 Subject: [PATCH 2/4] fix(capture): match the real AVERROR_INVALIDDATA in the seek path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decode_at (the seek/scrub path behind Decoder::seek_to) skipped a misaligned post-seek packet by comparing send_packet's return to -0x2A2A2A2A — the tag `****`, which is not an ffmpeg error code. AVERROR_INVALIDDATA is -1094995529, so the guard never matched and a genuinely fragmented first NAL after a BACKWARD seek fell through to the fatal bail!, aborting the whole seek. Symptom: the preview failed when time-travelling/scrubbing (as opposed to reaching the end), even after the EOF-drain fix. This is the same defect receive_into was already fixed for on the sequential pump; apply the identical correction here — compare against the AVERROR_INVALIDDATA constant, and use the named AVERROR_EAGAIN/AVERROR_EOF constants in the receive loop instead of bare -11 / -541478725 literals. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/compositor/src/linux_decode.rs | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/crates/compositor/src/linux_decode.rs b/crates/compositor/src/linux_decode.rs index 4df47663d..5fa1e3e60 100644 --- a/crates/compositor/src/linux_decode.rs +++ b/crates/compositor/src/linux_decode.rs @@ -435,18 +435,25 @@ impl SwDecoder { } let send_r = avcodec_send_packet(self.dec, pkt); av_packet_free(&mut pkt); - if send_r == -0x2A2A2A2A { - // AVERROR_INVALIDDATA — packet mal aligné après un seek. On le - // saute et on continue ; le decodeur attendra un packet propre. - // Valeur ffmpeg = -1094995529 (0xBEEBBEEB), ici écrite comme - // un nombre négatif littéral pour éviter la dépendance `ffi::`. + if send_r == AVERROR_INVALIDDATA { + // Packet mal aligné après un seek. On le saute et on continue ; le + // decodeur attendra un packet propre. + // + // La garde comparait `send_r` à `-0x2A2A2A2A` — le tag `****`, qui + // ne désigne AUCUNE erreur ffmpeg. `AVERROR_INVALIDDATA` vaut + // -1094995529 (`-MKTAG('I','N','D','A')`), jamais -707406378 : la + // garde ne matchait donc jamais et un vrai packet invalide tombait + // dans le `bail!` ci-dessous, avortant tout le seek. C'est + // exactement le bug du scrub/time-travel — chaque BACKWARD seek peut + // rendre un premier NAL fragmenté — et c'est la même correction que + // `receive_into` a déjà reçue pour le pompage séquentiel. invalid_skips += 1; if invalid_skips > 8 { bail!("plus de 8 packets invalides après seek — fichier ou codec cassé"); } continue; } - if send_r < 0 && send_r != -11 { + if send_r < 0 && send_r != AVERROR_EAGAIN { bail!("avcodec_send_packet: {send_r}"); } frame = av_frame_alloc(); @@ -482,10 +489,9 @@ impl SwDecoder { if (*found).best_effort_timestamp as f64 * self.stream_timebase >= target_ts_seconds { break 'outer; } - } else if recv_r == -11 { + } else if recv_r == AVERROR_EAGAIN { break; - } else if recv_r == -541478725 { - // AVERROR_EOF + } else if recv_r == AVERROR_EOF { break 'outer; } else if recv_r < 0 { bail!("avcodec_receive_frame: {recv_r}"); From da26344044af334992b806e0ad85d06c6d297d52 Mon Sep 17 00:00:00 2001 From: Benjamin Freeman Date: Mon, 31 Aug 2026 19:52:33 +0200 Subject: [PATCH 3/4] fix(capture): return the last frame when seeking at/past end (frame-dropped captures) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decode_at bailed with "aucune frame reçue" whenever the requested index was at or past the file's last frame. On a frame-dropped capture whose real avg_frame_rate (e.g. 56.34) is below the nominal 60, a frame index computed on the 60fps grid lands past the true frame count, so the preview failed the moment it switched to such a clip. Mechanism: for a target at/after the last frame, av_seek_frame BACKWARD succeeds but lands on a lone undecodable packet just before EOF; after avcodec_flush_buffers that reference-less frame yields nothing even when drained, so found stayed null. Confirmed by sweeping decode_at over a real 56.34fps/631-frame recording: indices 0..=630 decoded, 631+ failed. Fix: extract the packet pump into pump_to_target(); when the seek-based pump returns no frame, rewind to 0 and forward-scan (the same linear scan already used as the unindexed-WebM fallback). The forward pump keeps the last frame <= target, so a past-end index yields the final frame instead of erroring. Adds an env-gated regression test (OPENSCREEN_SWEEP_FILE) that asserts decode_at never fails for 0..nb_frames+30. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/compositor/src/linux_decode.rs | 87 ++++++++++++++++++++++----- 1 file changed, 73 insertions(+), 14 deletions(-) diff --git a/crates/compositor/src/linux_decode.rs b/crates/compositor/src/linux_decode.rs index 5fa1e3e60..d63ccc259 100644 --- a/crates/compositor/src/linux_decode.rs +++ b/crates/compositor/src/linux_decode.rs @@ -343,6 +343,7 @@ impl SwDecoder { // comme `live::Player::step` rattrape la webcam par une boucle monotone vers // l'avant, une fois garée dans le futur elle ne revient jamais — elle fige. let seek_flags = SEEK_SET | AVSEEK_FLAG_BACKWARD; + let target_ts_seconds = target_ts / 1_000_000.0; let r = av_seek_frame(self.fmt, -1, target_ts as i64, seek_flags); if r < 0 { // Repli : rembobiner au début et balayer en avant. @@ -355,7 +356,7 @@ impl SwDecoder { // écrivent des fichiers indexés. // // Rembobiner à 0 reste possible sans index (c'est le début du fichier), et - // la boucle ci-dessous sait déjà avancer jusqu'à `target_ts`. Le coût est + // `pump_to_target` sait déjà avancer jusqu'à `target_ts`. Le coût est // linéaire, ce qui n'est acceptable que depuis le pompage séquentiel : le // décodage mesure ~0,07 ms/frame, donc rejoindre la seconde 14 d'un // enregistrement coûte quelques dizaines de ms au lieu d'échouer. @@ -374,11 +375,49 @@ impl SwDecoder { // Le seek rouvre le flux : le drapeau EOF du pompage sequentiel retombe. self.sent_eof = false; + let mut found = self.pump_to_target(target_ts_seconds)?; + + // Repli si le seek BACKWARD s'est calé si près de l'EOF que le pompage n'a + // rendu AUCUNE frame. Cas concret : une cible AU niveau ou au-delà de la + // dernière frame — fréquent sur les captures à frames droppées, dont la + // cadence réelle (`avg_frame_rate`, p.ex. 56,34) est sous le nominal 60, + // si bien qu'un index calculé sur la grille 60 fps demande une frame + // au-delà du compte réel. `av_seek_frame` réussit alors (`r == 0`) mais + // atterrit sur un unique packet non décodable juste avant l'EOF : après + // `flush_buffers`, ce P-frame isolé sans référence ne sort rien, même + // drainé, et `decode_at` échouait avec « aucune frame reçue ». + // + // Rembobiner à 0 puis re-pomper garantit de rendre la dernière frame + // <= cible (donc la toute dernière du flux quand la cible est au-delà), + // au lieu d'échouer. C'est le même balayage linéaire que le repli WebM + // ci-dessus, réservé au cas rare où le seek indexé n'a rien produit. + if found.is_null() { + let rewound = av_seek_frame(self.fmt, -1, 0, seek_flags); + if rewound >= 0 { + avcodec_flush_buffers(self.dec); + self.sent_eof = false; + found = self.pump_to_target(target_ts_seconds)?; + } + } + + if found.is_null() { + bail!("decode_at(frame_idx={frame_idx}) : aucune frame reçue"); + } + let pts = (*found).best_effort_timestamp; + self.cur_pts = if pts == i64::MIN { None } else { Some(pts) }; + Ok(found) + } + + /// Pompe `read_frame` → `send_packet` → `receive_frame` depuis la position + /// COURANTE du démuxeur (le caller a déjà seeké et appelé + /// `avcodec_flush_buffers`) et renvoie la dernière frame dont le pts est + /// <= `target_ts_seconds`, ou la toute dernière frame du flux si la cible est + /// au-delà. Renvoie un pointeur NUL si aucune frame n'a pu être décodée — le + /// caller décide alors de rembobiner et de re-pomper. + unsafe fn pump_to_target(&mut self, target_ts_seconds: f64) -> Result<*mut AVFrame> { let mut pkt: *mut crate::ffi::AVPacket = ptr::null_mut(); let mut frame: *mut AVFrame = ptr::null_mut(); let mut found: *mut AVFrame = ptr::null_mut(); - - let target_ts_seconds = target_ts / 1_000_000.0; let mut invalid_skips = 0u32; 'outer: loop { pkt = av_packet_alloc(); @@ -391,12 +430,10 @@ impl SwDecoder { // frames en interne (latence de décodage / réordonnancement B-frames). // Sans drain, une cible proche de la fin — le seek BACKWARD se cale sur // la dernière keyframe et il reste alors trop peu de packets après elle - // pour vaincre la latence du décodeur — ne recevait JAMAIS de frame : - // `found` restait null et `decode_at` échouait avec « aucune frame - // reçue ». Symptôme signalé : le preview casse par intermittence quand on - // scrube jusqu'au bout. On envoie donc un packet NULL pour passer le - // décodeur en mode drain, puis on vide les frames restantes — exactement - // ce que fait `receive_into` du pompage séquentiel à l'EOF. + // pour vaincre la latence du décodeur — ne recevait JAMAIS de frame. + // On envoie donc un packet NULL pour passer le décodeur en mode drain, + // puis on vide les frames restantes — exactement ce que fait + // `receive_into` du pompage séquentiel à l'EOF. av_packet_free(&mut pkt); avcodec_send_packet(self.dec, ptr::null_mut()); frame = av_frame_alloc(); @@ -507,11 +544,6 @@ impl SwDecoder { if !pkt.is_null() { av_packet_free(&mut pkt); } - if found.is_null() { - bail!("decode_at(frame_idx={frame_idx}) : aucune frame reçue"); - } - let pts = (*found).best_effort_timestamp; - self.cur_pts = if pts == i64::MIN { None } else { Some(pts) }; Ok(found) } @@ -561,4 +593,31 @@ mod tests { let r = SwDecoder::open("Z:/does/not/exist.mp4"); assert!(r.is_err()); } + + /// Régression : `decode_at` doit rendre une frame pour TOUT index de 0 jusqu'à + /// AU-DELÀ de la dernière frame — jamais « aucune frame reçue ». Le dépassement + /// couvre le cas des captures à frames droppées (`avg_frame_rate` < 60), où un + /// index calculé sur la grille 60 fps demande une frame passé le compte réel : + /// le seek BACKWARD atterrit sur un packet isolé non décodable en fin de fichier + /// et `decode_at` doit basculer sur le repli rembobinage → dernière frame. + /// + /// Env-gated comme les tests `OPENSCREEN_GOLDEN_*` (pas de fixture mp4 commitée) : + /// `OPENSCREEN_SWEEP_FILE=/path/to.mp4 cargo test decode_at_never_fails_past_end -- --ignored` + #[test] + #[ignore] + fn decode_at_never_fails_past_end() { + let path = std::env::var("OPENSCREEN_SWEEP_FILE").expect("set OPENSCREEN_SWEEP_FILE"); + let mut dec = SwDecoder::open(&path).expect("open"); + let n = ((dec.duration_sec().unwrap_or(0.0) * dec.fps()).ceil() as u32) + 30; + let mut failures: Vec = Vec::new(); + for idx in 0..n { + unsafe { + match dec.decode_at(idx) { + Ok(f) => SwDecoder::free_frame(f), + Err(_) => failures.push(idx), + } + } + } + assert!(failures.is_empty(), "decode_at failed at indices {failures:?} (of 0..{n})"); + } } From 4fdb37cef0d25e9630670153ef066b90d9e228dd Mon Sep 17 00:00:00 2001 From: Benjamin Freeman Date: Mon, 31 Aug 2026 20:29:19 +0200 Subject: [PATCH 4/4] docs(capture): note the EOF drain is inert on B-frame-less captures Empirically (decode_at index sweep on a real 56.34fps recording) the EOF drain returns zero frames on the app's own captures: they are H.264 baseline with has_b_frames=0, so the sequential receive loop already has every frame and there is nothing buffered to drain. The drain stays necessary for any stream WITH B-frames (imported video, other codecs), where the last frame is held in the reorder buffer. Document that so the drain is not mistaken for dead code. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/compositor/src/linux_decode.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/compositor/src/linux_decode.rs b/crates/compositor/src/linux_decode.rs index d63ccc259..125ee3f9c 100644 --- a/crates/compositor/src/linux_decode.rs +++ b/crates/compositor/src/linux_decode.rs @@ -434,6 +434,12 @@ impl SwDecoder { // On envoie donc un packet NULL pour passer le décodeur en mode drain, // puis on vide les frames restantes — exactement ce que fait // `receive_into` du pompage séquentiel à l'EOF. + // + // NOTE : sur les captures de l'app (H.264 baseline, `has_b_frames=0`) + // ce drain ne rend rien — sans réordonnancement le pompage normal a déjà + // tout reçu. Il reste néanmoins nécessaire pour tout flux AVEC B-frames + // (vidéos importées, autres codecs) où la dernière frame est retenue dans + // le buffer de réordonnancement. av_packet_free(&mut pkt); avcodec_send_packet(self.dec, ptr::null_mut()); frame = av_frame_alloc();