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
30 changes: 26 additions & 4 deletions crates/compositor/src/live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<Option<LatestFrame>>,
/// 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.
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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<u8>)> {
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,
}
}
Expand Down Expand Up @@ -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;
Expand Down
63 changes: 63 additions & 0 deletions crates/compositor/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64>,
}

// SAFETY: `Decoder` only owns FFI pointers into FFmpeg's own heap-allocated state, which
Expand Down Expand Up @@ -447,6 +452,7 @@ impl Decoder {
pkt: av_packet_alloc(),
frame: av_frame_alloc(),
sent_eof: false,
cur_pts: None,
})
}

Expand Down Expand Up @@ -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()?;
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
17 changes: 17 additions & 0 deletions src/components/ai-edition/VirtualPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,23 @@ export function VirtualPreview({
if (v.readyState >= 2) {
setSourceTimeSec(v.currentTime);
}
// À L'ARRÊT, le `<video>` ne pilote PLUS la position de la timeline.
//
// Ce tick publie `updateVirtualTime(...)` dérivé de `v.currentTime`. Pendant la
// lecture c'est la bonne source : le média avance, la tête le suit. À l'arrêt
// c'est l'inverse — l'utilisateur possède la tête de lecture, et le `<video>`
// doit la SUIVRE. Sans ce garde, un scrub était écrasé à chaque frame par la
// position d'un élément encore en train de chercher ; et près d'une frontière de
// clips, `locateSourcePosition` ne résolvait pas, si bien que le repli
// `seekToVirtualTimeRef(nextClip.timelineStartSec)` plus bas renvoyait la tête au
// DÉBUT du clip voisin — le tressaillement observé au passage d'un clip à l'autre.
//
// `clockRef` et `setSourceTimeSec` ci-dessus continuent d'être publiés : la webcam
// et le calque curseur ont besoin du temps source même à l'arrêt. Seule la
// position de la TIMELINE cesse d'être dictée par le média.
if (v.paused) {
return;
}
if (clipsRef.current.length === 0) {
// ponytail: no clip yet (auto-create runs from
// handleLoadedMetadata on the next tick). Push the raw
Expand Down
Loading