Skip to content
Open
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
67 changes: 60 additions & 7 deletions crates/compositor/src/linux_decode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ pub struct SwDecoder {
frame: *mut AVFrame,
sent_eof: bool,
cur_pts: Option<i64>,
/// Buffer de lookahead pour `peek_next_time_sec` : symétrique de
/// `pipeline_macos::Decoder::peek_frame` — cf. là-bas pour la justification.
peek_frame: *mut AVFrame,
/// `true` si `peek_frame` porte une frame décodée en attente de `commit_peek`.
has_peek: bool,
}

/// Libère toutes les ressources ffmpeg. `Drop` ne peut pas faillir ; on
Expand All @@ -78,6 +83,9 @@ impl Drop for SwDecoder {
if !self.pkt.is_null() {
av_packet_free(&mut self.pkt);
}
if !self.peek_frame.is_null() {
av_frame_free(&mut self.peek_frame);
}
}
}
}
Expand Down Expand Up @@ -177,7 +185,8 @@ impl SwDecoder {
};
let pkt = av_packet_alloc();
let frame = av_frame_alloc();
if pkt.is_null() || frame.is_null() {
let peek_frame = av_frame_alloc();
if pkt.is_null() || frame.is_null() || peek_frame.is_null() {
avcodec_free_context(&mut dec);
avformat_close_input(&mut fmt);
bail!("av_packet_alloc/av_frame_alloc (pompage sequentiel)");
Expand All @@ -192,6 +201,8 @@ impl SwDecoder {
frame,
sent_eof: false,
cur_pts: None,
peek_frame,
has_peek: false,
})
}

