diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index a075887f9..7f18ee8c7 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -31,7 +31,7 @@ use windows::core::PCWSTR; use crate::d3d::Gpu; use crate::pipeline::Decoder; use anyhow::Result; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use std::time::{Duration, Instant}; @@ -463,6 +463,11 @@ struct Shared { /// Option pour distinguer "pas de frame encore composée" (avant le 1er compose, /// `read_frame` retourne `Ok(None)`) d'un buffer vide (qui n'arrive jamais). latest_frame: Mutex>, + /// Génération de la dernière frame publiée. Tenue À PART de `latest_frame` : la + /// livraison sans copie vide le slot en le lisant, et dériver la génération d'un slot + /// vide la ferait repartir à 1 — donc rejouer des générations déjà peintes. Monotone, + /// jamais remise à zéro. + frame_gen: AtomicU64, } /// Handle d'une vue live. `Drop` arrête le rendu. @@ -508,6 +513,7 @@ impl LiveView { playing: AtomicBool::new(true), stop: AtomicBool::new(false), latest_frame: Mutex::new(None), + frame_gen: AtomicU64::new(0), }); let sh = shared.clone(); let (s, wc, cj) = (screen.to_string(), webcam.to_string(), cursor_json.to_string()); @@ -556,9 +562,20 @@ impl LiveView { /// compteur. Le consommateur passe la dernière génération qu'il a peinte (`0` au /// départ) ; `None` ⇒ il ne fait rien, `Some` ⇒ il peint et retient `gen`. pub fn latest_frame_since(&self, since_gen: u64) -> Option<(u64, u32, u32, Vec)> { - let guard = self.shared.latest_frame.lock().ok()?; + let mut guard = self.shared.latest_frame.lock().ok()?; match guard.as_ref() { - Some((gen, w, h, px)) if *gen > since_gen => Some((*gen, *w, *h, px.clone())), + // Le buffer est EMPORTÉ, pas copié. Le thread de rendu le remplace à chaque + // frame composée et le consommateur garde ses pixels peints sur le canvas : + // personne ne relit jamais la même génération. Le `clone()` d'avant était un + // memcpy `O(w·h)` — 1,5 ms à 1280×720, 3,4 ms à 1920×1080 — payé sur le THREAD + // PRINCIPAL de Node, celui-là même qui doit rester libre pour que React peigne + // la tête de lecture. + // + // Contrepartie assumée : une relecture forcée (`since_gen = 0`) après la + // première ne retrouve rien tant qu'une nouvelle frame n'est pas composée. Sans + // conséquence ici — le seul consommateur ne l'utilise qu'au montage, et un + // redimensionnement provoque de toute façon une recomposition. + Some((gen, ..)) if *gen > since_gen => guard.take(), _ => None, } } @@ -1288,8 +1305,13 @@ unsafe fn render_thread( // premier publish. Les dims publiées sont celles du RENDU (`rw`×`rh`) : // le canvas JS s'y dimensionne (packet auto-descriptif) puis CSS met à // l'échelle vers la boîte du panneau — plus de resize GPU intermédiaire. + // La génération vient d'un compteur atomique et non du slot : la + // livraison sans copie VIDE le slot en le lisant, et un + // `unwrap_or(1)` repartirait alors de 1 — le consommateur recevrait + // des générations déjà peintes et boucherait. Séquence identique à + // l'ancienne dérivation tant que le slot n'est pas vidé. + let next_gen = shared.frame_gen.fetch_add(1, Ordering::Relaxed) + 1; if let Ok(mut slot) = shared.latest_frame.lock() { - let next_gen = slot.as_ref().map(|(g, ..)| g + 1).unwrap_or(1); *slot = Some((next_gen, rw, rh, rgba)); } first = false; diff --git a/crates/compositor/src/pipeline.rs b/crates/compositor/src/pipeline.rs index f2e03c302..f2361da4a 100644 --- a/crates/compositor/src/pipeline.rs +++ b/crates/compositor/src/pipeline.rs @@ -399,6 +399,11 @@ pub(crate) struct Decoder { pkt: *mut AVPacket, frame: *mut AVFrame, sent_eof: bool, + /// PTS de la frame actuellement décodée dans `frame`, ou `None` si l'état du décodeur + /// vient d'être jeté (ouverture, seek). Sert au chemin rapide de `seek_to` : sans lui, + /// impossible de savoir si `frame` contient quoi que ce soit d'exploitable — un + /// `AVFrame` fraîchement alloué a un `best_effort_timestamp` indéterminé. + cur_pts: Option, } // SAFETY: `Decoder` only owns FFI pointers into FFmpeg's own heap-allocated state, which @@ -447,6 +452,7 @@ impl Decoder { pkt: av_packet_alloc(), frame: av_frame_alloc(), sent_eof: false, + cur_pts: None, }) } @@ -477,9 +483,40 @@ impl Decoder { /// donc le débit par frame ne change pas. Renvoie la frame (ou null à EOF). pub(crate) unsafe fn seek_to(&mut self, seconds: f64) -> Result<*mut AVFrame> { let tb_sec = self.tb_sec(); + + // Chemin rapide. Le seek complet ci-dessous jette TOUT l'état du décodeur et repart + // de l'image clé précédente — jusqu'à `gop_size` frames à redécoder (60 sur nos + // captures), et deux fois puisque écran et webcam ont chacun leur décodeur, soit + // ~50 ms par pas de scrub mesurés. Or le cas dominant en édition n'est pas un saut : + // c'est « la frame suivante » (scrub, pas-à-pas) ou « la même frame » (un paramètre + // a changé, la scène est recomposée au même instant). Aucun des deux ne justifie de + // repartir d'une image clé. + // + // Le critère d'arrêt du déroulement est la MÊME expression que celui du seek complet + // (cf. `decode_forward_to`), donc les deux chemins rendent la même frame : c'est une + // optimisation, pas un changement de comportement. + if tb_sec > 0.0 { + if let Some(pts) = self.cur_pts { + let cur = pts as f64 * tb_sec; + let frame_dur = 1.0 / self.fps().max(1.0); + // 1) La frame courante EST celle demandée : rien à décoder du tout. + if (cur - seconds).abs() < frame_dur * 0.5 { + return Ok(self.frame); + } + // 2) La cible est DEVANT et à portée : dérouler depuis ici. Au-delà du seuil, + // repartir d'une image clé redevient moins cher — un seek coûte en moyenne + // un demi-GOP, soit ~0,5 s sur nos captures. + if cur < seconds && seconds - cur <= SEEK_FORWARD_MAX_SEC { + return self.decode_forward_to(seconds, tb_sec); + } + } + } + let target = if tb_sec > 0.0 { (seconds / tb_sec) as i64 } else { 0 }; averr(av_seek_frame(self.fmt, self.vidx, target, AVSEEK_FLAG_BACKWARD), "seek_to")?; avcodec_flush_buffers(self.dctx); + // L'état vient d'être jeté : plus aucune frame courante exploitable. + self.cur_pts = None; self.sent_eof = false; loop { let f = self.next()?; @@ -497,6 +534,25 @@ impl Decoder { } } + /// Déroule le décodeur en avant jusqu'à la première frame à `seconds` ou après, SANS + /// jeter son état. Critère d'arrêt identique à celui du seek complet — c'est ce qui + /// garantit que les deux chemins rendent exactement la même frame. + unsafe fn decode_forward_to(&mut self, seconds: f64, tb_sec: f64) -> Result<*mut AVFrame> { + loop { + let f = self.next()?; + if f.is_null() { + return Ok(ptr::null_mut()); + } + let pts = (*f).best_effort_timestamp; + if pts == i64::MIN { + return Ok(f); + } + if (pts as f64) * tb_sec >= seconds - tb_sec * 0.5 { + return Ok(f); + } + } + } + /// Temps (s) de la frame courante, via son pts. 0 si pas de pts fiable. pub(crate) unsafe fn cur_time_sec(&self) -> f64 { let pts = (*self.frame).best_effort_timestamp; @@ -535,6 +591,8 @@ impl Decoder { loop { let r = avcodec_receive_frame(self.dctx, self.frame); 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); } if r == AVERROR_EOF { @@ -596,6 +654,11 @@ unsafe fn advance_decoder_to( /// Frames-context de l'encodeur : NV12 sur notre device, bind RENDER_TARGET (§5) pour /// que le compositeur rende directement dans les surfaces de l'encodeur. +/// Au-delà de cette distance vers l'avant, `seek_to` repart d'une image clé plutôt que de +/// dérouler. Calé sur le demi-GOP de nos captures (GOP=60 à 60 fps) : en deçà, dérouler +/// coûte moins cher que de jeter l'état du décodeur et redécoder depuis la clé précédente. +const SEEK_FORWARD_MAX_SEC: f64 = 0.5; + unsafe fn make_enc_frames(gpu: &Gpu, w: i32, h: i32) -> Result<(*mut AVBufferRef, *mut AVBufferRef)> { let hwdev = av_hwdevice_ctx_alloc(AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA); let hwdc = (*hwdev).data as *mut AVHWDeviceContext; diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx index a5aa80136..4476be836 100644 --- a/src/components/ai-edition/VirtualPreview.tsx +++ b/src/components/ai-edition/VirtualPreview.tsx @@ -265,6 +265,23 @@ export function VirtualPreview({ if (v.readyState >= 2) { setSourceTimeSec(v.currentTime); } + // À L'ARRÊT, le `