From c05662ddd636901cb046c897b0cc986620d0d9c8 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 1 Sep 2026 09:31:39 +0200 Subject: [PATCH 1/8] perf(export): encode on its own thread, so the walk does not wait for it (Linux) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export ran on ONE thread: decode, compose, read back, de-pad, encode, mux, in a line, while seven cores did nothing. `avcodec_send_frame` alone was 29.5 s of a ~57 s export. Nothing about that work needs to be on the critical path. An `EncodeWorker` now owns the `VideoEncoder` and the muxer and consumes frames from a channel, so the timeline walk composes frame n+1 while the encoder is still on frame n. Measured on the same project, same binary otherwise (3600 frames, 1080p60): | | before | after | |---|---|---| | wall | 58.43 s | 40.90 s | | CPU | 98% (one core) | 189% | Output is BYTE-IDENTICAL — same md5, same 12,997,062 bytes. This buys throughput and changes nothing else. THE BOUND IS THE POOL CENSUS, NOT A CHANNEL CAPACITY. The walk thread is faster than the encoder, so an unbounded queue would accumulate all 3600 frames -- ~11.2 GB. Instead exactly `depth` AVFrames exist and circulate between an `empty` channel and a `full` one. Overrun is not avoided, it is unrepresentable, and `take_free` is the single place where the walk waits on the encoder. WHY THE DE-PAD STAYED ON THE WALK THREAD. Stripping the 256-byte row padding costs ~0.67 ms/frame. It could be removed entirely by handing the encoder the mapped staging buffer directly (an AVFrame whose linesize IS the GPU stride -- verified bit-exact against libopenh264). But once encoding is off the critical path the ENCODER is the bottleneck at ~29.5 s, so moving that copy to the thread with slack lands on the same total as deleting it, without holding a wgpu staging slot mapped across a thread boundary. This repo keeps its one existing worker (`segmentation.rs`) off the GPU entirely; this follows that. The muxer became one type because it has to travel as one: `av_interleaved_write_frame` touches `octx`, `ostream` and `opkt`, and `AacEncoder` holds an `*mut AVStream` pointing INTO `octx`'s stream table. Splitting them would leave a pointer into an object owned by another thread. It comes back through `join`, which is the happens-before edge that makes `octx` usable again here for the audio and the trailer. `Muxer` has a `Drop`, so an early `?` between opening and finishing no longer leaks the format context, the IO context and the packet. The frame returns to the pool even when the encode fails, so a dead worker surfaces as its real error instead of wedging the walk on an empty pool. And the readback now hands the mapped range to a closure instead of returning a `Vec`, which drops a 3.3 MB allocate-copy-free per frame and returns the staging slot to the ring even when the reader errors. The `ectx` alias is documented rather than removed: it is read only before the worker starts. A second copy living alongside the worker would be an unofficial `Sync` on a type that is deliberately `Send` and not `Sync`. --- crates/compositor/src/compositor_linux.rs | 48 ++- crates/compositor/src/pipeline_linux.rs | 350 +++++++++++++++++++--- 2 files changed, 341 insertions(+), 57 deletions(-) diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 30e9e7f5..ceb09606 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -3080,7 +3080,10 @@ impl Compositor { /// L'appelant recalcule ces strides depuis `w`/`h` — les depadder ici /// couterait une recopie de plus pour rien, l'encodeur sachant lire un /// `linesize`. - pub unsafe fn readback_submit_yuv(&self) -> Result)>> { + pub unsafe fn readback_submit_yuv(&self, f: F) -> Result + where + F: FnMut(u32, u32, &[u8]) -> Result<()>, + { self.ensure_yuv()?; let (w, h, cw, ch, bpr_y, bpr_uv, off_u, off_v, total) = { let g = self.yuv.borrow(); @@ -3167,29 +3170,50 @@ impl Compositor { let mut ring = self.readback_yuv.borrow_mut(); ring.pending.push_back(PendingCopy { buf, idx, rx, w, h, bpr: bpr_y }); if ring.pending.len() < ring.depth { - return Ok(None); // amorcage, comme la ring RGBA + return Ok(false); // amorcage, comme la ring RGBA } } - self.readback_take_yuv() + self.readback_take_yuv_with(f) } - /// Recolte la plus ancienne conversion en vol. Pendant de `readback_take`. - pub unsafe fn readback_take_yuv(&self) -> Result)>> { + /// Recolte la plus ancienne conversion en vol et la PRESENTE au lecteur sans + /// la copier : `f` recoit la vue mappee telle quelle, lignes paddees a 256 + /// comprises. Rend `false` si la ring est vide. Pendant de `readback_take`. + /// + /// POURQUOI UNE CLOSURE, ET PAS UN `Vec` RENDU. La version precedente faisait + /// `mapped.to_vec()` — 3,3 Mo alloues, copies puis liberes par frame, soit + /// 11,9 Go de va-et-vient sur un export de 3600 frames — dans le seul but que + /// la donnee survive a l'`unmap`. Or l'appelant la recopie immediatement dans + /// l'AVFrame de l'encodeur : la copie intermediaire ne servait que la + /// signature. Avec une closure, le lecteur travaille dans la fenetre ou le + /// buffer est mappe et il n'y a plus qu'une seule copie sur le chemin. + /// + /// LE SLOT EST RENDU MEME SI `f` ECHOUE. Autrement une erreur d'encodage + /// laisserait le buffer mappe et hors de la ring : la frame suivante en + /// allouerait un neuf, et ainsi de suite jusqu'a epuisement de la memoire + /// mappable — un mode de panne bien pire que l'erreur d'origine. + pub unsafe fn readback_take_yuv_with(&self, mut f: F) -> Result + where + F: FnMut(u32, u32, &[u8]) -> Result<()>, + { let Some(p) = self.readback_yuv.borrow_mut().pending.pop_front() else { - return Ok(None); + return Ok(false); }; self.gpu.device.poll(wgpu::Maintain::WaitForSubmissionIndex(p.idx)); p.rx .recv() .map_err(|_| anyhow::anyhow!("map_async channel (yuv)"))? .map_err(|e| anyhow::anyhow!("map_async yuv: {e:?}"))?; - let slice = p.buf.slice(..); - let mapped = slice.get_mapped_range(); - let out = mapped.to_vec(); - drop(mapped); + // `mapped` et `slice` meurent a la fin du bloc : `unmap` ne peut donc pas + // etre appele pendant qu'une vue est encore accessible (wgpu l'assert). + let r = { + let slice = p.buf.slice(..); + let mapped = slice.get_mapped_range(); + f(p.w, p.h, &mapped) + }; p.buf.unmap(); self.readback_yuv.borrow_mut().free.push(p.buf); - Ok(Some((p.w, p.h, out))) + r.map(|()| true) } /// Profondeur de la ring YUV. Meme role et memes raisons que @@ -3198,7 +3222,7 @@ impl Compositor { let depth = depth.max(1); // SAFETY : meme contrat que `set_readback_depth` — le drain ne touche que // des buffers dont la soumission est terminee. - while unsafe { self.readback_take_yuv()? }.is_some() {} + while unsafe { self.readback_take_yuv_with(|_, _, _| Ok(()))? } {} let mut ring = self.readback_yuv.borrow_mut(); ring.depth = depth; while ring.free.len() > depth { diff --git a/crates/compositor/src/pipeline_linux.rs b/crates/compositor/src/pipeline_linux.rs index 7fc98877..57e92b62 100644 --- a/crates/compositor/src/pipeline_linux.rs +++ b/crates/compositor/src/pipeline_linux.rs @@ -329,10 +329,21 @@ impl VideoEncoder { /// reste est de retirer le padding des trois plans — `copy_texture_to_buffer` /// aligne chaque `bytes_per_row` sur 256, donc en 1080p Y arrive en 2048 pour /// 1920 utiles et U/V en 1024 pour 960. - pub unsafe fn send_yuv420p(&mut self, planes: &[u8], rw: i32, rh: i32, pts: i64) -> Result<()> { + pub unsafe fn depad_into( + dst_frame: *mut AVFrame, + planes: &[u8], + rw: i32, + rh: i32, + enc_w: i32, + enc_h: i32, + ) -> Result<()> { use crate::ffi::*; - if rw != self.w || rh != self.h { - bail!("send_yuv420p {rw}x{rh} != encodeur {}x{}", self.w, self.h); + // Les DEUX bornes comptent. La verification de taille seule laisserait + // passer un buffer assez gros mais de mauvaise geometrie : les strides + // seraient recalcules depuis rw/rh et l'image sortirait silencieusement + // decalee, ce qui est bien plus difficile a diagnostiquer qu'un echec. + if rw != enc_w || rh != enc_h { + bail!("depad_into {rw}x{rh} != encodeur {enc_w}x{enc_h}"); } let (w, h) = (rw as usize, rh as usize); let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); @@ -344,7 +355,7 @@ impl VideoEncoder { bail!("plans YUV tronques : {} octets", planes.len()); } - averr(av_frame_make_writable(self.sw), "make_writable")?; + averr(av_frame_make_writable(dst_frame), "make_writable")?; // Ligne a ligne parce que les deux strides different : celui du GPU est // aligne a 256, celui de l'AVFrame a ce que ffmpeg a choisi. for (plane, src_off, src_stride, pw, ph) in [ @@ -352,8 +363,8 @@ impl VideoEncoder { (1, off_u, bpr_uv, cw, ch), (2, off_v, bpr_uv, cw, ch), ] { - let dst = (*self.sw).data[plane]; - let dst_stride = (*self.sw).linesize[plane] as usize; + let dst = (*dst_frame).data[plane]; + let dst_stride = (*dst_frame).linesize[plane] as usize; for y in 0..ph { std::ptr::copy_nonoverlapping( planes.as_ptr().add(src_off + y * src_stride), @@ -362,8 +373,7 @@ impl VideoEncoder { ); } } - (*self.sw).pts = pts; - averr(avcodec_send_frame(self.ctx, self.sw), "send_frame") + Ok(()) } /// Flush : une frame nulle finalise le bitstream de l'encodeur. @@ -406,24 +416,228 @@ unsafe fn alloc_sw_frame(pix_fmt: crate::ffi::AVPixelFormat::Type, w: i32, h: i3 Ok(frame) } -/// Draine les paquets de l'encodeur vers le muxer. Symetrique de -/// `pipeline_macos::drain_encoder`. -unsafe fn drain_encoder( - ectx: *mut crate::ffi::AVCodecContext, +/// Etat du muxer MP4, deplacable en bloc sur le thread d'encodage. +/// +/// POURQUOI UN SEUL TYPE PLUTOT QUE QUATRE VARIABLES. `av_interleaved_write_frame` +/// touche `octx`, la piste video `ostream` et le paquet de travail `opkt` ; et +/// `AacEncoder` garde un `*mut AVStream` qui pointe DANS la table de flux de +/// `octx` (audio.rs). Les separer laisserait un pointeur vers l'interieur d'un +/// objet possede par un autre thread. Ils partent donc ensemble, ou pas du tout. +struct Muxer { octx: *mut crate::ffi::AVFormatContext, + pb: *mut crate::ffi::AVIOContext, ostream: *mut crate::ffi::AVStream, opkt: *mut crate::ffi::AVPacket, -) -> Result<()> { - use crate::ffi::*; - loop { - let r = avcodec_receive_packet(ectx, opkt); - if r == AVERROR_EOF || r == AVERROR_EAGAIN { - return Ok(()); + aac: AacEncoder, +} + +// SAFETY : aucun de ces pointeurs n'a d'affinite de thread. Le muxer est DEPLACE +// vers le worker puis rendu au thread appelant par le `join` ; il n'est jamais +// partage, d'ou `Send` sans `Sync`. +unsafe impl Send for Muxer {} + +impl Muxer { + /// Draine les paquets de l'encodeur vers le fichier. Symetrique de + /// `pipeline_macos::drain_encoder`. + unsafe fn drain(&mut self, ectx: *mut crate::ffi::AVCodecContext) -> Result<()> { + use crate::ffi::*; + loop { + let r = avcodec_receive_packet(ectx, self.opkt); + if r == AVERROR_EOF || r == AVERROR_EAGAIN { + return Ok(()); + } + averr(r, "receive_packet")?; + av_packet_rescale_ts(self.opkt, (*ectx).time_base, (*self.ostream).time_base); + averr( + av_interleaved_write_frame(self.octx, self.opkt), + "interleaved_write_frame", + )?; + av_packet_unref(self.opkt); + } + } + + /// Ferme le conteneur. La liberation, elle, est dans `Drop` : un `?` entre + /// l'ouverture et ici ne doit pas fuir le contexte ni le fichier. + unsafe fn finish(&mut self) -> Result<()> { + crate::ffi::averr(crate::ffi::av_write_trailer(self.octx), "write_trailer") + } +} + +impl Drop for Muxer { + fn drop(&mut self) { + unsafe { + crate::ffi::avio_closep(&mut self.pb); + crate::ffi::avformat_free_context(self.octx); + crate::ffi::av_packet_free(&mut self.opkt); + } + } +} + +/// Une frame remplie, en route vers l'encodeur. +struct EncJob { + frame: *mut AVFrame, + pts: i64, +} +// SAFETY : la frame appartient au pool et n'est touchee que par UN thread a la +// fois — le passage par le canal est le transfert de propriete. +unsafe impl Send for EncJob {} + +/// Une frame vidée que le worker rend au pool. +struct FreeFrame(*mut AVFrame); +// SAFETY : idem `EncJob`, dans l'autre sens. +unsafe impl Send for FreeFrame {} + +/// Encodeur + muxer deportes sur leur propre thread. +/// +/// POURQUOI. L'export tenait sur UN thread : decodage, composition, relecture, +/// de-padding puis encodage a la queue leu leu, pendant que sept coeurs ne +/// faisaient rien. `avcodec_send_frame` pese a lui seul 29,5 s des ~57 s d'un +/// export de 3600 frames ; le sortir du chemin critique laisse la marche de +/// timeline avancer pendant que l'encodeur travaille la frame precedente. +/// +/// LE POOL BORNE LA MEMOIRE, PAS UN CANAL. Le thread de marche va plus vite que +/// l'encodeur : une file non bornee finirait par contenir les 3600 frames, soit +/// ~11,2 Go. Ici il existe EXACTEMENT `depth` AVFrames, qui tournent entre le +/// canal `empty` et le canal `full`. Le depassement n'est pas evite, il est +/// inexprimable — et `empty_rx.recv()` est le seul point ou la marche attend +/// l'encodeur, donc le seul endroit a instrumenter si le debit deçoit. +/// +/// LE DE-PADDING RESTE COTE MARCHE. Recopier les plans depuis le buffer relu +/// (lignes alignees a 256) vers l'AVFrame coute ~0,67 ms par frame. Le mettre +/// ici le poserait sur le thread qui est desormais le goulot ; le laisser sur la +/// marche, qui a du mou, ne coute rien. Meme raison pour laquelle il ne sert a +/// rien de donner la memoire mappee du GPU directement a l'encodeur : ca +/// supprimerait cette copie sans deplacer le goulot, en echange d'un slot de +/// staging maintenu mappe a travers une frontiere de thread. +struct EncodeWorker { + full_tx: Option>, + empty_rx: std::sync::mpsc::Receiver, + /// Pour rendre au pool une frame empruntee mais finalement pas remplie + /// (l'amorcage de la ring de relecture ne produit rien les premieres fois). + empty_tx: std::sync::mpsc::Sender, + handle: Option>>, + /// Premiere erreur rencontree par le worker. La marche la relit a chaque + /// frame : sans ca, un encodeur mort a la frame 12 laisserait composer les + /// 3588 suivantes avant que quiconque s'en apercoive. + fatal: std::sync::Arc>>, +} + +impl EncodeWorker { + /// Demarre le thread et alloue le pool. `enc` et `mux` lui appartiennent + /// jusqu'au `finish`. + fn spawn(mut enc: VideoEncoder, mut mux: Muxer, depth: usize) -> Result { + let (full_tx, full_rx) = std::sync::mpsc::channel::(); + let (empty_tx, empty_rx) = std::sync::mpsc::channel::(); + for _ in 0..depth.max(2) { + let f = unsafe { + alloc_sw_frame(crate::ffi::AVPixelFormat::AV_PIX_FMT_YUV420P, enc.w, enc.h)? + }; + empty_tx + .send(FreeFrame(f)) + .map_err(|_| anyhow::anyhow!("pool d'encodage: canal ferme a l'amorcage"))?; + } + let empty_tx_keep = empty_tx.clone(); + let fatal = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let fatal_worker = std::sync::Arc::clone(&fatal); + let handle = std::thread::Builder::new() + .name("openscreen-encode".into()) + .spawn(move || -> Result { + while let Ok(job) = full_rx.recv() { + let r = unsafe { + (*job.frame).pts = job.pts; + crate::ffi::averr( + crate::ffi::avcodec_send_frame(enc.ctx, job.frame), + "send_frame", + ) + .and_then(|()| mux.drain(enc.ctx)) + }; + // La frame retourne au pool DANS TOUS LES CAS : la garder + // sur une erreur bloquerait la marche sur `empty_rx.recv()` + // au lieu de lui laisser voir `fatal`. + let _ = empty_tx.send(FreeFrame(job.frame)); + if let Err(e) = r { + *fatal_worker.lock().unwrap() = Some(format!("{e:#}")); + return Err(e); + } + } + // Canal ferme = plus aucune frame ne viendra : on vide + // l'encodeur ici, pendant qu'il nous appartient encore. + unsafe { + enc.flush()?; + mux.drain(enc.ctx)?; + } + Ok(mux) + })?; + Ok(EncodeWorker { + full_tx: Some(full_tx), + empty_rx, + empty_tx: empty_tx_keep, + handle: Some(handle), + fatal, + }) + } + + /// Rend au pool une frame empruntee sans avoir ete remplie. + fn give_back(&self, frame: *mut AVFrame) { + let _ = self.empty_tx.send(FreeFrame(frame)); + } + + /// Emprunte une frame libre au pool. C'est ICI que la marche attend quand + /// l'encodeur prend du retard. + fn take_free(&self) -> Result<*mut AVFrame> { + match self.empty_rx.recv() { + Ok(FreeFrame(f)) => Ok(f), + Err(_) => Err(self.fatal_error("le thread d'encodage s'est arrete")), + } + } + + fn submit(&self, frame: *mut AVFrame, pts: i64) -> Result<()> { + match self.full_tx.as_ref() { + Some(tx) => tx + .send(EncJob { frame, pts }) + .map_err(|_| self.fatal_error("le thread d'encodage s'est arrete")), + None => Err(anyhow::anyhow!("submit apres finish")), + } + } + + /// Prefere l'erreur reelle du worker au symptome (« canal ferme »). + fn fatal_error(&self, fallback: &str) -> anyhow::Error { + match self.fatal.lock().unwrap().clone() { + Some(e) => anyhow::anyhow!("encodage: {e}"), + None => anyhow::anyhow!("{fallback}"), + } + } + + /// Ferme la file, attend le worker et RECUPERE le muxer : le `join` est + /// l'arete de synchronisation qui rend `octx` utilisable ici pour l'audio et + /// le trailer. + fn finish(&mut self) -> Result { + drop(self.full_tx.take()); + let handle = self + .handle + .take() + .ok_or_else(|| anyhow::anyhow!("finish appele deux fois"))?; + match handle.join() { + Ok(r) => r, + // Un panic du worker ne passe pas par `fatal` : le relayer en erreur + // plutot que de le repropager sur le thread de marche. + Err(_) => Err(self.fatal_error("le thread d'encodage a panique")), + } + } +} + +impl Drop for EncodeWorker { + fn drop(&mut self) { + // Chemin d'abandon (un `?` ailleurs) : fermer la file debloque le worker, + // et le join evite de liberer le pool sous ses pieds. + drop(self.full_tx.take()); + if let Some(h) = self.handle.take() { + let _ = h.join(); + } + while let Ok(FreeFrame(f)) = self.empty_rx.try_recv() { + let mut f = f; + unsafe { crate::ffi::av_frame_free(&mut f) }; } - averr(r, "receive_packet")?; - av_packet_rescale_ts(opkt, (*ectx).time_base, (*ostream).time_base); - averr(av_interleaved_write_frame(octx, opkt), "interleaved_write_frame")?; - av_packet_unref(opkt); } } @@ -452,7 +666,11 @@ pub fn run_composited_multi( let bit_rate = ((out_w as i64 * out_h as i64 * 8_000_000) / (1920 * 1080)).max(2_000_000); let t0 = std::time::Instant::now(); - let mut enc = VideoEncoder::open(¶ms.codec, out_w as i32, out_h as i32, out_fps, bit_rate)?; + let enc = VideoEncoder::open(¶ms.codec, out_w as i32, out_h as i32, out_fps, bit_rate)?; + // Le contexte de l'encodeur n'est PAS recopie ici. Une variable `ectx` + // partagee serait un `Sync` officieux : `VideoEncoder` est `Send` et + // volontairement pas `Sync`, et un `*mut AVCodecContext` copie efface + // exactement cette distinction au moment ou l'encodage part sur un thread. let ectx = enc.ctx; let mut screen_decs: HashMap = HashMap::new(); @@ -464,7 +682,7 @@ pub fn run_composited_multi( let mut pb: *mut crate::ffi::AVIOContext = ptr::null_mut(); let ostream; let opkt; - let mut audio_encoder; + let audio_encoder; unsafe { crate::ffi::averr( crate::ffi::avformat_alloc_output_context2(&mut octx, ptr::null(), ptr::null(), outc.as_ptr()), @@ -494,6 +712,12 @@ pub fn run_composited_multi( )?; opkt = crate::ffi::av_packet_alloc(); } + // A partir d'ici le muxer est un seul objet, et il part avec l'encodeur. + let mux = Muxer { octx, pb, ostream, opkt, aac: audio_encoder }; + // Profondeur 3 : deux frames en vol suffisent a couvrir l'encodeur, la + // troisieme absorbe les a-coups de la marche (une fin de clip y decode tout + // l'audio du clip d'un coup, cf. `on_clip_end`). + let mut worker = EncodeWorker::spawn(enc, mux, 3)?; // Un PCM par clip, assemble apres la marche video (elle seule dit combien de // frames chaque clip a produit, donc combien d'audio lui revient). @@ -523,13 +747,30 @@ pub fn run_composited_multi( &mut webcam_decs, &mut |n| { // Soumet la copie de la frame n SANS l'attendre et recolte la - // precedente : c'est tout le pipelining. Pendant que le CPU - // passe son temps dans `avcodec_send_frame` sur la frame n-1, - // le GPU finit la composition, la conversion YUV et la copie de n. - if let Some((rw, rh, planes)) = comp.readback_submit_yuv()? { - enc.send_yuv420p(&planes, rw as i32, rh as i32, encoded_pts)?; + // precedente : c'est tout le pipelining GPU. L'encodage, lui, + // n'est plus ici du tout — il tourne sur `worker` pendant que + // cette closure compose deja la frame suivante. + let frame = worker.take_free()?; + let mut filled = false; + comp.readback_submit_yuv(|rw, rh, planes| { + VideoEncoder::depad_into( + frame, + planes, + rw as i32, + rh as i32, + out_w as i32, + out_h as i32, + )?; + filled = true; + Ok(()) + })?; + if filled { + worker.submit(frame, encoded_pts)?; encoded_pts += 1; - drain_encoder(ectx, octx, ostream, opkt)?; + } else { + // Amorcage de la ring : rien a encoder, la frame empruntee + // retourne au pool telle quelle. + worker.give_back(frame); } // Progression = frames COMPOSEES (inchangee) : la barre ne doit // pas reculer d'une frame parce que l'encodage a un tour de @@ -560,33 +801,52 @@ pub fn run_composited_multi( }; unsafe { - // Drain de la ring AVANT le flush de l'encodeur : les `depth - 1` - // dernieres copies sont encore en vol, et sans ce drain la derniere - // frame composee ne serait jamais encodee (video amputee d'une frame). - while let Some((rw, rh, planes)) = comp.readback_take_yuv()? { - enc.send_yuv420p(&planes, rw as i32, rh as i32, encoded_pts)?; - encoded_pts += 1; - drain_encoder(ectx, octx, ostream, opkt)?; + // Drain de la ring AVANT de fermer la file : les `depth - 1` dernieres + // copies sont encore en vol, et sans ce drain la derniere frame composee + // ne serait jamais encodee (video amputee d'une frame). + loop { + let frame = worker.take_free()?; + let mut filled = false; + let got = comp.readback_take_yuv_with(|rw, rh, planes| { + VideoEncoder::depad_into( + frame, + planes, + rw as i32, + rh as i32, + out_w as i32, + out_h as i32, + )?; + filled = true; + Ok(()) + })?; + if filled { + worker.submit(frame, encoded_pts)?; + encoded_pts += 1; + } else { + worker.give_back(frame); + } + if !got { + break; + } } // Le compositeur peut survivre a l'export (l'appelant le possede) : on // lui rend sa profondeur par defaut plutot que de lui laisser une ring // a 2 et le buffer de 8 Mo qui va avec. comp.set_readback_yuv_depth(1)?; - enc.flush()?; - drain_encoder(ectx, octx, ostream, opkt)?; + // Fermer la file fait sortir le worker de sa boucle ; il vide l'encodeur + // et rend le muxer. Le `join` interne est l'arete de synchronisation qui + // rend `octx` de nouveau utilisable ici. + let mut mux = worker.finish()?; // Audio : le plan part des frames REELLEMENT produites par clip (un clip // raccourci voit son audio raccourci d'autant), puis un seul encode AAC. let declared_audio: Vec = clips.iter().map(|c| c.has_audio).collect(); let plan = build_audio_concat_plan(&clip_frame_counts, &declared_audio, out_fps as f64); - audio_encoder.encode( + let octx = mux.octx; + mux.aac.encode( &finish_audio(assemble_concatenated_pcm(&clip_pcm, &plan), audio_settings), octx, )?; - crate::ffi::averr(crate::ffi::av_write_trailer(octx), "write_trailer")?; - crate::ffi::avio_closep(&mut pb); - crate::ffi::avformat_free_context(octx); - let mut opkt = opkt; - crate::ffi::av_packet_free(&mut opkt); + mux.finish()?; } let wall_s = t0.elapsed().as_secs_f64(); From 474d151376afbd210552682708e39d5142ab53bd Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 1 Sep 2026 10:09:24 +0200 Subject: [PATCH 2/8] perf(compositor): upload the decoder's three planes, instead of interleaving to NV12 (Linux) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CpuFrames::present` ran a CPU `sws_scale` on every decoded frame to turn the decoder's YUV420P into NV12, purely so the carrier could be a two-texture Y + interleaved-UV pair. On a scene with a webcam that is TWICE per output frame, on the thread that is now the export's bottleneck. The conversion was never needed. YUV420P already has U and V as separate planes, and the GPU samples two R8 textures exactly as happily as one Rg8. So the carrier now holds three R8Unorm planes and the decoder's own buffers go straight to `write_texture`. Measured on a 3600-frame 1080p60 export: 40.90 s -> 39.92 s. Output is BYTE-IDENTICAL (same md5), which is the expected result — this removed an interleave, not a resampling. 191 tests pass. Less than the 2-3 s this was predicted to save. The prediction assumed the conversion cost what an isolated benchmark of it costs; in place, some of it was evidently already overlapping with the GPU. THE FALLBACK STILL EXISTS, and now targets YUV420P rather than NV12. A source that is not already 4:2:0 planar (4:2:2, 10-bit, an odd import) still goes through `sws_scale` — it just converts to the layout the textures now want. Everything this app records is h264 4:2:0, so the fast path is the normal one and the slow path is for imports. The V plane binds at 5, not 3: bindings 0-4 were already taken when the chroma plane split in two, and renumbering would have touched every bind group in the file to no purpose. Draws that bind no video (annotations, glyph atlas, cursor sprites) pass their one texture for all three slots, as they already did for two — those modes never sample chroma. Test helpers still speak interleaved NV12, because that is the readable form for writing a case; `nv12_textures` de-interleaves into the two planes rather than making every test carry the split. --- crates/compositor/src/compositor_linux.rs | 109 ++++++----- crates/compositor/src/linux_frames.rs | 190 +++++++++++--------- crates/compositor/src/vk_shaders/layer.wgsl | 11 +- 3 files changed, 177 insertions(+), 133 deletions(-) diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index ceb09606..2a955ca2 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -409,6 +409,11 @@ impl Compositor { // `dummy_view()` est lie a la place, et la branche du shader n'est de // toute facon prise que si fx.z > 0.5. tex_entry(4), + // Plan V. En 5 et pas en 3 : les bindings 0-4 etaient deja + // pris quand le chroma est passe d'un plan entrelace a deux + // plans, et renumeroter aurait touche tous les bind groups + // pour un gain nul. + tex_entry(5), ], }); let pipeline_layout = gpu.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { @@ -964,7 +969,7 @@ impl Compositor { unsafe fn nv12_srvs( &self, frame: *const AVFrame, - ) -> Result<(wgpu::TextureView, wgpu::TextureView)> { + ) -> Result<(wgpu::TextureView, wgpu::TextureView, wgpu::TextureView)> { crate::linux_frames::nv12_planes(frame) } @@ -1107,7 +1112,7 @@ impl Compositor { fn make_bind( &self, cb: &LayerCB, - planes: Option<(&wgpu::TextureView, &wgpu::TextureView)>, + planes: Option<(&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView)>, dummy: &wgpu::TextureView, ) -> (wgpu::Buffer, wgpu::BindGroup) { let uniform = self.gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { @@ -1115,7 +1120,7 @@ impl Compositor { contents: layer_bytes(cb), usage: wgpu::BufferUsages::UNIFORM, }); - let (y, uv) = planes.unwrap_or((dummy, dummy)); + let (y, u, v) = planes.unwrap_or((dummy, dummy, dummy)); // Le masque est lie sur TOUS les draws, pas seulement celui de la camera. // wgpu valide le bind group contre le layout : le binding 4 est declare // (`tex_entry(4)`), donc une entree absente ferait echouer CHAQUE draw et @@ -1139,7 +1144,7 @@ impl Compositor { }, wgpu::BindGroupEntry { binding: 2, - resource: wgpu::BindingResource::TextureView(uv), + resource: wgpu::BindingResource::TextureView(u), }, wgpu::BindGroupEntry { binding: 3, @@ -1149,6 +1154,10 @@ impl Compositor { binding: 4, resource: wgpu::BindingResource::TextureView(mask_view), }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(v), + }, ], }); (uniform, bind) @@ -1282,7 +1291,7 @@ impl Compositor { ..Default::default() }; let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); - let (buf, bind) = self.make_bind(&cb, Some((&view, &view)), dummy); + let (buf, bind) = self.make_bind(&cb, Some((&view, &view, &view)), dummy); Ok(BgDraw { _buf: buf, _tex: Some(tex), _view: Some(view), bind }) } @@ -1404,7 +1413,8 @@ impl Compositor { pub unsafe fn capture_webcam_rgb( &self, wy: &wgpu::TextureView, - wuv: &wgpu::TextureView, + wu: &wgpu::TextureView, + wv: &wgpu::TextureView, src: [f32; 4], width: u32, height: u32, @@ -1460,7 +1470,7 @@ impl Compositor { mb: [1.0, 1.0, 1.0, 0.0], ..Default::default() }, - Some((wy, wuv)), + Some((wy, wu, wv)), &self.dummy_view(), ); @@ -1616,7 +1626,8 @@ impl Compositor { unsafe fn pump_segmentation( &self, wy: &wgpu::TextureView, - wuv: &wgpu::TextureView, + wu: &wgpu::TextureView, + wv: &wgpu::TextureView, valid: [f32; 2], ) -> Result<()> { if *self.seg_failed.borrow() { @@ -1681,7 +1692,8 @@ impl Compositor { // `fx.xy`. self.capture_webcam_rgb( wy, - wuv, + wu, + wv, [0.0, 0.0, valid[0], valid[1]], crate::segmentation::MODEL_WIDTH, crate::segmentation::MODEL_HEIGHT, @@ -1801,7 +1813,7 @@ impl Compositor { if Self::pixel_buffer_of(screen).is_none() { return self.clear_rt(); } - let (sy, suv) = self.nv12_srvs(screen)?; + let (sy, su, sv) = self.nv12_srvs(screen)?; let (stw, sth) = self.tex_dims(screen); let (wtw, wth) = self.tex_dims(webcam); let (scw, sch) = ((*screen).width as f32, (*screen).height as f32); @@ -1839,8 +1851,8 @@ impl Compositor { if wants_seg && !webcam.is_null() { // `nv12_srvs` dereference `data[0]` sans verifier la frame elle-meme, // d'ou le garde de nullite au-dessus (meme condition que le draw PiP). - if let Ok((wy, wuv)) = self.nv12_srvs(webcam) { - self.pump_segmentation(&wy, &wuv, w_valid)?; + if let Ok((wy, wu, wv)) = self.nv12_srvs(webcam) { + self.pump_segmentation(&wy, &wu, &wv, w_valid)?; } } @@ -1947,7 +1959,7 @@ impl Compositor { // `_screen_uniform` garde le buffer uniforme en vie (reference par le bind). let dummy = self.dummy_view(); let (_screen_uniform, screen_bind) = - self.make_bind(&screen_layer, Some((&sy, &suv)), &dummy); + self.make_bind(&screen_layer, Some((&sy, &su, &sv)), &dummy); // OMBRE PORTEE de l'ecran, dessinee JUSTE AVANT le calque ecran. Le shader // la connait depuis le debut ; ce qui manquait etait uniquement le draw @@ -2062,7 +2074,7 @@ impl Compositor { scene_ref.as_ref().and_then(|s| s.webcam_effect.as_ref()), Some(e) if e.shader_code() == 1.0 ) && self.webcam_mask.borrow().is_some(); - let webcam_draw = webcam_planes.as_ref().map(|(wy, wuv)| { + let webcam_draw = webcam_planes.as_ref().map(|(wy, wu, wv)| { // COVER-CROP. `src` etait cable a [0,0,1,1], donc la texture entiere // etait etiree sur la boite quelle que soit sa forme : le facteur de // deformation valait exactement `box_ar / cam_ar`. Invisible en PiP @@ -2113,7 +2125,7 @@ impl Compositor { }; // Le masque est lie par `make_bind` sur tous les draws, pas seulement // celui-ci : le layout l'exige (cf. `tex_entry(4)`). - self.make_bind(&cb, Some((wy, wuv)), &dummy) + self.make_bind(&cb, Some((wy, wu, wv)), &dummy) }); // OMBRE de la camera. Pas dans les presets « bloc » (dual-frame, @@ -2267,7 +2279,7 @@ impl Compositor { // la lit. let (buf, bind) = self.make_bind( &cb, - Some((&self.ann_copy_view, &self.ann_copy_view)), + Some((&self.ann_copy_view, &self.ann_copy_view, &self.ann_copy_view)), &dummy, ); ann_draws.push(AnnDraw::plain(buf, bind)); @@ -2327,7 +2339,7 @@ impl Compositor { fx: [0.0, 0.0, 1.0, 1.0], ..Default::default() }; - let (buf, bind) = self.make_bind(&cb, Some((&view, &view)), &dummy); + let (buf, bind) = self.make_bind(&cb, Some((&view, &view, &view)), &dummy); ann_draws.push(AnnDraw { _buf: buf, _glyphs: None, @@ -2476,7 +2488,7 @@ impl Compositor { }; // Atlas R8 au binding 1 (texY) que le mode 11 echantillonne. let (buf, bind) = - self.make_bind(&cb, Some((&glyphs.view, &glyphs.view)), &dummy); + self.make_bind(&cb, Some((&glyphs.view, &glyphs.view, &glyphs.view)), &dummy); ann_draws.push(AnnDraw { _buf: buf, _glyphs: Some(glyphs), @@ -2623,7 +2635,7 @@ impl Compositor { } }; // Sprite RGBA au binding 1 (texY) que le mode 7 echantillonne. - let (buf, bind) = self.make_bind(&cb, Some((&view, &view)), &dummy); + let (buf, bind) = self.make_bind(&cb, Some((&view, &view, &view)), &dummy); bufs.push(buf); binds.push(bind); } @@ -3412,18 +3424,17 @@ mod tests { w: u32, h: u32, luma: impl Fn(u32, u32) -> u8, - ) -> (wgpu::TextureView, wgpu::TextureView) { + ) -> (wgpu::TextureView, wgpu::TextureView, wgpu::TextureView) { let mut y = vec![0u8; (w * h) as usize]; for row in 0..h { for col in 0..w { y[(row * w + col) as usize] = luma(col, row); } } - let (ytex, uvtex) = nv12_textures(gpu, w, h, &y, &vec![UV_NEUTRAL; (w * (h / 2)) as usize]); - ( - ytex.create_view(&wgpu::TextureViewDescriptor::default()), - uvtex.create_view(&wgpu::TextureViewDescriptor::default()), - ) + let (ytex, utex, vtex) = + nv12_textures(gpu, w, h, &y, &vec![UV_NEUTRAL; (w * (h / 2)) as usize]); + let d = wgpu::TextureViewDescriptor::default(); + (ytex.create_view(&d), utex.create_view(&d), vtex.create_view(&d)) } /// Le couple de textures NV12-split (Y `R8Unorm`, UV entrelacee `Rg8Unorm`) @@ -3434,7 +3445,7 @@ mod tests { h: u32, y: &[u8], uv: &[u8], - ) -> (wgpu::Texture, wgpu::Texture) { + ) -> (wgpu::Texture, wgpu::Texture, wgpu::Texture) { let mk = |label: &str, format, tw: u32, th: u32| { gpu.device.create_texture(&wgpu::TextureDescriptor { label: Some(label), @@ -3448,7 +3459,13 @@ mod tests { }) }; let ytex = mk("test-nv12-y", wgpu::TextureFormat::R8Unorm, w, h); - let uvtex = mk("test-nv12-uv", wgpu::TextureFormat::Rg8Unorm, w / 2, h / 2); + // Les helpers de test parlent encore NV12 entrelace parce que c'est la + // forme lisible pour ecrire un cas ; le carrier, lui, veut deux plans. + // On desentrelace ici plutot que de reecrire chaque test. + let utex = mk("test-yuv-u", wgpu::TextureFormat::R8Unorm, w / 2, h / 2); + let vtex = mk("test-yuv-v", wgpu::TextureFormat::R8Unorm, w / 2, h / 2); + let u_plane: Vec = uv.iter().step_by(2).copied().collect(); + let v_plane: Vec = uv.iter().skip(1).step_by(2).copied().collect(); let write = |tex: &wgpu::Texture, data: &[u8], bpr: u32, tw: u32, th: u32| { gpu.context.write_texture( wgpu::TexelCopyTextureInfo { @@ -3467,10 +3484,9 @@ mod tests { ); }; write(&ytex, y, w, w, h); - // UV : `w / 2` texels de 2 octets par ligne, soit `w` octets — la meme - // valeur que pour Y, par coincidence arithmetique et non par symetrie. - write(&uvtex, uv, w, w / 2, h / 2); - (ytex, uvtex) + write(&utex, &u_plane, w / 2, w / 2, h / 2); + write(&vtex, &v_plane, w / 2, w / 2, h / 2); + (ytex, utex, vtex) } /// Masque 0 sur la moitie gauche, 255 sur la droite. La frontiere tombe pile @@ -3489,7 +3505,7 @@ mod tests { comp: &Compositor, clear: wgpu::Color, cb: &LayerCB, - planes: (&wgpu::TextureView, &wgpu::TextureView), + planes: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView), ) -> (u32, u32, Vec) { let dummy = comp.dummy_view(); let (_buf, bind) = comp.make_bind(cb, Some(planes), &dummy); @@ -3530,13 +3546,14 @@ mod tests { // Moitie gauche noire, moitie droite blanche : la capture doit rendre les // deux dans le bon sens. Une inversion d'axe passerait un test de taille // sans se voir. - let (y, uv) = nv12_views(&gpu, 64, 64, |col, _| if col < 32 { Y_BLACK } else { Y_WHITE }); + let (y, u, v) = nv12_views(&gpu, 64, 64, |col, _| if col < 32 { Y_BLACK } else { Y_WHITE }); let mut out = Vec::new(); unsafe { comp.capture_webcam_rgb( &y, - &uv, + &u, + &v, [0.0, 0.0, 1.0, 1.0], crate::segmentation::MODEL_WIDTH, crate::segmentation::MODEL_HEIGHT, @@ -3567,7 +3584,8 @@ mod tests { unsafe { comp.capture_webcam_rgb( &y, - &uv, + &u, + &v, [0.0, 0.0, 1.0, 1.0], crate::segmentation::MODEL_WIDTH, crate::segmentation::MODEL_HEIGHT, @@ -3594,13 +3612,13 @@ mod tests { fn a_capture_whose_rows_need_padding_is_depadded_correctly() { let Some(gpu) = gpu() else { return }; let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized"); - let (y, uv) = nv12_views(&gpu, 64, 64, |col, _| if col < 32 { Y_BLACK } else { Y_WHITE }); + let (y, u, v) = nv12_views(&gpu, 64, 64, |col, _| if col < 32 { Y_BLACK } else { Y_WHITE }); let (w, h) = (100usize, 56usize); assert_ne!((w * 4) % 256, 0, "cette largeur doit justement ETRE mal alignee"); let mut out = Vec::new(); unsafe { - comp.capture_webcam_rgb(&y, &uv, [0.0, 0.0, 1.0, 1.0], w as u32, h as u32, &mut out) + comp.capture_webcam_rgb(&y, &u, &v, [0.0, 0.0, 1.0, 1.0], w as u32, h as u32, &mut out) .expect("capture_webcam_rgb"); } assert_eq!(out.len(), w * h * 3, "le padding d'alignement a fuit dans la sortie"); @@ -3621,9 +3639,9 @@ mod tests { fn a_capture_of_zero_size_is_refused_rather_than_rendered() { let Some(gpu) = gpu() else { return }; let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized"); - let (y, uv) = nv12_views(&gpu, 16, 16, |_, _| Y_WHITE); + let (y, u, v) = nv12_views(&gpu, 16, 16, |_, _| Y_WHITE); let mut out = Vec::new(); - let r = unsafe { comp.capture_webcam_rgb(&y, &uv, [0.0, 0.0, 1.0, 1.0], 0, 144, &mut out) }; + let r = unsafe { comp.capture_webcam_rgb(&y, &u, &v, [0.0, 0.0, 1.0, 1.0], 0, 144, &mut out) }; assert!(r.is_err(), "une cible de largeur nulle doit etre refusee"); } @@ -3666,7 +3684,7 @@ mod tests { let Some(gpu) = gpu() else { return }; let comp = Compositor::new_sized(&gpu, 64, 64).expect("Compositor::new_sized"); comp.set_webcam_mask(&half_mask(8, 8), 8, 8).expect("set_webcam_mask"); - let (y, uv) = nv12_views(&gpu, 16, 16, |_, _| Y_WHITE); + let (y, u, v) = nv12_views(&gpu, 16, 16, |_, _| Y_WHITE); // Fond bleu franc : une couleur que la camera (blanche, chroma neutre) ne // peut pas produire, donc « il reste du bleu » signifie « la camera a ete @@ -3687,7 +3705,7 @@ mod tests { mb: [1.0, 1.0, 1.0, 0.0], ..Default::default() }, - (&y, &uv), + (&y, &u, &v), ); let px = |col: usize, row: usize| -> [u8; 4] { @@ -3707,7 +3725,7 @@ mod tests { let Some(gpu) = gpu() else { return }; let comp = Compositor::new_sized(&gpu, 64, 64).expect("Compositor::new_sized"); comp.set_webcam_mask(&half_mask(8, 8), 8, 8).expect("set_webcam_mask"); - let (y, uv) = nv12_views(&gpu, 16, 16, |_, _| Y_WHITE); + let (y, u, v) = nv12_views(&gpu, 16, 16, |_, _| Y_WHITE); let (rw, _, rgba) = draw_one_layer( &comp, @@ -3724,7 +3742,7 @@ mod tests { mb: [1.0, 1.0, 1.0, 0.0], ..Default::default() }, - (&y, &uv), + (&y, &u, &v), ); let px = |col: usize, row: usize| -> [u8; 4] { let i = (row * rw as usize + col) * 4; @@ -3763,13 +3781,14 @@ mod tests { } fn from_planes(gpu: &Gpu, w: u32, h: u32, y: &[u8], uv: &[u8]) -> FakeFrame { - let (ytex, uvtex) = nv12_textures(gpu, w, h, y, uv); + let (ytex, utex, vtex) = nv12_textures(gpu, w, h, y, uv); // Le carrier que `linux_frames::nv12_planes` et `carrier_dims` // deballent. `Box::into_raw` ici, `Box::from_raw` dans `Drop` — c'est // exactement la mecanique de `CpuFrames::attach_carrier`. let carrier = Box::into_raw(Box::new(crate::linux_frames::VkFrameTex { y: ytex, - uv: uvtex, + u: utex, + v: vtex, width: w, height: h, })) as *mut u8; diff --git a/crates/compositor/src/linux_frames.rs b/crates/compositor/src/linux_frames.rs index dcb99bed..185cae0b 100644 --- a/crates/compositor/src/linux_frames.rs +++ b/crates/compositor/src/linux_frames.rs @@ -35,13 +35,22 @@ use crate::ffi::{ /// genere pas les `SWS_*`, ce sont des macros). const SWS_POINT: i32 = 0x10; -/// Une frame decodee presentee au compositor sous forme de deux textures wgpu : -/// plane Y (`R8Unorm`, `w x h`) et plane UV entrelacee (`Rg8Unorm`, -/// `(w/2) x (h/2)`). Equivalent NV12-split de la `ID3D11Texture2D` NV12 (D3D11) -/// / du CVPixelBuffer (macOS). +/// Une frame decodee presentee au compositor sous forme de TROIS textures wgpu +/// `R8Unorm` : Y en `w x h`, U et V en `(w/2) x (h/2)`. Pendant Linux de la +/// `ID3D11Texture2D` NV12 (D3D11) / du CVPixelBuffer (macOS), qui eux portent un +/// plan de chroma entrelace parce que leur decodeur materiel le rend ainsi. +/// +/// POURQUOI TROIS PLANS ET PAS UN UV ENTRELACE. Le decodeur software rend du +/// YUV420P, ou U et V sont DEJA deux plans distincts. Les entrelacer en NV12 +/// demandait un `sws_scale` CPU par frame et par flux — deux fois par frame de +/// sortie sur une scene avec webcam — pour produire une disposition que le GPU +/// echantillonne tout aussi bien en deux textures. Les uploader tels quels +/// supprime cette conversion sans changer un pixel : c'etait un entrelacement, +/// pas un reechantillonnage. pub(crate) struct VkFrameTex { pub y: wgpu::Texture, - pub uv: wgpu::Texture, + pub u: wgpu::Texture, + pub v: wgpu::Texture, pub width: u32, pub height: u32, } @@ -107,24 +116,34 @@ impl CpuFrames { if w <= 0 || h <= 0 { bail!("frame decodee sans dimensions ({w}x{h})"); } - self.ensure_sws(w, h, (*src).format)?; - self.ensure_nv12(w, h)?; self.ensure_textures(w as u32, h as u32)?; - let converted = sws_scale( - self.sws, - (*src).data.as_ptr() as *const *const u8, - (*src).linesize.as_ptr(), - 0, - h, - (*self.nv12).data.as_ptr(), - (*self.nv12).linesize.as_ptr(), - ); - if converted <= 0 { - bail!("sws_scale a converti {converted} lignes"); + // CHEMIN RAPIDE : le decodeur rend deja du YUV420P (c'est le cas de tout + // h264 4:2:0, donc de tout ce que cette app enregistre), et c'est + // exactement la disposition que les trois textures attendent. Rien a + // convertir : on uploade les plans du decodeur tels quels. + if (*src).format == AVPixelFormat::AV_PIX_FMT_YUV420P as i32 { + self.upload_planes(src)?; + } else { + // REPLI : format exotique (4:2:2, 10 bits, un import quelconque). + // `sws_scale` ramene en YUV420P — pas en NV12 : la cible n'a plus de + // plan entrelace — et on uploade le resultat par le meme chemin. + self.ensure_sws(w, h, (*src).format)?; + self.ensure_nv12(w, h)?; + let converted = sws_scale( + self.sws, + (*src).data.as_ptr() as *const *const u8, + (*src).linesize.as_ptr(), + 0, + h, + (*self.nv12).data.as_ptr(), + (*self.nv12).linesize.as_ptr(), + ); + if converted <= 0 { + bail!("sws_scale a converti {converted} lignes"); + } + self.upload_planes(self.nv12)?; } - - self.upload()?; self.attach_carrier(w, h)?; // Contrat lu par le compositor : sentinel + timestamps recopies (sinon la // timeline se croit a t=0). @@ -148,14 +167,14 @@ impl CpuFrames { src_fmt as AVPixelFormat::Type, w, h, - AVPixelFormat::AV_PIX_FMT_NV12, + AVPixelFormat::AV_PIX_FMT_YUV420P, SWS_POINT, ptr::null_mut(), ptr::null_mut(), ptr::null(), ); if self.sws.is_null() { - bail!("sws_getContext {w}x{h} fmt {src_fmt} -> NV12"); + bail!("sws_getContext {w}x{h} fmt {src_fmt} -> YUV420P"); } self.sws_key = key; Ok(()) @@ -164,14 +183,14 @@ impl CpuFrames { unsafe fn ensure_nv12(&mut self, w: i32, h: i32) -> Result<()> { if (*self.nv12).width == w && (*self.nv12).height == h - && (*self.nv12).format == AVPixelFormat::AV_PIX_FMT_NV12 as i32 + && (*self.nv12).format == AVPixelFormat::AV_PIX_FMT_YUV420P as i32 { return Ok(()); } av_frame_unref(self.nv12); (*self.nv12).width = w; (*self.nv12).height = h; - (*self.nv12).format = AVPixelFormat::AV_PIX_FMT_NV12 as i32; + (*self.nv12).format = AVPixelFormat::AV_PIX_FMT_YUV420P as i32; if av_frame_get_buffer(self.nv12, 32) < 0 { bail!("av_frame_get_buffer NV12 {w}x{h}"); } @@ -202,23 +221,28 @@ impl CpuFrames { usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); - let uv = self.device.create_texture(&wgpu::TextureDescriptor { - label: Some("nv12-uv"), - size: wgpu::Extent3d { - width: dims.0 / 2, - height: dims.1 / 2, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rg8Unorm, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); + let mut chroma = |label| { + self.device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { + width: dims.0 / 2, + height: dims.1 / 2, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }) + }; + let u = chroma("yuv420p-u"); + let v = chroma("yuv420p-v"); self.tex = Some(Box::new(VkFrameTex { y, - uv, + u, + v, width: dims.0, height: dims.1, })); @@ -229,53 +253,44 @@ impl CpuFrames { /// Upload du NV12 swscale dans les deux textures wgpu. `linesize[0]/[1]` sont /// les strides memoire (paddes SIMD par swscale), passes tels quels a /// `bytes_per_row`. - unsafe fn upload(&mut self) -> Result<()> { + /// Uploade les trois plans YUV420P de `f` dans les trois textures. `f` est + /// soit la frame du decodeur (chemin rapide), soit la sortie de swscale + /// (repli) : la disposition est la meme, seule la provenance change. + unsafe fn upload_planes(&mut self, f: *mut AVFrame) -> Result<()> { let tex = match self.tex.as_ref() { Some(t) => t, None => bail!("upload avant ensure_textures"), }; - let y_stride = (*self.nv12).linesize[0] as usize; - let uv_stride = (*self.nv12).linesize[1] as usize; - let y_size = y_stride * tex.height as usize; - let uv_size = uv_stride * tex.height.div_ceil(2) as usize; - self.queue.write_texture( - wgpu::TexelCopyTextureInfo { - texture: &tex.y, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - std::slice::from_raw_parts((*self.nv12).data[0], y_size), - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(y_stride as u32), - rows_per_image: Some(tex.height), - }, - wgpu::Extent3d { - width: tex.width, - height: tex.height, - depth_or_array_layers: 1, - }, - ); - self.queue.write_texture( - wgpu::TexelCopyTextureInfo { - texture: &tex.uv, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - std::slice::from_raw_parts((*self.nv12).data[1], uv_size), - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(uv_stride as u32), - rows_per_image: Some(tex.height / 2), - }, - wgpu::Extent3d { - width: tex.width / 2, - height: tex.height / 2, - depth_or_array_layers: 1, - }, - ); + let (cw, chh) = (tex.width / 2, tex.height / 2); + for (plane, texture, pw, ph) in [ + (0usize, &tex.y, tex.width, tex.height), + (1, &tex.u, cw, chh), + (2, &tex.v, cw, chh), + ] { + let stride = (*f).linesize[plane] as usize; + if stride == 0 || (*f).data[plane].is_null() { + bail!("plan YUV {plane} absent (linesize={stride})"); + } + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + std::slice::from_raw_parts((*f).data[plane], stride * ph as usize), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(stride as u32), + rows_per_image: Some(ph), + }, + wgpu::Extent3d { + width: pw, + height: ph, + depth_or_array_layers: 1, + }, + ); + } Ok(()) } @@ -292,7 +307,8 @@ impl CpuFrames { } (*self.present).data[0] = pack_carrier(Box::new(VkFrameTex { y: tex.y.clone(), - uv: tex.uv.clone(), + u: tex.u.clone(), + v: tex.v.clone(), width: tex.width, height: tex.height, })); @@ -322,14 +338,16 @@ pub(crate) unsafe fn carrier_dims(frame: *const AVFrame) -> (u32, u32) { /// depuis le carrier `frame.data[0]`. Appele par `compositor_linux`. pub(crate) unsafe fn nv12_planes( frame: *const AVFrame, -) -> Result<(wgpu::TextureView, wgpu::TextureView)> { +) -> Result<(wgpu::TextureView, wgpu::TextureView, wgpu::TextureView)> { if (*frame).data[0].is_null() { bail!("nv12_planes: carrier nul dans data[0]"); } let tex = unpack_carrier((*frame).data[0]); + let d = wgpu::TextureViewDescriptor::default(); Ok(( - tex.y.create_view(&wgpu::TextureViewDescriptor::default()), - tex.uv.create_view(&wgpu::TextureViewDescriptor::default()), + tex.y.create_view(&d), + tex.u.create_view(&d), + tex.v.create_view(&d), )) } diff --git a/crates/compositor/src/vk_shaders/layer.wgsl b/crates/compositor/src/vk_shaders/layer.wgsl index 97293381..15315b94 100644 --- a/crates/compositor/src/vk_shaders/layer.wgsl +++ b/crates/compositor/src/vk_shaders/layer.wgsl @@ -32,11 +32,15 @@ struct Layer { @group(0) @binding(0) var layer: Layer; @group(0) @binding(1) var texY: texture_2d; // R8Unorm, sample .r -@group(0) @binding(2) var texUV: texture_2d; // Rg8Unorm, sample .rg +@group(0) @binding(2) var texU: texture_2d; // R8Unorm, sample .r @group(0) @binding(3) var samp: sampler; // Masque de segmentation du sujet webcam, R8. Une vue 1x1 est liee quand aucun masque // n'existe : la branche n'est de toute facon prise que si layer.fx.z > 0.5. @group(0) @binding(4) var texMask: texture_2d; +// V est en binding 5 et pas 3 : les bindings 0-4 etaient deja pris quand le plan +// de chroma a ete dedouble, et renumeroter aurait touche tous les bind groups +// pour un gain nul. +@group(0) @binding(5) var texV: texture_2d; // R8Unorm, sample .r struct VsOut { @builtin(position) pos: vec4, @@ -72,7 +76,10 @@ fn yuv709_limited(y: f32, cbcr: vec2) -> vec3 { fn sample_yuv(uv: vec2) -> vec3 { let y = textureSample(texY, samp, uv).r; - let cbcr = textureSample(texUV, samp, uv).rg; + let cbcr = vec2( + textureSample(texU, samp, uv).r, + textureSample(texV, samp, uv).r, + ); return yuv709_limited(y, cbcr); } From d09ff0109cff72155089a66a229e388c359f4a6d Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 1 Sep 2026 15:27:18 +0200 Subject: [PATCH 3/8] perf(export): give the pool frames the GPU's own strides (Linux) The copy from the readback buffer into the encoder's AVFrame was 2160 short strided memcpys per frame, for one reason: `av_frame_get_buffer` picks its own linesizes -- 1920 and 960 at 1080p -- while `copy_texture_to_buffer` aligns every `bytes_per_row` to 256, giving 2048 and 1024. Two conventions, so the copy had to reformat row by row. But we allocate those frames. `alloc_padded_yuv_frame` now builds them with linesize EQUAL to the GPU stride and the three planes laid out in one allocation in the same order, so the same bytes move as a single contiguous block. libopenh264 reads `linesize[i]` and `data[i]` as given; an over-strided plane does not bother it. Measured on a fixed project, 3600 frames at 1080p60: | | wall | |---|---| | before | 39.92 s | | after | 38.82 s / 39.26 s (two runs) | Output is BYTE-IDENTICAL, same md5 as the previous two commits. 194 tests pass. THE FRAME STAYS REFCOUNTED. `av_buffer_alloc` rather than a bare pointer, because with `buf[0]` null `av_frame_ref` inside `avcodec_send_frame` takes the "duplicate unrefcounted data" branch and redoes a full allocate-and-copy -- inside the encoder, which is the last place anyone would look for it. `av_frame_make_writable` is gone rather than kept "just in case". The frame comes from the pool, is never shared, and its refcount is back to 1 the moment `avcodec_send_frame` returns. The call was a no-op at best; at worst, on a buffer carrying `AV_BUFFER_FLAG_READONLY`, it is another allocation and copy. THE COPY ITSELF REMAINS, DELIBERATELY. Removing it means encoding straight from the staging buffer, which means keeping a wgpu slot mapped across a thread boundary while the worker holds it. That trades ~0.30 ms/frame for a slot whose lifetime depends on the encoder, and this repo keeps its one existing worker off the GPU entirely. Not the right trade while this thread is not the one being waited on. `YuvLayout` exists so the geometry is computed in exactly one place: the producer (compositor) and the consumer (pool frame) have to agree to the byte, and two copies of that arithmetic would eventually disagree. The dimension check stays alongside the size check -- a buffer that is merely big enough but the wrong shape would produce a silently shifted image, which is far worse to diagnose than an outright failure. --- crates/compositor/src/pipeline_linux.rs | 138 ++++++++++++++++-------- 1 file changed, 95 insertions(+), 43 deletions(-) diff --git a/crates/compositor/src/pipeline_linux.rs b/crates/compositor/src/pipeline_linux.rs index 57e92b62..79fc7386 100644 --- a/crates/compositor/src/pipeline_linux.rs +++ b/crates/compositor/src/pipeline_linux.rs @@ -324,12 +324,18 @@ impl VideoEncoder { /// Envoie une frame deja en YUV420P, convertie par le GPU. /// - /// Remplace `send_rgba` sur le chemin d'export : plus de `sws_scale`, et le - /// buffer relu fait 3,1 Mo au lieu de 8,3 en 1080p. Le seul travail CPU qui - /// reste est de retirer le padding des trois plans — `copy_texture_to_buffer` - /// aligne chaque `bytes_per_row` sur 256, donc en 1080p Y arrive en 2048 pour - /// 1920 utiles et U/V en 1024 pour 960. - pub unsafe fn depad_into( + /// Recopie le buffer relu dans une AVFrame du pool. Les deux ont la MEME + /// disposition (`alloc_padded_yuv_frame`), donc c'est un seul bloc contigu : + /// pas de reformatage, juste un transfert hors de la memoire mappee avant que + /// la ring ne recycle le slot. + /// + /// C'EST UNE COPIE, ET ELLE RESTE. La supprimer voudrait dire encoder + /// directement depuis le buffer de staging, donc le maintenir mappe pendant + /// que le worker travaille, a travers une frontiere de thread. Le gain est le + /// meme ~0,30 ms/frame que ce memcpy coute deja ; le prix serait un slot wgpu + /// dont la duree de vie depend de l'encodeur. Pas le bon echange tant que ce + /// n'est pas ce thread-ci le goulot. + pub unsafe fn copy_into( dst_frame: *mut AVFrame, planes: &[u8], rw: i32, @@ -337,42 +343,25 @@ impl VideoEncoder { enc_w: i32, enc_h: i32, ) -> Result<()> { - use crate::ffi::*; // Les DEUX bornes comptent. La verification de taille seule laisserait - // passer un buffer assez gros mais de mauvaise geometrie : les strides - // seraient recalcules depuis rw/rh et l'image sortirait silencieusement - // decalee, ce qui est bien plus difficile a diagnostiquer qu'un echec. + // passer un buffer assez gros mais de mauvaise geometrie : la disposition + // serait recalculee depuis rw/rh et l'image sortirait silencieusement + // decalee, bien plus difficile a diagnostiquer qu'un echec franc. if rw != enc_w || rh != enc_h { - bail!("depad_into {rw}x{rh} != encodeur {enc_w}x{enc_h}"); - } - let (w, h) = (rw as usize, rh as usize); - let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); - let bpr_y = w.div_ceil(256) * 256; - let bpr_uv = cw.div_ceil(256) * 256; - let off_u = bpr_y * h; - let off_v = off_u + bpr_uv * ch; - if planes.len() < off_v + bpr_uv * ch { - bail!("plans YUV tronques : {} octets", planes.len()); + bail!("copy_into {rw}x{rh} != encodeur {enc_w}x{enc_h}"); } - - averr(av_frame_make_writable(dst_frame), "make_writable")?; - // Ligne a ligne parce que les deux strides different : celui du GPU est - // aligne a 256, celui de l'AVFrame a ce que ffmpeg a choisi. - for (plane, src_off, src_stride, pw, ph) in [ - (0usize, 0usize, bpr_y, w, h), - (1, off_u, bpr_uv, cw, ch), - (2, off_v, bpr_uv, cw, ch), - ] { - let dst = (*dst_frame).data[plane]; - let dst_stride = (*dst_frame).linesize[plane] as usize; - for y in 0..ph { - std::ptr::copy_nonoverlapping( - planes.as_ptr().add(src_off + y * src_stride), - dst.add(y * dst_stride), - pw, - ); - } + let lay = YuvLayout::for_size(rw, rh); + if planes.len() < lay.total { + bail!("plans YUV tronques : {} octets pour {}", planes.len(), lay.total); } + // Pas de `av_frame_make_writable` : la frame vient du pool, elle n'est + // jamais partagee, et son refcount est retombe a 1 des le retour + // d'`avcodec_send_frame`. L'appeler ici serait au mieux un no-op, au pire + // — si le buffer portait `AV_BUFFER_FLAG_READONLY` — une reallocation et + // une copie de plus. + debug_assert_eq!((*dst_frame).linesize[0] as usize, lay.bpr_y); + debug_assert_eq!((*dst_frame).linesize[1] as usize, lay.bpr_uv); + std::ptr::copy_nonoverlapping(planes.as_ptr(), (*dst_frame).data[0], lay.total); Ok(()) } @@ -416,6 +405,71 @@ unsafe fn alloc_sw_frame(pix_fmt: crate::ffi::AVPixelFormat::Type, w: i32, h: i3 Ok(frame) } +/// Geometrie du buffer relu : strides alignes a 256 (ce que +/// `copy_texture_to_buffer` impose) et offsets des trois plans dans l'allocation +/// unique. Calculee a UN SEUL endroit, parce que le producteur (le compositeur) +/// et le consommateur (l'AVFrame du pool) doivent s'accorder a l'octet pres. +#[derive(Clone, Copy)] +struct YuvLayout { + bpr_y: usize, + bpr_uv: usize, + off_u: usize, + off_v: usize, + total: usize, +} + +impl YuvLayout { + fn for_size(w: i32, h: i32) -> YuvLayout { + let (w, h) = (w as usize, h as usize); + let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); + let bpr_y = w.div_ceil(256) * 256; + let bpr_uv = cw.div_ceil(256) * 256; + let off_u = bpr_y * h; + let off_v = off_u + bpr_uv * ch; + YuvLayout { bpr_y, bpr_uv, off_u, off_v, total: off_v + bpr_uv * ch } + } +} + +/// Alloue une AVFrame YUV420P dont les `linesize` sont EXACTEMENT les strides du +/// buffer relu, et dont les trois plans se suivent dans une seule allocation, +/// dans le meme ordre. +/// +/// POURQUOI PAS `av_frame_get_buffer`. Il choisit ses propres strides — 1920 et +/// 960 en 1080p — la ou le GPU impose 2048 et 1024. Recopier de l'un vers +/// l'autre demandait 3240 petits memcpy decales par frame (~0,67 ms) ; avec une +/// disposition identique des deux cotes, la meme donnee se recopie d'un seul +/// bloc contigu (~0,30 ms). libopenh264 lit `linesize[i]` et `data[i]` tels +/// quels et se moque qu'un plan soit sur-stride. +/// +/// LA FRAME RESTE REFCOMPTEE (`av_buffer_alloc`). Sans `buf[0]`, `av_frame_ref` +/// a l'interieur d'`avcodec_send_frame` prend la branche « donnee non +/// refcomptee » et REFAIT une allocation plus une copie complete — a l'interieur +/// de l'encodeur, donc precisement la ou on ne penserait pas a la chercher. +unsafe fn alloc_padded_yuv_frame(w: i32, h: i32) -> Result<*mut AVFrame> { + let lay = YuvLayout::for_size(w, h); + let mut frame = crate::ffi::av_frame_alloc(); + if frame.is_null() { + bail!("av_frame_alloc (pool)"); + } + (*frame).format = crate::ffi::AVPixelFormat::AV_PIX_FMT_YUV420P as i32; + (*frame).width = w; + (*frame).height = h; + let buf = crate::ffi::av_buffer_alloc(lay.total); + if buf.is_null() { + crate::ffi::av_frame_free(&mut frame); + bail!("av_buffer_alloc {} octets", lay.total); + } + let base = (*buf).data; + (*frame).buf[0] = buf; + (*frame).data[0] = base; + (*frame).data[1] = base.add(lay.off_u); + (*frame).data[2] = base.add(lay.off_v); + (*frame).linesize[0] = lay.bpr_y as i32; + (*frame).linesize[1] = lay.bpr_uv as i32; + (*frame).linesize[2] = lay.bpr_uv as i32; + Ok(frame) +} + /// Etat du muxer MP4, deplacable en bloc sur le thread d'encodage. /// /// POURQUOI UN SEUL TYPE PLUTOT QUE QUATRE VARIABLES. `av_interleaved_write_frame` @@ -529,9 +583,7 @@ impl EncodeWorker { let (full_tx, full_rx) = std::sync::mpsc::channel::(); let (empty_tx, empty_rx) = std::sync::mpsc::channel::(); for _ in 0..depth.max(2) { - let f = unsafe { - alloc_sw_frame(crate::ffi::AVPixelFormat::AV_PIX_FMT_YUV420P, enc.w, enc.h)? - }; + let f = unsafe { alloc_padded_yuv_frame(enc.w, enc.h)? }; empty_tx .send(FreeFrame(f)) .map_err(|_| anyhow::anyhow!("pool d'encodage: canal ferme a l'amorcage"))?; @@ -753,7 +805,7 @@ pub fn run_composited_multi( let frame = worker.take_free()?; let mut filled = false; comp.readback_submit_yuv(|rw, rh, planes| { - VideoEncoder::depad_into( + VideoEncoder::copy_into( frame, planes, rw as i32, @@ -808,7 +860,7 @@ pub fn run_composited_multi( let frame = worker.take_free()?; let mut filled = false; let got = comp.readback_take_yuv_with(|rw, rh, planes| { - VideoEncoder::depad_into( + VideoEncoder::copy_into( frame, planes, rw as i32, From aea5fcc48924f9eb1624cc0de411f65562e533d9 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 1 Sep 2026 18:02:09 +0200 Subject: [PATCH 4/8] fix(export): a panicking encode worker deadlocked the walk thread (Linux) `EncodeWorker` kept a clone of the pool's `Sender` so `give_back` could return a frame borrowed but never filled. That clone was alive for as long as the worker struct, which meant `empty_rx.recv()` in `take_free` could never observe a disconnect -- its `Err` arm was unreachable code. So if the encode thread panicked, the walk thread did not get an error. It blocked in `take_free` forever: the frames in flight died with the worker, the pool was empty, and the only sender left was the one the caller itself held. `finish` was never reached, and nothing timed out. Found by re-reading the branch against its own description, which claimed a panic was "reported rather than re-raised". It was not reported at all. The unfilled frame is now kept in a `Cell` on the worker handle instead of being posted back through the channel. The channel has exactly one sender -- the worker's -- so its disappearance is observable, and `take_free` returns the latched error, or failing that a plain "the encode thread stopped". Output is unchanged: byte-identical md5 to the three preceding commits, and 194 unit tests pass (`cargo test --lib --tests`). The frames still in flight when a worker panics are leaked rather than freed. That is deliberate for now -- it is an abort path where the export has already failed, and reclaiming them would mean tracking ownership across the unwind for no benefit to the user. The deadlock was the bug worth fixing. --- crates/compositor/src/pipeline_linux.rs | 31 +++++++++++++++++++------ 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/crates/compositor/src/pipeline_linux.rs b/crates/compositor/src/pipeline_linux.rs index 79fc7386..4705082b 100644 --- a/crates/compositor/src/pipeline_linux.rs +++ b/crates/compositor/src/pipeline_linux.rs @@ -566,9 +566,18 @@ unsafe impl Send for FreeFrame {} struct EncodeWorker { full_tx: Option>, empty_rx: std::sync::mpsc::Receiver, - /// Pour rendre au pool une frame empruntee mais finalement pas remplie - /// (l'amorcage de la ring de relecture ne produit rien les premieres fois). - empty_tx: std::sync::mpsc::Sender, + /// Frame empruntee mais finalement pas remplie — l'amorcage de la ring de + /// relecture ne produit rien les premiers tours — gardee ici pour le tour + /// suivant. `null` quand il n'y en a pas. + /// + /// POURQUOI PAS UN CLONE DU `Sender`. C'etait la premiere version, et elle + /// interdisait de detecter la mort du worker : tant que `EncodeWorker` + /// gardait un emetteur vivant, `empty_rx.recv()` ne pouvait JAMAIS rendre + /// `Err`, donc un worker qui panique laissait la marche bloquee pour + /// toujours sur `take_free` — `finish` n'etait jamais atteint. Le canal ne + /// doit avoir qu'un seul emetteur, celui du worker, pour que sa disparition + /// soit observable. + spare: std::cell::Cell<*mut AVFrame>, handle: Option>>, /// Premiere erreur rencontree par le worker. La marche la relit a chaque /// frame : sans ca, un encodeur mort a la frame 12 laisserait composer les @@ -588,7 +597,6 @@ impl EncodeWorker { .send(FreeFrame(f)) .map_err(|_| anyhow::anyhow!("pool d'encodage: canal ferme a l'amorcage"))?; } - let empty_tx_keep = empty_tx.clone(); let fatal = std::sync::Arc::new(std::sync::Mutex::new(None::)); let fatal_worker = std::sync::Arc::clone(&fatal); let handle = std::thread::Builder::new() @@ -623,20 +631,25 @@ impl EncodeWorker { Ok(EncodeWorker { full_tx: Some(full_tx), empty_rx, - empty_tx: empty_tx_keep, + spare: std::cell::Cell::new(std::ptr::null_mut()), handle: Some(handle), fatal, }) } - /// Rend au pool une frame empruntee sans avoir ete remplie. + /// Garde une frame empruntee sans avoir ete remplie, pour le tour suivant. fn give_back(&self, frame: *mut AVFrame) { - let _ = self.empty_tx.send(FreeFrame(frame)); + let prev = self.spare.replace(frame); + debug_assert!(prev.is_null(), "give_back deux fois sans take_free"); } /// Emprunte une frame libre au pool. C'est ICI que la marche attend quand /// l'encodeur prend du retard. fn take_free(&self) -> Result<*mut AVFrame> { + let spare = self.spare.replace(std::ptr::null_mut()); + if !spare.is_null() { + return Ok(spare); + } match self.empty_rx.recv() { Ok(FreeFrame(f)) => Ok(f), Err(_) => Err(self.fatal_error("le thread d'encodage s'est arrete")), @@ -686,6 +699,10 @@ impl Drop for EncodeWorker { if let Some(h) = self.handle.take() { let _ = h.join(); } + let mut spare = self.spare.replace(std::ptr::null_mut()); + if !spare.is_null() { + unsafe { crate::ffi::av_frame_free(&mut spare) }; + } while let Ok(FreeFrame(f)) = self.empty_rx.try_recv() { let mut f = f; unsafe { crate::ffi::av_frame_free(&mut f) }; From 9d814b13fb8f14b8b8a0fdc594a776d84eab98e4 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 1 Sep 2026 18:04:09 +0200 Subject: [PATCH 5/8] docs(export): make the ectx comment state the rule it is guarding It read 'le contexte de l'encodeur n'est PAS recopie ici' immediately above the line copying it. The invariant it was reaching for is real -- the alias is read only before the worker starts -- but nowhere written down, so a reader sent there by the review found documentation contradicting the code beneath it. --- crates/compositor/src/pipeline_linux.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/compositor/src/pipeline_linux.rs b/crates/compositor/src/pipeline_linux.rs index 4705082b..87cf06b0 100644 --- a/crates/compositor/src/pipeline_linux.rs +++ b/crates/compositor/src/pipeline_linux.rs @@ -736,10 +736,12 @@ pub fn run_composited_multi( let t0 = std::time::Instant::now(); let enc = VideoEncoder::open(¶ms.codec, out_w as i32, out_h as i32, out_fps, bit_rate)?; - // Le contexte de l'encodeur n'est PAS recopie ici. Une variable `ectx` - // partagee serait un `Sync` officieux : `VideoEncoder` est `Send` et - // volontairement pas `Sync`, et un `*mut AVCodecContext` copie efface - // exactement cette distinction au moment ou l'encodage part sur un thread. + // ALIAS LU UNIQUEMENT AVANT LE DEMARRAGE DU WORKER. Il ne sert qu'a decrire + // le flux au muxer, juste en dessous ; passe `EncodeWorker::spawn`, le + // contexte appartient au thread d'encodage et cette variable ne doit plus + // etre touchee. S'en resservir apres serait un `Sync` officieux : + // `VideoEncoder` est `Send` et volontairement pas `Sync`, et un + // `*mut AVCodecContext` recopie efface exactement cette distinction. let ectx = enc.ctx; let mut screen_decs: HashMap = HashMap::new(); From 3dfd178e98ebd25e2780c9e4997d631fce414e49 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 1 Sep 2026 19:46:28 +0200 Subject: [PATCH 6/8] feat(gpu): open the Vulkan device with the external-memory extensions (Linux) Groundwork for feeding h264_vaapi from the compositor without a readback. This commit enables the capability and nothing else: no dmabuf is exported, no encoder changes, no pixel moves. `request_device` has no way to ask for a Vulkan extension -- wgpu enables only what its own `Features` imply, and dmabuf export has no `Feature` equivalent. The only entry point is to build the `VkDevice` by hand and hand it back through `create_device_from_hal`, which is what `open_device_with_dmabuf_export` does. It appends three extensions to the ones wgpu already requires: VK_KHR_external_memory_fd VK_EXT_external_memory_dma_buf VK_EXT_image_drm_format_modifier and returns `None` if any is missing, leaving the caller on the ordinary path. THE FALLBACK IS A NORMAL CASE, NOT AN EDGE. lavapipe does not expose `VK_EXT_image_drm_format_modifier`, so every software-rasteriser host takes it. Verified both ways on this machine: RADV -> backend Hardware, export dmabuf actif llvmpipe -> backend Cpu, export dmabuf indisponible and the adapter log now carries which one happened, so a host that silently lost the capability is diagnosable from a bug report rather than by guesswork. Behaviour is unchanged: a 3600-frame 1080p60 export produces a byte-identical file (same md5 as the four preceding commits) in the same time, and 194 unit tests pass. WHY THIS LANDS ALONE. It is the step most likely to fail on someone else's machine -- a driver without one of the three, a wgpu version whose `physical_device_features` no longer round-trips -- and it is verifiable by itself, from one line of log. Landing it before anything depends on it means a failure here is a fallback rather than a broken export. `ash` and `wgpu-hal` are pinned to the versions wgpu 24 already pulls in; taking others would put two sets of bindings on one `VkDevice`. The path this prepares is proven end to end on this hardware: an exportable NV12 `VkImage` with `DRM_FORMAT_MOD_LINEAR`, mapped via `av_hwframe_map` to a VAAPI frame and encoded by `h264_vaapi`. It never calls `av_hwframe_transfer_data`, which is what aborts on libva 2.20 (#552). --- crates/Cargo.lock | 2 + crates/compositor/Cargo.toml | 14 ++- crates/compositor/src/d3d_linux.rs | 134 ++++++++++++++++++++++++++--- 3 files changed, 132 insertions(+), 18 deletions(-) diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 940c3b7d..da41d707 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -986,6 +986,7 @@ name = "openscreen-compositor" version = "0.0.0" dependencies = [ "anyhow", + "ash", "bindgen", "block", "cc", @@ -1000,6 +1001,7 @@ dependencies = [ "serde", "serde_json", "wgpu", + "wgpu-hal", "windows", ] diff --git a/crates/compositor/Cargo.toml b/crates/compositor/Cargo.toml index c3a3fae2..1af738e5 100644 --- a/crates/compositor/Cargo.toml +++ b/crates/compositor/Cargo.toml @@ -24,6 +24,16 @@ segmentation = ["dep:ort", "dep:ndarray"] [dependencies] anyhow.workspace = true +[target.'cfg(target_os = "linux")'.dependencies] +# Acces Vulkan brut, UNIQUEMENT pour ouvrir le device avec les extensions de +# memoire externe (cf. `d3d_linux::open_device_with_dmabuf_export`). Les versions +# sont celles que wgpu 24 tire deja : en prendre d'autres ferait cohabiter deux +# bindings pour un meme `VkDevice`. +wgpu.workspace = true +pollster.workspace = true +cosmic-text.workspace = true +ash = "0.38" +wgpu-hal = { version = "24", features = ["vulkan"] } ort = { workspace = true, optional = true } ndarray = { workspace = true, optional = true } serde.workspace = true @@ -59,7 +69,3 @@ core-foundation = "0.9" # Linux : wgpu (Vulkan) pour le rendu, pollster pour block_on les ops wgpu, # cosmic-text pour la rastérisation du texte (remplace DirectWrite/CoreText). -[target.'cfg(target_os = "linux")'.dependencies] -wgpu.workspace = true -pollster.workspace = true -cosmic-text.workspace = true diff --git a/crates/compositor/src/d3d_linux.rs b/crates/compositor/src/d3d_linux.rs index 7c344d83..9cd8d3c7 100644 --- a/crates/compositor/src/d3d_linux.rs +++ b/crates/compositor/src/d3d_linux.rs @@ -139,24 +139,35 @@ async fn create_async(want: Backend) -> Result { info.name ); } - let (device, queue) = adapter - .request_device( - &wgpu::DeviceDescriptor { - label: Some("openscreen-linux"), - required_features: wgpu::Features::empty(), - required_limits: wgpu::Limits::default(), - memory_hints: wgpu::MemoryHints::default(), - }, - None, - ) - .await - .context("request_device a echoue")?; + let desc = wgpu::DeviceDescriptor { + label: Some("openscreen-linux"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + memory_hints: wgpu::MemoryHints::default(), + }; + // Ouvre le device AVEC les extensions de memoire externe si la machine les a, + // et retombe sur le chemin standard sinon. Le repli n'est pas theorique : le + // rasteriseur logiciel n'expose pas `VK_EXT_image_drm_format_modifier`. + let (device, queue, dmabuf_export) = match open_device_with_dmabuf_export(&adapter, &desc) { + Some((d, q)) => (d, q, true), + None => { + let (d, q) = adapter + .request_device(&desc, None) + .await + .context("request_device a echoue")?; + (d, q, false) + } + }; // Windows loggue son repli (`d3d_windows.rs`), Linux ne loggait rien : un hote // tombe sur lavapipe rendait a quelques fps sans que rien -- ni log, ni rapport // de bug -- ne permette de l'etablir a distance. eprintln!( - "[d3d] adaptateur Vulkan : {} ({:?}, {:?}) -> backend {:?}", - info.name, info.device_type, info.backend, got + "[d3d] adaptateur Vulkan : {} ({:?}, {:?}) -> backend {:?}, export dmabuf {}", + info.name, + info.device_type, + info.backend, + got, + if dmabuf_export { "actif" } else { "indisponible" } ); Ok(Gpu { device, @@ -394,3 +405,98 @@ mod tests { } } } + +/// Ouvre le `VkDevice` en AJOUTANT les extensions qui permettent d'exporter une +/// image en dmabuf, et rend `None` si la machine ne les a pas toutes. +/// +/// POURQUOI PASSER SOUS wgpu. `request_device` n'a aucun moyen de demander une +/// extension Vulkan : wgpu n'active que ce que ses propres `Features` imposent. +/// Or l'export dmabuf n'a pas de `Feature` equivalente. Le seul point d'entree +/// est donc de construire le device soi-meme et de le rendre a wgpu via +/// `create_device_from_hal`. +/// +/// CE QUE CETTE FONCTION NE FAIT PAS. Elle n'exporte rien : elle rend seulement +/// l'export POSSIBLE plus tard. Tant que personne n'appelle `vkGetMemoryFdKHR`, +/// activer ces extensions ne change ni le rendu ni les performances -- c'est +/// justement ce qui permet de la livrer seule et de la verifier seule. +/// +/// LE REPLI EST LE CAS NORMAL, PAS L'EXCEPTION. Le rasteriseur logiciel +/// (lavapipe) n'expose pas `VK_EXT_image_drm_format_modifier`, et une machine +/// sans pilote GPU utilisable non plus. Rendre `None` doit donc rester +/// silencieux et sans consequence : l'appelant repart sur `request_device`. +#[cfg(target_os = "linux")] +fn open_device_with_dmabuf_export( + adapter: &wgpu::Adapter, + desc: &wgpu::DeviceDescriptor<'_>, +) -> Option<(wgpu::Device, wgpu::Queue)> { + use std::ffi::CStr; + + // Les trois qu'il faut EN PLUS de ce que wgpu demande deja. `dma_buf` depend + // de `external_memory_fd`, et le modificateur DRM est ce qui rend la + // disposition de l'image intelligible a VAAPI. + const WANTED: [&CStr; 3] = [ + c"VK_KHR_external_memory_fd", + c"VK_EXT_external_memory_dma_buf", + c"VK_EXT_image_drm_format_modifier", + ]; + + unsafe { + adapter.as_hal::(|hal_adapter| { + let hal_adapter = hal_adapter?; + let phys = hal_adapter.raw_physical_device(); + let instance = hal_adapter.shared_instance().raw_instance(); + + // Refuser tot plutot que d'echouer a `vkCreateDevice` : une extension + // absente y devient une erreur opaque. + let available = instance.enumerate_device_extension_properties(phys).ok()?; + let has = |name: &CStr| { + available + .iter() + .any(|e| e.extension_name_as_c_str() == Ok(name)) + }; + if !WANTED.iter().all(|n| has(n)) { + return None; + } + + // Aux extensions de wgpu, pas a la place : en omettre une casserait + // le rendu, pas l'export. + let mut exts = hal_adapter.required_device_extensions(desc.required_features); + exts.extend_from_slice(&WANTED); + let ext_ptrs: Vec<*const std::os::raw::c_char> = + exts.iter().map(|e| e.as_ptr()).collect(); + + let family_index = 0u32; + let queue_prio = [1.0f32]; + let queue_info = ash::vk::DeviceQueueCreateInfo::default() + .queue_family_index(family_index) + .queue_priorities(&queue_prio); + let queue_infos = [queue_info]; + + // `physical_device_features` porte les activations que wgpu attend + // (elles vivent dans des structures chainees) : les reprendre telles + // quelles est ce qui garantit que le device rendu est celui que wgpu + // aurait construit, extensions en plus. + let mut phys_features = + hal_adapter.physical_device_features(&exts, desc.required_features); + let info = phys_features.add_to_device_create( + ash::vk::DeviceCreateInfo::default() + .queue_create_infos(&queue_infos) + .enabled_extension_names(&ext_ptrs), + ); + let raw_device = instance.create_device(phys, &info, None).ok()?; + + let open = hal_adapter + .device_from_raw( + raw_device, + None, + &exts, + desc.required_features, + &desc.memory_hints, + family_index, + 0, + ) + .ok()?; + adapter.create_device_from_hal(open, desc, None).ok() + }) + } +} From cc3d3d8e8ff2436983fe62d3ab39fc58bea62584 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 1 Sep 2026 20:56:35 +0200 Subject: [PATCH 7/8] feat(compositor): teach the YUV pass to emit NV12 as well as I420 (Linux) Second piece of groundwork for VAAPI export. Additive: I420 stays the default and the export path is untouched, so this changes no output. WHY BOTH FORMATS HAVE TO EXIST. It is not a preference, it is a consequence of the encoder downstream. `libopenh264` advertises exactly two pixel formats, yuv420p and yuvj420p -- it cannot take NV12. `h264_vaapi` advertises only vaapi, and encodes from NV12 surfaces. So the compositor cannot simply switch: it has to be able to produce whichever the consumer needs. That also corrects the plan this work was following. NV12 could not land ahead of a VAAPI consumer as a straight replacement, because doing so would leave the software encoder -- the only one that works today -- with nothing it can read. The chroma targets became an enum rather than three optional fields: I420 renders U and V into two R8Unorm planes, NV12 renders both into one Rg8Unorm plane via a new `fs_uv` entry point. Same coefficients, same 2x2 linear-sampler subsampling; only the destination differs. The layout arithmetic is the part worth reviewing, because a mistake in it produces a SHIFTED IMAGE rather than a failure. `bpr_uv` is a row width in BYTES, not texels: NV12's chroma plane is 2 bytes per texel, so at 1080p I420 gives 960 -> 1024 and NV12 gives 1920 -> 2048. Those numbers are not derived on paper. They were read off this hardware with `vkGetImageSubresourceLayout` on a real NV12 `VkImage` in `DRM_FORMAT_MOD_LINEAR` -- Y pitch 2048, UV at offset 2211840, pitch 2048, total 3317760 -- and `nv12_layout_matches_what_the_driver_produces` pins exactly those values, since they are what a future `AVDRMFrameDescriptor` will claim. `i420_layout_is_unchanged` guards the other direction: that adding NV12 did not move the format the software encoder still depends on. In NV12 there is one chroma plane, so `off_v` duplicates `off_u` and must not be read; the field carries that in its doc rather than a sentinel. 196 unit tests pass, and a 3600-frame export still produces a byte-identical file (same md5 as the five preceding commits) in the same time. --- crates/compositor/src/compositor_linux.rs | 215 ++++++++++++++++++---- crates/compositor/src/vk_shaders/yuv.wgsl | 17 ++ 2 files changed, 192 insertions(+), 40 deletions(-) diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 2a955ca2..707f474f 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -138,18 +138,43 @@ struct ReadbackRing { /// Trois cibles R8Unorm plutot qu'une seule : Y est en pleine resolution et U/V /// en demie (4:2:0), et wgpu ne sait pas ecrire des attachements de tailles /// differentes dans une meme passe. +/// Disposition de la chrominance. PAS un gout : une consequence de l'encodeur +/// qui va consommer la frame. `libopenh264` n'accepte que du YUV420P planaire +/// (ses `pix_fmts` sont yuv420p/yuvj420p), VAAPI encode depuis du NV12. Le +/// compositeur doit donc savoir produire les deux. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum YuvFormat { + /// U et V dans deux plans `R8Unorm` separes. + I420, + /// U et V entrelaces dans un seul plan `Rg8Unorm`. + Nv12, +} + +/// Les cibles de chrominance, dont la forme depend du format. +enum Chroma { + Planar { + _u: wgpu::Texture, + _v: wgpu::Texture, + u_view: wgpu::TextureView, + v_view: wgpu::TextureView, + pipe_u: wgpu::RenderPipeline, + pipe_v: wgpu::RenderPipeline, + }, + Interleaved { + _uv: wgpu::Texture, + uv_view: wgpu::TextureView, + pipe_uv: wgpu::RenderPipeline, + }, +} + struct YuvTargets { - /// Gardees en vie pour leurs vues ; seules les vues servent au rendu. + /// Gardee en vie pour sa vue ; seule la vue sert au rendu. _y: wgpu::Texture, - _u: wgpu::Texture, - _v: wgpu::Texture, y_view: wgpu::TextureView, - u_view: wgpu::TextureView, - v_view: wgpu::TextureView, + chroma: Chroma, + fmt: YuvFormat, bind: wgpu::BindGroup, pipe_y: wgpu::RenderPipeline, - pipe_u: wgpu::RenderPipeline, - pipe_v: wgpu::RenderPipeline, /// Dimensions pour lesquelles tout ceci a ete construit : un resize doit /// tout refaire, et comparer ici est moins fragile que de s'en souvenir. w: u32, @@ -159,9 +184,10 @@ struct YuvTargets { /// PORTENT du padding, et le lecteur doit le retirer ligne a ligne. bpr_y: u32, bpr_uv: u32, - /// Offsets des trois plans dans le buffer de staging unique. Alignes a 256 - /// (exigence de `copy_texture_to_buffer`), ce que la taille du plan Y - /// garantit deja puisque `bpr_y` l'est. + /// Offsets des plans de chrominance dans le buffer de staging unique. + /// Alignes a 256 (exigence de `copy_texture_to_buffer`), ce que la taille du + /// plan Y garantit deja puisque `bpr_y` l'est. En NV12 il n'y a qu'un plan de + /// chrominance : `off_v` vaut alors `off_u` et ne doit pas etre lu. off_u: u64, off_v: u64, total: u64, @@ -2946,9 +2972,39 @@ impl Compositor { /// Construit (ou reconstruit apres resize) les cibles et pipelines YUV. fn ensure_yuv(&self) -> Result<()> { + // I420 par defaut : c'est le seul format que l'encodeur software sait + // lire, donc le seul que l'export utilise aujourd'hui. + self.ensure_yuv_fmt(YuvFormat::I420) + } + + /// La disposition du buffer de staging pour un format donne, sans rien + /// construire. Existe pour que le test puisse verifier l'arithmetique sans + /// GPU — c'est elle qui doit correspondre a ce que VAAPI attend, et une + /// erreur d'un octet y donnerait une image decalee plutot qu'une panne. + pub fn yuv_layout_for(w: u32, h: u32, fmt: YuvFormat) -> (u32, u32, u64, u64) { + let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); + let bpr_y = w.div_ceil(256) * 256; + let chroma_row_bytes = match fmt { + YuvFormat::I420 => cw, + YuvFormat::Nv12 => cw * 2, + }; + let bpr_uv = chroma_row_bytes.div_ceil(256) * 256; + let size_y = u64::from(bpr_y) * u64::from(h); + let size_uv = u64::from(bpr_uv) * u64::from(ch); + let total = match fmt { + YuvFormat::I420 => size_y + 2 * size_uv, + YuvFormat::Nv12 => size_y + size_uv, + }; + (bpr_y, bpr_uv, size_y, total) + } + + /// Comme `ensure_yuv`, pour un format donne. Reconstruit tout si le format + /// change : les cibles, les pipelines et la disposition du buffer en + /// dependent toutes. + fn ensure_yuv_fmt(&self, fmt: YuvFormat) -> Result<()> { let (w, h) = (self.render_w, self.render_h); if let Some(t) = self.yuv.borrow().as_ref() { - if t.w == w && t.h == h { + if t.w == w && t.h == h && t.fmt == fmt { return Ok(()); } } @@ -2957,24 +3013,21 @@ impl Compositor { let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); let gpu = &self.gpu; - let mk = |label: &str, tw: u32, th: u32| { + let mk = |label: &str, tw: u32, th: u32, f: wgpu::TextureFormat| { gpu.device.create_texture(&wgpu::TextureDescriptor { label: Some(label), size: wgpu::Extent3d { width: tw, height: th, depth_or_array_layers: 1 }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::R8Unorm, + format: f, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }) }; - let y = mk("yuv-y", w, h); - let u = mk("yuv-u", cw, ch); - let v = mk("yuv-v", cw, ch); + let r8 = wgpu::TextureFormat::R8Unorm; + let y = mk("yuv-y", w, h, r8); let y_view = y.create_view(&wgpu::TextureViewDescriptor::default()); - let u_view = u.create_view(&wgpu::TextureViewDescriptor::default()); - let v_view = v.create_view(&wgpu::TextureViewDescriptor::default()); let module = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("yuv"), @@ -3023,7 +3076,7 @@ impl Compositor { bind_group_layouts: &[&bgl], push_constant_ranges: &[], }); - let mk_pipe = |entry: &str, label: &str| { + let mk_pipe = |entry: &str, label: &str, target: wgpu::TextureFormat| { gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some(label), layout: Some(&layout), @@ -3038,7 +3091,7 @@ impl Compositor { entry_point: Some(entry), compilation_options: wgpu::PipelineCompilationOptions::default(), targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::R8Unorm, + format: target, blend: None, write_mask: wgpu::ColorWrites::ALL, })], @@ -3055,27 +3108,66 @@ impl Compositor { }; let bpr_y = w.div_ceil(256) * 256; - let bpr_uv = cw.div_ceil(256) * 256; + // La LARGEUR EN OCTETS d'une ligne de chrominance, pas en texels : en NV12 + // le plan est `Rg8Unorm`, donc 2 octets par texel. En 1080p, I420 donne + // 960 -> 1024 et NV12 1920 -> 2048. + let chroma_row_bytes = match fmt { + YuvFormat::I420 => cw, + YuvFormat::Nv12 => cw * 2, + }; + let bpr_uv = chroma_row_bytes.div_ceil(256) * 256; let size_y = u64::from(bpr_y) * u64::from(h); let size_uv = u64::from(bpr_uv) * u64::from(ch); + let (chroma, off_v, total) = match fmt { + YuvFormat::I420 => { + let u = mk("yuv-u", cw, ch, r8); + let v = mk("yuv-v", cw, ch, r8); + let d = wgpu::TextureViewDescriptor::default(); + let (u_view, v_view) = (u.create_view(&d), v.create_view(&d)); + ( + Chroma::Planar { + _u: u, + _v: v, + u_view, + v_view, + pipe_u: mk_pipe("fs_u", "yuv-u", r8), + pipe_v: mk_pipe("fs_v", "yuv-v", r8), + }, + size_y + size_uv, + size_y + 2 * size_uv, + ) + } + YuvFormat::Nv12 => { + let rg8 = wgpu::TextureFormat::Rg8Unorm; + let uv = mk("yuv-uv", cw, ch, rg8); + let uv_view = uv.create_view(&wgpu::TextureViewDescriptor::default()); + ( + Chroma::Interleaved { + _uv: uv, + uv_view, + pipe_uv: mk_pipe("fs_uv", "yuv-uv", rg8), + }, + // Un seul plan de chrominance : `off_v` duplique `off_u` et + // n'est jamais lu (cf. le commentaire du champ). + size_y, + size_y + size_uv, + ) + } + }; let targets = YuvTargets { _y: y, - _u: u, - _v: v, y_view, - u_view, - v_view, + chroma, + fmt, bind, - pipe_y: mk_pipe("fs_y", "yuv-y"), - pipe_u: mk_pipe("fs_u", "yuv-u"), - pipe_v: mk_pipe("fs_v", "yuv-v"), + pipe_y: mk_pipe("fs_y", "yuv-y", r8), w, h, bpr_y, bpr_uv, off_u: size_y, - off_v: size_y + size_uv, - total: size_y + 2 * size_uv, + off_v, + total, }; // Les buffers de l'ancienne taille ne conviennent plus. self.readback_yuv.borrow_mut().free.clear(); @@ -3123,11 +3215,18 @@ impl Compositor { { let g = self.yuv.borrow(); let t = g.as_ref().expect("ensure_yuv"); - for (view, pipe) in [ - (&t.y_view, &t.pipe_y), - (&t.u_view, &t.pipe_u), - (&t.v_view, &t.pipe_v), - ] { + // Une passe par plan : Y toujours, puis U et V separement (I420) ou + // un seul plan entrelace (NV12). + let mut passes: Vec<(&wgpu::TextureView, &wgpu::RenderPipeline)> = + vec![(&t.y_view, &t.pipe_y)]; + match &t.chroma { + Chroma::Planar { u_view, v_view, pipe_u, pipe_v, .. } => { + passes.push((u_view, pipe_u)); + passes.push((v_view, pipe_v)); + } + Chroma::Interleaved { uv_view, pipe_uv, .. } => passes.push((uv_view, pipe_uv)), + } + for (view, pipe) in passes { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("yuv-plane"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { @@ -3148,11 +3247,19 @@ impl Compositor { pass.set_bind_group(0, &t.bind, &[]); pass.draw(0..3, 0..1); } - for (tex, off, bpr, pw, ph) in [ - (&t._y, 0u64, bpr_y, w, h), - (&t._u, off_u, bpr_uv, cw, ch), - (&t._v, off_v, bpr_uv, cw, ch), - ] { + // `pw` est en TEXELS (`copy_texture_to_buffer` veut une extent), et + // `bpr` en octets : en NV12 le plan de chrominance fait `cw` texels de + // 2 octets, d'ou le meme `cw` avec un `bpr_uv` deux fois plus grand. + let mut copies: Vec<(&wgpu::Texture, u64, u32, u32, u32)> = + vec![(&t._y, 0u64, bpr_y, w, h)]; + match &t.chroma { + Chroma::Planar { _u, _v, .. } => { + copies.push((_u, off_u, bpr_uv, cw, ch)); + copies.push((_v, off_v, bpr_uv, cw, ch)); + } + Chroma::Interleaved { _uv, .. } => copies.push((_uv, off_u, bpr_uv, cw, ch)), + } + for (tex, off, bpr, pw, ph) in copies { encoder.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { texture: tex, @@ -3496,6 +3603,34 @@ mod tests { (0..w * h).map(|i| if i % w < w / 2 { 0u8 } else { 255u8 }).collect() } + /// La disposition NV12 doit etre EXACTEMENT celle que le pilote produit pour + /// une image NV12 lineaire, parce que c'est elle qu'on decrira a VAAPI dans + /// un `AVDRMFrameDescriptor`. Les valeurs ci-dessous ne sont pas devinees : + /// elles ont ete relevees sur ce materiel via `vkGetImageSubresourceLayout` + /// d'une `VkImage` NV12 en `DRM_FORMAT_MOD_LINEAR` (Y pitch 2048, UV a + /// l'offset 2211840, pitch 2048, total 3317760). Un ecart d'un octet ici + /// donnerait une image decalee et non une panne, d'ou le test. + #[test] + fn nv12_layout_matches_what_the_driver_produces() { + let (bpr_y, bpr_uv, off_uv, total) = + Compositor::yuv_layout_for(1920, 1080, YuvFormat::Nv12); + assert_eq!(bpr_y, 2048, "pitch du plan Y"); + assert_eq!(bpr_uv, 2048, "pitch du plan UV entrelace (960 texels x 2 octets)"); + assert_eq!(off_uv, 2_211_840, "offset du plan UV"); + assert_eq!(total, 3_317_760, "taille totale"); + } + + /// I420 reste ce qu'il etait : c'est le format que l'encodeur software lit, + /// et ce test est ce qui garantit qu'ajouter NV12 ne l'a pas deplace. + #[test] + fn i420_layout_is_unchanged() { + let (bpr_y, bpr_uv, off_u, total) = + Compositor::yuv_layout_for(1920, 1080, YuvFormat::I420); + assert_eq!((bpr_y, bpr_uv), (2048, 1024)); + assert_eq!(off_u, 2_211_840); + assert_eq!(total, 2_211_840 + 2 * 1024 * 540); + } + /// Dessine UN calque plein cadre sur le RT, par-dessus `clear`, et rend le /// RGBA relu. /// diff --git a/crates/compositor/src/vk_shaders/yuv.wgsl b/crates/compositor/src/vk_shaders/yuv.wgsl index e0284da5..68a1ed68 100644 --- a/crates/compositor/src/vk_shaders/yuv.wgsl +++ b/crates/compositor/src/vk_shaders/yuv.wgsl @@ -64,3 +64,20 @@ fn fs_v(i: VsOut) -> @location(0) vec4 { let v = (128.0 + 224.0 * (0.5 * c.r - 0.418688 * c.g - 0.081312 * c.b)) / 255.0; return vec4(v, 0.0, 0.0, 1.0); } + +// U ET V ENTRELACES, pour NV12. Meme mathematique et meme sous-echantillonnage +// que `fs_u`/`fs_v` : seule la destination change, un unique plan `Rg8Unorm` au +// lieu de deux plans `R8Unorm`. +// +// POURQUOI LES DEUX EXISTENT. `libopenh264` n'accepte que du YUV420P planaire +// (verifie : ses `pix_fmts` sont yuv420p/yuvj420p), tandis que VAAPI encode +// depuis du NV12. Le format n'est donc pas un gout mais une consequence de +// l'encodeur qui va consommer la frame, et le compositeur doit savoir produire +// les deux. +@fragment +fn fs_uv(i: VsOut) -> @location(0) vec4 { + let c = textureSample(tex, samp, i.uv).rgb; + let u = (128.0 + 224.0 * (-0.168736 * c.r - 0.331264 * c.g + 0.5 * c.b)) / 255.0; + let v = (128.0 + 224.0 * (0.5 * c.r - 0.418688 * c.g - 0.081312 * c.b)) / 255.0; + return vec4(u, v, 0.0, 1.0); +} From 0ac3e5a517ee254ed1af87c6214a7108384535f4 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 1 Sep 2026 23:10:44 +0200 Subject: [PATCH 8/8] fix(gpu): stop requiring the DRM modifier extension for dmabuf export (Linux) The device-opening check demanded three extensions. Only two are needed, and the third was refusing the capability to hosts that have it. `VK_EXT_image_drm_format_modifier` describes the tiling of an exported IMAGE. What this path exports is the staging buffer the compositor already fills with `copy_texture_to_buffer` -- linear by construction, with pitches we choose. There is no tiling to describe, so there is nothing to ask the driver for. Measured, not reasoned: it is precisely the extension the software rasteriser does not expose, so requiring it flipped lavapipe to "unavailable". before RADV -> actif llvmpipe -> indisponible after RADV -> actif llvmpipe -> actif I had assumed the export would need an image, because the spike that proved the chain used one. The buffer-backed variant of that spike had failed earlier and I had attributed it to the shape -- wrongly. The real cause was a missing ref-counted `buf[0]` on the source AVFrame, and once that was fixed BOTH shapes encode. Re-running the buffer variant is what surfaced this. CONSEQUENCE FOR REVIEW: on this machine the fallback branch is now unreachable, because both adapters advertise the two remaining extensions. It is still the right branch to keep -- a host with no Vulkan driver at all, or a driver without external-memory support, will take it -- but it is no longer exercised here, and I would rather say so than let the log imply otherwise. 196 tests pass; no output changes (nothing exports a dmabuf yet). --- crates/compositor/src/d3d_linux.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/compositor/src/d3d_linux.rs b/crates/compositor/src/d3d_linux.rs index 9cd8d3c7..099b936a 100644 --- a/crates/compositor/src/d3d_linux.rs +++ b/crates/compositor/src/d3d_linux.rs @@ -431,14 +431,22 @@ fn open_device_with_dmabuf_export( ) -> Option<(wgpu::Device, wgpu::Queue)> { use std::ffi::CStr; - // Les trois qu'il faut EN PLUS de ce que wgpu demande deja. `dma_buf` depend - // de `external_memory_fd`, et le modificateur DRM est ce qui rend la - // disposition de l'image intelligible a VAAPI. - const WANTED: [&CStr; 3] = [ - c"VK_KHR_external_memory_fd", - c"VK_EXT_external_memory_dma_buf", - c"VK_EXT_image_drm_format_modifier", - ]; + // Les deux qu'il faut EN PLUS de ce que wgpu demande deja. `dma_buf` depend + // de `external_memory_fd` ; ensemble elles suffisent a exporter la memoire + // d'un buffer sous forme de descripteur dmabuf. + // + // PAS `VK_EXT_image_drm_format_modifier`. Il ne servirait qu'a exporter une + // IMAGE, dont la disposition en memoire depend du pavage et doit donc etre + // decrite au consommateur. Ce qu'on exporte ici est le buffer de staging que + // le compositeur remplit deja par `copy_texture_to_buffer` : lineaire par + // construction, avec des pitches qu'on choisit. Il n'y a aucun pavage a + // decrire, donc rien a demander au pilote. + // + // L'exiger etait une erreur mesurable, pas une precaution : c'est + // precisement l'extension que le rasteriseur logiciel n'expose pas, donc + // reclamer les trois refusait l'export a des machines parfaitement capables + // de le faire. + const WANTED: [&CStr; 2] = [c"VK_KHR_external_memory_fd", c"VK_EXT_external_memory_dma_buf"]; unsafe { adapter.as_hal::(|hal_adapter| {