Expand All @@ -201,21 +212,33 @@ impl SwDecoder {
/// seek PAS : le decodeur garde son etat, donc une lecture sequentielle coute
/// UN packet par frame au lieu d'un re-parcours de demi-GOP.
pub unsafe fn next_frame(&mut self) -> Result<*mut AVFrame> {
if self.has_peek {
return self.commit_peek();
}
if !self.receive_into(self.frame)? {
return Ok(ptr::null_mut());
}
let pts = (*self.frame).best_effort_timestamp;
self.cur_pts = if pts == i64::MIN { None } else { Some(pts) };
Ok(self.frame)
}

/// Décode dans `into` (buffer courant ou de lookahead) jusqu'à obtenir une frame ou
/// l'EOF — cf. `pipeline_macos::Decoder::receive_into` pour la justification.
unsafe fn receive_into(&mut self, into: *mut AVFrame) -> Result<bool> {
loop {
let r = avcodec_receive_frame(self.dec, self.frame);
let r = avcodec_receive_frame(self.dec, into);
if r == 0 {
let pts = (*self.frame).best_effort_timestamp;
self.cur_pts = if pts == i64::MIN { None } else { Some(pts) };
return Ok(self.frame);
return Ok(true);
}
if r == AVERROR_EOF {
return Ok(ptr::null_mut());
return Ok(false);
}
if r != AVERROR_EAGAIN {
bail!("avcodec_receive_frame: {r}");
}
if self.sent_eof {
return Ok(ptr::null_mut());
return Ok(false);
}
let rr = av_read_frame(self.fmt, self.pkt);
if rr < 0 {
Expand All @@ -240,6 +263,34 @@ impl SwDecoder {
}
}

/// Décode la prochaine frame dans le buffer de lookahead et renvoie son temps (s) —
/// `None` à EOF. Cf. `pipeline_macos::Decoder::peek_next_time_sec`.
pub unsafe fn peek_next_time_sec(&mut self) -> Result<Option<f64>> {
if !self.has_peek {
if !self.receive_into(self.peek_frame)? {
return Ok(None);
}
self.has_peek = true;
}
let pts = (*self.peek_frame).best_effort_timestamp;
Ok(Some(if pts == i64::MIN {
0.0
} else {
pts as f64 * self.stream_timebase
}))
}

/// Promeut la frame de lookahead au rang de frame courante. Cf.
/// `pipeline_macos::Decoder::commit_peek`.
pub unsafe fn commit_peek(&mut self) -> Result<*mut AVFrame> {
debug_assert!(self.has_peek, "commit_peek sans peek_next_time_sec préalable");
std::mem::swap(&mut self.frame, &mut self.peek_frame);
self.has_peek = false;
let pts = (*self.frame).best_effort_timestamp;
self.cur_pts = if pts == i64::MIN { None } else { Some(pts) };
Ok(self.frame)
}

Comment on lines +266 to +293

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

commit_peek protects its precondition with debug_assert! only. All three decoders expose commit_peek as a public promotion API and check has_peek with debug_assert!, which compiles out in release builds. Without a prior successful peek_next_time_sec, the pointer swap promotes an unfilled or stale AVFrame, sets cur_pts from an undefined best_effort_timestamp, and passes that frame to the presentation path.

  • crates/compositor/src/linux_decode.rs#L266-L293: replace the debug_assert! in SwDecoder::commit_peek with a bail! when has_peek is false.
  • crates/compositor/src/pipeline_macos.rs#L399-L412: replace the debug_assert! in Decoder::commit_peek with an error return before cpu.present(self.frame) runs.
  • crates/compositor/src/pipeline_windows.rs#L786-L798: replace the debug_assert! in Decoder::commit_peek with an error return before cpu.present(self.frame) runs.
📍 Affects 3 files
  • crates/compositor/src/linux_decode.rs#L266-L293 (this comment)
  • crates/compositor/src/pipeline_macos.rs#L399-L412
  • crates/compositor/src/pipeline_windows.rs#L786-L798
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/compositor/src/linux_decode.rs` around lines 266 - 293, Replace the
release-unsafe precondition checks in SwDecoder::commit_peek,
Decoder::commit_peek in crates/compositor/src/pipeline_macos.rs, and
Decoder::commit_peek in crates/compositor/src/pipeline_windows.rs with immediate
bail!-style error returns when has_peek is false, before swapping or presenting
the frame; preserve normal promotion behavior when a peek exists.

/// Temps source (secondes) de la derniere frame rendue par `next_frame` /
/// `decode_at`, tire du pts REEL et non d'un compteur d'index.
pub fn cur_time_sec(&self) -> Option<f64> {
Expand All @@ -263,6 +314,8 @@ impl SwDecoder {
/// `AVERROR_INVALIDDATA` plutôt que de paniquer : la prochaine itération
/// lira le packet complet suivant.
pub unsafe fn decode_at(&mut self, frame_idx: u32) -> Result<*mut AVFrame> {
// Tout seek invalide un éventuel peek en attente — cf. pipeline_macos::Decoder::seek_to.
self.has_peek = false;
let fps = self.fps;
let target_ts = (frame_idx as f64 / fps) * 1_000_000.0; // AV_TIME_BASE = µs
// `AVSEEK_FLAG_BACKWARD` vaut 1, pas 4 — 4 est `AVSEEK_FLAG_ANY`. La constante
Expand Down
138 changes: 92 additions & 46 deletions crates/compositor/src/live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,62 +338,87 @@ impl Player {
}

/// Temps source courant du décodeur écran — utilisé par `render_thread` pour détecter le
/// franchissement de la fin de fenêtre du clip actif pendant la lecture libre.
pub(crate) unsafe fn screen_time_sec(&self) -> f64 {
/// franchissement de la fin de fenêtre du clip actif pendant la lecture libre, et pour
/// calculer la cible de `step` en lecture libre. `pub` (pas `pub(crate)`) : le harnais
/// `poc-d3d` (crate externe) en a besoin pour piloter sa propre boucle de lecture libre.
pub unsafe fn screen_time_sec(&self) -> f64 {
self.sdec.cur_time_sec()
}

/// Compose la frame suivante (→ `comp.rt`). Boucle sur EOF. `false` si fixture vide.
/// Compose la PROCHAINE frame due (→ `comp.rt`), au plus une, si `target_source_time`
/// (temps écran) est atteint. Sémantique de "hold" : `false` sans rien composer quand la
/// frame suivante n'est pas encore due — l'appelant garde alors l'image déjà affichée,
/// au lieu d'avancer aveuglément. Boucle sur EOF réel. `false` aussi si fixture vide.
///
/// L'écran pilote la cadence (1 frame/tick) ; la webcam suit son PROPRE temps source
/// (`screen_time - webcam_offset_sec`), pas un pas 1:1 avec l'écran — BUG corrigé : les
/// deux décodeurs avançaient d'exactement une frame par tick chacun, quelle que soit leur
/// cadence réelle. Écran et webcam sont capturés par des pipelines indépendants (souvent
/// à des fps différents), donc la webcam jouait 2× trop vite dès que sa cadence était
/// inférieure à celle de l'écran. Même logique que `advance_decoder_to` (pipeline.rs),
/// déjà correcte côté export — la preview live ne l'avait jamais reprise. La webcam boucle
/// aussi de façon INDÉPENDANTE à son propre EOF (un clip webcam plus court que l'écran ne
/// doit pas réinitialiser le décodeur écran).
pub unsafe fn step(&mut self, comp: &Compositor, cfg: &Cfg) -> Result<bool> {
/// BUG corrigé : cette fonction consommait auparavant EXACTEMENT une frame réelle par
/// appel (un `next()` inconditionnel), et `render_thread` l'appelait une fois par tranche
/// de 1/60s de temps réel écoulé — une hypothèse de vidéo à ~60 fps constant. Or
/// ScreenCaptureKit (et les captures équivalentes) ne livre une frame que quand l'écran
/// change : un enregistrement de 26s avec de longs plans fixes peut ne contenir que
/// quelques centaines de frames RÉELLES. Consommer 1 frame/tick épuisait alors le flux
/// bien avant que le temps réel écoulé n'atteigne la durée de l'enregistrement — le
/// décodeur retombait sur l'EOF, rebouclait sur `seek_to(0.0)`, et la preview semblait
/// « accélérer puis sauter au début » en boucle. Le curseur/zoom, eux, suivent le pts réel
/// (`sync_time`) et se retrouvaient donc en avance sur ce que l'œil voyait défiler.
///
/// Le correctif : ne décoder/adopter (`commit_peek`) la frame suivante QUE si son pts a
/// réellement été atteint par `target_source_time` (le temps réel écoulé, mis à l'échelle
/// par la vitesse active — cf. `render_thread`) ; sinon on continue de tenir la frame
/// courante, aussi longtemps qu'il le faut. Même principe que `advance_decoder_to`
/// (`timeline_walk.rs`), déjà correct côté export.
///
/// La webcam suit le MÊME principe indépendamment (son propre temps source =
/// `screen_time - webcam_offset_sec`, pas un pas 1:1 avec l'écran) : deux pipelines de
/// capture indépendants n'ont pas la même cadence ni les mêmes trous. Elle boucle aussi
/// de façon indépendante à son propre EOF (un clip webcam plus court que l'écran ne doit
/// pas réinitialiser le décodeur écran).
pub unsafe fn step(&mut self, comp: &Compositor, cfg: &Cfg, target_source_time: f64) -> Result<bool> {
let use_current = self.use_current_on_next_step;
self.use_current_on_next_step = false;

let mut sf = if use_current {
let sf = if use_current {
self.sdec.cur_frame()
} else {
self.sdec.next()?
match self.sdec.peek_next_time_sec()? {
Some(t) if t <= target_source_time => self.sdec.commit_peek()?,
Some(_) => return Ok(false), // pas encore due : on tient la frame courante.
None => {
// EOF réel (plus aucune frame à décoder) : reboucle sur le début.
self.idx = 0;
self.sdec.seek_to(0.0)?
}
}
};
if sf.is_null() {
sf = self.sdec.seek_to(0.0)?;
self.idx = 0;
}
if sf.is_null() {
self.has_current_frame = false;
return Ok(false);
}

let target_webcam_t = (self.sdec.cur_time_sec() - self.webcam_offset_sec).max(0.0);
let mut wf = if use_current {
let wf = if use_current {
self.wdec.cur_frame()
} else {
let cur = self.wdec.cur_frame();
if cur.is_null() {
// Jamais décodée (nouvelle ouverture) : on saute directement au temps synchronisé.
self.wdec.seek_to(target_webcam_t)?
} else {
// Rattrape la webcam vers `target_webcam_t`, au pire une poignée de frames par
// tick (fps proches) — le garde-fou n'existe que contre un cas pathologique.
// Rattrape la webcam vers `target_webcam_t` par pts réel, jamais au-delà —
// même sémantique de hold que l'écran ci-dessus (et que `advance_decoder_to`) :
// adopter une frame webcam dont le pts dépasse `target_webcam_t` l'afficherait
// en avance sur son heure. Le garde-fou ne joue que contre un cas pathologique.
let mut wf = cur;
let mut guard = 0u32;
while self.wdec.cur_time_sec() < target_webcam_t {
match self.wdec.next()? {
f if f.is_null() => {
loop {
match self.wdec.peek_next_time_sec()? {
Some(t) if t <= target_webcam_t => wf = self.wdec.commit_peek()?,
Some(_) => break, // pas encore due : hold sur la frame webcam courante.
None => {
// Fin de la webcam avant l'écran : elle boucle SEULE — l'écran
// garde sa propre position, inchangée.
wf = self.wdec.seek_to(0.0)?;
break;
}
f => wf = f,
}
guard += 1;
if guard > 1000 {
Expand Down Expand Up @@ -1450,26 +1475,34 @@ unsafe fn render_thread(
}
acc = 0.0; // resynchronise l'accumulateur de lecture libre après un seek
} else if shared.playing.load(Ordering::Relaxed) {
// BUG corrigé : la lecture libre décodait toujours exactement 1 frame par tick de
// 1/60s réel, quelle que soit la speed region active au temps source courant — ni
// l'écran ni la webcam n'accéléraient/ralentissaient jamais en preview live (seul
// l'export, via `speed_segments_for_window`/`advance_decoder_to` dans pipeline.rs,
// retimait correctement). Mod 3 corrige déjà le fps-mismatch webcam/écran (la webcam
// suit le temps source RÉEL de l'écran, pas un pas 1:1) — reprend ici la même idée :
// l'accumulateur de temps réel est mis à l'échelle par le multiplicateur de vitesse
// actif, donc `step()` (qui resynchronise la webcam sur le temps écran courant,
// cf. plus haut) décode plus/moins de frames par seconde réelle selon la région.
// BUG corrigé : la lecture libre décodait auparavant exactement 1 frame RÉELLE par
// tranche de 1/60s de temps réel écoulé, quelle que soit la densité effective de
// frames de la source. ScreenCaptureKit (et les captures équivalentes) ne livre une
// frame que quand l'écran change : un enregistrement avec de longs plans fixes peut
// ne contenir que quelques centaines de frames RÉELLES sur toute sa durée. Consommer
// 1 frame/tick épuisait alors le flux bien avant que le temps réel écoulé n'atteigne
// la durée de l'enregistrement — `step()` retombait sur l'EOF et rebouclait sur
// `seek_to(0.0)`, d'où la preview qui semblait « accélérer puis sauter au début » en
// boucle (cf. doc de `step()` pour le détail).
//
// Le correctif : `acc` (mis à l'échelle par la speed region active, cf. mod 3 plus
// bas pour le fps-mismatch webcam/écran) n'est plus consommé par tranches fixes de
// 1/60s — c'est une CIBLE de temps source (`target = temps courant + acc`) que
// `step()` n'atteint qu'en adoptant une frame dont le pts est réellement dû (hold
// sinon). `acc` n'est décrémenté que du temps source RÉELLEMENT consommé par chaque
// frame adoptée, jamais d'un pas fixe — donc les plans fixes ne consomment aucune
// frame et n'avancent le décodeur que quand une frame due existe vraiment.
let speed = full_scene
.as_ref()
.map(|scene| speed_at(&scene.speed_regions, active_clip_index, player.screen_time_sec()))
.unwrap_or(1.0);
acc += dt * speed;
let step = 1.0 / 60.0;
let mut n = 0;
// Cap proportionnel à la vitesse (borné) : à vitesse élevée, plus de frames doivent
// être décodées par tick réel pour ne pas prendre du retard sur l'accumulateur.
// Cap sur le nombre de frames RÉELLEMENT adoptées par tick (pas sur le nombre de
// ticks) : à vitesse élevée sur du contenu dense, plus de frames doivent être
// décodées par tick réel pour ne pas prendre du retard sur l'accumulateur.
let max_steps = ((3.0 * speed.max(1.0)).ceil() as i32).min(64);
while acc >= step && n < max_steps {
loop {
// Timeline = niveau d'abstraction AU-DESSUS des clips : dès que le décodeur
// écran atteint la fin de fenêtre du clip actif, on enchaîne nous-mêmes sur
// le clip suivant (ou on reboucle sur le premier après le dernier) — sans
Expand Down Expand Up @@ -1507,9 +1540,9 @@ unsafe fn render_thread(
}
}
let screen_time_before_step = full_scene.as_ref().map(|_| player.screen_time_sec());
if player.step(&comp, &cfg)? {
stepped = true;
}
let before = player.screen_time_sec();
let target = before + acc;
let committed = player.step(&comp, &cfg, target)?;
// Filet de sécurité : un clip NON trimmé (source_end_sec == durée totale du
// fichier) peut ne jamais franchir le seuil ci-dessus si la dernière frame
// réelle a un PTS strictement inférieur à `source_end_sec` déclaré — `step()`
Expand All @@ -1532,11 +1565,24 @@ unsafe fn render_thread(
);
}
}
acc -= step;
if !committed {
// Rien n'est dû pour l'instant (hold) : `acc` reste tel quel — il continue
// de s'accumuler aux ticks suivants jusqu'à ce qu'une vraie frame arrive.
break;
}
stepped = true;
let after = player.screen_time_sec();
// `after < before` : `step()` a rebouclé sur l'EOF (temps qui recule) — le calcul
// de delta n'a alors aucun sens, on repart d'un accumulateur propre.
acc = if after >= before { (acc - (after - before)).max(0.0) } else { 0.0 };
n += 1;
}
if acc > step {
acc = 0.0;
if n >= max_steps {
// Rattrapage plafonné : le contenu dû est plus dense que ce qu'on peut décoder
// en un tick réel. On laisse tomber le reliquat plutôt que de creuser une
// dette qui s'accumulerait indéfiniment d'un tick à l'autre.
acc = 0.0;
break;
}
}
} else if first || ip_changed || scene_changed || clip_changed || resized {
// pause : recompose la frame courante (param / scène / clip / résolution changés).
Expand Down
18 changes: 18 additions & 0 deletions crates/compositor/src/pipeline_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,24 @@ impl Decoder {
Ok(carrier)
}

/// Décode la prochaine frame dans le buffer de lookahead du décodeur sous-jacent et
/// renvoie son temps (s), sans la présenter (donc sans toucher `self.cur`) — `None` à
/// EOF. Cf. `pipeline_macos::Decoder::peek_next_time_sec` pour la sémantique "hold".
pub unsafe fn peek_next_time_sec(&mut self) -> Result<Option<f64>> {
self.sw.peek_next_time_sec()
}

/// Promeut la frame de lookahead au rang de frame courante ET la présente (upload NV12
/// vers la texture carrier), contrairement au chemin macOS/Windows où la promotion est
/// un pur échange de pointeurs — ici la présentation est le pas qui manque.
pub unsafe fn commit_peek(&mut self) -> Result<*mut AVFrame> {
let raw = self.sw.commit_peek()?;
let carrier = self.frames.present(raw)?;
self.cur = carrier;
self.next_idx = self.next_idx.saturating_add(1);
Ok(carrier)
}

pub unsafe fn cur_frame(&self) -> *mut AVFrame {
self.cur
}
Expand Down
Loading
Loading