diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 0d3164b81..981189a2f 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -239,6 +239,17 @@ struct WebcamMask { height: u32, } +/// Pyramide de profondeur de champ (cf. `Compositor::dof_pyramid`). `_tex` garde la +/// texture en vie ; `view` porte tous les niveaux (binding 4 du mode 8), `mips` un +/// niveau chacune (cible du remplissage puis de `generate_mips`). +struct DofPyramid { + _tex: wgpu::Texture, + view: wgpu::TextureView, + mips: Vec, + width: u32, + height: u32, +} + pub struct Compositor { gpu: Gpu, render_w: u32, @@ -328,6 +339,12 @@ pub struct Compositor { ann_copy_view: wgpu::TextureView, ann_copy_mips: Vec, + /// Pyramide de profondeur de champ (mode 8) : la video en RGBA8 a demi-resolution + /// de la texture DECODEUR, mips compris (texture, vue tous niveaux, vue par niveau), + /// et sa taille de niveau 0. Allouee au premier ecran incline, recreee quand la + /// texture decodeur change de taille. Cf. `compositor_windows::DofPyramid`. + dof_pyramid: RefCell>, + /// Images d'annotation, indexees par ID d'annotation -- PAS par chemin comme /// `img_cache`. Une annotation image porte souvent une data-URI de plusieurs /// mega-octets ; s'en servir comme cle de HashMap la ferait hacher a chaque @@ -677,6 +694,7 @@ impl Compositor { ann_copy, ann_copy_view, ann_copy_mips, + dof_pyramid: RefCell::new(None), ann_img_cache: RefCell::new(std::collections::HashMap::new()), webcam_mask: RefCell::new(None), seg_capture: RefCell::new(None), @@ -746,8 +764,21 @@ impl Compositor { ) -> (wgpu::Texture, wgpu::TextureView, Vec) { // floor(log2(max)) + 1 : le dernier niveau mesure 1x1. let levels = 32 - w.max(h).max(1).leading_zeros(); + Self::make_mip_chain(gpu, "ann-copy", w, h, levels) + } + + /// Texture RGBA8 a `levels` niveaux : la texture, une vue sur tous les niveaux + /// (echantillonnage) et une vue PAR niveau (cibles de `generate_mips`). Partagee + /// par `ann_copy` et la pyramide de profondeur de champ. + fn make_mip_chain( + gpu: &Gpu, + label: &str, + w: u32, + h: u32, + levels: u32, + ) -> (wgpu::Texture, wgpu::TextureView, Vec) { let tex = gpu.device.create_texture(&wgpu::TextureDescriptor { - label: Some("ann-copy"), + label: Some(label), size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, mip_level_count: levels, sample_count: 1, @@ -762,7 +793,7 @@ impl Compositor { let mips = (0..levels) .map(|level| { tex.create_view(&wgpu::TextureViewDescriptor { - label: Some("ann-copy-mip"), + label: Some("mip-level"), base_mip_level: level, mip_level_count: Some(1), ..Default::default() @@ -779,10 +810,6 @@ impl Compositor { /// recouvrent s'echantillonnent l'une l'autre selon l'ordre de dessin. Meme /// contrat que le `blit` + `generate_mipmaps` de `compositor_macos`. /// - /// wgpu n'a pas de `generate_mipmaps` : chaque niveau est une passe de rendu - /// plein ecran qui echantillonne le precedent. Le filtre lineaire sur une - /// source exactement deux fois plus grande EST la moyenne 2x2, donc cette - /// boucle produit la meme pyramide que le blit Metal. fn generate_ann_mips(&self, encoder: &mut wgpu::CommandEncoder) { encoder.copy_texture_to_texture( self.rt.as_image_copy(), @@ -793,9 +820,34 @@ impl Compositor { depth_or_array_layers: 1, }, ); + self.generate_mips(encoder, &self.ann_copy_mips); + } + + /// Alloue (ou realloue a la taille de la texture decodeur) la pyramide de profondeur + /// de champ : demi-resolution, `dof_pyramid_levels` niveaux. Ses UV sont ceux des + /// plans video, le calcul d'UV du mode 8 ne change donc pas. + fn ensure_dof_pyramid(&self, tex_w: u32, tex_h: u32) { + let (w, h) = (tex_w.div_ceil(2).max(1), tex_h.div_ceil(2).max(1)); + let stale = + self.dof_pyramid.borrow().as_ref().is_none_or(|p| (p.width, p.height) != (w, h)); + if stale { + let levels = crate::frame_geometry::dof_pyramid_levels(w, h); + let (tex, view, mips) = Self::make_mip_chain(&self.gpu, "dof-pyramid", w, h, levels); + *self.dof_pyramid.borrow_mut() = + Some(DofPyramid { _tex: tex, view, mips, width: w, height: h }); + } + } + + /// Remplit les niveaux 1.. de `mips` depuis le niveau 0 (une vue par niveau). + /// + /// wgpu n'a pas de `generate_mipmaps` : chaque niveau est une passe de rendu + /// plein ecran qui echantillonne le precedent. Le filtre lineaire sur une + /// source exactement deux fois plus grande EST la moyenne 2x2, donc cette + /// boucle produit la meme pyramide que le blit Metal. + fn generate_mips(&self, encoder: &mut wgpu::CommandEncoder, mips: &[wgpu::TextureView]) { // Les bind groups doivent survivre a leur passe : on les garde tous ici. let mut keep: Vec<(wgpu::Buffer, wgpu::BindGroup)> = Vec::new(); - for level in 1..self.ann_copy_mips.len() { + for level in 1..mips.len() { let uniform = self.gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("ann-mip-uniform"), // `fs_copy` ne lit pas l'uniforme, mais le layout du blur l'exige. @@ -809,9 +861,7 @@ impl Compositor { wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }, wgpu::BindGroupEntry { binding: 1, - resource: wgpu::BindingResource::TextureView( - &self.ann_copy_mips[level - 1], - ), + resource: wgpu::BindingResource::TextureView(&mips[level - 1]), }, wgpu::BindGroupEntry { binding: 2, @@ -823,7 +873,7 @@ impl Compositor { let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("ann-mip-pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.ann_copy_mips[level], + view: &mips[level], resolve_target: None, ops: wgpu::Operations { // Clear plutot que Load : le niveau n'a jamais ete ecrit, @@ -1109,6 +1159,7 @@ impl Compositor { cut: [f32; 4], focus_plane: [f32; 2], radius: f32, + dof: bool, ) -> LayerCB { let (rw, rh) = (self.render_w as f32, self.render_h as f32); let corners = quad.corners; @@ -1142,9 +1193,8 @@ impl Compositor { fx: [tl0, tl1, tr0, tr1], src_prev: [br0, br1, bl0, bl1], dst_prev: [plane_px[0], plane_px[1], 0.0, 0.0], - // Gradient de profondeur du plan et profondeur du focus (`depth_mb`) ; `k = 0`, - // le shader ne les lit pas encore. - mb: quad.depth_mb(s_px, focus_plane), + // Gradient de profondeur du plan, profondeur du focus et `k` (`depth_mb`). + mb: quad.depth_mb(s_px, focus_plane, dof), ..Default::default() } } @@ -1154,6 +1204,19 @@ impl Compositor { cb: &LayerCB, planes: Option<(&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView)>, dummy: &wgpu::TextureView, + ) -> (wgpu::Buffer, wgpu::BindGroup) { + self.make_bind_b4(cb, planes, dummy, None) + } + + /// `make_bind`, binding 4 impose. Seul le mode 8 s'en sert, pour y lier la pyramide + /// de profondeur de champ a la place du masque webcam qu'il ne lit pas -- jamais le + /// binding 1, qui porte la luma. + fn make_bind_b4( + &self, + cb: &LayerCB, + planes: Option<(&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView)>, + dummy: &wgpu::TextureView, + b4: Option<&wgpu::TextureView>, ) -> (wgpu::Buffer, wgpu::BindGroup) { let uniform = self.gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("layer-uniform"), @@ -1169,7 +1232,7 @@ impl Compositor { // calque webcam leve `fx.z`. `dummy` reste le repli tant qu'aucune frame // n'a ete segmentee. let mask = self.webcam_mask.borrow(); - let mask_view = mask.as_ref().map_or(dummy, |m| &m.view); + let mask_view = b4.unwrap_or_else(|| mask.as_ref().map_or(dummy, |m| &m.view)); let bind = self.gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("layer"), layout: &self.bind_group_layout, @@ -1960,6 +2023,14 @@ impl Compositor { (g.s_dst[0] + g.s_dst[2] * 0.5) * rw, (g.s_dst[1] + g.s_dst[3] * 0.5) * rh, ]; + // Profondeur de champ : pyramide allouee et remplie seulement sur une frame + // inclinee qui la lit. L'emprunt tient jusqu'a la soumission de l'encodeur. + let dof = g.depth_of_field_on(self.gpu.backend == crate::d3d::Backend::Cpu); + if dof { + self.ensure_dof_pyramid(stw, sth); + } + let dof_guard = self.dof_pyramid.borrow(); + let dof_pyramid = dof_guard.as_ref().filter(|_| dof); // Calque ecran : mode 0 (rect droit, NV12 -> RGB) quand la rotation est // neutre, mode 8 (warp bilineaire inverse dans la bbox du quad projete) @@ -1993,15 +2064,44 @@ impl Compositor { mb: [g.mb_taps, g.mb_amount, 1.0, 0.0], ..Default::default() }, - Some(quad) => { - self.tilted_screen_cb(quad, s_px, quad_center_px, g.cut, g.focus_plane, g.s_radius) - } + Some(quad) => self.tilted_screen_cb( + quad, + s_px, + quad_center_px, + g.cut, + g.focus_plane, + g.s_radius, + dof, + ), }; // Bind group construit AVANT le pass (doit vivre pendant tout le pass) ; // `_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, &su, &sv)), &dummy); + // Binding 4 EXPLICITE sur le mode 8 : la pyramide si l'effet tourne, sinon le + // repli habituel (`k = 0`, le shader n'y lit rien). + let (_screen_uniform, screen_bind) = self.make_bind_b4( + &screen_layer, + Some((&sy, &su, &sv)), + &dummy, + dof_pyramid.map(|p| &p.view), + ); + // Remplissage de la pyramide : UN draw du mode 0 en UV plein vers son niveau 0 + // (cf. `compositor_windows::fill_dof_pyramid`), `color.a = 1`, un seul tap. + let dof_fill = dof_pyramid.map(|p| { + let full = [0.0, 0.0, 1.0, 1.0]; + let cb = LayerCB { + dst: full, + src: full, + quad_px: [p.width as f32, p.height as f32], + mode: 0.0, + color: [1.0, 1.0, 1.0, 1.0], + src_prev: full, + dst_prev: full, + mb: [1.0, 0.0, 1.0, 0.0], + ..Default::default() + }; + self.make_bind(&cb, 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 @@ -2729,6 +2829,30 @@ impl Compositor { let mut encoder = self.gpu.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("compose"), }); + // Passe 0 : pyramide de profondeur de champ, niveau 0 vide d'abord (le « over » + // rend alors la source telle quelle), puis ses mips. + if let (Some(p), Some((_buf, bind))) = (dof_pyramid, &dof_fill) { + { + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("dof-pyramid-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &p.mips[0], + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + rpass.set_pipeline(&self.pipeline); + rpass.set_bind_group(0, bind, &[]); + rpass.draw(0..4, 0..1); + } + self.generate_mips(&mut encoder, &p.mips); + } // Passe 1 : fond (clear a `bg_clear` + gradient mode 5 eventuel). { let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index 35c2c3a32..3cb7f788a 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -325,6 +325,10 @@ pub struct Compositor { /// échantillonner la cible sur laquelle on dessine, et le mode 10 lit un niveau de mip /// pour flouter à coût constant. ann_copy: metal::Texture, + /// Pyramide de profondeur de champ (mode 8) : la vidéo en RGBA8 à demi-résolution de la + /// texture DÉCODEUR, mips compris. Allouée au premier écran incliné, recréée quand la + /// texture décodeur change de taille. Cf. `compositor_windows::DofPyramid`. + dof_pyramid: RefCell>, /// Images d'annotation, indexées par ID d'annotation (pas par data-URL : celle-ci pèse /// souvent des mégaoctets et la hacher à chaque frame coûterait plus que le décodage). /// La longueur sert de garde-fou quand l'utilisateur change l'image. @@ -636,6 +640,7 @@ impl Compositor { pipeline_kdown, pipeline_kup, ann_copy, + dof_pyramid: RefCell::new(None), ann_img_cache: RefCell::new(std::collections::HashMap::new()), text_cache: RefCell::new(std::collections::HashMap::new()), text_raster: crate::text::TextRasterizer::new().ok(), @@ -1083,6 +1088,66 @@ impl Compositor { } + /// Remplit la pyramide de profondeur de champ depuis la frame écran et la rend. Port de + /// `compositor_windows::fill_dof_pyramid` : UN draw du mode 0 en UV plein vers une cible + /// demi-résolution vidée d'abord (`color.a = 1`, un seul tap), puis `generate_mipmaps`, + /// l'appel qui sert déjà `ann_copy`. Le viewport découle de la taille de l'attachement. + unsafe fn fill_dof_pyramid( + &self, + cmd: &metal::CommandBufferRef, + y: &metal::Texture, + uv: &metal::Texture, + tex_w: u32, + tex_h: u32, + ) -> Result { + let (w, h) = (tex_w.div_ceil(2).max(1), tex_h.div_ceil(2).max(1)); + let stale = self + .dof_pyramid + .borrow() + .as_ref() + .is_none_or(|p| (p.width(), p.height()) != (w as u64, h as u64)); + if stale { + let d = metal::TextureDescriptor::new(); + d.set_texture_type(metal::MTLTextureType::D2); + d.set_pixel_format(metal::MTLPixelFormat::RGBA8Unorm); + d.set_width(w as u64); + d.set_height(h as u64); + d.set_storage_mode(metal::MTLStorageMode::Private); + d.set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead); + d.set_mipmap_level_count(crate::frame_geometry::dof_pyramid_levels(w, h) as u64); + *self.dof_pyramid.borrow_mut() = Some(self.gpu.device.new_texture(&d)); + } + let pyr = self.dof_pyramid.borrow().clone().expect("pyramide allouée ci-dessus"); + let enc = self.begin_pass( + cmd, + &pyr, + Some(metal::MTLClearColor::new(0.0, 0.0, 0.0, 0.0)), + &self.pipeline_main, + )?; + let full = [0.0, 0.0, 1.0, 1.0]; + self.draw_video( + enc, + &LayerCB { + dst: full, + src: full, + quad_px: [w as f32, h as f32], + mode: 0.0, + color: [1.0, 1.0, 1.0, 1.0], + src_prev: full, + dst_prev: full, + mb: [1.0, 0.0, 1.0, 0.0], + ..Default::default() + }, + y, + uv, + ); + enc.end_encoding(); + let blit = cmd.new_blit_command_encoder(); + blit.generate_mipmaps(&pyr); + blit.end_encoding(); + Ok(pyr) + } + /// Ombre d'un écran incliné en 3D : la pénombre suit le QUADRILATÈRE projeté (mode 12), /// pas son rect englobant. Port de `compositor_windows::draw_quad_shadow`. #[allow(clippy::too_many_arguments)] @@ -1143,6 +1208,7 @@ impl Compositor { radius: f32, y: &metal::Texture, uv: &metal::Texture, + dof_pyramid: Option<&metal::Texture>, ) { let (rw, rh) = (self.render_w as f32, self.render_h as f32); let corners = quad.corners; @@ -1161,6 +1227,9 @@ impl Compositor { let [tr0, tr1] = local(corners[1]); let [br0, br1] = local(corners[2]); let [bl0, bl1] = local(corners[3]); + // texture(2) EXPLICITE : `draw_video` ne lie que 0/1, et le slot 2 garde sinon ce que + // le draw précédent y a laissé. `None` quand l'effet est coupé : `k = 0`, rien n'y est lu. + enc.set_fragment_texture(2, dof_pyramid.map(|t| &**t)); self.draw_video( enc, &LayerCB { @@ -1177,9 +1246,9 @@ impl Compositor { fx: [tl0, tl1, tr0, tr1], src_prev: [br0, br1, bl0, bl1], dst_prev: [plane_px[0], plane_px[1], 0.0, 0.0], - // Gradient de profondeur du plan et profondeur du focus (`depth_mb`) ; `k = 0`, - // le shader ne les lit pas encore. - mb: quad.depth_mb(s_px, focus_plane), + // Gradient de profondeur du plan, profondeur du focus et `k` (`depth_mb`) : la + // pyramide liée décide seule si l'effet tourne. + mb: quad.depth_mb(s_px, focus_plane, dof_pyramid.is_some()), ..Default::default() }, y, @@ -1995,6 +2064,13 @@ impl Compositor { }); let cmd_buf = self.gpu.context.new_command_buffer(); + // Profondeur de champ : pyramide remplie seulement sur une frame inclinée qui la lit, + // dans ses propres passes, avant celles de la composition. + let dof_pyramid = if g.depth_of_field_on(self.gpu.backend == crate::d3d::Backend::Cpu) { + Some(self.fill_dof_pyramid(cmd_buf, &sy, &suv, stw, sth)?) + } else { + None + }; let enc = self.begin_pass( cmd_buf, &self.rt, @@ -2122,7 +2198,16 @@ impl Compositor { &suv, ), Some(quad) => self.draw_tilted_screen( - enc, quad, s_px, quad_center_px, g.cut, g.focus_plane, g.s_radius, &sy, &suv, + enc, + quad, + s_px, + quad_center_px, + g.cut, + g.focus_plane, + g.s_radius, + &sy, + &suv, + dof_pyramid.as_ref(), ), } @@ -3291,6 +3376,124 @@ mod tests { } } + // ----------------------------------------------------------------------- + // Profondeur de champ du mode 8 (pendant macOS de `tests/tilted_depth_of_field.rs`) + // + // Le seul golden de l'effet qui tourne en CI : celui de Windows est opt-in (source vidéo), + // celui de Linux aussi (`OPENSCREEN_LINUX_COMPOSE`). C'est aussi le premier vrai passage de + // `fill_dof_pyramid`, de `generate_mipmaps` et du `texture(2)` du mode 8. Le piège qu'il + // garde : un `level(lod)` qui retomberait au niveau 0 laisserait macOS net là où Windows + // floute. + // ----------------------------------------------------------------------- + + /// Écran seul sur fond magenta (couleur qu'aucune luma neutre ne produit), focus manuel sur + /// le coin proche d'`iso` (haut-droit), zoom 1 en plein palier sur 0..6 s. + fn dof_scene_json(rotation: &str, dof: bool) -> String { + format!( + r##"{{"clips":[], + "layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle", + "webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}}, + "effects":{{"padding":0.2,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0, + "depthOfField":{dof}}}, + "background":{{"kind":"color","color":"#ff00ff"}}, + "zoomRegions":[{{"clipIndex":0,"startSec":0,"endSec":6,"scale":1.0, + "focusX":0.94,"focusY":0.06,"focusMode":"manual", + "rotation":{rotation}}}], + "annotations":[], + "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0, + "clipToBounds":false,"theme":"default"}}, + "cropByClip":[], + "output":{{"width":1280,"height":720,"fps":30}}}}"## + ) + } + + fn compose_dof(comp: &super::Compositor, screen: &FakeFrame, rotation: &str, dof: bool) -> Vec { + let scene = crate::scene::Scene::from_json(&dof_scene_json(rotation, dof)).expect("scene json"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_has_webcam(false); + comp.set_scene(Some(scene)); + let mut cfg = crate::config::Cfg::c8(); + cfg.shadow = false; + cfg.cursor = false; + cfg.mblur_n = 1; + unsafe { + // Frame 180 à `FPS` (60) = 3 s : en plein palier de la région. + comp.compose_frame(screen.as_ptr(), std::ptr::null(), 180.0, &cfg) + .expect("compose_frame"); + comp.readback_direct().expect("readback_direct").2 + } + } + + /// Coin du plan (hors magenta) extrême dans la direction `dir`, rentré de `inset` vers le + /// centroïde : une fenêtre posée là est entièrement sur le plan, loin du feather. + fn dof_corner(rgba: &[u8], w: u32, h: u32, dir: (i64, i64), inset: f64) -> (u32, u32) { + let (mut best, mut at) = (i64::MIN, (0i64, 0i64)); + let (mut sx, mut sy, mut n) = (0f64, 0f64, 0f64); + for y in 0..h as i64 { + for x in 0..w as i64 { + let p = &rgba[((y * w as i64 + x) * 4) as usize..]; + if p[0] > 200 && p[1] < 60 && p[2] > 200 { + continue; + } + sx += x as f64; + sy += y as f64; + n += 1.0; + if x * dir.0 + y * dir.1 > best { + best = x * dir.0 + y * dir.1; + at = (x, y); + } + } + } + let x = at.0 as f64 + (sx / n - at.0 as f64) * inset; + let y = at.1 as f64 + (sy / n - at.1 as f64) * inset; + (x as u32, y as u32) + } + + /// Octets et netteté (écart moyen entre voisins, vert) d'une fenêtre `r`×`r` centrée en `c`. + fn dof_window(rgba: &[u8], w: u32, c: (u32, u32), r: u32) -> (Vec, f64) { + let g = |x: u32, y: u32| rgba[((y * w + x) * 4 + 1) as usize] as f64; + let (mut bytes, mut acc) = (Vec::new(), 0.0); + for y in c.1 - r / 2..c.1 + r / 2 { + for x in c.0 - r / 2..c.0 + r / 2 { + let i = ((y * w + x) * 4) as usize; + bytes.extend_from_slice(&rgba[i..i + 4]); + acc += (g(x + 1, y) - g(x, y)).abs() + (g(x, y + 1) - g(x, y)).abs(); + } + } + (bytes, acc / (r * r) as f64) + } + + #[test] + fn depth_of_field_defocuses_the_far_corner_of_a_tilted_screen_only() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let (w, h) = (1280u32, 720u32); + let comp = super::Compositor::new_sized(&gpu, w, h).expect("Compositor::new_sized"); + // Damier de 2 texels : le détail le plus fin possible, que le moindre niveau de mip efface. + let screen = FakeFrame::new(640, 360, |col, row| if (col / 2 + row / 2) % 2 == 0 { 180 } else { 60 }); + + let on = compose_dof(&comp, &screen, "\"iso\"", true); + let off = compose_dof(&comp, &screen, "\"iso\"", false); + let r = 32; + let near = dof_corner(&off, w, h, (1, -1), 0.12); + let far = dof_corner(&off, w, h, (-1, 1), 0.12); + let ((near_on_px, near_on), (near_off_px, near_off)) = + (dof_window(&on, w, near, r), dof_window(&off, w, near, r)); + let ((_, far_on), (_, far_off)) = (dof_window(&on, w, far, r), dof_window(&off, w, far, r)); + println!("dof : proche {near_off:.2} -> {near_on:.2}, lointain {far_off:.2} -> {far_on:.2}"); + assert!(far_off > 2.0, "fenêtre lointaine sans détail ({far_off})"); + assert!(near_on_px == near_off_px, "coin proche modifié : {near_off} -> {near_on}"); + assert!(far_on < far_off * 0.7, "coin lointain pas flouté : {far_off} -> {far_on}"); + + // À plat, l'effet n'existe pas : allumé ou non, la frame est la même à l'octet. + assert!( + compose_dof(&comp, &screen, "null", true) == compose_dof(&comp, &screen, "null", false), + "rotation nulle : la profondeur de champ a changé la frame" + ); + } + /// Le pendant macOS de `compositor_windows`'s `every_shader_entry_point_compiles`. /// /// `shaders.metal` est compilé À L'EXÉCUTION par `new_library_with_source` : une diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 873a1b8f2..e46e34783 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -75,6 +75,16 @@ struct WebcamMask { height: u32, } +/// Pyramide de la profondeur de champ (mode 8) : la vidéo en RGBA8 à demi-résolution de la +/// texture DÉCODEUR, avec sa chaîne de mips. Dimensionnée sur la texture décodeur et remplie en +/// UV plein (0..1) : ses UV sont ceux de t0/t1, et le calcul d'UV du mode 8 ne change pas. +struct DofPyramid { + rtv: ID3D11RenderTargetView, + srv: ID3D11ShaderResourceView, + width: u32, + height: u32, +} + pub struct Compositor { dev: ID3D11Device, ctx: ID3D11DeviceContext, @@ -217,6 +227,11 @@ pub struct Compositor { /// que `live_readback_staging`, mais en NV12 et non en RGBA : l'encodeur logiciel veut /// les plans Y/UV, pas des pixels RGBA. Voir `read_nv12_scaled`. nv12_readback_staging: RefCell>, + /// Pyramide de profondeur de champ, allouée au premier écran incliné et recréée quand la + /// texture décodeur change de taille. Rien n'est alloué tant qu'aucun tilt n'est rendu. + dof_pyramid: RefCell>, + /// Device WARP : la profondeur de champ y obéit à `DOF_ON_CPU_BACKEND`. + cpu_backend: bool, } /// Ressources d'un resize export à une taille cible : RGBA intermédiaire (résultat du @@ -634,6 +649,8 @@ impl Compositor { seg_scratch: RefCell::new(Vec::new()), seg_failed: RefCell::new(false), nv12_readback_staging: RefCell::new(None), + dof_pyramid: RefCell::new(None), + cpu_backend: gpu.backend == crate::d3d::Backend::Cpu, }) } @@ -808,6 +825,86 @@ impl Compositor { [1.0 / hw, 1.0 / hh, off, 0.0]); } + /// Remplit la pyramide de profondeur de champ depuis la frame écran et rend sa SRV. + /// + /// UN draw du mode 0 existant, en UV plein, vers une cible demi-résolution : un échantillon + /// bilinéaire 2:1 est une moyenne 2x2, cette cible EST donc déjà le niveau 1 d'une pyramide + /// pleine résolution. `GenerateMips` fait le reste, comme pour `ann_copy`. Cible vidée + /// d'abord et `color.a = 1` : un `LayerCB` par défaut a `color.a = 0`, et le mode 0 sortirait + /// une pyramide transparente. `mb.x = 1` : un seul tap, pas de flou de mouvement. + /// + /// Laisse l'état de composition lié sur le RT principal ; l'appelant enchaîne sur `begin`. + unsafe fn fill_dof_pyramid( + &self, + sy: &ID3D11ShaderResourceView, + suv: &ID3D11ShaderResourceView, + tex_w: u32, + tex_h: u32, + ) -> Result { + let (w, h) = (tex_w.div_ceil(2).max(1), tex_h.div_ceil(2).max(1)); + let stale = self.dof_pyramid.borrow().as_ref().is_none_or(|p| (p.width, p.height) != (w, h)); + if stale { + let desc = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: crate::frame_geometry::dof_pyramid_levels(w, h), + ArraySize: 1, + Format: DXGI_FORMAT_R8G8B8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, + CPUAccessFlags: 0, + MiscFlags: D3D11_RESOURCE_MISC_GENERATE_MIPS.0 as u32, + }; + let mut tex: Option = None; + self.dev.CreateTexture2D(&desc, None, Some(&mut tex))?; + let tex = tex.unwrap(); + // RTV sur le niveau 0 seul (défaut), SRV sur toute la chaîne (défaut). + let mut rtv: Option = None; + self.dev.CreateRenderTargetView(&tex, None, Some(&mut rtv))?; + let mut srv: Option = None; + self.dev.CreateShaderResourceView(&tex, None, Some(&mut srv))?; + *self.dof_pyramid.borrow_mut() = + Some(DofPyramid { rtv: rtv.unwrap(), srv: srv.unwrap(), width: w, height: h }); + } + let pyr = self.dof_pyramid.borrow(); + let pyr = pyr.as_ref().expect("pyramide allouée ci-dessus"); + self.bind_compose_state(); + // Délie t2 : la pyramide y est peut-être encore liée depuis le mode 8 précédent, et une + // ressource liée en lecture ET en écriture est retirée d'office par le runtime. + self.ctx.PSSetShaderResources(2, Some(&[None])); + self.ctx.OMSetRenderTargets(Some(&[Some(pyr.rtv.clone())]), None); + self.ctx.ClearRenderTargetView(&pyr.rtv, &[0.0, 0.0, 0.0, 0.0]); + self.ctx.RSSetViewports(Some(&[D3D11_VIEWPORT { + TopLeftX: 0.0, + TopLeftY: 0.0, + Width: w as f32, + Height: h as f32, + MinDepth: 0.0, + MaxDepth: 1.0, + }])); + let full = [0.0, 0.0, 1.0, 1.0]; + self.draw_video( + &LayerCB { + dst: full, + src: full, + quad_px: [w as f32, h as f32], + mode: 0.0, + color: [1.0, 1.0, 1.0, 1.0], + src_prev: full, + dst_prev: full, + mb: [1.0, 0.0, 1.0, 0.0], + ..Default::default() + }, + sy, + suv, + ); + // La pyramide quitte la sortie avant de générer ses mips. + self.bind_compose_state(); + self.ctx.GenerateMips(&pyr.srv); + Ok(pyr.srv.clone()) + } + unsafe fn upload_cb(&self, cb: &LayerCB) { let mut m = D3D11_MAPPED_SUBRESOURCE::default(); self.ctx @@ -1661,6 +1758,10 @@ impl Compositor { let w_radius = g.w_radius; let shape_fade = g.shape_fade; + // Profondeur de champ : la pyramide n'est remplie que sur une frame inclinée qui la lit, + // AVANT `begin`, qui rebranche ensuite le RT principal et le vide. + let dof = g.depth_of_field_on(self.cpu_backend); + let dof_srv = if dof { Some(self.fill_dof_pyramid(&sy, &suv, stw, sth)?) } else { None }; self.begin([0.0, 0.0, 0.0, 1.0]); @@ -1846,6 +1947,10 @@ impl Compositor { let [tr0, tr1] = local(corners[1]); let [br0, br1] = local(corners[2]); let [bl0, bl1] = local(corners[3]); + // t2 EXPLICITE : `draw_video` ne lie que t0/t1, et t2 garde sinon ce que le draw + // précédent y a laissé (un wallpaper, un sprite). `None` quand l'effet est coupé : + // `k = 0`, le shader n'y lit rien. + self.ctx.PSSetShaderResources(2, Some(&[dof_srv.clone()])); self.draw_video( &LayerCB { dst: bbox_dst, @@ -1858,14 +1963,14 @@ impl Compositor { fx: [tl0, tl1, tr0, tr1], src_prev: [br0, br1, bl0, bl1], dst_prev: [plane_px[0], plane_px[1], 0.0, 0.0], - // Gradient de profondeur du plan et profondeur du focus (`depth_mb`) ; `k = 0`, - // le shader ne les lit pas encore. - mb: quad.depth_mb(s_px, g.focus_plane), + // Gradient de profondeur du plan, profondeur du focus et `k` (`depth_mb`). + mb: quad.depth_mb(s_px, g.focus_plane, dof), ..Default::default() }, &sy, &suv, ); + self.ctx.PSSetShaderResources(2, Some(&[None])); } // --- curseur custom : suit le mapping src/dst (zoom+layout), click bounce, diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 46774bfeb..9c0440b32 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -361,6 +361,20 @@ pub const HALF_W: u32 = OUT_W / 2; pub const HALF_H: u32 = OUT_H / 2; pub const FIXTURE_FRAMES: u32 = 360; pub(crate) const FPS: f32 = 60.0; +/// La profondeur de champ tourne-t-elle sur le backend CPU (WARP, lavapipe) ? Le seul drapeau +/// partagé qui la coupe là-bas si son coût y devient prohibitif (seuil fixé par la spec : +/// 10 ms/frame). Mesuré (`tests/tilted_depth_of_field.rs`, 1080p iso, trois passes) : WARP +/// +3.8 / +6.3 / +12.2 ms/frame, soit +35 à +45 % ; le matériel +0.04 à +0.08 ms/frame. Médiane +/// sous le seuil : l'effet reste allumé partout, ce drapeau est le levier si ça change. +pub const DOF_ON_CPU_BACKEND: bool = true; +/// Niveaux de la pyramide de profondeur de champ (demi-résolution de la texture décodeur, donc +/// son niveau 0 est le niveau 1 d'une pyramide pleine résolution). Le shader plafonne sa lecture +/// à `DOF_MAX_LOD` (1.5) ; les niveaux au-delà laissent la marge de relever ce plafond sans +/// toucher aux trois backends. Jamais plus que la chaîne complète d'une très petite source. +pub fn dof_pyramid_levels(w: u32, h: u32) -> u32 { + const DOF_PYRAMID_LEVELS: u32 = 5; + DOF_PYRAMID_LEVELS.min(32 - w.max(h).max(1).leading_zeros()) +} /// Longueurs de style exprimées en FRACTION du petit côté du cadre, et non en pixels. /// /// Elles étaient écrites en px bruts au point d'appel, ce qui voulait dire « px du render @@ -767,6 +781,8 @@ pub struct FrameGeometry { /// dessinée `cut`, c'est-à-dire dans le plan que le mode 8 incline. Borné au plan : un focus /// que le cover a rogné retombe sur le bord. C'est lui qui fixe `z_focus` (`depth_mb`). pub focus_plane: [f32; 2], + /// Réglage « Depth of field » du projet. Ne décide rien seul : voir `depth_of_field_on`. + pub depth_of_field: bool, pub s_dst: [f32; 4], pub s_dst_prev: [f32; 4], /// Boîte écran **sans le zoom** : le conteneur auquel les annotations et les @@ -815,6 +831,16 @@ pub fn annotation_dst_in(anchor: [f32; 4], x: f32, y: f32, w: f32, h: f32) -> [f } impl FrameGeometry { + /// La profondeur de champ tourne-t-elle sur CETTE frame ? Réglage allumé, écran réellement + /// incliné (à plat le mode 8 n'est pas dessiné), et backend qui en a les moyens (cf. + /// `DOF_ON_CPU_BACKEND`). Une seule réponse pour les trois backends : elle décide à la fois + /// du remplissage de la pyramide et du `k` du mode 8, qui ne doivent jamais diverger. + pub fn depth_of_field_on(&self, cpu_backend: bool) -> bool { + self.depth_of_field + && !crate::regions::is_identity_rotation(self.zoom_rotation) + && (DOF_ON_CPU_BACKEND || !cpu_backend) + } + /// `annotation_dst_in` appliqué à `s_ann`, pour les backends qui tiennent la géométrie /// entière — c'est-à-dire ceux qui n'ont aucune raison de choisir un rect. pub fn annotation_dst(&self, x: f32, y: f32, w: f32, h: f32) -> [f32; 4] { @@ -1382,6 +1408,8 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { padding_scale, cut, focus_plane, + // Sans scène (bench fixture), aucune zoom region, donc aucun tilt : rien à défocaliser. + depth_of_field: scene.is_some_and(|s| s.effects.depth_of_field), s_dst, s_dst_prev, // La boîte écran telle qu'elle serait sans zoom : `remap_box` n'est PAS appliqué. @@ -1858,7 +1886,9 @@ mod tests { let render = input.render_px; let s_px = [g.s_dst[2] * render[0], g.s_dst[3] * render[1]]; let quad = crate::regions::rotated_quad_corners_px(s_px[0], s_px[1], g.zoom_rotation); - let mb = quad.depth_mb(s_px, g.focus_plane); + let mb = quad.depth_mb(s_px, g.focus_plane, g.depth_of_field_on(false)); + assert!(mb[3] > 0.0, "réglage absent de la scène : la profondeur de champ est allumée"); + assert!(!g.depth_of_field_on(true) || DOF_ON_CPU_BACKEND); let want = (0.95 - 0.5) * mb[0] + (0.3 - 0.5) * mb[1]; assert!((mb[2] - want).abs() < 1e-3 && mb[2].abs() > 1.0, "z_focus {} au lieu de {want}", mb[2]); } @@ -2499,6 +2529,7 @@ mod tests { padding_scale: 1.0, cut: [0.0, 0.0, 1.0, 1.0], focus_plane: [0.5, 0.5], + depth_of_field: false, s_dst: [0.0, 0.0, 1.0, 1.0], s_dst_prev: [0.0, 0.0, 1.0, 1.0], s_ann: [0.0, 0.0, 1.0, 1.0], diff --git a/crates/compositor/src/regions.rs b/crates/compositor/src/regions.rs index c5c379ba1..e0515bd5b 100644 --- a/crates/compositor/src/regions.rs +++ b/crates/compositor/src/regions.rs @@ -596,6 +596,24 @@ fn depth_coefficients(rot: [f32; 3]) -> (f32, f32) { (sa * sg - sb * ca * cg, sa * cg + sb * ca * sg) } +/// ROTATION_3D_PERSPECTIVE_FACTOR (TS) — à garder synchronisé avec `types.ts`, que la passe 3D +/// de l'exporteur canvas lit encore. Distance de fuite = facteur × min(w,h) : plus le facteur +/// est grand, plus la caméra est loin et plus la convergence des arêtes s'aplatit. À 2.6 elle +/// était si faible que l'inclinaison ne se lisait plus (le bord haut d'iso ressortait à 0.08° de +/// l'horizontale). +const PERSPECTIVE_FACTOR: f32 = 1.6; + +/// Profondeur de champ du mode 8 : cercle de confusion, en texels SOURCE, par unité d'écart de +/// profondeur rapporté à la distance de fuite (`|z − z_focus| / P`). +/// +/// Réglé une fois, en unités de P : cet écart ne dépend ni de la résolution ni du zoom (la +/// perspective se déduit de la taille du plan lui-même). Sur iso en 16:9, focus au centre, le +/// coin lointain est à ~0.19 P, soit un flou de ~4.6 texels ; focus sur le coin proche, l'écart +/// double et le shader plafonne (`DOF_MAX_LOD`, niveau 1.5 de la pyramide demi-résolution, soit +/// ~5.7 texels). Un flou exprimé en texels suit le CONTENU : la même zone est aussi floue quel +/// que soit le zoom qui l'affiche. +pub const DOF_COC_PER_DEPTH: f32 = 24.0; + /// Les 4 coins d'un quad `width`×`height` réduit de `scale`, projetés. `None` si un coin part /// derrière le plan de fuite. fn project_scaled_corners( @@ -638,12 +656,6 @@ pub struct TiltedQuad { /// px = la taille du rect d'origine, aussi utilisée comme référence de perspective (comme le /// web : la perspective/le containScale sont calculés sur la taille de l'élément lui-même). pub fn rotated_quad_corners_px(width: f32, height: f32, rot: [f32; 3]) -> TiltedQuad { - // ROTATION_3D_PERSPECTIVE_FACTOR (TS) — à garder synchronisé avec `types.ts`, que la passe 3D - // de l'exporteur canvas lit encore. Distance de fuite = facteur × min(w,h) : plus le facteur - // est grand, plus la caméra est loin et plus la convergence des arêtes s'aplatit. À 2.6 elle - // était si faible que l'inclinaison ne se lisait plus (le bord haut d'iso ressortait à 0.08° de - // l'horizontale). - const PERSPECTIVE_FACTOR: f32 = 1.6; let perspective = width.min(height) * PERSPECTIVE_FACTOR; let (half_w, half_h) = (width * 0.5, height * 0.5); @@ -716,16 +728,21 @@ impl TiltedQuad { /// La profondeur du point (s, t) du plan (0..1, ce que le warp inverse du shader retrouve) /// vaut `(s − 0.5)·gx + (t − 0.5)·gy`, en px, positive vers la caméra : deux multiply-add par /// pixel, aucune trigo. `z_focus` est cette profondeur au point de focus du zoom - /// (`focus_plane`, 0..1 dans le plan — `FrameGeometry::focus_plane`). `k` reste à 0 : le - /// shader n'en lit encore rien, la sortie ne change pas d'un octet. + /// (`focus_plane`, 0..1 dans le plan — `FrameGeometry::focus_plane`). + /// + /// `k` convertit l'écart de profondeur en cercle de confusion : `coc = k·|z − z_focus|`, en + /// texels source (`DOF_COC_PER_DEPTH / P`). `dof = false` le laisse à 0 : le shader ne + /// quitte alors jamais son échantillon net, et la sortie est celle d'avant l'effet à l'octet. /// /// `screen_px` est la taille du rect d'origine, celle passée à `rotated_quad_corners_px` : le - /// plan mesure `screen_px × scale` dans son propre repère. - pub fn depth_mb(&self, screen_px: [f32; 2], focus_plane: [f32; 2]) -> [f32; 4] { + /// plan mesure `screen_px × scale` dans son propre repère, et la distance de fuite en dérive. + pub fn depth_mb(&self, screen_px: [f32; 2], focus_plane: [f32; 2], dof: bool) -> [f32; 4] { let gx = screen_px[0] * self.scale * self.depth_k.0; let gy = screen_px[1] * self.scale * self.depth_k.1; let z_focus = (focus_plane[0] - 0.5) * gx + (focus_plane[1] - 0.5) * gy; - [gx, gy, z_focus, 0.0] + let perspective = screen_px[0].min(screen_px[1]) * PERSPECTIVE_FACTOR; + let k = if dof && perspective > 0.0 { DOF_COC_PER_DEPTH / perspective } else { 0.0 }; + [gx, gy, z_focus, k] } /// Demi-largeur / demi-hauteur de la bounding box des coins projetés, en px. @@ -755,6 +772,23 @@ mod zoom_focus_tests { } } + /// Le focus automatique ne « perd » jamais la piste : hors de ses bornes, il tient la + /// dernière (ou la première) position suivie au lieu de retomber sur le point statique de la + /// région. Sans ça, le plan net sauterait d'un bout de l'écran à l'autre à la fin de la + /// télémétrie — et la netteté avec lui, maintenant que `z_focus` en dépend. + #[test] + fn auto_focus_holds_the_last_tracked_point_past_the_track() { + let track = CursorTrack::new(vec![(3.0, 0.8, 0.2), (4.0, 0.9, 0.1)], Vec::new(), Vec::new()); + let mut r = region(1.5, 0.1); + r.focus_mode = Some("auto".into()); + let last = track.follow_at(4.0).unwrap(); + for t in [4.5f32, 6.0, 7.9] { + assert_eq!(resolve_focus(&r, t, Some(&track)), [last.0, last.1], "t = {t}"); + } + let first = track.follow_at(3.0).unwrap(); + assert_eq!(resolve_focus(&r, 2.1, Some(&track)), [first.0, first.1]); + } + #[test] fn zoom_cursor_alpha_hides_during_zoom() { let mut r = region(2.0, 0.5); @@ -1124,8 +1158,8 @@ mod tilt_tests { for (name, rot) in presets() { for (w, h) in [(1920.0f32, 1080.0f32), (1080.0, 1920.0), (800.0, 800.0)] { let quad = rotated_quad_corners_px(w, h, rot); - let mb = quad.depth_mb([w, h], [0.5, 0.5]); - let perspective = w.min(h) * 1.6; + let mb = quad.depth_mb([w, h], [0.5, 0.5], false); + let perspective = w.min(h) * PERSPECTIVE_FACTOR; for (i, (s, t)) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)].into_iter().enumerate() { let (x0, y0) = ((s - 0.5) * w * quad.scale, (t - 0.5) * h * quad.scale); @@ -1158,7 +1192,7 @@ mod tilt_tests { let (w, h) = (1920.0f32, 1080.0f32); let rot = rotation3d_for(&Some("iso".into())); let quad = rotated_quad_corners_px(w, h, rot); - let mb = quad.depth_mb([w, h], [0.5, 0.5]); + let mb = quad.depth_mb([w, h], [0.5, 0.5], false); let (z_tr, z_bl) = (depth_at(mb, 1.0, 0.0), depth_at(mb, 0.0, 1.0)); assert!(z_bl < z_tr, "z(BL) = {z_bl} doit être < z(TR) = {z_tr}"); // Grossissement perspective d'un coin = position projetée / position tournée. @@ -1171,20 +1205,45 @@ mod tilt_tests { assert!(m_tr > m_bl * 1.2, "TR x{m_tr} devrait être nettement plus grossi que BL x{m_bl}"); } - /// Le focus au centre du plan est à la profondeur du centre (0), et `k` reste nul : le slot - /// ne change rien au rendu tant qu'un shader ne le lit pas. + /// Le focus au centre du plan est à la profondeur du centre (0), et `k` reste nul réglage + /// coupé : le shader ne quitte alors jamais son échantillon net. #[test] - fn the_depth_slot_is_inert_and_centred() { + fn the_depth_slot_is_inert_when_off_and_centred() { for (name, rot) in presets() { let quad = rotated_quad_corners_px(1920.0, 1080.0, rot); - let mb = quad.depth_mb([1920.0, 1080.0], [0.5, 0.5]); + let mb = quad.depth_mb([1920.0, 1080.0], [0.5, 0.5], false); assert_eq!(mb[2], 0.0, "{name}"); assert_eq!(mb[3], 0.0, "{name}"); - let off = quad.depth_mb([1920.0, 1080.0], [0.9, 0.2]); + let off = quad.depth_mb([1920.0, 1080.0], [0.9, 0.2], false); assert!((off[2] - depth_at(off, 0.9, 0.2)).abs() < 1e-4, "{name}"); } let flat = rotated_quad_corners_px(1920.0, 1080.0, [0.0, 0.0, 0.0]); - assert_eq!(flat.depth_mb([1920.0, 1080.0], [0.1, 0.9]), [0.0; 4]); + assert_eq!(flat.depth_mb([1920.0, 1080.0], [0.1, 0.9], false), [0.0; 4]); + // À plat, allumer ne floute rien : gx = gy = 0, donc |z − z_focus| = 0 partout. + let lit = flat.depth_mb([1920.0, 1080.0], [0.1, 0.9], true); + assert_eq!([lit[0], lit[1], lit[2]], [0.0; 3]); + } + + /// Le cercle de confusion que le shader calcule (`k·|z − z_focus|`, texels source) : nul au + /// focus, croissant vers le coin LOINTAIN, et le même en 1080p qu'en 4K — il est réglé en + /// unités de la distance de fuite, pas en pixels de sortie. + #[test] + fn the_circle_of_confusion_grows_away_from_the_focus_and_ignores_resolution() { + let rot = rotation3d_for(&Some("iso".into())); + let coc = |w: f32, h: f32, focus: [f32; 2], s: f32, t: f32| { + let quad = rotated_quad_corners_px(w, h, rot); + let mb = quad.depth_mb([w, h], focus, true); + mb[3] * (depth_at(mb, s, t) - mb[2]).abs() + }; + let centre = [0.5, 0.5]; + assert!(coc(1920.0, 1080.0, centre, 0.5, 0.5) < 1e-4); + // Coin lointain (BL) : ~0.19 P, donc ~4.6 texels, bien au-dessus du seuil net (0.5). + let far = coc(1920.0, 1080.0, centre, 0.0, 1.0); + assert!((3.5..6.0).contains(&far), "coin lointain {far}"); + // Focus posé sur le coin proche (TR) : le lointain s'en éloigne encore. + assert!(coc(1920.0, 1080.0, [1.0, 0.0], 0.0, 1.0) > far * 1.8); + let far_4k = coc(3840.0, 2160.0, centre, 0.0, 1.0); + assert!((far_4k - far).abs() < 1e-3 * far, "{far_4k} != {far}"); } #[test] diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index 3371aa8cd..3fa2bf94a 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -162,6 +162,15 @@ pub struct SceneEffects { pub roundness_frac: f32, /// 0..1 flou de mouvement. pub motion_blur: f32, + /// Réglage « Depth of field » : défocalise l'écran incliné selon sa profondeur (mode 8), + /// net au focus du zoom. Sans effet hors tilt. Allumé par défaut, clé absente comprise : il + /// ne s'applique qu'aux zooms inclinés, où il suit l'angle réel. + #[serde(default = "default_true")] + pub depth_of_field: bool, +} + +fn default_true() -> bool { + true } /// Fond derrière l'écran (parsé depuis `settings.wallpaper`). diff --git a/crates/compositor/src/shaders.hlsl b/crates/compositor/src/shaders.hlsl index f415842b1..0cd57bbf1 100644 --- a/crates/compositor/src/shaders.hlsl +++ b/crates/compositor/src/shaders.hlsl @@ -38,13 +38,17 @@ VSOut vs_main(uint vid : SV_VertexID) Texture2D texY : register(t0); Texture2D texUV : register(t1); -Texture2D texImg : register(t2); // wallpaper image RGBA (fond, mode 6) +Texture2D texImg : register(t2); // wallpaper image RGBA (fond, mode 6) ; mode 8 : pyramide de flou // Masque de segmentation du sujet, 0 = fond, 1 = sujet. Produit par `segmentation.rs` a la // resolution du modele (256x144) ; l'upscale vers la resolution webcam est fait par le sampler // lineaire, ce qui est exactement le filtrage qu'on veut sur un masque. Texture2D texMask : register(t3); SamplerState samp : register(s0); +// Plafond de la profondeur de champ du mode 8, en niveau de la pyramide demi-résolution (1.5 = +// ~5.7 texels source). Validé à l'œil sur du texte : cf. `tests/tilted_depth_of_field.rs`. +#define DOF_MAX_LOD 1.5 + // BT.709 limited -> RGB (§7 E1), matrice en dur, range mesuré en S1. float3 yuv709_limited(float y, float2 cbcr) { @@ -256,8 +260,10 @@ float4 ps_main(VSOut i) : SV_Target // échantillonne la vidéo à l'UV correspondant, sinon transparent (hors du quad projeté). // fx.xy/fx.zw = coins TL/TR (px locaux, 0..quad_px) ; src_prev.xy/.zw = coins BR/BL. // mb = [gx, gy, z_focus, k] : profondeur du point r du plan = (r.x - 0.5)*gx + (r.y - 0.5)*gy - // en px, positive vers la caméra ; z_focus = celle du focus du zoom (`TiltedQuad::depth_mb`). - // Pas encore lu : k = 0, la sortie ne dépend pas de ce slot. + // en px, positive vers la caméra ; z_focus = celle du focus du zoom (`TiltedQuad::depth_mb`) ; + // k = texels source de flou par px d'écart de profondeur (0 = profondeur de champ coupée). + // t2 (texImg) = pyramide demi-résolution de la vidéo, 5 niveaux, mêmes UV que t0/t1 ; liée + // explicitement à chaque draw du mode 8 (`draw_video` ne lie que t0/t1). // mode 11 : texte d'annotation, rastérisé par Direct2D (voir text.rs). D2D écrit sur une // surface DXGI en alpha PRÉMULTIPLIÉ, donc contrairement au mode 7 (sprite curseur, alpha // droit) il ne faut SURTOUT pas re-multiplier ici : les bords adoucis des glyphes @@ -417,7 +423,23 @@ float4 ps_main(VSOut i) : SV_Target float2 p = float2(r.x, r.y) * plane_px - plane_px * 0.5; float d = sd_round_rect(p, plane_px * 0.5, max(radius_px, 0.0)); float tilt_a = 1.0 - smoothstep(0.0, 1.5, d); - return float4(sample_yuv(uv) * tilt_a, tilt_a); // prémultiplié, comme les autres modes + // Profondeur de champ : cercle de confusion en texels source, nul au focus du zoom. + // Sous un demi-texel, l'échantillon net d'avant, à l'octet : le texte net ne passe + // jamais par le RGBA de la pyramide, et `k = 0` (réglage coupé) ne quitte jamais cette + // voie. Au-delà, fondu vers la pyramide demi-résolution (t2) au niveau `log2(coc) - 1` + // (son niveau 0 est déjà une moyenne 2x2), plafonné à DOF_MAX_LOD : au niveau 2, un + // bloc 4x4 soude les jambages d'un « m » en 1080p. + float3 rgb = sample_yuv(uv); + float2 rs = saturate(float2(r.x, r.y)); + float z = (rs.x - 0.5) * mb.x + (rs.y - 0.5) * mb.y; + float coc = mb.w * abs(z - mb.z); + if (coc > 0.5) + { + float lod = clamp(log2(coc) - 1.0, 0.0, DOF_MAX_LOD); + float3 far_rgb = texImg.SampleLevel(samp, uv, lod).rgb; + rgb = lerp(rgb, far_rgb, saturate((coc - 0.5) / 1.5)); + } + return float4(rgb * tilt_a, tilt_a); // prémultiplié, comme les autres modes } // mode 7 : sprite curseur thème (PNG alpha droite, arrow.png etc.). Prémultiplie ici diff --git a/crates/compositor/src/shaders.metal b/crates/compositor/src/shaders.metal index 462d7873b..d4bd4e450 100644 --- a/crates/compositor/src/shaders.metal +++ b/crates/compositor/src/shaders.metal @@ -102,6 +102,10 @@ vertex VSOut vs_main(uint vid [[vertex_id]], constexpr sampler samp(filter::linear, mip_filter::linear, address::clamp_to_edge); constexpr sampler sampNV(filter::linear, address::clamp_to_edge); +// Plafond de la profondeur de champ du mode 8, en niveau de la pyramide demi-résolution. +// Même valeur que `DOF_MAX_LOD` du HLSL. +constant float DOF_MAX_LOD = 1.5; + // Slots de texture, tenus par les paramètres des entry points : // ps_main : 0 = texY (Y, R8), 1 = texUV (CbCr, RG8), 2 = texImg (RGBA) // ps_fs_* : 0 = rgbTex (RGBA) @@ -359,8 +363,10 @@ fragment float4 ps_main(VSOut i [[stage_in]], // mode 8 : écran tilté (zoom regions "rotation"). Warp bilinéaire inverse. // mb = [gx, gy, z_focus, k] : profondeur du point r du plan = (r.x - 0.5)*gx + (r.y - 0.5)*gy - // en px, positive vers la caméra ; z_focus = celle du focus du zoom (`TiltedQuad::depth_mb`). - // Pas encore lu : k = 0, la sortie ne dépend pas de ce slot. + // en px, positive vers la caméra ; z_focus = celle du focus du zoom (`TiltedQuad::depth_mb`) ; + // k = texels source de flou par px d'écart de profondeur (0 = profondeur de champ coupée). + // texture(2) (texImg) = pyramide demi-résolution de la vidéo, 5 niveaux, mêmes UV que + // texture(0/1) ; liée explicitement à chaque draw du mode 8. Cf. commentaires HLSL. if (layer.mode > 7.5 && layer.mode < 8.5) { // PAS de test de clip sur `dst_prev` ici — le port en avait copié un depuis le @@ -387,9 +393,22 @@ fragment float4 ps_main(VSOut i [[stage_in]], float2 p = float2(r.x, r.y) * plane_px - plane_px * 0.5; float d = sd_round_rect(p, plane_px * 0.5, max(layer.radius_px, 0.0)); float tilt_a = 1.0 - smoothstep(0.0, 1.5, d); + // Profondeur de champ : net sous un demi-texel de flou (l'échantillon d'avant, à + // l'octet), fondu au-delà vers la pyramide au niveau `log2(coc) - 1`, plafonné. + // `level(lod)` exige `mip_filter::linear` sur `samp` : sans lui, niveau 0 partout. + float3 rgb = sample_yuv(uv, texY, texUV); + float2 rs = clamp(float2(r.x, r.y), 0.0, 1.0); + float z = (rs.x - 0.5) * layer.mb.x + (rs.y - 0.5) * layer.mb.y; + float coc = layer.mb.w * abs(z - layer.mb.z); + if (coc > 0.5) + { + float lod = clamp(log2(coc) - 1.0, 0.0, DOF_MAX_LOD); + float3 far_rgb = texImg.sample(samp, uv, level(lod)).rgb; + rgb = mix(rgb, far_rgb, clamp((coc - 0.5) / 1.5, 0.0, 1.0)); + } // L'alpha est cette couverture, pas `color.a` : les draws du mode 8 laissent `color` // à zéro, donc le port rendait de toute façon un plan totalement transparent. - return float4(sample_yuv(uv, texY, texUV) * tilt_a, tilt_a); + return float4(rgb * tilt_a, tilt_a); } // mode 7 : sprite curseur thème (PNG alpha droite). Prémultiplie ici, comme partout diff --git a/crates/compositor/src/vk_shaders/layer.wgsl b/crates/compositor/src/vk_shaders/layer.wgsl index e219d1d43..be28cb097 100644 --- a/crates/compositor/src/vk_shaders/layer.wgsl +++ b/crates/compositor/src/vk_shaders/layer.wgsl @@ -27,7 +27,7 @@ struct Layer { fx: vec4, // mode 2 : spread ombre en px ; modes 8/12/13 : coins TL,TR du quad projeté ; mode 9 : hampe de la flèche ; mode 10 : (flou?, rayon/bloc px, ovale?, teinté?) src_prev: vec4, // modes 8/12/13 : coins BR,BL du quad projeté ; mode 9 : barbe 1 ; mode 10 incliné : coins BR,BL du masque dst_prev: vec4, // mode 8 : taille du plan en px AVANT projection (le rayon y vit) ; mode 13 : rect de clip ; mode 9 : barbe 2 ; mode 10 incliné : coins TL,TR du masque - mb: vec4, // mode 8 : [gx, gy, z_focus, k], profondeur du plan (pas encore lue, k = 0) ; mode 12 : mb.y = spread de la pénombre en px ; mode 9 : mb.y = demi-épaisseur du trait en px ; mode 10 : mb.z = 1 si masque incliné + mb: vec4, // mode 8 : [gx, gy, z_focus, k], profondeur du plan et flou (texels source) par px d'ecart, k = 0 coupe ; mode 12 : mb.y = spread de la pénombre en px ; mode 9 : mb.y = demi-épaisseur du trait en px ; mode 10 : mb.z = 1 si masque incliné } @group(0) @binding(0) var layer: Layer; @@ -36,7 +36,13 @@ struct Layer { @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. +// Mode 8 : ce binding porte a la place la pyramide RGBA de profondeur de champ (jamais le +// binding 1, qui porte la luma). @group(0) @binding(4) var texMask: texture_2d; + +// Plafond de la profondeur de champ du mode 8, en niveau de la pyramide demi-resolution. +// Meme valeur que `DOF_MAX_LOD` du HLSL. +const DOF_MAX_LOD: f32 = 1.5; // 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. @@ -486,9 +492,22 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { let p = vec2(r.x, r.y) * plane_px - plane_px * 0.5; let d = sd_round_rect(p, plane_px * 0.5, max(layer.radius_px, 0.0)); let tilt_a = 1.0 - smoothstep(0.0, 1.5, d); + // Profondeur de champ (cf. HLSL) : net sous un demi-texel de flou, l'echantillon + // d'avant a l'octet ; au-dela, fondu vers la pyramide demi-resolution liee en binding 4 + // (a la place du masque webcam, que ce mode ne lit pas), au niveau `log2(coc) - 1`, + // plafonne. LOD explicite : pas de derivees dans cette branche. + var tilt_rgb = sample_yuv(uv); + let rs = clamp(vec2(r.x, r.y), vec2(0.0), vec2(1.0)); + let z = (rs.x - 0.5) * layer.mb.x + (rs.y - 0.5) * layer.mb.y; + let coc = layer.mb.w * abs(z - layer.mb.z); + if coc > 0.5 { + let lod = clamp(log2(coc) - 1.0, 0.0, DOF_MAX_LOD); + let far_rgb = textureSampleLevel(texMask, samp, uv, lod).rgb; + tilt_rgb = mix(tilt_rgb, far_rgb, clamp((coc - 0.5) / 1.5, 0.0, 1.0)); + } // L'alpha est cette couverture, pas `color.a` : les draws du mode 8 laissent // `color` a zero, donc s'en servir rendrait un plan totalement transparent. - return vec4(sample_yuv(uv) * tilt_a, tilt_a); + return vec4(tilt_rgb * tilt_a, tilt_a); } else if layer.mode > 11.5 && layer.mode < 12.5 { // Mode 12 -- ombre du quad projete. La penombre suit le QUADRILATERE, pas son // rect englobant : un rect droit derriere un ecran incline se lit comme une diff --git a/crates/compositor/tests/compose_linux.rs b/crates/compositor/tests/compose_linux.rs index 62361572d..9d0d642f8 100644 --- a/crates/compositor/tests/compose_linux.rs +++ b/crates/compositor/tests/compose_linux.rs @@ -1593,3 +1593,113 @@ fn compose_linux_annotation_ancree_hors_zoom() { ); } } + +// --------------------------------------------------------------------------- +// Profondeur de champ du mode 8 (pendant Linux de `tilted_depth_of_field.rs`) +// --------------------------------------------------------------------------- + +/// Nettete d'une fenetre `r`x`r` centree en `c` : moyenne des ecarts entre voisins (vert). +fn dof_sharpness(rgba: &[u8], w: u32, c: (u32, u32), r: u32) -> f64 { + let g = |x: u32, y: u32| rgba[((y * w + x) * 4 + 1) as usize] as f64; + let (mut acc, mut n) = (0.0, 0.0); + for y in c.1 - r / 2..c.1 + r / 2 { + for x in c.0 - r / 2..c.0 + r / 2 { + acc += (g(x + 1, y) - g(x, y)).abs() + (g(x, y + 1) - g(x, y)).abs(); + n += 1.0; + } + } + acc / n +} + +/// Octets RGBA de la fenetre `r`x`r` centree en `c`. +fn dof_window_bytes(rgba: &[u8], w: u32, c: (u32, u32), r: u32) -> Vec { + let mut out = Vec::new(); + for y in c.1 - r / 2..c.1 + r / 2 { + let i = ((y * w + c.0 - r / 2) * 4) as usize; + out.extend_from_slice(&rgba[i..i + (r * 4) as usize]); + } + out +} + +/// Coin du plan (hors fond magenta) extreme dans la direction `dir`, rentre de `inset` +/// vers le centroide. +fn dof_corner(rgba: &[u8], w: u32, h: u32, dir: (i64, i64), inset: f64) -> (u32, u32) { + let (mut best, mut at) = (i64::MIN, (0i64, 0i64)); + let (mut sx, mut sy, mut n) = (0f64, 0f64, 0f64); + for y in 0..h as i64 { + for x in 0..w as i64 { + let i = ((y * w as i64 + x) * 4) as usize; + if !not_bg(&rgba[i..i + 4]) { + continue; + } + sx += x as f64; + sy += y as f64; + n += 1.0; + if x * dir.0 + y * dir.1 > best { + best = x * dir.0 + y * dir.1; + at = (x, y); + } + } + } + let x = at.0 as f64 + (sx / n - at.0 as f64) * inset; + let y = at.1 as f64 + (sy / n - at.1 as f64) * inset; + (x as u32, y as u32) +} + +/// Golden par backend : iso, focus sur le coin proche. Le coin lointain perd son detail, le +/// proche ne bouge pas d'un octet, et a plat le reglage ne change rien. Un `level(lod)` qui +/// retomberait au niveau 0 (vue a un seul niveau, binding 4 mal lie) rendrait le lointain net. +/// Source : `OPENSCREEN_DOF_SOURCE` (du texte de preference), sinon la fixture. +#[test] +fn compose_linux_profondeur_de_champ() { + let source = std::env::var("OPENSCREEN_DOF_SOURCE").unwrap_or_else(|_| FIXTURE.into()); + if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(&source).is_file() { + eprintln!("compose_linux dof: opt-in (OPENSCREEN_LINUX_COMPOSE=1 + source). Skip."); + return; + } + let (w, h) = (1920u32, 1080u32); + // `create_auto` : lavapipe (backend CPU) compte, et `DOF_ON_CPU_BACKEND` l'y laisse tourner. + let gpu = Gpu::create_auto(false).expect("Gpu::create_auto"); + let comp = Compositor::new_sized(&gpu, w, h).expect("Compositor::new_sized"); + let mut dec = Decoder::open(&source, &gpu).expect("Decoder::open"); + let mut cfg = Cfg::c8(); + cfg.shadow = false; + let scene = |rotation: &str, dof: bool| { + tilt_scene_json(rotation, 0, 0.0) + .replace(r#""motionBlur":0}"#, &format!(r#""motionBlur":0,"depthOfField":{dof}}}"#)) + .replace(r#""focusX":0.5,"focusY":0.5"#, r#""focusX":0.94,"focusY":0.06"#) + }; + let (on, off, flat_on, flat_off) = unsafe { + let sf = dec.seek_to(1.0).expect("Decoder::seek_to"); + let render = |json: String| { + let scene = Scene::from_json(&json).expect("scene json"); + comp.set_live_params(openscreen_compositor::compositor::live_params_from_scene(&scene)); + comp.set_scene(Some(scene)); + comp.compose_frame(sf, sf, 90.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback_direct").2 + }; + ( + render(scene("\"iso\"", true)), + render(scene("\"iso\"", false)), + render(scene("null", true)), + render(scene("null", false)), + ) + }; + write_ppm("compose_linux_dof_on", w, h, &on); + write_ppm("compose_linux_dof_off", w, h, &off); + let r = 48; + let near = dof_corner(&off, w, h, (1, -1), 0.12); + let far = dof_corner(&off, w, h, (-1, 1), 0.12); + let (near_on, near_off) = (dof_sharpness(&on, w, near, r), dof_sharpness(&off, w, near, r)); + let (far_on, far_off) = (dof_sharpness(&on, w, far, r), dof_sharpness(&off, w, far, r)); + println!( + "compose_linux dof : proche {near_off:.2} -> {near_on:.2}, lointain {far_off:.2} -> {far_on:.2}" + ); + assert!(far_off > 2.0, "fenetre lointaine sans detail ({far_off})"); + assert!( + dof_window_bytes(&on, w, near, r) == dof_window_bytes(&off, w, near, r), + "coin proche modifie : {near_off} -> {near_on}" + ); + assert!(far_on < far_off * 0.7, "coin lointain pas floute : {far_off} -> {far_on}"); + assert!(flat_on == flat_off, "rotation nulle : la profondeur de champ a change la frame"); +} diff --git a/crates/compositor/tests/tilted_depth_of_field.rs b/crates/compositor/tests/tilted_depth_of_field.rs new file mode 100644 index 000000000..94734294a --- /dev/null +++ b/crates/compositor/tests/tilted_depth_of_field.rs @@ -0,0 +1,263 @@ +//! La profondeur de champ du mode 8 floute le coin LOINTAIN d'un écran incliné et laisse le +//! coin proche, où tombe le focus, intact à l'octet. +//! +//! Rend de vraies frames par le compositeur D3D11, sur une source quelconque (de préférence du +//! texte : c'est là qu'un plafond de flou trop haut se voit). Le fond est magenta, une couleur +//! qu'aucune source réaliste ne produit, pour isoler le plan au pixel près. +//! +//! ```powershell +//! $env:OPENSCREEN_DOF_SOURCE = "...\text.mp4" # obligatoire, sinon le test se saute +//! $env:OPENSCREEN_DOF_OUT = "...\renders" # facultatif : PPM par cas +//! $env:OPENSCREEN_DOF_BENCH = "1" # facultatif : A/B de coût, matériel et WARP +//! cargo test -p openscreen-compositor --test tilted_depth_of_field -- --nocapture +//! ``` +//! +//! Le pendant « réglage coupé = rendu d'avant à l'octet » est `tilted_depth_slot_inert.rs`, qui +//! compare à une référence produite sans l'effet. + +// Windows seulement : le readback et le décodage D3D11VA de ce harnais n'existent que là. +#![cfg(windows)] + +use std::time::Instant; + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config; +use openscreen_compositor::d3d::{Backend, Gpu}; +use openscreen_compositor::frame_geometry::live_params_from_scene; +use openscreen_compositor::live::Player; +use openscreen_compositor::scene::Scene; + +/// En plein palier du zoom : la région couvre 0..6 s. +const AT_SEC: f64 = 3.0; + +fn scene_json(source: &str, rotation: &str, dof: bool, focus: [f32; 2], w: u32, h: u32) -> String { + let s = source.replace('\\', "/"); + let [fx, fy] = focus; + format!( + r##"{{ + "clips": [{{"screenPath":"{s}","webcamPath":"","sourceStartSec":0,"sourceEndSec":6,"webcamOffsetSec":0,"hasAudio":false}}], + "layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{{"x":0.05,"y":0.05,"width":0.9,"height":0.9}}}}, + "effects": {{"padding":0.1,"blur":false,"shadow":0.0,"roundnessFrac":0.0,"motionBlur":0.0,"depthOfField":{dof}}}, + "background": {{"kind":"color","color":"#ff00ff"}}, + "zoomRegions": [{{"id":"z","startSec":0,"endSec":6,"scale":1.0,"focusX":{fx},"focusY":{fy},"focusMode":"manual","rotation":{rotation}}}], + "annotations": [], + "speedRegions": [], + "cursor": {{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip": [null], + "output": {{"width":{w},"height":{h},"fps":null}} + }}"## + ) +} + +fn compositor(gpu: &Gpu, source: &str, rotation: &str, dof: bool, focus: [f32; 2], w: u32, h: u32) -> Compositor { + let comp = Compositor::new_sized(gpu, w, h).expect("compositor"); + let scene = Scene::from_json(&scene_json(source, rotation, dof, focus, w, h)).expect("scene valide"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_scene(Some(scene)); + comp.clear_cursor(); + comp +} + +fn cfg() -> config::Cfg { + let mut cfg = config::all().pop().expect("au moins une config"); + cfg.zoom = false; + cfg.layout_anim = false; + cfg.shadow = false; + cfg +} + +fn render(gpu: &Gpu, source: &str, rotation: &str, dof: bool, focus: [f32; 2], w: u32, h: u32) -> Vec { + let comp = compositor(gpu, source, rotation, dof, focus, w, h); + unsafe { + let mut player = Player::open(source, "", gpu).expect("ouvrir la source"); + player.present_frame(&comp, &cfg(), AT_SEC).expect("composer la frame"); + comp.readback_resized(w, h).expect("readback") + } +} + +fn write_ppm(dir: &Option, name: &str, rgba: &[u8], w: u32, h: u32) { + let Some(dir) = dir else { return }; + std::fs::create_dir_all(dir).expect("creer le dossier de sortie"); + let mut out = Vec::with_capacity(rgba.len() / 4 * 3 + 32); + out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes()); + for px in rgba.chunks_exact(4) { + out.extend_from_slice(&px[..3]); + } + std::fs::write(dir.join(format!("{name}.ppm")), out).expect("ecrire le ppm"); +} + +/// Pixel du plan (pas le fond magenta). Seuil large : le feather des bords ne compte pas. +fn on_plane(p: &[u8]) -> bool { + !(p[0] > 200 && p[1] < 60 && p[2] > 200) +} + +/// Coin du plan extrême dans la direction `(dx, dy)`, rentré de `inset` vers le centroïde : +/// une fenêtre posée là est entièrement sur le plan, loin du feather. +fn corner_window(rgba: &[u8], w: u32, h: u32, dir: (i64, i64), inset: f64) -> (u32, u32) { + let (mut best, mut at) = (i64::MIN, (0i64, 0i64)); + let (mut sx, mut sy, mut n) = (0f64, 0f64, 0f64); + for y in 0..h as i64 { + for x in 0..w as i64 { + let i = ((y * w as i64 + x) * 4) as usize; + if !on_plane(&rgba[i..i + 4]) { + continue; + } + sx += x as f64; + sy += y as f64; + n += 1.0; + let score = x * dir.0 + y * dir.1; + if score > best { + best = score; + at = (x, y); + } + } + } + let (cx, cy) = (sx / n, sy / n); + let x = at.0 as f64 + (cx - at.0 as f64) * inset; + let y = at.1 as f64 + (cy - at.1 as f64) * inset; + (x as u32, y as u32) +} + +/// Netteté d'une fenêtre `r`×`r` centrée en `c` : moyenne des écarts absolus entre voisins +/// (luma approchée par le vert). Le texte net en a beaucoup, le texte flou peu. +fn sharpness(rgba: &[u8], w: u32, c: (u32, u32), r: u32) -> f64 { + let g = |x: u32, y: u32| rgba[((y * w + x) * 4 + 1) as usize] as f64; + let (mut acc, mut n) = (0.0, 0.0); + for y in c.1 - r / 2..c.1 + r / 2 { + for x in c.0 - r / 2..c.0 + r / 2 { + acc += (g(x + 1, y) - g(x, y)).abs() + (g(x, y + 1) - g(x, y)).abs(); + n += 1.0; + } + } + acc / n +} + +fn window_bytes(rgba: &[u8], w: u32, c: (u32, u32), r: u32) -> Vec { + let mut out = Vec::new(); + for y in c.1 - r / 2..c.1 + r / 2 { + let i = ((y * w + c.0 - r / 2) * 4) as usize; + out.extend_from_slice(&rgba[i..i + (r * 4) as usize]); + } + out +} + +#[test] +fn the_far_corner_of_a_tilted_screen_is_defocused_and_the_near_one_is_not() { + let Ok(source) = std::env::var("OPENSCREEN_DOF_SOURCE") else { + println!("SKIP: definir OPENSCREEN_DOF_SOURCE (voir l'en-tete du fichier)."); + return; + }; + let out = std::env::var("OPENSCREEN_DOF_OUT").ok().map(std::path::PathBuf::from); + let gpu = Gpu::create(false).expect("device d3d11"); + let (w, h) = (1920u32, 1080u32); + let rot = r#""iso""#; + + // Focus sur le coin PROCHE (haut-droit d'iso) : le lointain (bas-gauche) en est au plus loin. + let near_focus = [0.94, 0.06]; + let on = render(&gpu, &source, rot, true, near_focus, w, h); + let off = render(&gpu, &source, rot, false, near_focus, w, h); + write_ppm(&out, "iso-dof-on", &on, w, h); + write_ppm(&out, "iso-dof-off", &off, w, h); + + let r = 48; + let near = corner_window(&off, w, h, (1, -1), 0.12); + let far = corner_window(&off, w, h, (-1, 1), 0.12); + let (near_on, near_off) = (sharpness(&on, w, near, r), sharpness(&off, w, near, r)); + let (far_on, far_off) = (sharpness(&on, w, far, r), sharpness(&off, w, far, r)); + println!("iso : proche {near:?} nettete {near_off:.2} -> {near_on:.2}"); + println!("iso : lointain {far:?} nettete {far_off:.2} -> {far_on:.2}"); + // Garde : les deux fenêtres voient du contenu, pas un aplat. + assert!(near_off > 2.0 && far_off > 2.0, "fenetres sans detail ({near_off}, {far_off})"); + // Le coin proche, au focus, reste l'échantillon net d'avant : pas un octet ne bouge. + assert_eq!(window_bytes(&on, w, near, r), window_bytes(&off, w, near, r), "coin proche modifie"); + // Le coin lointain perd nettement de son détail, et plus que le proche : l'ordre des deux + // est ce qu'un signe de profondeur inversé casserait. + assert!(far_on < far_off * 0.7, "coin lointain pas floute : {far_off} -> {far_on}"); + assert!(far_on / far_off < near_on / near_off, "le lointain n'est pas le plus flou"); + + // Focus au centre : les deux coins sont à la même distance du plan net, ni l'un ni l'autre + // n'est épargné — la preuve que c'est bien le focus qui décide, pas la position. + let centre = render(&gpu, &source, rot, true, [0.5, 0.5], w, h); + write_ppm(&out, "iso-dof-on-centre-focus", ¢re, w, h); + let near_c = sharpness(¢re, w, near, r); + println!("iso focus centre : proche {near_off:.2} -> {near_c:.2}"); + assert!(near_c < near_off * 0.9, "focus au centre : le coin proche devrait se flouter aussi"); + + // À plat, l'effet n'existe pas : allumé ou non, la frame est la même à l'octet. + let flat_on = render(&gpu, &source, "null", true, near_focus, w, h); + let flat_off = render(&gpu, &source, "null", false, near_focus, w, h); + assert!(flat_on == flat_off, "rotation nulle : la profondeur de champ a change la frame"); +} + +/// Coût mesuré, A/B entrelacé : la même frame décodée, recomposée par deux compositeurs qui ne +/// diffèrent que par le réglage. Le readback synchronise le GPU à chaque tour ; il est payé des +/// deux côtés, donc l'écart est celui de l'effet. +/// +/// Trois formes, parce que la pyramide suit la texture SOURCE et non le rendu : +/// - export : rendu à la taille de sortie, `readback_resized` (ce que fait l'export) ; +/// - preview : rendu à la taille du panneau (1280×720 ici, cf. `preview_render_size`), lu par +/// `readback_direct` comme la boucle live — la pyramide y reste pleine taille, donc son coût +/// pèse relativement plus ; +/// - 4K : source et sortie 3840×2160, si `OPENSCREEN_DOF_SOURCE_4K` est posée. +#[test] +fn depth_of_field_cost_a_b() { + let (Ok(source), Ok(_)) = (std::env::var("OPENSCREEN_DOF_SOURCE"), std::env::var("OPENSCREEN_DOF_BENCH")) else { + println!("SKIP: definir OPENSCREEN_DOF_SOURCE et OPENSCREEN_DOF_BENCH."); + return; + }; + let source_4k = std::env::var("OPENSCREEN_DOF_SOURCE_4K").ok(); + let mut cases = vec![ + (Backend::Hardware, source.clone(), (1920u32, 1080u32), false, 300usize), + (Backend::Hardware, source.clone(), (1280, 720), true, 300), + (Backend::Cpu, source.clone(), (1920, 1080), false, 20), + (Backend::Cpu, source.clone(), (1280, 720), true, 20), + ]; + if let Some(s4) = source_4k { + cases.insert(2, (Backend::Hardware, s4, (3840, 2160), false, 150)); + } + for (backend, source, (w, h), preview, rounds) in cases { + let Ok(gpu) = Gpu::create_backend(backend, false) else { + println!("{backend:?} indisponible, saute"); + continue; + }; + let focus = [0.92, 0.08]; + let on = compositor(&gpu, &source, r#""iso""#, true, focus, w, h); + let off = compositor(&gpu, &source, r#""iso""#, false, focus, w, h); + let cfg = cfg(); + let read = |comp: &Compositor| unsafe { + if preview { + comp.readback_direct().expect("readback").2 + } else { + comp.readback_resized(w, h).expect("readback") + } + }; + let (mut t_on, mut t_off) = (0f64, 0f64); + unsafe { + let mut player = Player::open(&source, "", &gpu).expect("ouvrir la source"); + player.present_frame(&on, &cfg, AT_SEC).expect("composer la frame"); + // Chauffe : allocation de la pyramide, caches du pilote. + for comp in [&on, &off, &on, &off] { + player.recompose(comp, &cfg).expect("recomposer"); + read(comp); + } + for i in 0..rounds { + // Ordre alterné : aucun des deux ne profite toujours d'être second. + let pair = if i % 2 == 0 { [(&on, true), (&off, false)] } else { [(&off, false), (&on, true)] }; + for (comp, is_on) in pair { + let t0 = Instant::now(); + player.recompose(comp, &cfg).expect("recomposer"); + read(comp); + let dt = t0.elapsed().as_secs_f64() * 1e3; + if is_on { t_on += dt } else { t_off += dt } + } + } + } + let (a, b) = (t_on / rounds as f64, t_off / rounds as f64); + let shape = if preview { "preview (readback_direct)" } else { "export (readback_resized)" }; + println!( + "{backend:?} {w}x{h} {shape}, iso, {rounds} tours : DoF on {a:.2} ms/frame, off {b:.2} ms/frame, surcout {:.2} ms/frame", + a - b + ); + } +} diff --git a/crates/compositor/tests/tilted_depth_slot_inert.rs b/crates/compositor/tests/tilted_depth_slot_inert.rs index 0f935cc94..ec1cd24b2 100644 --- a/crates/compositor/tests/tilted_depth_slot_inert.rs +++ b/crates/compositor/tests/tilted_depth_slot_inert.rs @@ -1,8 +1,10 @@ -//! Le slot de profondeur du mode 8 (`mb = [gx, gy, z_focus, 0]`) ne change pas un octet du rendu. +//! La profondeur de champ éteinte ne change pas un octet du rendu. //! -//! `TiltedQuad::depth_mb` remplit un champ que le shader du mode 8 ne lisait pas. Tant que `k` -//! vaut 0, une frame inclinée doit sortir identique, octet pour octet, à celle d'avant ce slot. -//! La seule façon de le montrer est de comparer à un rendu de référence produit SANS le slot : +//! `TiltedQuad::depth_mb` remplit le `mb` du mode 8. Réglage « Depth of field » coupé, `k` vaut 0 +//! et une frame inclinée doit sortir identique, octet pour octet, à celle d'avant ce slot. Même +//! chose, réglage ALLUMÉ, pour une frame sans rotation : le mode 8 n'y est pas dessiné et la +//! pyramide n'est pas remplie. La seule façon de le montrer est de comparer à un rendu de +//! référence produit SANS le slot (la clé `depthOfField` y est ignorée) : //! //! ```powershell //! # 1) sur le code d'avant (sources de src/ de la base), écrire la référence @@ -33,14 +35,14 @@ const H: u32 = 540; /// En plein palier du zoom : la région couvre 0..6 s. const AT_SEC: f64 = 3.0; -fn scene_json(secret: &str, fx: f32, fy: f32, rotation: &str) -> String { +fn scene_json(secret: &str, fx: f32, fy: f32, rotation: &str, dof: bool) -> String { let s = secret.replace('\\', "/"); format!( r##"{{ "clips": [{{"screenPath":"{s}","webcamPath":"","sourceStartSec":0,"sourceEndSec":6,"webcamOffsetSec":0,"hasAudio":false}}], "layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, "screenRect":{{"x":0.1,"y":0.1,"width":0.8,"height":0.8}}}}, - "effects": {{"padding":0.1,"blur":false,"shadow":0.35,"roundnessFrac":0.03,"motionBlur":0.0}}, + "effects": {{"padding":0.1,"blur":false,"shadow":0.35,"roundnessFrac":0.03,"motionBlur":0.0,"depthOfField":{dof}}}, "background": {{"kind":"color","color":"#303030"}}, "zoomRegions": [{{"id":"z","startSec":0,"endSec":6,"scale":1.6,"focusX":{fx},"focusY":{fy},"focusMode":"manual","rotation":"{rotation}"}}], "annotations": [], @@ -52,12 +54,12 @@ fn scene_json(secret: &str, fx: f32, fy: f32, rotation: &str) -> String { ) } -fn render(gpu: &Gpu, secret: &str, fx: f32, fy: f32, rotation: &str) -> Vec { +fn render(gpu: &Gpu, secret: &str, fx: f32, fy: f32, rotation: &str, dof: bool) -> Vec { let mut cfg = config::all().pop().expect("au moins une config"); cfg.zoom = false; cfg.layout_anim = false; let comp = Compositor::new_sized(gpu, W, H).expect("compositor"); - let scene = Scene::from_json(&scene_json(secret, fx, fy, rotation)).expect("scene valide"); + let scene = Scene::from_json(&scene_json(secret, fx, fy, rotation, dof)).expect("scene valide"); comp.set_live_params(live_params_from_scene(&scene)); comp.set_scene(Some(scene)); comp.clear_cursor(); @@ -91,10 +93,16 @@ fn a_tilted_frame_is_unchanged_by_the_depth_slot() { let gpu = Gpu::create(false).expect("device d3d11"); // Focus décentrés : un `z_focus` nul (focus au centre) ne prouverait rien sur le 3e champ. - let cases = [("iso", 0.8, 0.3), ("left", 0.2, 0.7), ("right", 0.9, 0.9)]; + // Les trois présets inclinés réglage coupé ; la frame droite réglage allumé. + let cases = [ + ("iso", 0.8, 0.3, false), + ("left", 0.2, 0.7, false), + ("right", 0.9, 0.9, false), + ("none", 0.8, 0.3, true), + ]; let mut diffs = Vec::new(); - for (rotation, fx, fy) in cases { - let rgba = render(&gpu, &secret, fx, fy, rotation); + for (rotation, fx, fy, dof) in cases { + let rgba = render(&gpu, &secret, fx, fy, rotation, dof); // Garde : la frame montre bien l'écran, pas un fond uni. let red = rgba.chunks_exact(4).filter(|p| p[0] > 150 && p[1] < 80 && p[2] < 80).count(); assert!(red > 1_000, "{rotation}: pave rouge absent ({red} px)"); @@ -114,5 +122,5 @@ fn a_tilted_frame_is_unchanged_by_the_depth_slot() { } } } - assert!(diffs.is_empty(), "le slot de profondeur a change le rendu :\n{}", diffs.join("\n")); + assert!(diffs.is_empty(), "la profondeur de champ eteinte a change le rendu :\n{}", diffs.join("\n")); } diff --git a/electron/ai-edition/style-preset-service.test.ts b/electron/ai-edition/style-preset-service.test.ts index 4a7158dca..88fd185ec 100644 --- a/electron/ai-edition/style-preset-service.test.ts +++ b/electron/ai-edition/style-preset-service.test.ts @@ -11,6 +11,7 @@ const APPEARANCE: StylePresetAppearance = { shadowIntensity: 0.2, showBlur: false, motionBlurAmount: 0.2, + depthOfField: true, borderRadius: 40, padding: 50, webcamLayoutPreset: "picture-in-picture", diff --git a/electron/ipc/nativeBridge.presets.test.ts b/electron/ipc/nativeBridge.presets.test.ts index a04ec169c..4e5ad0c95 100644 --- a/electron/ipc/nativeBridge.presets.test.ts +++ b/electron/ipc/nativeBridge.presets.test.ts @@ -31,6 +31,7 @@ const APPEARANCE: StylePresetAppearance = { shadowIntensity: 0.2, showBlur: false, motionBlurAmount: 0.2, + depthOfField: true, borderRadius: 40, padding: 50, webcamLayoutPreset: "picture-in-picture", diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index bfa10f273..f5cf7ce3d 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -2355,6 +2355,7 @@ export function VideoEffectsPane() { // can never disagree about what shape the footage is. Already sorted by clip count then by // pixel area, so [0] is "the shape most of this timeline is in" with no heuristic of ours. const nativeFormats = useMemo(() => (document ? collectNativeFormats(document) : []), [document]); + const hasTiltedZoom = (document?.zoomRanges ?? []).some((z) => z.rotationPreset != null); const [fitMenuOpen, setFitMenuOpen] = useState(false); const [ratioMenuOpen, setRatioMenuOpen] = useState(false); const { locale } = useI18n(); @@ -2611,11 +2612,28 @@ export function VideoEffectsPane() { onCommit={() => void commit()} /> + {/* Next to motion blur because it is the other blur of the RECORDING. It only ever + acts on a 3D-tilted zoom, so with none in the project the switch would move + nothing on screen: it is disabled then, and the row says why. */} +
+ + {ts("effects.depthOfField")} + + {hasTiltedZoom ? ts("effects.depthOfFieldHint") : ts("effects.depthOfFieldNoTilt")} + + + void set({ depthOfField: v })} + /> +
); } -// ─── Layout (webcam) ────────────────────────────────────────────── +// ─── Layout (webcam)────────────────────────────────────────────── const WEBCAM_PRESETS = [ { value: "picture-in-picture", labelKey: "layout.pictureInPicture" }, diff --git a/src/components/ai-edition/depthOfFieldToggle.test.tsx b/src/components/ai-edition/depthOfFieldToggle.test.tsx new file mode 100644 index 000000000..c3fd590c7 --- /dev/null +++ b/src/components/ai-edition/depthOfFieldToggle.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom +// The depth-of-field switch only moves something on a 3D-tilted zoom. Without one it must be +// disabled and say why; with one it must write the project setting the compositor reads. + +import "@testing-library/jest-dom"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +import type { AxcutDocument } from "@/lib/ai-edition/schema"; +import { createEmptyDocument } from "@/lib/ai-edition/schema"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { VideoEffectsPane } from "./RightPanes"; + +vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } })); + +function documentWithZoom(rotationPreset?: "iso"): AxcutDocument { + const base = createEmptyDocument({ title: "T", projectId: "p1" }); + return { + ...base, + zoomRanges: [ + { + id: "z1", + startMs: 0, + endMs: 2000, + depth: 3, + focus: { cx: 0.5, cy: 0.5 }, + ...(rotationPreset ? { rotationPreset } : {}), + }, + ], + } as unknown as AxcutDocument; +} + +function mount(doc: AxcutDocument) { + useProjectStore.setState({ document: doc }); + render( + + + , + ); + return screen.getByRole("button", { name: "Depth of field" }); +} + +const stored = () => + (useProjectStore.getState().document?.legacyEditor as Record | undefined) + ?.depthOfField; + +beforeEach(() => { + localStorage.clear(); + useProjectStore.setState({ document: null }); +}); +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +describe("depth of field toggle", () => { + it("is disabled and says why when no zoom is tilted", () => { + const toggle = mount(documentWithZoom()); + expect(toggle).toBeDisabled(); + expect(screen.getByText("No 3D zoom in this project")).toBeInTheDocument(); + }); + + it("is on by default with a tilted zoom, and turns off then on again", async () => { + const toggle = mount(documentWithZoom("iso")); + expect(toggle).toBeEnabled(); + expect(toggle).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText("3D zooms")).toBeInTheDocument(); + + fireEvent.click(toggle); + await waitFor(() => expect(stored()).toBe(false)); + await waitFor(() => expect(toggle).toHaveAttribute("aria-pressed", "false")); + + fireEvent.click(toggle); + await waitFor(() => expect(stored()).toBe(true)); + }); +}); diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index 1ba5192c7..48b78ec1e 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -35,6 +35,7 @@ describe("projectPersistence media compatibility", () => { shadowIntensity: 0, showBlur: false, motionBlurAmount: 0, + depthOfField: true, borderRadius: 0, padding: 50, cropRegion: { x: 0, y: 0, width: 1, height: 1 }, @@ -186,6 +187,7 @@ it("creates stable snapshots for identical project state", () => { shadowIntensity: 0, showBlur: false, motionBlurAmount: 0, + depthOfField: true, borderRadius: 0, padding: 50, cropRegion: { x: 0, y: 0, width: 1, height: 1 }, diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 627402844..1b9633f45 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -2,6 +2,7 @@ import { normalizeTextAnimation } from "@/lib/annotationTextAnimation"; import { normalizeBlurColor, normalizeBlurType } from "@/lib/blurEffects"; import { normalizeCursorThemeId } from "@/lib/cursor/cursorThemes"; import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; +import { DEFAULT_PROJECT_APPEARANCE } from "@/lib/projectDefaults"; import type { ProjectMedia } from "@/lib/recordingSession"; import { normalizeProjectMedia } from "@/lib/recordingSession"; import { DEFAULT_WALLPAPER, WALLPAPER_PATHS } from "@/lib/wallpaper"; @@ -71,6 +72,7 @@ export interface ProjectEditorState { shadowIntensity: number; showBlur: boolean; motionBlurAmount: number; + depthOfField: boolean; borderRadius: number; padding: number; cropRegion: CropRegion; @@ -490,6 +492,10 @@ export function normalizeProjectEditor(editor: Partial): Pro ? DEFAULT_ZOOM_MOTION_BLUR : DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount : DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount, + depthOfField: + typeof editor.depthOfField === "boolean" + ? editor.depthOfField + : DEFAULT_PROJECT_APPEARANCE.depthOfField, borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 26c9dcd3d..d3db6be3d 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -108,6 +108,9 @@ "format": "التنسيق", "fitClip": "ملاءمة", "motionBlur": "ضبابية الحركة", + "depthOfField": "عمق المجال", + "depthOfFieldHint": "تكبير ثلاثي الأبعاد", + "depthOfFieldNoTilt": "لا يوجد تكبير ثلاثي الأبعاد في هذا المشروع", "roundness": "الاستدارة" }, "stylePresets": { diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json index 447186de1..acaa60a28 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -108,6 +108,9 @@ "format": "Formát", "fitClip": "Přizpůsobit", "motionBlur": "Pohybové rozmazání", + "depthOfField": "Hloubka ostrosti", + "depthOfFieldHint": "3D přiblížení", + "depthOfFieldNoTilt": "Projekt nemá žádné 3D přiblížení", "roundness": "Zaoblení" }, "stylePresets": { diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 8f2021ed5..f62794a29 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -108,6 +108,9 @@ "format": "Format", "fitClip": "Fit", "motionBlur": "Motion Blur", + "depthOfField": "Depth of field", + "depthOfFieldHint": "3D zooms", + "depthOfFieldNoTilt": "No 3D zoom in this project", "roundness": "Roundness" }, "stylePresets": { diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index b416a152e..bd0ff5bbe 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -108,6 +108,9 @@ "format": "Formato", "fitClip": "Ajustar", "motionBlur": "Desenfoque de movimiento", + "depthOfField": "Profundidad de campo", + "depthOfFieldHint": "Zooms 3D", + "depthOfFieldNoTilt": "No hay zoom 3D en este proyecto", "roundness": "Redondez" }, "stylePresets": { diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index cca7f87df..825eceab3 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -108,6 +108,9 @@ "format": "Format", "fitClip": "Ajuster", "motionBlur": "Flou de mouvement", + "depthOfField": "Profondeur de champ", + "depthOfFieldHint": "Zooms 3D", + "depthOfFieldNoTilt": "Aucun zoom 3D dans ce projet", "roundness": "Arrondi" }, "stylePresets": { diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index e17ac4cd9..c83f6bd2e 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -108,6 +108,9 @@ "format": "Formato", "fitClip": "Adatta", "motionBlur": "Sfocatura movimento", + "depthOfField": "Profondità di campo", + "depthOfFieldHint": "Zoom 3D", + "depthOfFieldNoTilt": "Nessuno zoom 3D in questo progetto", "roundness": "Arrotondamento" }, "stylePresets": { diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 87ebd99bd..1b4e24e4d 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -108,6 +108,9 @@ "format": "フォーマット", "fitClip": "合わせる", "motionBlur": "モーションブラー", + "depthOfField": "被写界深度", + "depthOfFieldHint": "3D ズーム", + "depthOfFieldNoTilt": "このプロジェクトに 3D ズームはありません", "roundness": "丸み" }, "stylePresets": { diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index fbab36004..077cc43c2 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -108,6 +108,9 @@ "format": "형식", "fitClip": "맞추기", "motionBlur": "모션 블러", + "depthOfField": "피사계 심도", + "depthOfFieldHint": "3D 확대", + "depthOfFieldNoTilt": "이 프로젝트에 3D 확대가 없습니다", "roundness": "모서리 둥글기" }, "stylePresets": { diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 32daeea43..61bd52b94 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -108,6 +108,9 @@ "format": "Formato", "fitClip": "Ajustar", "motionBlur": "Desfoque de Movimento", + "depthOfField": "Profundidade de campo", + "depthOfFieldHint": "Zooms 3D", + "depthOfFieldNoTilt": "Nenhum zoom 3D neste projeto", "roundness": "Arredondamento" }, "stylePresets": { diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 35cc24dfa..423782d79 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -108,6 +108,9 @@ "format": "Формат", "fitClip": "Подогнать", "motionBlur": "Размытие движения", + "depthOfField": "Глубина резкости", + "depthOfFieldHint": "3D-зум", + "depthOfFieldNoTilt": "В проекте нет 3D-зума", "roundness": "Скругление" }, "stylePresets": { diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index b6bf0f521..c71964831 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -108,6 +108,9 @@ "format": "Biçim", "fitClip": "Sığdır", "motionBlur": "Hareket Bulanıklığı", + "depthOfField": "Alan derinliği", + "depthOfFieldHint": "3D yakınlaştırmalar", + "depthOfFieldNoTilt": "Bu projede 3D yakınlaştırma yok", "roundness": "Yuvarlaklık" }, "stylePresets": { diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index 43be7d8fd..683004fd4 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -108,6 +108,9 @@ "format": "Định dạng", "fitClip": "Vừa khít", "motionBlur": "Làm mờ chuyển động", + "depthOfField": "Độ sâu trường ảnh", + "depthOfFieldHint": "Thu phóng 3D", + "depthOfFieldNoTilt": "Dự án chưa có thu phóng 3D", "roundness": "Độ bo tròn" }, "stylePresets": { diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 2c17ad141..3db7310b0 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -108,6 +108,9 @@ "format": "格式", "fitClip": "适配", "motionBlur": "运动模糊", + "depthOfField": "景深", + "depthOfFieldHint": "3D 缩放", + "depthOfFieldNoTilt": "此项目没有 3D 缩放", "roundness": "圆角" }, "stylePresets": { diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index d4a2c5c12..c908dcc98 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -108,6 +108,9 @@ "format": "格式", "fitClip": "符合", "motionBlur": "動態模糊", + "depthOfField": "景深", + "depthOfFieldHint": "3D 縮放", + "depthOfFieldNoTilt": "此專案沒有 3D 縮放", "roundness": "圓角" }, "stylePresets": { diff --git a/src/lib/ai-edition/document/migrate.test.ts b/src/lib/ai-edition/document/migrate.test.ts index da2f74c2b..c5c39f5af 100644 --- a/src/lib/ai-edition/document/migrate.test.ts +++ b/src/lib/ai-edition/document/migrate.test.ts @@ -17,6 +17,7 @@ function makeV2Project(overrides: Partial = {}): EditorProjec shadowIntensity: 0, showBlur: false, motionBlurAmount: 0, + depthOfField: true, borderRadius: 0, padding: 50, cropRegion: { x: 0, y: 0, width: 1, height: 1 }, @@ -260,6 +261,17 @@ describe("migrateAxcutDocumentToProjectData", () => { expect(back.editor.webcamMaskShape).toBe("circle"); }); + it("round-trips depthOfField: on by default, an explicit off stays off", () => { + const roundTrip = (depthOfField: boolean) => + migrateAxcutDocumentToProjectData( + migrateProjectDataToAxcutDocument( + makeV2Project({ editor: { ...makeV2Project().editor, depthOfField } }), + ), + ).editor.depthOfField; + expect(roundTrip(true)).toBe(true); + expect(roundTrip(false)).toBe(false); + }); + it("round-trips zoomRegions and annotationRegions back to ms", () => { const v2 = makeV2Project({ editor: { diff --git a/src/lib/ai-edition/document/migrate.ts b/src/lib/ai-edition/document/migrate.ts index 1f7dcc088..1fdd87890 100644 --- a/src/lib/ai-edition/document/migrate.ts +++ b/src/lib/ai-edition/document/migrate.ts @@ -289,6 +289,7 @@ export function migrateAxcutDocumentToProjectData(input: AxcutDocument): EditorP shadowIntensity: 0, showBlur: false, motionBlurAmount: 0, + depthOfField: true, borderRadius: 0, padding: 50, cropRegion: { x: 0, y: 0, width: 1, height: 1 } as CropRegion, diff --git a/src/lib/ai-edition/store/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts index 3501d0e8f..ba8b9bcbe 100644 --- a/src/lib/ai-edition/store/editorSettings.test.ts +++ b/src/lib/ai-edition/store/editorSettings.test.ts @@ -85,6 +85,17 @@ describe("getEditorSettings", () => { const snap = getEditorSettings(doc); expect(snap.showBlur).toBe(false); }); + + it("keeps depth of field on unless the project stored a boolean off", () => { + expect(getEditorSettings(baseDoc).depthOfField).toBe(true); + const junk: AxcutDocument = { + ...baseDoc, + legacyEditor: { depthOfField: "no" as unknown as boolean }, + }; + expect(getEditorSettings(junk).depthOfField).toBe(true); + const off = patchEditorSettings(baseDoc, { depthOfField: false }); + expect(getEditorSettings(off).depthOfField).toBe(false); + }); }); describe("patchEditorSettings", () => { diff --git a/src/lib/ai-edition/store/editorSettings.ts b/src/lib/ai-edition/store/editorSettings.ts index 5f2b28bca..ec01baefb 100644 --- a/src/lib/ai-edition/store/editorSettings.ts +++ b/src/lib/ai-edition/store/editorSettings.ts @@ -76,6 +76,7 @@ export interface EditorSettingsSnapshot { shadowIntensity: number; showBlur: boolean; motionBlurAmount: number; + depthOfField: boolean; borderRadius: number; padding: number; cropRegion: CropRegion; @@ -119,6 +120,7 @@ interface LegacyShape { shadowIntensity?: number; showBlur?: boolean; motionBlurAmount?: number; + depthOfField?: boolean; borderRadius?: number; padding?: number; cropRegion?: CropRegion; @@ -191,6 +193,7 @@ export function getEditorSettings(doc: AxcutDocument | null | undefined): Editor shadowIntensity: num(legacy?.shadowIntensity, DEFAULT_EDITOR_SETTINGS.shadowIntensity), showBlur: bool(legacy?.showBlur, DEFAULT_EDITOR_SETTINGS.showBlur), motionBlurAmount: num(legacy?.motionBlurAmount, DEFAULT_EDITOR_SETTINGS.motionBlurAmount), + depthOfField: bool(legacy?.depthOfField, DEFAULT_EDITOR_SETTINGS.depthOfField), borderRadius: num(legacy?.borderRadius, DEFAULT_EDITOR_SETTINGS.borderRadius), padding: num(legacy?.padding, DEFAULT_EDITOR_SETTINGS.padding), cropRegion: legacy?.cropRegion ?? DEFAULT_EDITOR_SETTINGS.cropRegion, @@ -234,6 +237,7 @@ export interface EditorSettingsPatch { shadowIntensity?: number; showBlur?: boolean; motionBlurAmount?: number; + depthOfField?: boolean; borderRadius?: number; padding?: number; cropRegion?: CropRegion; diff --git a/src/lib/ai-edition/stylePresets.test.ts b/src/lib/ai-edition/stylePresets.test.ts index 7ba0a6492..acbf9ed1d 100644 --- a/src/lib/ai-edition/stylePresets.test.ts +++ b/src/lib/ai-edition/stylePresets.test.ts @@ -19,6 +19,7 @@ function appearance(overrides: Partial = {}): StylePreset shadowIntensity: 0.2, showBlur: false, motionBlurAmount: 0.2, + depthOfField: true, borderRadius: 40, padding: 50, webcamLayoutPreset: "picture-in-picture", @@ -49,6 +50,17 @@ describe("parseStylePresetAppearance", () => { expect(() => parseStylePresetAppearance(rest)).toThrow(/padding/); }); + it("keeps depth of field on for a preset saved before the setting existed", () => { + const { depthOfField: _dof, ...older } = appearance({ depthOfField: false }); + expect(parseStylePresetAppearance(older).depthOfField).toBe(true); + expect(parseStylePresetAppearance(appearance({ depthOfField: false })).depthOfField).toBe( + false, + ); + expect(() => parseStylePresetAppearance({ ...appearance(), depthOfField: "on" })).toThrow( + /depthOfField/, + ); + }); + it("rejects out-of-range numbers, wrong types and unknown enum values", () => { expect(() => parseStylePresetAppearance(appearance({ padding: 101 }))).toThrow(TypeError); expect(() => parseStylePresetAppearance(appearance({ borderRadius: -1 }))).toThrow( diff --git a/src/lib/ai-edition/stylePresets.ts b/src/lib/ai-edition/stylePresets.ts index 1b46ce44d..a6975f4cc 100644 --- a/src/lib/ai-edition/stylePresets.ts +++ b/src/lib/ai-edition/stylePresets.ts @@ -38,6 +38,7 @@ export interface StylePresetAppearance { shadowIntensity: number; showBlur: boolean; motionBlurAmount: number; + depthOfField: boolean; borderRadius: number; padding: number; webcamLayoutPreset: WebcamLayoutPreset; @@ -203,8 +204,10 @@ export function parseStylePresetWallpaper(value: unknown, key = "wallpaper"): st * guessing a factory value for it would apply something the author never chose. The one * lenient field is `cursorTheme`: themes come and go between builds, so an id this build * does not ship falls back to the default cursor instead of rejecting a preset that is - * otherwise sound (the editor does the same when it renders one). Unknown extra keys are - * dropped. + * otherwise sound (the editor does the same when it renders one). `depthOfField` is the other: + * it postdates the first version-1 files, so a preset saved before it existed carries no + * choice about it and keeps the factory value (on) — a present but non-boolean value is still + * refused. Unknown extra keys are dropped. */ export function parseStylePresetAppearance(value: unknown): StylePresetAppearance { if (!isRecord(value)) { @@ -226,6 +229,7 @@ export function parseStylePresetAppearance(value: unknown): StylePresetAppearanc shadowIntensity: readNumber(value, "shadowIntensity", NUMBER_RANGES.shadowIntensity), showBlur: readBoolean(value, "showBlur"), motionBlurAmount: readNumber(value, "motionBlurAmount", NUMBER_RANGES.motionBlurAmount), + depthOfField: value.depthOfField === undefined ? true : readBoolean(value, "depthOfField"), borderRadius: readNumber(value, "borderRadius", NUMBER_RANGES.borderRadius), padding: readNumber(value, "padding", NUMBER_RANGES.padding), webcamLayoutPreset: readEnum(value, "webcamLayoutPreset", WEBCAM_LAYOUT_PRESETS), diff --git a/src/lib/ai-edition/stylePresetsEditor.ts b/src/lib/ai-edition/stylePresetsEditor.ts index 898c63fab..9805fc5d6 100644 --- a/src/lib/ai-edition/stylePresetsEditor.ts +++ b/src/lib/ai-edition/stylePresetsEditor.ts @@ -19,6 +19,7 @@ export function stylePresetAppearanceFromSettings( shadowIntensity: settings.shadowIntensity, showBlur: settings.showBlur, motionBlurAmount: settings.motionBlurAmount, + depthOfField: settings.depthOfField, borderRadius: settings.borderRadius, padding: settings.padding, webcamLayoutPreset: settings.webcamLayoutPreset, @@ -61,6 +62,7 @@ export function stylePresetPatch(appearance: StylePresetAppearance): EditorSetti shadowIntensity: appearance.shadowIntensity, showBlur: appearance.showBlur, motionBlurAmount: appearance.motionBlurAmount, + depthOfField: appearance.depthOfField, borderRadius: appearance.borderRadius, padding: appearance.padding, webcamLayoutPreset: appearance.webcamLayoutPreset, diff --git a/src/lib/projectDefaults.ts b/src/lib/projectDefaults.ts index 2deb8687d..25592c320 100644 --- a/src/lib/projectDefaults.ts +++ b/src/lib/projectDefaults.ts @@ -4,6 +4,8 @@ export interface ProjectAppearanceDefaults { shadowIntensity: number; showBlur: boolean; motionBlurAmount: number; + /** Defocus a 3D-tilted screen by its depth; inert on flat zooms. */ + depthOfField: boolean; borderRadius: number; padding: number; webcamLayoutPreset: "picture-in-picture" | "vertical-stack" | "dual-frame" | "no-webcam"; @@ -36,6 +38,8 @@ export const DEFAULT_PROJECT_APPEARANCE: ProjectAppearanceDefaults = { shadowIntensity: 0.2, showBlur: false, motionBlurAmount: 0.2, + // On: it only acts on tilted zooms, where the blur already scales with the real angle. + depthOfField: true, borderRadius: 40, padding: 50, webcamLayoutPreset: "picture-in-picture", diff --git a/src/native/browserShim.presets.test.ts b/src/native/browserShim.presets.test.ts index 35172982f..4aa0b52c4 100644 --- a/src/native/browserShim.presets.test.ts +++ b/src/native/browserShim.presets.test.ts @@ -15,6 +15,7 @@ const APPEARANCE: StylePresetAppearance = { shadowIntensity: 0.2, showBlur: false, motionBlurAmount: 0.2, + depthOfField: true, borderRadius: 40, padding: 50, webcamLayoutPreset: "picture-in-picture", diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index a581b5439..8ba1c606d 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -992,6 +992,12 @@ describe("buildSceneDescription.settings mapping", () => { expect(buildSceneDescription(high).layout.webcamSize).toBeCloseTo(0.5, 5); }); + it("carries depth of field: on by default, off when the project turns it off", () => { + expect(buildSceneDescription(makeDoc({})).effects.depthOfField).toBe(true); + const off = makeDoc({ legacyEditor: { depthOfField: false } }); + expect(buildSceneDescription(off).effects.depthOfField).toBe(false); + }); + it("maps the cursor sub-settings", () => { const doc = makeDoc({ legacyEditor: { diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts index 4fdbeae76..e1730ccbb 100644 --- a/src/native/sceneDescription.ts +++ b/src/native/sceneDescription.ts @@ -359,6 +359,11 @@ export interface SceneEffects { roundnessFrac: number; /** 0..1 motion blur. */ motionBlur: number; + /** + * Defocus a 3D-tilted screen by its depth, sharp at the zoom focus. Inert on flat zooms: + * the native side only reads it where it draws a tilted plane. + */ + depthOfField: boolean; } /** Cursor rendering, from the editor settings. */ @@ -1025,6 +1030,7 @@ export function buildSceneDescription( roundnessFrac: settings.borderRadius / Math.max(1, Math.min(outputDims.width, outputDims.height)), motionBlur: settings.motionBlurAmount, + depthOfField: settings.depthOfField, }, cursor: { show: settings.cursorShow, diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 38b4b312f..b942d924e 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -628,3 +628,4 @@ The mask comes from the native compositor (ONNX Runtime + the vendored selfie-se | 2026-09-15 | dev build (`npm run dev`), worktree `e2e-macos-pr-652-b93321` on `pr/509-hud-session-prefs`: main pass on `8fa0740a` (PR #652 head before the scope narrowing), rescope smoke on `ac36aab4` | macOS 26.5, M1 | Pass — 1 defect found, fixed on the branch before merge | **Settings-persistence slice for PR #652, driven with real OS mouse/keyboard.** Capture prefs persist across relaunches from both the HUD and the Rec stage — display source, system audio, mic toggle, cursor mode, auto-zoom — and `recording-settings.json` matched the UI after every restart (final smoke state: mic on, system audio on, `editable-overlay` cursor, autoZoom on, `screen:1:0`). A window source is restored after restart; when that window no longer exists, refocusing the HUD falls back cleanly to "Screen". Re-enumeration on HUD focus: no flicker, no permission prompt, no errors. New-project appearance defaults applied without touching existing projects (on `8fa0740a`, before the narrowing dropped them). **Defect: the HUD stop button did nothing** — select source → record → stop pill left the take recording with no log line. Root cause: in `startWhenDevicesReady`, when every device is ready on the first check the async body never reaches an `await`, so its `finally` clears `startWhenDevicesReadyInFlight` before `startWhenDevicesReadyInFlight.current = pending` runs; the ref keeps a settled promise forever and every later stop (or countdown cancel) returns it. The existing tests all make the gate wait once, so none caught it. Reported on PR #652; fixed on the branch in `e159ced0` (merged to main as `1df32fed`). **The fix was not re-verified in a dedicated run** — the post-narrowing smoke on `ac36aab4` ("drop the Settings dialog") confirmed prefs still persist and an existing project still opens, nothing more. Not covered: explicit mic/camera device restore and preview stop on reset (host has no camera, mic is default-only), the full capture-to-export flow, export, transcript/AI sections, Windows/Linux. | | 2026-09-15 | dev build (`npm run dev`), worktree `editor-presets` on `feat/editor-presets` (PR #675) | macOS 26.5, M1 | Pass — no defect | **Style-presets header slice, driven with real OS mouse input.** Confirmed the Presets trigger is in the editor header between the Media/Edit/Rec switcher and theme control, and is absent from the Composition pane header. Opened the menu from the new location and confirmed the built-in `OpenScreen` preset plus the empty user-preset state. Created `E2E Header Smoke`; the success toast appeared and the resulting `.openscreenpreset` file contained format/version metadata and appearance-only state. Applied the built-in preset and observed `Applied “OpenScreen”`; renamed the user preset to `E2E Header Renamed` and verified the file rename on disk; updated it from the current project and observed `Preset updated`; deleted it through the confirmation UI and verified the menu returned to `No saved presets yet` and the preset directory contained no files. Not covered: capture, media import, preview/export, transcript/AI sections, Windows/Linux, or preset portability between machines. | | 2026-09-16 | worktree `trim-modifiers-visibility-771405` on `fix/privacy-blur-follows-zoom` @ `cd927180`; `compositor_view.node` rebuilt from this tree by `npm run build:native:compositor` (2026-09-16 10:26, contains the new `privacy_mask` shader comment), ffmpeg DLLs hash-identical to the vendored SDK; renderer rebuilt with `npm run build-vite` | Windows 11 26200, NVENC export | Partial — no defect | **Privacy blur under zoom and 3D tilt, through the app's real export (`electron . export -o `).** Source: synthetic 1920×1080 clip with a red box at 55–70 % on both axes. Eight legacy projects (rest, 2.2× zoom centred, 2.2× zoom at 0.75/0.75, 2.2× zoom + iso), each exported without and with a white mosaic blur annotation over the box; frame at 3 s. Red pixels still visible: rest 0 (clear 42 777), centred 0 (207 933), off-centre 0 (207 932), iso 0 (140 398); the masked iso frame shows the mask following the tilted box. Before the fix the same geometry leaked 89–100 % of the box (D3D11 pixel test `privacy_blur_under_zoom.rs`). **Not covered:** the GUI editor preview (the HUD rendered as a black rectangle in computer-use screenshots, so the pass went through the CLI export, which shares `compose_frame` with the preview), oval and Gaussian styles, motion-blurred zoom ramps (unit-tested only), macOS and Linux rendering (Linux lib tests pass under WSL/lavapipe; Metal only compiles on macOS CI). | +| 2026-09-16 | worktree `wf_62afabe4-4fb-7` on `feat/3d-depth-of-field` @ `7587de8e` + the macOS golden and bench commits; `compositor_view.node` rebuilt from this tree by `npm run build:native:compositor` (string probe `DOF_MAX_LOD` HIT, control `OPENSCREEN_EXPORT_ENCODER` HIT), ffmpeg DLLs hash-identical to the vendored SDK; renderer rebuilt with `npm run build-vite` | Windows 11 26200, NVENC export | Partial — no defect | **Depth of field on a tilted zoom, through the app's real export (`electron . export -o `).** Source: synthetic 1920×1080 text-and-grid clip. Three legacy projects: `iso` zoom with focus at 0.92/0.08 and no `depthOfField` key (the default must apply), the same with `depthOfField: false`, and a flat zoom. Frame at 3 s: with the default the far (bottom-left) half of the tilted screen is visibly defocused and the near half stays sharp; with `false` the whole plane is sharp; the flat project renders a flat, sharp screen. Export wall time was the same with and without the effect (2.22–2.36 s over two warm runs each). **Not covered:** the GUI editor. Computer-use access to `electron.exe` was declined for this run, so the toggle on, the toggle off and the disabled state on a project without a tilt were checked only by the jsdom test `depthOfFieldToggle.test.tsx`. The preview cost was measured by the A/B harness instead (`tilted_depth_of_field.rs`, preview shape at 1280×720 + `readback_direct`: +0.03 ms/frame on hardware, +2.5 ms/frame on WARP). macOS and Linux rendering were not run here (the macOS golden runs on macOS CI; the Linux golden is opt-in under WSL). |