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/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 30e9e7f5..c3a43878 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, @@ -409,6 +435,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 +995,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 +1138,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 +1146,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 +1170,7 @@ impl Compositor { }, wgpu::BindGroupEntry { binding: 2, - resource: wgpu::BindingResource::TextureView(uv), + resource: wgpu::BindingResource::TextureView(u), }, wgpu::BindGroupEntry { binding: 3, @@ -1149,6 +1180,10 @@ impl Compositor { binding: 4, resource: wgpu::BindingResource::TextureView(mask_view), }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(v), + }, ], }); (uniform, bind) @@ -1282,7 +1317,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 +1439,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 +1496,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 +1652,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 +1718,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 +1839,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 +1877,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 +1985,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 +2100,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 +2151,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 +2305,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 +2365,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 +2514,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 +2661,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); } @@ -2934,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(()); } } @@ -2945,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"), @@ -3011,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), @@ -3026,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, })], @@ -3043,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(); @@ -3080,7 +3184,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(); @@ -3108,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 { @@ -3133,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, @@ -3167,29 +3289,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 +3341,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 { @@ -3388,18 +3531,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`) @@ -3410,7 +3552,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), @@ -3424,7 +3566,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 { @@ -3443,10 +3591,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 @@ -3456,6 +3603,65 @@ mod tests { (0..w * h).map(|i| if i % w < w / 2 { 0u8 } else { 255u8 }).collect() } + /// Le buffer de staging exportable doit etre une VRAIE zone partagee : ce que + /// wgpu y ecrit, notre propre mapping doit le relire a l'identique. + /// + /// C'est le seul point reellement incertain de l'export dmabuf, et il se + /// verifie sans encodeur. Si ce test passe, la memoire qu'on remettra a VAAPI + /// est bien celle que le compositeur remplit ; s'il echoue, tout ce qui est + /// bati dessus produirait une image fausse plutot qu'une panne. + #[test] + fn exportable_staging_round_trips_through_wgpu() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized"); + const N: u64 = 4096; + let Some(st) = comp.create_exportable_staging(N) else { + eprintln!("pas d'extensions de memoire externe — test saute"); + return; + }; + assert!(st.fd >= 0, "descripteur dmabuf invalide"); + assert_eq!(st.size, N); + + // Un motif non trivial : un remplissage constant passerait meme si les + // deux cotes regardaient deux zones differentes mais nulles. + let pattern: Vec = (0..N as usize).map(|i| (i * 31 + 7) as u8).collect(); + gpu.context.write_buffer(st.buffer(), 0, &pattern); + gpu.context.submit(std::iter::empty()); + gpu.device.poll(wgpu::Maintain::Wait); + + let got = st.read_back().expect("read_back"); + assert_eq!(got.len(), N as usize); + assert_eq!(got, pattern, "la memoire exportee ne porte pas ce que wgpu y a ecrit"); + } + + /// 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. /// @@ -3465,7 +3671,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); @@ -3506,13 +3712,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, @@ -3543,7 +3750,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, @@ -3570,13 +3778,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"); @@ -3597,9 +3805,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"); } @@ -3642,7 +3850,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 @@ -3663,7 +3871,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] { @@ -3683,7 +3891,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, @@ -3700,7 +3908,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; @@ -3739,13 +3947,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; @@ -4151,3 +4360,271 @@ mod tests { } } } + +// --------------------------------------------------------------------------- +// Staging exportable en dmabuf +// --------------------------------------------------------------------------- + +/// Un buffer de staging dont la MEMOIRE est exportable en dmabuf, pour qu'un +/// encodeur materiel puisse la lire sans repasser par le CPU. +/// +/// POURQUOI IL EN FAUT UN DEUXIEME, ET PAS UN DRAPEAU SUR L'EXISTANT. wgpu +/// n'expose aucun moyen de demander une allocation exportable : il faut la +/// fabriquer soi-meme et la lui confier. Or `buffer_from_raw` construit un +/// `Buffer { block: None }` -- wgpu accepte d'y ECRIRE (c'est une cible de +/// `copy_texture_to_buffer` comme une autre) mais ne peut pas le faire lire par +/// le CPU, sa mecanique de mapping passant par ce bloc qu'il ne possede pas. +/// Le chemin logiciel, lui, DOIT le lire. Les deux ne peuvent donc pas partager +/// un buffer, et l'export choisit lequel il alloue selon l'encodeur retenu. +/// +/// La memoire est demandee HOST_VISIBLE et HOST_COHERENT pour que la +/// verification puisse la relire directement et sans invalidation ; un chemin +/// purement GPU pourrait se passer des deux. +pub struct ExportableStaging { + /// Vue wgpu, utilisable comme destination de copie. En `Option` UNIQUEMENT + /// pour pouvoir la relacher explicitement avant la memoire dans `Drop`, cf. + /// l'ordre impose la-bas. + buffer: Option, + /// Le descripteur a passer au consommateur. Possede : ferme dans `Drop`. + pub fd: i32, + pub size: u64, + device: ash::Device, + memory: ash::vk::DeviceMemory, +} + +impl ExportableStaging { + /// La cible de copie a passer a wgpu. + pub fn buffer(&self) -> &wgpu::Buffer { + self.buffer.as_ref().expect("buffer relache") + } +} + +impl ExportableStaging { + /// Relit la memoire exportee telle que le GPU l'a laissee. + /// + /// Passe par `vkMapMemory` et NON par wgpu, pour la raison ci-dessus. C'est + /// ce qui permet de verifier le contenu sans encodeur : si ces octets sont + /// ceux du chemin de relecture normal, la memoire exportee porte bien + /// l'image composee. + pub fn read_back(&self) -> Result> { + unsafe { + let p = self + .device + .map_memory(self.memory, 0, self.size, ash::vk::MemoryMapFlags::empty()) + .map_err(|e| anyhow::anyhow!("vkMapMemory: {e}"))?; + let out = std::slice::from_raw_parts(p as *const u8, self.size as usize).to_vec(); + self.device.unmap_memory(self.memory); + Ok(out) + } + } +} + +impl Drop for ExportableStaging { + fn drop(&mut self) { + // L'ORDRE EST LE FOND DU SUJET, et le premier jet le faisait a l'envers : + // il detruisait le `VkBuffer` puis liberait la memoire, alors que wgpu + // detruit DEJA le buffer quand son wrapper tombe -- double liberation, + // et par-dessus, memoire liberee alors qu'un buffer y etait encore lie. + // + // Le partage est donc : wgpu possede le HANDLE (il l'a recu par + // `buffer_from_raw` et le detruira), nous possedons la MEMOIRE (son + // `block` est `None`, personne d'autre ne la liberera). D'ou : relacher + // le wrapper d'abord, liberer la memoire ensuite. + drop(self.buffer.take()); + unsafe { + self.device.free_memory(self.memory, None); + } + // Le fd est un handle a part : l'exporter duplique la propriete, donc le + // fermer ne libere pas la memoire -- mais l'oublier fuirait un + // descripteur par frame. + if self.fd >= 0 { + let _ = nix_close(self.fd); + } + } +} + +fn nix_close(fd: i32) -> std::io::Result<()> { + // `libc::close` sans dependance supplementaire : la libc est deja liee. + extern "C" { + fn close(fd: i32) -> i32; + } + if unsafe { close(fd) } == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +impl Compositor { + /// Alloue un buffer de staging exportable de `size` octets, ou `None` si le + /// device n'a pas ete ouvert avec les extensions de memoire externe (cf. + /// `d3d_linux::open_device_with_dmabuf_export`). + pub fn create_exportable_staging(&self, size: u64) -> Option { + use ash::vk; + unsafe { + self.gpu.device.as_hal::(|hal| { + let hal = hal?; + let dev = hal.raw_device().clone(); + let phys = hal.raw_physical_device(); + let instance = hal.shared_instance().raw_instance(); + + let mut ext_info = vk::ExternalMemoryBufferCreateInfo::default() + .handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT); + let bci = vk::BufferCreateInfo::default() + .push_next(&mut ext_info) + .size(size) + .usage(vk::BufferUsageFlags::TRANSFER_DST) + .sharing_mode(vk::SharingMode::EXCLUSIVE); + let raw = dev.create_buffer(&bci, None).ok()?; + + let req = dev.get_buffer_memory_requirements(raw); + let props = instance.get_physical_device_memory_properties(phys); + // HOST_VISIBLE pour que `read_back` puisse verifier le contenu, + // et COHERENT parce qu'il lit SANS invalider : sur une memoire + // seulement visible, le mapping peut rendre des octets perimes et + // le test passerait ou echouerait selon le cache, pas selon le + // code. Exiger les deux est plus simple qu'un + // `vkInvalidateMappedMemoryRanges` correct a chaque lecture. + let want = vk::MemoryPropertyFlags::HOST_VISIBLE + | vk::MemoryPropertyFlags::HOST_COHERENT; + let mt = (0..props.memory_type_count).find(|i| { + req.memory_type_bits & (1 << i) != 0 + && props.memory_types[*i as usize].property_flags.contains(want) + })?; + + let mut export = vk::ExportMemoryAllocateInfo::default() + .handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT); + let mai = vk::MemoryAllocateInfo::default() + .push_next(&mut export) + .allocation_size(req.size) + .memory_type_index(mt); + let memory = dev.allocate_memory(&mai, None).ok()?; + dev.bind_buffer_memory(raw, memory, 0).ok()?; + + let getter = ash::khr::external_memory_fd::Device::new(instance, &dev); + let fd = getter + .get_memory_fd( + &vk::MemoryGetFdInfoKHR::default() + .memory(memory) + .handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT), + ) + .ok()?; + + let hal_buf = wgpu_hal::vulkan::Device::buffer_from_raw(raw); + let buffer = self.gpu.device.create_buffer_from_hal::( + hal_buf, + &wgpu::BufferDescriptor { + label: Some("staging-exportable"), + size, + usage: wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }, + ); + Some(ExportableStaging { buffer: Some(buffer), fd, size, device: dev, memory }) + }) + } + } +} + +impl Compositor { + /// Compose la frame courante en NV12 et la depose dans `staging`, dont la + /// memoire est exportable en dmabuf. Rend la main quand le GPU a fini. + /// + /// PAS DE RING, PAS DE `map_async`, CONTRAIREMENT A `readback_submit_yuv`. + /// Cette variante-ci n'a rien a faire relire par le CPU : le consommateur est + /// l'encodeur materiel, qui lit la meme memoire par son fd. Toute la + /// mecanique de staging mappe et de recolte differee n'aurait donc personne a + /// servir. + /// + /// NE BLOQUE PAS. Rend l'index de soumission ; l'appelant attend dessus juste + /// avant de donner le fd a l'encodeur, ce qui lui laisse la fenetre pour + /// composer la frame suivante pendant que celle-ci finit. C'est le meme + /// pipelining que la ring de relecture software, avec des tampons + /// exportables a la place des buffers mappes. + pub unsafe fn compose_into_dmabuf( + &self, + staging: &ExportableStaging, + ) -> Result { + self.ensure_yuv_fmt(YuvFormat::Nv12)?; + let (bpr_y, bpr_uv, off_uv, total) = { + let g = self.yuv.borrow(); + let t = g.as_ref().expect("ensure_yuv"); + (t.bpr_y, t.bpr_uv, t.off_u, t.total) + }; + if staging.size < total { + anyhow::bail!("staging de {} octets pour {total} attendus", staging.size); + } + let (w, h) = (self.render_w, self.render_h); + let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); + + let mut encoder = self + .gpu + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("yuv-dmabuf") }); + { + let g = self.yuv.borrow(); + let t = g.as_ref().expect("ensure_yuv"); + let (uv_view, pipe_uv, _uv) = match &t.chroma { + Chroma::Interleaved { uv_view, pipe_uv, _uv } => (uv_view, pipe_uv, _uv), + Chroma::Planar { .. } => { + anyhow::bail!("compose_into_dmabuf attend des cibles NV12") + } + }; + for (view, pipe) in [(&t.y_view, &t.pipe_y), (uv_view, pipe_uv)] { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("yuv-dmabuf-plane"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_pipeline(pipe); + 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), (_uv, off_uv, bpr_uv, cw, ch)] + { + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: staging.buffer(), + layout: wgpu::TexelCopyBufferLayout { + offset: off, + bytes_per_row: Some(bpr), + rows_per_image: Some(ph), + }, + }, + wgpu::Extent3d { width: pw, height: ph, depth_or_array_layers: 1 }, + ); + } + } + Ok(self.gpu.context.submit(std::iter::once(encoder.finish()))) + } + + /// Attend qu'une soumission soit terminee. + /// + /// INDISPENSABLE AVANT DE PASSER LE FD. L'encodeur lit cette memoire par un + /// chemin que wgpu ignore : rien d'autre ne garantirait que la copie a bien + /// atterri. + pub fn wait_submission(&self, idx: wgpu::SubmissionIndex) { + self.gpu.device.poll(wgpu::Maintain::WaitForSubmissionIndex(idx)); + } + + /// La geometrie NV12 courante, pour decrire le dmabuf au consommateur. + pub fn nv12_geometry(&self) -> (u32, u32, u64, u64) { + Compositor::yuv_layout_for(self.render_w, self.render_h, YuvFormat::Nv12) + } +} diff --git a/crates/compositor/src/d3d_linux.rs b/crates/compositor/src/d3d_linux.rs index 7c344d83..3d74edf6 100644 --- a/crates/compositor/src/d3d_linux.rs +++ b/crates/compositor/src/d3d_linux.rs @@ -139,24 +139,36 @@ 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 couvre un hote sans + // pilote Vulkan utilisable, ou un pilote sans memoire externe ; sur cette + // machine il n'est plus atteint depuis qu'on n'exige que deux extensions. + 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 +406,114 @@ 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 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| { + 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(); + + // La famille 0 n'est PAS garantie graphique. Elle l'est sur la + // plupart des pilotes, ce qui rend l'erreur invisible jusqu'a la + // machine ou elle ne l'est pas — et la panne serait alors un device + // qui s'ouvre puis ne sait rien dessiner. + let families = instance.get_physical_device_queue_family_properties(phys); + let family_index = families + .iter() + .position(|f| f.queue_flags.contains(ash::vk::QueueFlags::GRAPHICS))? + as u32; + 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() + }) + } +} diff --git a/crates/compositor/src/linux_frames.rs b/crates/compositor/src/linux_frames.rs index dcb99bed..044c05b7 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,52 @@ 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, - }, - ); + // LES DIMENSIONS DE TEXTURE SONT ARRONDIES AU PAIR, PAS LES PLANS. + // `ensure_textures` arrondit pour que le chroma 4:2:0 tombe juste, mais + // le decodeur, lui, alloue au visible : lire `stride * hauteur_arrondie` + // depasse le plan d'une ligne sur une source de hauteur impaire. On lit + // donc le VISIBLE et on laisse la derniere ligne de la texture telle + // qu'elle est — elle n'existe que pour l'alignement. + let (vw, vh) = ((*f).width.max(0) as u32, (*f).height.max(0) as u32); + let (vw, vh) = (vw.min(tex.width), vh.min(tex.height)); + let (cw, chh) = (vw.div_ceil(2), vh.div_ceil(2)); + for (plane, texture, pw, ph) in [ + (0usize, &tex.y, vw, vh), + (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 +315,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 +346,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/pipeline_linux.rs b/crates/compositor/src/pipeline_linux.rs index a5022ca2..75b9604c 100644 --- a/crates/compositor/src/pipeline_linux.rs +++ b/crates/compositor/src/pipeline_linux.rs @@ -325,46 +325,53 @@ 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 send_yuv420p(&mut self, planes: &[u8], rw: i32, rh: i32, pts: i64) -> Result<()> { - use crate::ffi::*; - if rw != self.w || rh != self.h { - bail!("send_yuv420p {rw}x{rh} != encodeur {}x{}", self.w, self.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()); + /// 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, + rh: i32, + enc_w: i32, + enc_h: i32, + ) -> Result<()> { + // Les DEUX bornes comptent. La verification de taille seule laisserait + // 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!("copy_into {rw}x{rh} != encodeur {enc_w}x{enc_h}"); } - - averr(av_frame_make_writable(self.sw), "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 = (*self.sw).data[plane]; - let dst_stride = (*self.sw).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); } - (*self.sw).pts = pts; - averr(avcodec_send_frame(self.ctx, self.sw), "send_frame") + // REND LA FRAME ECRIVABLE AVANT DE LA REECRIRE. `avcodec_send_frame` + // prend une reference sur le buffer ; un encodeur qui garde la frame — + // parce qu'il a du delai, ou parce que `OPENSCREEN_EXPORT_ENCODER` en a + // choisi un autre — la tiendrait encore quand le pool la recycle, et on + // ecrirait dans une image en cours d'encodage. + // + // J'avais retire cet appel en le jugeant inutile : avec `libopenh264` le + // refcount EST retombe a 1 au retour, mesure. Mais c'est une propriete de + // CET encodeur-la, pas du pool, et rien dans le code ne la maintenait. + // Ici l'appel est gratuit quand elle tient (refcount 1 = no-op) et + // correct quand elle ne tient pas. Le buffer ne porte pas + // `AV_BUFFER_FLAG_READONLY`, donc pas de branche recopie a redouter. + crate::ffi::averr(crate::ffi::av_frame_make_writable(dst_frame), "make_writable")?; + 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(()) } /// Flush : une frame nulle finalise le bitstream de l'encodeur. @@ -407,24 +414,321 @@ 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, +/// 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 { + /// DERIVE de `Compositor::yuv_layout_for`, jamais recalculee. Cette + /// arithmetique existait ici en double, et c'est precisement le genre de + /// duplication qui ne casse rien tant qu'elle est identique : le producteur + /// (le compositeur, qui remplit le buffer) et le consommateur (l'AVFrame du + /// pool) doivent s'accorder A L'OCTET, et un ecart ne donnerait pas une + /// panne mais une image decalee. + fn for_size(w: i32, h: i32) -> YuvLayout { + let (bpr_y, bpr_uv, off_u, total) = crate::compositor::Compositor::yuv_layout_for( + w.max(0) as u32, + h.max(0) as u32, + crate::compositor::YuvFormat::I420, + ); + let ch = (h.max(0) as u64).div_ceil(2); + let size_uv = u64::from(bpr_uv) * ch; + YuvLayout { + bpr_y: bpr_y as usize, + bpr_uv: bpr_uv as usize, + off_u: off_u as usize, + off_v: (off_u + size_uv) as usize, + total: total as usize, + } + } +} + +/// 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` +/// 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, + /// 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 + /// 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_padded_yuv_frame(enc.w, enc.h)? }; + empty_tx + .send(FreeFrame(f)) + .map_err(|_| anyhow::anyhow!("pool d'encodage: canal ferme a l'amorcage"))?; + } + 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, + spare: std::cell::Cell::new(std::ptr::null_mut()), + handle: Some(handle), + fatal, + }) + } + + /// Garde une frame empruntee sans avoir ete remplie, pour le tour suivant. + fn give_back(&self, frame: *mut AVFrame) { + 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")), + } + } + + 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(); + } + 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) }; } - 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); } } @@ -435,6 +739,82 @@ unsafe fn drain_encoder( /// La marche de timeline est PARTAGEE (`walk_composited_timeline`) : elle compose /// chaque frame de sortie (vitesse/fenetrage/curseur inclus) puis appelle /// `on_frame(n)`, ou on relit + encode + draine. + +/// Fin de marche du chemin SOFTWARE : vider la ring de relecture, puis rendre au +/// compositeur sa profondeur par defaut. +/// +/// Le drain doit avoir lieu AVANT de fermer la file : les `depth - 1` dernieres +/// copies sont encore en vol, et sans lui la derniere frame composee ne serait +/// jamais encodee — video amputee d'une frame. +fn hw_none_tail( + comp: &crate::compositor::Compositor, + worker: &mut EncodeWorker, + out_w: u32, + out_h: u32, + encoded_pts: &mut i64, +) -> Result<()> { + unsafe { + loop { + let frame = worker.take_free()?; + let mut filled = false; + let got = comp.readback_take_yuv_with(|rw, rh, planes| { + VideoEncoder::copy_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 qui va avec. + comp.set_readback_yuv_depth(1)?; + } + Ok(()) +} + +/// Ou partent les frames composees. Voir le commentaire au point de choix. +enum Sink { + /// Encodage software, deporte sur un thread. + Software(Box), + /// Encodage materiel depuis un dmabuf, sur place. + /// + /// PLUSIEURS TAMPONS, PAS UN. Avec un seul, composer et encoder se + /// serialisent : le GPU compose, on l'attend, on encode, et rien ne se + /// recouvre. Deux tampons suffisent a decaler d'une frame — on compose la + /// n pendant que la n-1 s'encode — et c'est le meme raisonnement que la + /// profondeur 2 de la ring de relecture software. + Hardware { + enc: VaapiEncoder, + mux: Muxer, + staging: Vec, + /// Frame soumise mais pas encore encodee : (slot, soumission, pts). + pending: Option<(usize, wgpu::SubmissionIndex, i64)>, + /// Frame mappee remise a l'encodeur pour le slot precedent, gardee VIVANTE + /// tant qu'il peut la lire. `(slot, frame)`. + in_flight: Option<(usize, *mut AVFrame)>, + next: usize, + }, +} + +impl Sink { + /// Le worker software. Ne doit etre appele qu'apres avoir ecarte le cas + /// materiel — le chemin materiel n'en a pas. + fn worker(&mut self) -> &mut EncodeWorker { + match self { + Sink::Software(w) => w, + Sink::Hardware { .. } => unreachable!("worker() sur le chemin materiel"), + } + } +} + pub fn run_composited_multi( clips: &[ClipSource], out: &str, @@ -453,8 +833,76 @@ 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 ectx = enc.ctx; + // L'ENCODEUR SE CHOISIT AVANT LE MUXER, parce que c'est lui qui decrit le + // flux video. `h264_vaapi` s'il s'ouvre et que le compositeur sait exporter + // sa memoire ; sinon l'encodeur software, inchange. + // + // Le repli couvre plus que l'absence de GPU : pas de `/dev/dri/renderD128`, + // un pilote sans VAAPI, un device wgpu ouvert sans les extensions de memoire + // externe. Aucun de ces cas n'est une erreur — l'export doit juste rester + // celui d'avant. + // L'ECHAPPATOIRE DOIT AUSSI COUVRIR CE CHOIX. `OPENSCREEN_EXPORT_ENCODER` + // existe pour forcer un encodeur ; si le chemin materiel l'ignorait, demander + // `libopenh264` donnerait quand meme du VAAPI — et le reglage servirait + // surtout a diagnostiquer, donc mentir ici est pire qu'ailleurs. + let forced = std::env::var("OPENSCREEN_EXPORT_ENCODER").ok(); + let hw_allowed = match forced.as_deref() { + None => true, + Some(name) => name.contains("vaapi"), + }; + let hw = if hw_allowed && matches!(params.codec, ExportCodec::H264) { + unsafe { VaapiEncoder::open(out_w as i32, out_h as i32, out_fps, bit_rate) } + .and_then(|v| { + // PLUS DE TAMPONS QUE L'ENCODEUR N'A DE LATENCE. Deux suffisaient + // pour recouvrir composition et encodage, mais pas pour la + // question de propriete : `h264_vaapi` garde plusieurs frames + // avant d'emettre le premier paquet, donc a deux tampons on + // revenait sur le slot 0 alors que la surface qui le mappe etait + // encore detenue. Le garde-fou de `frame_released` le prouve — + // avec deux, il declenche des la premiere boucle. + // + // Six, pas deux : c'est au-dessus de la latence observee, ca + // coute 6 x 3,3 Mo, et le garde-fou reste en place pour le cas ou + // un pilote irait plus loin. + let total = comp.nv12_geometry().3; + let mut v_st = Vec::new(); + for _ in 0..6 { + v_st.push(comp.create_exportable_staging(total)?); + } + Some((v, v_st)) + }) + } else { + None + }; + // N'OUVRE PAS L'ENCODEUR SOFTWARE SI LE MATERIEL A GAGNE. Il etait construit + // dans tous les cas, donc alloue puis jamais utilise — visible par deux + // lignes « encodeur video » dans le log, et par un AVFrame de 3,1 Mo qui ne + // sert a rien. + let enc = match &hw { + Some(_) => None, + None => Some(VideoEncoder::open( + ¶ms.codec, + out_w as i32, + out_h as i32, + out_fps, + bit_rate, + )?), + }; + // 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 = match (&hw, &enc) { + (Some((v, _)), _) => v.ctx(), + (None, Some(e)) => e.ctx, + (None, None) => bail!("aucun encodeur video disponible"), + }; + eprintln!( + "[pipeline] encodeur video : {}", + if hw.is_some() { "h264_vaapi (materiel, dmabuf)" } else { "software" } + ); let mut screen_decs: HashMap = HashMap::new(); let mut webcam_decs: HashMap = HashMap::new(); @@ -465,7 +913,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()), @@ -495,6 +943,29 @@ 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`). + // Deux formes, pas deux variantes d'une meme : le chemin software encode sur + // un thread (l'encodeur y coute ~8 ms/frame, il faut le sortir du chemin + // critique), le chemin materiel encode sur place (~3 ms) et garde le muxer + // sous la main. Les melanger rendrait les deux illisibles. + let mut sink = match hw { + Some((venc, staging)) => Sink::Hardware { + enc: venc, + mux, + staging, + pending: None, + in_flight: None, + next: 0, + }, + None => { + let enc = enc.ok_or_else(|| anyhow::anyhow!("aucun encodeur video disponible"))?; + Sink::Software(Box::new(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). @@ -524,13 +995,80 @@ 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. + match &mut sink { + Sink::Hardware { enc, mux, staging, pending, in_flight, next } => { + // Soumet la frame n SANS l'attendre, puis encode la + // precedente : le GPU compose pendant que l'encodeur + // travaille. La toute premiere passe n'a rien a encoder, + // comme l'amorcage de la ring software. + let slot = *next; + // AVANT d'ecrire dans ce slot : s'assurer que l'encodeur + // ne lit plus la surface qui le mappait. Draine tant qu'il + // la retient — c'est le drain qui fait sortir les paquets + // et relache les references, donc la boucle progresse. + if let Some((busy, frame)) = in_flight.take() { + if busy == slot { + let mut spins = 0; + while !VaapiEncoder::frame_released(frame) { + mux.drain(enc.ctx())?; + spins += 1; + if spins > 1000 { + bail!("l'encodeur retient la surface du slot {slot}"); + } + } + let mut f = frame; + crate::ffi::av_frame_free(&mut f); + } else { + *in_flight = Some((busy, frame)); + } + } + let idx = comp.compose_into_dmabuf(&staging[slot])?; + if let Some((prev, prev_idx, pts)) = pending.take() { + comp.wait_submission(prev_idx); + let (bpr_y, bpr_uv, off_uv, _) = comp.nv12_geometry(); + let f = enc.send_dmabuf(staging[prev].fd, bpr_y, bpr_uv, off_uv, pts)?; + mux.drain(enc.ctx())?; + // Remplace le precedent : il a ete relache plus haut + // si son slot revenait, sinon il l'est par ce drain. + if let Some((_, old)) = in_flight.take() { + let mut o = old; + crate::ffi::av_frame_free(&mut o); + } + *in_flight = Some((prev, f)); + } + *pending = Some((slot, idx, encoded_pts)); + encoded_pts += 1; + *next = (slot + 1) % staging.len(); + progress(n + 1); + return Ok(()); + } + Sink::Software(_) => {} + } + let worker = sink.worker(); + let frame = worker.take_free()?; + let mut filled = false; + comp.readback_submit_yuv(|rw, rh, planes| { + VideoEncoder::copy_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 @@ -566,21 +1104,48 @@ pub fn run_composited_multi( )? }; + // Le chemin materiel n'a ni ring ni file : il ne reste qu'a vider l'encodeur. + if let Sink::Hardware { enc, mux, staging, pending, in_flight, .. } = &mut sink { + unsafe { + // La derniere frame composee est encore en vol : sans ca la video + // sortirait amputee d'une frame, exactement comme le drain de la + // ring cote software. + if let Some((prev, prev_idx, pts)) = pending.take() { + comp.wait_submission(prev_idx); + let (bpr_y, bpr_uv, off_uv, _) = comp.nv12_geometry(); + let f = enc.send_dmabuf(staging[prev].fd, bpr_y, bpr_uv, off_uv, pts)?; + mux.drain(enc.ctx())?; + if let Some((_, old)) = in_flight.take() { + let mut o = old; + crate::ffi::av_frame_free(&mut o); + } + *in_flight = Some((prev, f)); + } + crate::ffi::avcodec_send_frame(enc.ctx(), ptr::null_mut()); + mux.drain(enc.ctx())?; + // Le flush a fait sortir tout ce qui restait : plus rien ne reference + // les surfaces, on peut liberer la derniere. + if let Some((_, f)) = in_flight.take() { + let mut f = f; + crate::ffi::av_frame_free(&mut f); + } + // Le compositeur survit a l'export : lui rendre sa profondeur par + // defaut vaut pour LES DEUX chemins. Le chemin materiel n'utilise pas + // la ring, mais `ensure_yuv_fmt` a pu la vider et la redimensionner, + // et la preview qui suit n'a pas a heriter de cet etat. + comp.set_readback_yuv_depth(1)?; + } + } + let mut mux = match sink { + Sink::Hardware { mux, .. } => mux, + Sink::Software(worker) => { + let mut worker = worker; + hw_none_tail(comp, &mut worker, out_w, out_h, &mut encoded_pts)?; + worker.finish()? + } + }; + 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)?; - } - // 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)?; // Audio : le plan part des frames REELLEMENT produites par clip (un clip // raccourci voit son audio raccourci d'autant), puis un seul encode AAC. // Récupération des jobs audio lancés pendant le parcours. `spawn` en admet quatre @@ -595,15 +1160,12 @@ pub fn run_composited_multi( 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(); @@ -614,3 +1176,308 @@ pub fn run_composited_multi( video_duration_s: frames as f64 / out_fps as f64, }) } + +// --------------------------------------------------------------------------- +// Encodage materiel depuis un dmabuf +// --------------------------------------------------------------------------- + +/// Encodeur `h264_vaapi` alimente par un dmabuf, sans relecture CPU. +/// +/// POURQUOI `av_hwframe_map` ET JAMAIS `av_hwframe_transfer_data`. Le second +/// est le chemin d'UPLOAD CPU -> GPU, et c'est lui qui appelle `vaMapBuffer2`, +/// absent de libva avant 2.22 : sur Ubuntu 24.04 (libva 2.20) il ne rend pas une +/// erreur, il `assert(0)` et le processus meurt (cf. issue #552). Le mapping, +/// lui, ne prend pas ce chemin -- c'est ce qui rend cet encodeur utilisable la +/// ou l'upload ne l'est pas. +pub struct VaapiEncoder { + ctx: *mut crate::ffi::AVCodecContext, + drm_device: *mut crate::ffi::AVBufferRef, + va_device: *mut crate::ffi::AVBufferRef, + drm_frames: *mut crate::ffi::AVBufferRef, + va_frames: *mut crate::ffi::AVBufferRef, + w: i32, + h: i32, +} + +// SAFETY : memes raisons que `VideoEncoder` -- pointeurs FFI sans affinite de +// thread, un seul thread a la fois. +unsafe impl Send for VaapiEncoder {} + +/// Libere le descripteur porte par l'`AVBufferRef` de la frame source. +unsafe extern "C" fn drm_desc_free(_opaque: *mut std::ffi::c_void, data: *mut u8) { + crate::ffi::av_free(data as *mut std::ffi::c_void); +} + +impl VaapiEncoder { + /// Ouvre la chaine DRM -> VAAPI -> `h264_vaapi`. `None` si quoi que ce soit + /// manque : l'appelant retombe alors sur l'encodeur software. + pub unsafe fn open(w: i32, h: i32, fps: i32, bit_rate: i64) -> Option { + use crate::ffi::*; + let mut me = VaapiEncoder { + ctx: ptr::null_mut(), + drm_device: ptr::null_mut(), + va_device: ptr::null_mut(), + drm_frames: ptr::null_mut(), + va_frames: ptr::null_mut(), + w, + h, + }; + // LE DEVICE DRM D'ABORD, PUIS VAAPI DERIVE DE LUI. L'ordre inverse + // (VAAPI ouvert seul) rend ENOSYS sur radeonsi : le mapping veut les deux + // cotes d'un meme device. + let node = std::ffi::CString::new("/dev/dri/renderD128").ok()?; + if av_hwdevice_ctx_create( + &mut me.drm_device, + AVHWDeviceType::AV_HWDEVICE_TYPE_DRM, + node.as_ptr(), + ptr::null_mut(), + 0, + ) < 0 + { + return None; + } + if av_hwdevice_ctx_create_derived( + &mut me.va_device, + AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI, + me.drm_device, + 0, + ) < 0 + { + return None; + } + // `initial_pool_size = 0` sur LES DEUX contextes : ils ne font + // qu'ENVELOPPER des surfaces fournies de l'exterieur (le dmabuf d'un + // cote, ce que `av_hwframe_map` remplit de l'autre). Demander un pool + // pre-alloue fait rejeter le format par `av_hwframe_ctx_init` en EINVAL, + // faute d'allocateur pour ces dispositions. + let mk_frames = |dev: *mut AVBufferRef, fmt: AVPixelFormat::Type| -> *mut AVBufferRef { + let frames = av_hwframe_ctx_alloc(dev); + if frames.is_null() { + return ptr::null_mut(); + } + let c = (*frames).data as *mut AVHWFramesContext; + (*c).format = fmt; + (*c).sw_format = AVPixelFormat::AV_PIX_FMT_NV12; + (*c).width = w; + (*c).height = h; + (*c).initial_pool_size = 0; + if av_hwframe_ctx_init(frames) < 0 { + return ptr::null_mut(); + } + frames + }; + me.drm_frames = mk_frames(me.drm_device, AVPixelFormat::AV_PIX_FMT_DRM_PRIME); + me.va_frames = mk_frames(me.va_device, AVPixelFormat::AV_PIX_FMT_VAAPI); + if me.drm_frames.is_null() || me.va_frames.is_null() { + return None; + } + + let name = std::ffi::CString::new("h264_vaapi").ok()?; + let enc = avcodec_find_encoder_by_name(name.as_ptr()); + if enc.is_null() { + return None; + } + me.ctx = avcodec_alloc_context3(enc); + if me.ctx.is_null() { + return None; + } + (*me.ctx).width = w; + (*me.ctx).height = h; + (*me.ctx).pix_fmt = AVPixelFormat::AV_PIX_FMT_VAAPI as i32; + (*me.ctx).time_base = AVRational { num: 1, den: fps }; + (*me.ctx).framerate = AVRational { num: fps, den: 1 }; + (*me.ctx).bit_rate = bit_rate; + // MP4 veut SPS/PPS dans l'extradata, pas repetes devant chaque image + // cle. Le chemin software le pose depuis toujours (`try_open`) ; l'avoir + // oublie ici produisait un fichier qui se lit quand meme, parce que le + // muxer recupere ce qu'il trouve — mais un lecteur qui se fie a + // `codecpar` seul aurait de quoi echouer. + (*me.ctx).flags |= AV_CODEC_FLAG_GLOBAL_HEADER as i32; + (*me.ctx).hw_frames_ctx = av_buffer_ref(me.va_frames); + if avcodec_open2(me.ctx, enc, ptr::null_mut()) < 0 { + return None; + } + Some(me) + } + + /// Envoie a l'encodeur l'image qui se trouve derriere `fd`, decrite comme un + /// NV12 lineaire de pitches `bpr_y` / `bpr_uv`. + pub unsafe fn send_dmabuf( + &mut self, + fd: i32, + bpr_y: u32, + bpr_uv: u32, + off_uv: u64, + pts: i64, + ) -> Result<*mut AVFrame> { + use crate::ffi::*; + let desc = av_mallocz(std::mem::size_of::()) + as *mut AVDRMFrameDescriptor; + if desc.is_null() { + bail!("av_mallocz(AVDRMFrameDescriptor)"); + } + (*desc).nb_objects = 1; + (*desc).objects[0].fd = fd; + // 0 : la taille est retrouvee par le pilote depuis le fd lui-meme. + (*desc).objects[0].size = 0; + (*desc).objects[0].format_modifier = 0; // DRM_FORMAT_MOD_LINEAR + (*desc).nb_layers = 1; + // fourcc 'NV12', ecrit a la main : bindgen ne genere pas MKTAG. + (*desc).layers[0].format = u32::from_le_bytes(*b"NV12"); + (*desc).layers[0].nb_planes = 2; + (*desc).layers[0].planes[0].object_index = 0; + (*desc).layers[0].planes[0].offset = 0; + (*desc).layers[0].planes[0].pitch = bpr_y as isize; + (*desc).layers[0].planes[1].object_index = 0; + (*desc).layers[0].planes[1].offset = off_uv as isize; + (*desc).layers[0].planes[1].pitch = bpr_uv as isize; + + let src = av_frame_alloc(); + (*src).format = AVPixelFormat::AV_PIX_FMT_DRM_PRIME as i32; + (*src).width = self.w; + (*src).height = self.h; + (*src).data[0] = desc as *mut u8; + // LA SOURCE DOIT ETRE REFCOMPTEE. Sans `buf[0]`, `av_hwframe_map` rend + // EINVAL -- et son message ne dit pas un mot de comptage de references, + // ce qui rend la panne tres difficile a lire. + (*src).buf[0] = av_buffer_create( + desc as *mut u8, + std::mem::size_of::(), + Some(drm_desc_free), + ptr::null_mut(), + 0, + ); + (*src).hw_frames_ctx = av_buffer_ref(self.drm_frames); + + let dst = av_frame_alloc(); + (*dst).format = AVPixelFormat::AV_PIX_FMT_VAAPI as i32; + (*dst).width = self.w; + (*dst).height = self.h; + (*dst).hw_frames_ctx = av_buffer_ref(self.va_frames); + // Bindgen range les `AV_HWFRAME_MAP_*` dans un module anonyme : les + // nommer par leur valeur serait plus fragile que de passer par lui. + let flags = (crate::ffi::_bindgen_ty_3::AV_HWFRAME_MAP_READ + | crate::ffi::_bindgen_ty_3::AV_HWFRAME_MAP_DIRECT) as i32; + let mapped = av_hwframe_map(dst, src, flags); + if mapped < 0 { + let mut s = src; + let mut d = dst; + av_frame_free(&mut s); + av_frame_free(&mut d); + averr(mapped, "av_hwframe_map(DRM -> VAAPI)")?; + unreachable!("averr rend une erreur pour mapped < 0"); + } + (*dst).pts = pts; + let r = averr(avcodec_send_frame(self.ctx, dst), "send_frame(vaapi)"); + // `src` a fini son role : `av_hwframe_map` a copie ce qu'il fallait dans + // `dst`, et le descripteur DRM meurt avec lui. + let mut s = src; + av_frame_free(&mut s); + // `dst` PAS libere ici. `avcodec_send_frame` en a pris une reference, et + // cette frame mappe le dmabuf du slot : tant qu'elle vit, l'encodeur peut + // encore lire cette memoire. L'appelant la garde et ne la relache — donc + // ne recycle le slot — qu'apres avoir draine le paquet correspondant. + r.map(|()| dst) + } + + /// Vrai si l'encodeur ne detient plus la frame mappee, donc si le slot qu'elle + /// couvre peut etre reecrit. + /// + /// C'est la SEULE question qui compte pour reutiliser un slot. Un `drain` qui + /// rend `EAGAIN` ne dit rien la-dessus : il signale qu'aucun paquet n'est + /// pret, pas que la surface est relachee. + pub unsafe fn frame_released(frame: *mut AVFrame) -> bool { + frame.is_null() + || (*frame).buf[0].is_null() + || crate::ffi::av_buffer_get_ref_count((*frame).buf[0]) <= 1 + } + + pub fn ctx(&self) -> *mut crate::ffi::AVCodecContext { + self.ctx + } +} + +impl Drop for VaapiEncoder { + fn drop(&mut self) { + unsafe { + if !self.ctx.is_null() { + crate::ffi::avcodec_free_context(&mut self.ctx); + } + for b in [ + &mut self.va_frames, + &mut self.drm_frames, + &mut self.va_device, + &mut self.drm_device, + ] { + if !b.is_null() { + crate::ffi::av_buffer_unref(b); + } + } + } + } +} + +#[cfg(test)] +mod vaapi_tests { + use super::*; + + /// La chaine complete, dans le crate et non dans un bac a sable : un tampon + /// de staging EXPORTABLE alloue par le compositeur, son fd donne a + /// `av_hwframe_map`, et `h264_vaapi` qui en sort un paquet. + /// + /// C'est le premier test qui touche reellement l'encodeur materiel. Il se + /// saute proprement partout ou la chaine n'existe pas (pas de GPU, pas de + /// `/dev/dri/renderD128`, pas de VAAPI) -- la CI rend sur lavapipe, et + /// l'echec y serait un faux negatif. + #[test] + fn vaapi_encodes_from_an_exported_dmabuf() { + let Ok(gpu) = crate::d3d::Gpu::create_auto(false) else { + eprintln!("pas d'adaptateur Vulkan — test saute"); + return; + }; + let (w, h) = (640i32, 480i32); + let comp = match crate::compositor::Compositor::new_sized(&gpu, w as u32, h as u32) { + Ok(c) => c, + Err(e) => { + eprintln!("compositeur indisponible ({e:#}) — test saute"); + return; + } + }; + let (bpr_y, bpr_uv, off_uv, total) = crate::compositor::Compositor::yuv_layout_for( + w as u32, + h as u32, + crate::compositor::YuvFormat::Nv12, + ); + let Some(st) = comp.create_exportable_staging(total) else { + eprintln!("pas de memoire externe — test saute"); + return; + }; + + // Du gris legal plutot que des zeros : un plan Y a 0 est du noir hors + // plage en BT.601 limite, et on veut que l'encodeur voie une image + // valide, pas qu'il la rattrape. + let mut grey = vec![128u8; total as usize]; + grey[..off_uv as usize].fill(128); + gpu.context.write_buffer(st.buffer(), 0, &grey); + gpu.context.submit(std::iter::empty()); + gpu.device.poll(wgpu::Maintain::Wait); + + unsafe { + let Some(mut enc) = VaapiEncoder::open(w, h, 60, 4_000_000) else { + eprintln!("h264_vaapi indisponible — test saute"); + return; + }; + enc.send_dmabuf(st.fd, bpr_y, bpr_uv, off_uv, 0) + .expect("send_dmabuf"); + // Un encodeur peut legitimement retenir la premiere frame : on le + // vide pour forcer la sortie du paquet. + let _ = crate::ffi::avcodec_send_frame(enc.ctx(), std::ptr::null_mut()); + let pkt = crate::ffi::av_packet_alloc(); + let r = crate::ffi::avcodec_receive_packet(enc.ctx(), pkt); + assert!(r >= 0, "avcodec_receive_packet a rendu {r}"); + assert!((*pkt).size > 0, "paquet H.264 vide"); + let mut p = pkt; + crate::ffi::av_packet_free(&mut p); + } + } +} 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); } 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); +} diff --git a/crates/compositor/wrapper_linux.h b/crates/compositor/wrapper_linux.h index 0ee8cf4a..70407440 100644 --- a/crates/compositor/wrapper_linux.h +++ b/crates/compositor/wrapper_linux.h @@ -2,11 +2,22 @@ * * Software/VAAPI decode+encode only: no D3D11VA (Windows) nor VideoToolbox * (macOS) hwcontext headers, which pull platform-specific system headers - * (d3d11.h / CoreVideo) that don't exist on Linux. The generic hwcontext.h is - * kept for AVHWDeviceContext should the VAAPI path need it later. */ + * (d3d11.h / CoreVideo) that don't exist on Linux. + * + * hwcontext_drm.h IS included: the export path needs AVDRMFrameDescriptor to + * describe an exported dmabuf to av_hwframe_map, and that header pulls nothing + * beyond what the vendored ffmpeg already ships. + * + * hwcontext_vaapi.h is deliberately NOT included, though the export encodes with + * VAAPI. It #includes , which is not in the vendored tree -- adding it + * would make libva's DEVELOPMENT headers a build dependency of this crate on + * every Linux builder, to gain nothing: reaching VAAPI needs only + * AV_HWDEVICE_TYPE_VAAPI, an enumerator of the generic hwcontext.h, and + * av_hwdevice_ctx_create_derived. AVVAAPIDeviceContext itself is never touched. */ #include #include #include +#include #include #include #include