diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index dc7567373e..e35a4b3c41 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -415,8 +415,12 @@ pub struct Compositor { /// 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`. + /// texture decodeur change de taille. Cf. `compositor_windows::RgbaTarget`. dof_pyramid: RefCell>, + /// L'enregistrement a sa resolution, pour les effets qui lisent `footage()` (taille, texture, + /// vue) : alloue au premier d'entre eux, recree quand la coupe change de taille, peint + /// seulement aux frames ou un tel effet est actif. Cf. `compositor_windows::fill_footage_copy`. + footage_copy: 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 @@ -758,6 +762,14 @@ impl Compositor { fx_entry(1, wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering)), fx_entry(2, fx_uniform), fx_entry(3, fx_uniform), + fx_entry( + 4, + wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + ), ], }); let shader_fx_layout = gpu.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { @@ -828,6 +840,7 @@ impl Compositor { ann_copy_view, ann_copy_mips, dof_pyramid: RefCell::new(None), + footage_copy: RefCell::new(None), ann_img_cache: RefCell::new(std::collections::HashMap::new()), webcam_mask: RefCell::new(None), seg_capture: RefCell::new(None), @@ -1399,6 +1412,7 @@ impl Compositor { effect: &SceneShaderEffect, dst: (&wgpu::Texture, &wgpu::TextureView), ctx: &crate::shader_fx::FxContext, + footage: Option<&wgpu::TextureView>, ) { let Some(pipeline) = self.shader_fx_ready(effect) else { return }; let ctx_bytes = unsafe { @@ -1417,7 +1431,10 @@ impl Compositor { }) }; let (ctx_buf, params_buf) = (uniform("shader-fx-ctx", ctx_bytes), uniform("shader-fx-params", ¶ms)); - // Les bindings du prélude : la frame (0), le sampler (1), `ctx` (2), `params` (3). + // Les bindings du prélude : la frame (0), le sampler (1), `ctx` (2), `params` (3) et + // `footage()` (4), une texture vide pour un effet qui ne la lit pas. + let dummy = footage.is_none().then(|| self.dummy_view()); + let footage = footage.or(dummy.as_ref()).expect("une vue ou l'autre"); let bind = self.gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("shader-fx"), layout: &self.shader_fx_bgl, @@ -1432,6 +1449,7 @@ impl Compositor { }, wgpu::BindGroupEntry { binding: 2, resource: ctx_buf.as_entire_binding() }, wgpu::BindGroupEntry { binding: 3, resource: params_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(footage) }, ], }); encoder.copy_texture_to_texture( @@ -1455,6 +1473,66 @@ impl Compositor { rpass.draw(0..3, 0..1); } + /// Peint l'enregistrement a sa resolution pour `footage()` (`docs/shader-effects.md`, C.2). Port + /// de `compositor_windows::fill_footage_copy` : la coupe du metrage (`content_cut`, le repere de + /// `fx_to_footage`) en UN draw du mode 0, par la meme conversion que l'ecran dessine. + #[allow(clippy::too_many_arguments)] + fn encode_footage_copy( + &self, + encoder: &mut wgpu::CommandEncoder, + planes: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView), + cut: [f32; 4], + tex_w: u32, + tex_h: u32, + ) -> wgpu::TextureView { + let (w, h) = crate::shader_fx::footage_copy_px(cut, [tex_w as f32, tex_h as f32]); + let stale = self.footage_copy.borrow().as_ref().is_none_or(|c| (c.0, c.1) != (w, h)); + if stale { + let tex = self.gpu.device.create_texture(&wgpu::TextureDescriptor { + label: Some("footage-copy"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + *self.footage_copy.borrow_mut() = Some((w, h, tex, view)); + } + let view = self.footage_copy.borrow().as_ref().expect("copie allouee ci-dessus").3.clone(); + let full = [0.0, 0.0, 1.0, 1.0]; + let cb = LayerCB { + dst: full, + src: cut, + quad_px: [w as f32, h as f32], + mode: 0.0, + color: [1.0, 1.0, 1.0, 1.0], + src_prev: cut, + dst_prev: full, + mb: [1.0, 0.0, 1.0, 0.0], + ..Default::default() + }; + let dummy = self.dummy_view(); + let fill = self.make_bind(&cb, Some(planes), &dummy); + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("footage-copy-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + 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); + self.draw_layer(&mut rpass, &fill, false); + drop(rpass); + view + } + /// Le calque transparent des textes, à la taille de rendu, alloué à la première frame qui en a /// besoin (un effet `ShaderEffectTarget::Text`). fn text_layer(&self) -> (wgpu::Texture, wgpu::TextureView) { @@ -3765,8 +3843,13 @@ impl Compositor { let with_target = |target: ShaderEffectTarget| -> Vec<&SceneShaderEffect> { effects.iter().copied().filter(|e| e.target == target).collect() }; + // L'enregistrement a sa resolution, peint une fois pour la frame si un effet le lit. + let footage = effects + .iter() + .any(|e| crate::shader_fx::reads_footage(e)) + .then(|| self.encode_footage_copy(&mut encoder, (&sy, &su, &sv), g.content_cut, stw, sth)); for e in with_target(ShaderEffectTarget::Image) { - self.encode_shader_effect(&mut encoder, e, (&self.rt, &self.rt_view), &fx_context(e)); + self.encode_shader_effect(&mut encoder, e, (&self.rt, &self.rt_view), &fx_context(e), footage.as_ref()); } // Un effet `text` : les textes sur un calque transparent, l'effet sur ce calque seul, puis // le calque pose sur l'image (« over » premultiplie). L'image dessous reste intacte. @@ -3800,7 +3883,7 @@ impl Compositor { } if let Some((tex, view)) = &text_layer { for e in text_effects { - self.encode_shader_effect(&mut encoder, e, (tex, view), &fx_context(e)); + self.encode_shader_effect(&mut encoder, e, (tex, view), &fx_context(e), footage.as_ref()); } // Le calque pose sur le RT par la passe qui recopie `accum` : layout du blur, et un // uniforme que `fs_copy` ne lit pas. @@ -3837,7 +3920,7 @@ impl Compositor { rpass.draw(0..3, 0..1); } for e in with_target(ShaderEffectTarget::Both) { - self.encode_shader_effect(&mut encoder, e, (&self.rt, &self.rt_view), &fx_context(e)); + self.encode_shader_effect(&mut encoder, e, (&self.rt, &self.rt_view), &fx_context(e), footage.as_ref()); } self.gpu.context.submit(std::iter::once(encoder.finish())); Ok(()) @@ -6993,6 +7076,45 @@ mod tests { Some(corner_at(&fx_compositor(&gpu, 320, 180, effects), &gpu, 2.0)) } + /// `footage()` lit l'enregistrement à sa résolution, pas l'image composée : la source de 512 px + /// en colonnes d'un pixel est réduite à ~90 px sous le padding, où l'image composée ne les sépare + /// plus, mais chacune se lit blanche ou noire. Hors de 0..1, rien. Cf. compositor_windows. + #[test] + fn footage_reads_the_recording_at_its_own_resolution() { + let Some(gpu) = gpu() else { return }; + let pixel = |code: &str| { + let comp = fx_compositor(&gpu, 320, 180, serde_json::json!([fx(code, "image")])); + let screen = FakeFrame::new(&gpu, 512, 512, |x, _| if x % 2 == 0 { 235 } else { 16 }); + comp.set_timeline_time(Some(2.0)); + comp.set_programme_time(Some(2.0)); + unsafe { comp.compose_frame(screen.as_ptr(), std::ptr::null(), 0.0, &fx_cfg()) }.expect("compose"); + let (_, _, rgba) = unsafe { comp.readback_direct() }.expect("readback"); + [rgba[0], rgba[1], rgba[2], rgba[3]] + }; + let at = |x: &str| format!("fn effect(uv: vec2) -> vec4 {{ return footage(vec2({x}, 0.5)); }}"); + let near = |got: [u8; 4], want: [u8; 4]| got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 2); + let white = pixel(&at("10.5 / 512.0")); + let black = pixel(&at("11.5 / 512.0")); + assert!(near(white, [255, 255, 255, 255]), "colonne 10 : {white:?}"); + assert!(near(black, [0, 0, 0, 255]), "colonne 11 : {black:?}"); + assert_eq!(pixel(&at("1.5")), [0, 0, 0, 0], "hors de l'enregistrement"); + let composed = |x: &str| { + pixel(&format!("fn effect(uv: vec2) -> vec4 {{ return frame(fx_from_footage(vec2({x}, 0.5))); }}")) + }; + let (left, right) = (composed("10.5 / 512.0"), composed("11.5 / 512.0")); + assert!(left[0].abs_diff(right[0]) < 128, "l'image composée sépare les colonnes : {left:?} {right:?}"); + } + + /// La copie de l'enregistrement coûte une passe : un effet qui ne lit pas `footage()` ne la + /// fait jamais faire. + #[test] + fn an_effect_that_does_not_read_the_footage_never_paints_it() { + let Some(gpu) = gpu() else { return }; + let comp = fx_compositor(&gpu, 320, 180, serde_json::json!([fx(INVERT, "image")])); + corner_at(&comp, &gpu, 2.0); + assert!(comp.footage_copy.borrow().is_none()); + } + /// La passe de l'agent repeint l'image composée, dans sa région et nulle part ailleurs. Le /// compositeur par défaut est celui de l'export : il attend le pipeline, jamais de frame sans. #[test] diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index 2756f372aa..c5e12e9fc4 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -353,8 +353,12 @@ pub struct Compositor { 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`. + /// texture décodeur change de taille. Cf. `compositor_windows::RgbaTarget`. dof_pyramid: RefCell>, + /// L'enregistrement à sa résolution, pour les effets qui lisent `footage()` : alloué au premier + /// d'entre eux, recréé quand la coupe change de taille, peint seulement aux frames où un tel + /// effet est actif. Cf. `compositor_windows::fill_footage_copy`. + footage_copy: 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. @@ -845,6 +849,7 @@ impl Compositor { pipeline_kup, ann_copy, dof_pyramid: RefCell::new(None), + footage_copy: 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(), @@ -1033,6 +1038,7 @@ impl Compositor { effect: &SceneShaderEffect, dst: &metal::Texture, ctx: &crate::shader_fx::FxContext, + footage: Option<&metal::Texture>, ) -> Result<()> { let Some(pipeline) = self.shader_fx_ready(effect) else { return Ok(()) }; // On ne lit pas la cible sur laquelle on dessine : copie dans `ann_copy` (les flous @@ -1041,6 +1047,10 @@ impl Compositor { let params = crate::shader_fx::pack_params(effect); let e = self.begin_pass(cmd, dst, None, &pipeline)?; e.set_fragment_texture(0, Some(&self.ann_copy)); + // `footage()`, liée seulement pour un effet qui la lit : les autres ne la déclarent pas. + if let Some(footage) = footage { + e.set_fragment_texture(crate::shader_fx::MSL_FOOTAGE_TEXTURE, Some(footage)); + } e.set_fragment_bytes( crate::shader_fx::MSL_CTX_BUFFER, std::mem::size_of::() as u64, @@ -1616,6 +1626,62 @@ impl Compositor { Ok(pyr) } + /// Peint l'enregistrement à sa résolution pour `footage()` (`docs/shader-effects.md`, C.2). Port + /// de `compositor_windows::fill_footage_copy` : la coupe du métrage (`content_cut`, le repère de + /// `fx_to_footage`) en UN draw du mode 0, par la même conversion que l'écran dessiné. + unsafe fn fill_footage_copy( + &self, + cmd: &metal::CommandBufferRef, + y: &metal::Texture, + uv: &metal::Texture, + cut: [f32; 4], + tex_w: u32, + tex_h: u32, + ) -> Result { + let (w, h) = crate::shader_fx::footage_copy_px(cut, [tex_w as f32, tex_h as f32]); + let stale = self + .footage_copy + .borrow() + .as_ref() + .is_none_or(|c| (c.width(), c.height()) != (w as u64, h as u64)); + if stale { + *self.footage_copy.borrow_mut() = Some(make_texture( + &self.gpu.device, + metal::MTLPixelFormat::RGBA8Unorm, + w, + h, + metal::MTLStorageMode::Private, + metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead, + )); + } + let copy = self.footage_copy.borrow().clone().expect("copie allouée ci-dessus"); + let enc = self.begin_pass( + cmd, + ©, + 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: cut, + quad_px: [w as f32, h as f32], + mode: 0.0, + color: [1.0, 1.0, 1.0, 1.0], + src_prev: cut, + dst_prev: full, + mb: [1.0, 0.0, 1.0, 0.0], + ..Default::default() + }, + y, + uv, + ); + enc.end_encoding(); + Ok(copy) + } + /// 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)] @@ -3026,8 +3092,14 @@ impl Compositor { let with_target = |target: ShaderEffectTarget| -> Vec<&SceneShaderEffect> { effects.iter().copied().filter(|e| e.target == target).collect() }; + // L'enregistrement à sa résolution, peint une fois pour la frame si un effet le lit. + let footage = if effects.iter().any(|e| crate::shader_fx::reads_footage(e)) { + Some(self.fill_footage_copy(cmd_buf, &sy, &suv, g.content_cut, stw, sth)?) + } else { + None + }; for e in with_target(ShaderEffectTarget::Image) { - self.apply_shader_effect(cmd_buf, e, &self.rt, &fx_context(e))?; + self.apply_shader_effect(cmd_buf, e, &self.rt, &fx_context(e), footage.as_ref())?; } // --- annotations : calque le plus haut, ancré sur le rect ÉCRAN SANS ZOOM --- @@ -3053,7 +3125,7 @@ impl Compositor { .end_encoding(); self.draw_annotations(cmd_buf, &layer, scene_ref.as_ref(), g.source_t, g.s_ann, &g, false)?; for e in text_effects { - self.apply_shader_effect(cmd_buf, e, &layer, &fx_context(e))?; + self.apply_shader_effect(cmd_buf, e, &layer, &fx_context(e), footage.as_ref())?; } let c = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_fs_tex)?; c.set_fragment_texture(0, Some(&layer)); @@ -3062,7 +3134,7 @@ impl Compositor { } for e in with_target(ShaderEffectTarget::Both) { - self.apply_shader_effect(cmd_buf, e, &self.rt, &fx_context(e))?; + self.apply_shader_effect(cmd_buf, e, &self.rt, &fx_context(e), footage.as_ref())?; } // Ni miroir RGBA ni attente ici : le miroir ne sert qu'à `readback_direct` (la @@ -4950,6 +5022,53 @@ mod tests { Some(corner_at(&comp, 2.0)) } + /// `footage()` lit l'enregistrement à sa résolution, pas l'image composée : la source de 512 px + /// en colonnes d'un pixel est réduite à ~90 px sous le padding, où l'image composée ne les sépare + /// plus, mais chacune se lit blanche ou noire. Hors de 0..1, rien. Cf. compositor_windows. + #[test] + fn footage_reads_the_recording_at_its_own_resolution() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let pixel = |code: &str| { + let comp = super::Compositor::new_sized(&gpu, 320, 180).expect("compositeur"); + comp.set_scene(Some(shader_fx_scene(serde_json::json!([fx(code, "image")])))); + let screen = FakeFrame::new(512, 512, |x, _| if x % 2 == 0 { 235 } else { 16 }); + comp.set_timeline_time(Some(2.0)); + comp.set_programme_time(Some(2.0)); + unsafe { comp.compose_frame(screen.as_ptr(), screen.as_ptr(), 0.0, &fx_cfg()) }.expect("compose"); + let (_, _, rgba) = unsafe { comp.readback_direct() }.expect("readback"); + [rgba[0], rgba[1], rgba[2], rgba[3]] + }; + let at = |x: &str| format!("fn effect(uv: vec2) -> vec4 {{ return footage(vec2({x}, 0.5)); }}"); + let near = |got: [u8; 4], want: [u8; 4]| got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 2); + let white = pixel(&at("10.5 / 512.0")); + let black = pixel(&at("11.5 / 512.0")); + assert!(near(white, [255, 255, 255, 255]), "colonne 10 : {white:?}"); + assert!(near(black, [0, 0, 0, 255]), "colonne 11 : {black:?}"); + assert_eq!(pixel(&at("1.5")), [0, 0, 0, 0], "hors de l'enregistrement"); + let composed = |x: &str| { + pixel(&format!("fn effect(uv: vec2) -> vec4 {{ return frame(fx_from_footage(vec2({x}, 0.5))); }}")) + }; + let (left, right) = (composed("10.5 / 512.0"), composed("11.5 / 512.0")); + assert!(left[0].abs_diff(right[0]) < 128, "l'image composée sépare les colonnes : {left:?} {right:?}"); + } + + /// La copie de l'enregistrement coûte une passe : un effet qui ne lit pas `footage()` ne la + /// fait jamais faire. + #[test] + fn an_effect_that_does_not_read_the_footage_never_paints_it() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let comp = super::Compositor::new_sized(&gpu, 320, 180).expect("compositeur"); + comp.set_scene(Some(shader_fx_scene(serde_json::json!([fx(INVERT, "image")])))); + corner_at(&comp, 2.0); + assert!(comp.footage_copy.borrow().is_none()); + } + /// La passe de l'agent repeint l'image composée, dans sa région et nulle part ailleurs. Le /// compositeur par défaut est celui de l'export : il attend la pipeline, jamais de frame sans. #[test] diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 81ba78b743..d386472634 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -114,7 +114,9 @@ struct TextLayer { srv: ID3D11ShaderResourceView, } -struct DofPyramid { +/// Une cible RGBA qu'on peint puis qu'on lit : la pyramide de profondeur de champ, la copie de +/// l'enregistrement que lit `footage()`. +struct RgbaTarget { rtv: ID3D11RenderTargetView, srv: ID3D11ShaderResourceView, width: u32, @@ -292,7 +294,11 @@ pub struct Compositor { 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>, + dof_pyramid: RefCell>, + /// L'enregistrement à sa résolution, pour les effets qui lisent `footage()` : alloué au premier + /// d'entre eux, recréé quand la coupe change de taille, peint seulement aux frames où un tel + /// effet est actif. + footage_copy: RefCell>, /// Effets shader de la scène, compilés une fois par source, les moins récents évincés au-delà /// de `SHADER_FX_CACHE`. shader_fx: RefCell>, @@ -846,6 +852,7 @@ impl Compositor { seg_failed: RefCell::new(false), nv12_readback_staging: RefCell::new(None), dof_pyramid: RefCell::new(None), + footage_copy: RefCell::new(None), shader_fx: RefCell::new(HashMap::new()), shader_fx_tick: Cell::new(0), shader_fx_cb: shader_fx_cb.unwrap(), @@ -1030,6 +1037,7 @@ impl Compositor { effect: &SceneShaderEffect, dst: (&ID3D11Texture2D, &ID3D11RenderTargetView), cb: &crate::shader_fx::FxContext, + footage: Option<&ID3D11ShaderResourceView>, ) { let Some((vs, ps)) = self.shader_fx_ready(effect) else { return }; let ctx_bytes = std::slice::from_raw_parts( @@ -1062,8 +1070,10 @@ impl Compositor { self.ctx.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST); self.ctx.VSSetShader(&vs, None); self.ctx.PSSetShader(&ps, None); - // Les registres du prélude tels que naga les sort : t0, s1, b2 (ctx), b3 (params). + // Les registres du prélude tels que naga les sort : t0, s1, b2 (ctx), b3 (params), et t4 + // pour `footage()` (rien quand l'effet ne la lit pas : une lecture y rendrait 0). self.ctx.PSSetShaderResources(0, Some(&[Some(self.ann_copy_srv.clone())])); + self.ctx.PSSetShaderResources(4, Some(&[footage.cloned()])); self.ctx.PSSetSamplers(1, Some(&[Some(self.sampler.clone())])); self.ctx.PSSetConstantBuffers( 2, @@ -1072,6 +1082,7 @@ impl Compositor { self.ctx.OMSetBlendState(&self.blend_none, None, 0xffffffff); self.ctx.Draw(3, 0); self.ctx.PSSetShaderResources(0, Some(&[None])); + self.ctx.PSSetShaderResources(4, Some(&[None])); self.ctx.PSSetConstantBuffers(2, Some(&[None, None])); self.bind_compose_state(); } @@ -1354,7 +1365,7 @@ impl Compositor { 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 }); + Some(RgbaTarget { 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"); @@ -1394,6 +1405,78 @@ impl Compositor { Ok(pyr.srv.clone()) } + /// Peint l'enregistrement à sa résolution pour `footage()` (`docs/shader-effects.md`, C.2) : la + /// coupe du métrage (`content_cut`, le repère de `fx_to_footage`) en UN draw du mode 0, comme + /// `fill_dof_pyramid`, donc par la même conversion NV12 que l'écran dessiné. + unsafe fn fill_footage_copy( + &self, + sy: &ID3D11ShaderResourceView, + suv: &ID3D11ShaderResourceView, + cut: [f32; 4], + tex_w: u32, + tex_h: u32, + ) -> Result { + let (w, h) = crate::shader_fx::footage_copy_px(cut, [tex_w as f32, tex_h as f32]); + let stale = self.footage_copy.borrow().as_ref().is_none_or(|c| (c.width, c.height) != (w, h)); + if stale { + let desc = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: 1, + 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: 0, + }; + let mut tex: Option = None; + self.dev.CreateTexture2D(&desc, None, Some(&mut tex))?; + let tex = tex.unwrap(); + 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.footage_copy.borrow_mut() = + Some(RgbaTarget { rtv: rtv.unwrap(), srv: srv.unwrap(), width: w, height: h }); + } + let copy = self.footage_copy.borrow(); + let copy = copy.as_ref().expect("copie allouée ci-dessus"); + self.bind_compose_state(); + // Délie t4 : la copie y est encore liée depuis l'effet de la frame précédente, et une + // ressource liée en lecture ET en écriture est retirée d'office par le runtime. + self.ctx.PSSetShaderResources(4, Some(&[None])); + self.ctx.OMSetRenderTargets(Some(&[Some(copy.rtv.clone())]), None); + self.ctx.ClearRenderTargetView(©.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: cut, + quad_px: [w as f32, h as f32], + mode: 0.0, + color: [1.0, 1.0, 1.0, 1.0], + src_prev: cut, + dst_prev: full, + mb: [1.0, 0.0, 1.0, 0.0], + ..Default::default() + }, + sy, + suv, + ); + self.bind_compose_state(); + Ok(copy.srv.clone()) + } + unsafe fn upload_cb(&self, cb: &LayerCB) { let mut m = D3D11_MAPPED_SUBRESOURCE::default(); self.ctx @@ -2827,8 +2910,14 @@ impl Compositor { let with_target = |target: ShaderEffectTarget| -> Vec<&SceneShaderEffect> { effects.iter().copied().filter(|e| e.target == target).collect() }; + // L'enregistrement à sa résolution, peint une fois pour la frame si un effet le lit. + let footage = if effects.iter().any(|e| crate::shader_fx::reads_footage(e)) { + Some(self.fill_footage_copy(&sy, &suv, g.content_cut, stw, sth)?) + } else { + None + }; for e in with_target(ShaderEffectTarget::Image) { - self.apply_shader_effect(e, (&self.rt, &self.rtv), &fx_context(e)); + self.apply_shader_effect(e, (&self.rt, &self.rtv), &fx_context(e), footage.as_ref()); } // --- annotations : calque le plus haut, comme dans le DOM de la preview (le calque y est @@ -2856,7 +2945,7 @@ impl Compositor { self.ctx.ClearRenderTargetView(&rtv, &[0.0, 0.0, 0.0, 0.0]); self.draw_annotations(scene_ref.as_ref(), source_t, s_ann, &g, false); for e in text_effects { - self.apply_shader_effect(e, (&tex, &rtv), &fx_context(e)); + self.apply_shader_effect(e, (&tex, &rtv), &fx_context(e), footage.as_ref()); } self.ctx.OMSetRenderTargets(Some(&[Some(self.rtv.clone())]), None); self.ctx.PSSetShaderResources(0, Some(&[Some(srv)])); @@ -2870,7 +2959,7 @@ impl Compositor { } for e in with_target(ShaderEffectTarget::Both) { - self.apply_shader_effect(e, (&self.rt, &self.rtv), &fx_context(e)); + self.apply_shader_effect(e, (&self.rt, &self.rtv), &fx_context(e), footage.as_ref()); } Ok(()) } @@ -3971,6 +4060,87 @@ mod tests { assert_eq!(corner_at(&comp, &gpu, 2.0), [0, 255, 255]); } + /// Une source NV12 en colonnes d'un pixel, blanches aux x pairs, noires aux x impairs. + fn nv12_columns(gpu: &crate::d3d::Gpu, w: u32, h: u32) -> (Box, ID3D11Texture2D) { + let (w_us, h_us) = (w as usize, h as usize); + let mut data = vec![128u8; w_us * h_us * 3 / 2]; + for (i, y) in data[..w_us * h_us].iter_mut().enumerate() { + *y = if i % w_us % 2 == 0 { 235 } else { 16 }; + } + let desc = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_NV12, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + // Les deux plans se suivent dans les données initiales, au même pas : Y puis UV. + let init = D3D11_SUBRESOURCE_DATA { pSysMem: data.as_ptr().cast(), SysMemPitch: w, SysMemSlicePitch: 0 }; + unsafe { + let mut tex: Option = None; + gpu.device.CreateTexture2D(&desc, Some(&init), Some(&mut tex)).expect("texture NV12"); + let tex = tex.expect("texture NV12"); + let mut frame: Box = Box::new(std::mem::zeroed()); + frame.data[0] = tex.as_raw() as *mut u8; + frame.width = w as i32; + frame.height = h as i32; + (frame, tex) + } + } + + /// `footage()` lit l'enregistrement à sa résolution, pas l'image composée : la source de 512 px + /// en colonnes d'un pixel est réduite à ~90 px sous le padding, où l'image composée ne les sépare + /// plus, mais chacune se lit blanche ou noire. Hors de 0..1, rien. + #[test] + fn footage_reads_the_recording_at_its_own_resolution() { + let Ok(gpu) = crate::d3d::Gpu::create_auto(false) else { + eprintln!("pas de device D3D11 — test sauté"); + return; + }; + let pixel = |code: &str| { + let comp = Compositor::new_sized(&gpu, 320, 180).expect("compositeur"); + comp.set_scene(Some(shader_fx_scene(serde_json::json!([fx(code, "image")])))); + let (screen, _tex) = nv12_columns(&gpu, 512, 512); + comp.set_timeline_time(Some(2.0)); + comp.set_programme_time(Some(2.0)); + unsafe { comp.compose_frame(&*screen, &*screen, 0.0, &fx_cfg()) }.expect("compose"); + let (_, _, rgba) = unsafe { comp.readback_direct() }.expect("readback"); + [rgba[0], rgba[1], rgba[2], rgba[3]] + }; + let at = |x: &str| format!("fn effect(uv: vec2) -> vec4 {{ return footage(vec2({x}, 0.5)); }}"); + let near = |got: [u8; 4], want: [u8; 4]| got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 2); + let white = pixel(&at("10.5 / 512.0")); + let black = pixel(&at("11.5 / 512.0")); + assert!(near(white, [255, 255, 255, 255]), "colonne 10 : {white:?}"); + assert!(near(black, [0, 0, 0, 255]), "colonne 11 : {black:?}"); + assert_eq!(pixel(&at("1.5")), [0, 0, 0, 0], "hors de l'enregistrement"); + // Le contrôle : l'image composée ne sépare plus deux colonnes voisines, à moitié de contraste près. + let composed = |x: &str| { + pixel(&format!("fn effect(uv: vec2) -> vec4 {{ return frame(fx_from_footage(vec2({x}, 0.5))); }}")) + }; + let (left, right) = (composed("10.5 / 512.0"), composed("11.5 / 512.0")); + assert!(left[0].abs_diff(right[0]) < 128, "l'image composée sépare les colonnes : {left:?} {right:?}"); + } + + /// La copie de l'enregistrement coûte une passe : un effet qui ne lit pas `footage()` ne la + /// fait jamais faire. + #[test] + fn an_effect_that_does_not_read_the_footage_never_paints_it() { + let Ok(gpu) = crate::d3d::Gpu::create_auto(false) else { + eprintln!("pas de device D3D11 — test sauté"); + return; + }; + let comp = Compositor::new_sized(&gpu, 320, 180).expect("compositeur"); + comp.set_scene(Some(shader_fx_scene(serde_json::json!([fx(INVERT, "image")])))); + corner_at(&comp, &gpu, 2.0); + assert!(comp.footage_copy.borrow().is_none()); + } + /// Un WGSL invalide, ou un contrat inconnu, ne casse pas le rendu : l'effet ne se dessine /// pas, la frame reste celle de la composition. #[test] diff --git a/crates/compositor/src/shader_fx.rs b/crates/compositor/src/shader_fx.rs index 08299edc82..0f19f01ec7 100644 --- a/crates/compositor/src/shader_fx.rs +++ b/crates/compositor/src/shader_fx.rs @@ -7,7 +7,7 @@ //! sélection des régions actives et le remplissage des constant buffers. Chaque backend traduit le //! module par naga et le compile une fois, hors de la boucle de rendu. //! -//! L'effet est une fonction PURE de l'image composée, du temps, du curseur et de ses paramètres : +//! L'effet est une fonction PURE de l'image composée, de l'enregistrement, du temps, du curseur et de ses paramètres : //! pas d'état d'une frame à l'autre. C'est ce qui garde la preview (qui saute d'une frame à //! l'autre au seek) et l'export (qui les rend dans l'ordre) d'accord. @@ -39,11 +39,19 @@ struct FxContext { @group(0) @binding(0) var fx_frame: texture_2d; @group(0) @binding(1) var fx_sampler: sampler; @group(0) @binding(2) var ctx: FxContext; +@group(0) @binding(4) var fx_footage: texture_2d; fn frame(uv: vec2) -> vec4 { return textureSampleLevel(fx_frame, fx_sampler, uv, 0.0); } +// The recording itself at its own resolution, at a point of its surface (the space of +// fx_to_footage): no cursor, webcam, frame or background on it. Transparent outside 0..1. +fn footage(p: vec2) -> vec4 { + let inside = all(p >= vec2(0.0)) && all(p <= vec2(1.0)); + return select(vec4(0.0), textureSampleLevel(fx_footage, fx_sampler, p, 0.0), inside); +} + fn fx_px(n: f32) -> f32 { return n / 1080.0; } @@ -316,6 +324,8 @@ pub struct Parsed { pub module: naga::Module, pub info: naga::valid::ModuleInfo, pub report: EffectReport, + /// L'effet lit `footage()` : le backend doit lui préparer l'enregistrement à sa résolution. + pub footage: bool, } /// Valide un effet comme l'agent doit le lire : paramètres, syntaxe et types (naga), bornes des @@ -330,22 +340,30 @@ pub fn analyze(user: &str, params: &[ShaderParamDecl]) -> Result ) .validate(&module) .map_err(|e| e.emit_to_string(&src))?; - // Les bindings sont ceux du prélude, et eux seuls : chaque backend lie ces quatre-là. Linux + // Les bindings sont ceux du prélude, et eux seuls : chaque backend lie ces cinq-là. Linux // valide le pipeline contre ce layout, et une erreur de validation wgpu y fait paniquer. for (handle, var) in module.global_variables.iter() { let name = var.name.as_deref().unwrap_or("?"); - if var.binding.is_some() && !["fx_frame", "fx_sampler", "ctx", "params"].contains(&name) { + if var.binding.is_some() && !["fx_frame", "fx_sampler", "ctx", "params", "fx_footage"].contains(&name) { let line = module.global_variables.get_span(handle).location(&src).line_number; return Err(format!( - "`{name}` at line {line}: an effect declares no binding of its own, it reads frame(), ctx and params" + "`{name}` at line {line}: an effect declares no binding of its own, it reads frame(), footage(), ctx and params" )); } } - let entry = module + let (at, entry) = module .entry_points .iter() - .find(|e| e.name == "fx_fs") + .enumerate() + .find(|(_, e)| e.name == "fx_fs") .ok_or("the module has no fx_fs entry point")?; + // `footage()` coûte au backend une copie de l'enregistrement par frame : il ne la fait que + // pour un effet qui la lit vraiment, ce que dit l'usage de `fx_footage` depuis `fx_fs`. + let footage = module + .global_variables + .iter() + .find(|(_, v)| v.name.as_deref() == Some("fx_footage")) + .is_some_and(|(h, _)| !info.get_entry_point(at)[h].is_empty()); let mut costs = CostWalk { module: &module, src: &src, by_function: Default::default() }; let cost = costs.block(&entry.function, &entry.function.body)?; if cost.iterations > MAX_ITERATIONS_PER_PIXEL { @@ -356,7 +374,31 @@ pub fn analyze(user: &str, params: &[ShaderParamDecl]) -> Result )); } let report = EffectReport { frame_reads: cost.reads, iterations: cost.iterations, tier: Tier::of(cost.reads) }; - Ok(Parsed { module, info, report }) + Ok(Parsed { module, info, report, footage }) +} + +/// L'effet lit-il `footage()` ? Mémorisé par source : le rendu le demande à chaque frame, l'analyse +/// ne tourne qu'une fois. Un code invalide n'est jamais rendu, il répond non. +pub fn reads_footage(effect: &SceneShaderEffect) -> bool { + use std::collections::HashMap; + use std::sync::{Mutex, OnceLock}; + static SEEN: OnceLock>> = OnceLock::new(); + let key = source_hash(effect); + let seen = SEEN.get_or_init(Default::default); + if let Some(&footage) = seen.lock().unwrap().get(&key) { + return footage; + } + let footage = analyze(&effect.code, &effect.params).is_ok_and(|p| p.footage); + seen.lock().unwrap().insert(key, footage); + footage +} + +/// La copie de l'enregistrement que lit `footage()` : la coupe du métrage (`content_cut`, en uv +/// de sa texture) à la résolution de la texture, bornée à 4 096 de côté comme les textures des +/// GPU les plus modestes que vise le compositeur. +pub fn footage_copy_px(content_cut: [f32; 4], tex_px: [f32; 2]) -> (u32, u32) { + let side = |a: f32, b: f32, px: f32| (((b - a).abs() * px).round() as u32).clamp(1, 4096); + (side(content_cut[0], content_cut[2], tex_px[0]), side(content_cut[1], content_cut[3], tex_px[1])) } /// Ce que coûte un bloc, par pixel. @@ -628,6 +670,9 @@ pub fn to_hlsl(parsed: &Parsed) -> Result { pub const MSL_CTX_BUFFER: u64 = 2; #[cfg(target_os = "macos")] pub const MSL_PARAMS_BUFFER: u64 = 3; +/// L'index de texture Metal de `fx_footage`, la copie de l'enregistrement que lit `footage()`. +#[cfg(target_os = "macos")] +pub const MSL_FOOTAGE_TEXTURE: u64 = 1; /// Le module traduit en MSL, et les noms que naga a donnés à ses deux étages. #[cfg(target_os = "macos")] @@ -651,6 +696,7 @@ pub fn to_msl(parsed: &Parsed) -> Result { (at(1), msl::BindTarget { sampler: Some(msl::BindSamplerTarget::Inline(0)), ..Default::default() }), (at(2), msl::BindTarget { buffer: Some(MSL_CTX_BUFFER as u8), ..Default::default() }), (at(3), msl::BindTarget { buffer: Some(MSL_PARAMS_BUFFER as u8), ..Default::default() }), + (at(4), msl::BindTarget { texture: Some(MSL_FOOTAGE_TEXTURE as u8), ..Default::default() }), ]); let options = msl::Options { lang_version: (2, 0), @@ -992,12 +1038,35 @@ mod tests { #[test] fn an_effect_declares_no_binding_of_its_own() { - let code = "@group(0) @binding(4) var extra: texture_2d;\n\ + let code = "@group(0) @binding(5) var extra: texture_2d;\n\ fn effect(uv: vec2) -> vec4 { return textureLoad(extra, vec2(0), 0); }\n"; let err = analyze(code, &[]).err().expect("refusé"); assert!(err.contains("`extra` at line 1"), "{err}"); } + const LOUPE: &str = "fn effect(uv: vec2) -> vec4 {\n\ + let p = fx_to_footage(uv);\n\ + return select(frame(uv), footage(p), all(abs(p - 0.5) < vec2(0.5)));\n}\n"; + + /// `footage()` est une lecture comme `frame()`, comptée dans le palier, et l'analyse dit au + /// backend s'il doit préparer l'enregistrement : jamais pour un effet qui ne le lit pas. + #[test] + fn footage_reads_count_and_only_an_effect_that_reads_it_asks_for_the_copy() { + let parsed = analyze(LOUPE, &[]).expect("analyze"); + assert!(parsed.footage); + assert_eq!(parsed.report.frame_reads, 2); + let plain = analyze(VIGNETTE, &[decl("strength", ShaderParamType::Number)]).expect("analyze"); + assert!(!plain.footage); + } + + #[test] + fn the_footage_copy_is_the_cut_at_the_texture_resolution_within_4096() { + assert_eq!(footage_copy_px([0.0, 0.0, 1.0, 1.0], [3840.0, 2160.0]), (3840, 2160)); + assert_eq!(footage_copy_px([0.25, 0.5, 0.75, 1.0], [1920.0, 1088.0]), (960, 544)); + assert_eq!(footage_copy_px([0.0, 0.0, 1.0, 1.0], [7680.0, 4320.0]), (4096, 4096)); + assert_eq!(footage_copy_px([0.5, 0.5, 0.5, 0.5], [1920.0, 1080.0]), (1, 1)); + } + #[cfg(target_os = "linux")] #[test] fn a_valid_effect_becomes_spirv() { @@ -1243,6 +1312,13 @@ mod tests { assert_eq!((ctx.footage_aspect, ctx.footage_warp), (16.0 / 9.0, 1.0)); } + #[test] + fn the_renderer_asks_for_the_footage_only_for_an_effect_that_reads_it() { + assert!(reads_footage(&test_effect(LOUPE, vec![], &[]))); + assert!(!reads_footage(&test_effect(VIGNETTE, vec![decl("strength", ShaderParamType::Number)], &[]))); + assert!(!reads_footage(&test_effect("fn effect(uv: vec2) -> vec4 { return footage(", vec![], &[]))); + } + #[test] fn eviction_spares_the_effects_the_scene_shows() { let scene = [test_effect(VIGNETTE, vec![], &[]), test_effect("fn effect(uv: vec2) -> vec4 { return frame(uv); }", vec![], &[])]; @@ -1268,6 +1344,10 @@ mod tests { fn fxc_compiles_both_stages() { let c = compile_dxbc(VIGNETTE, &[decl("strength", ShaderParamType::Number)]).expect("compile"); assert!(!c.vs.is_empty() && !c.ps.is_empty()); + // `fx_footage` en t4, le registre que `compositor_windows::apply_shader_effect` lie. + let hlsl = to_hlsl(&analyze(LOUPE, &[]).expect("analyze")).expect("translate"); + assert!(hlsl.contains("register(t4)"), "footage() hors de t4 :\n{hlsl}"); + compile_dxbc(LOUPE, &[]).expect("compile"); } /// Les bindings que `compositor_macos` lie sont ceux du MSL : la texture en 0, `ctx` et @@ -1282,6 +1362,8 @@ mod tests { assert!(msl.source.contains(binding), "{binding} missing:\n{}", msl.source); } assert!(!msl.source.contains("fake"), "binding inventé :\n{}", msl.source); + let loupe = to_msl(&analyze(LOUPE, &[]).expect("analyze")).expect("translate"); + assert!(loupe.source.contains("[[texture(1)]]"), "footage() hors de texture(1) :\n{}", loupe.source); } /// « Accepté » veut dire « se dessinera » : le compilateur de Metal et le pipeline state diff --git a/docs/shader-effects.md b/docs/shader-effects.md index ab4a96b5f9..ef9db3a477 100644 --- a/docs/shader-effects.md +++ b/docs/shader-effects.md @@ -27,8 +27,8 @@ Il part du spike Windows (branche `worktree-custom-shader-fx-spike`, commits `9c chemin de rendu propre aux effets. 2. **Le document ne stocke que la source.** Le WGSL, son nom, sa description, ses paramètres et les valeurs posées. Tout binaire compilé en est dérivé, et n'est jamais écrit dans le projet. -3. **Un effet est une fonction pure** de l'image composée, du temps, du curseur et de ses - paramètres. Pas d'état d'une frame à l'autre. +3. **Un effet est une fonction pure** de l'image composée, de l'enregistrement, du temps, du + curseur et de ses paramètres. Pas d'état d'une frame à l'autre. 4. **Le rendu ne compile jamais.** Le code est compilé à l'écriture, puis à la pose de la scène, sur un thread de fond. 5. **Ce que seul le compositeur sait entre dans le contrat.** La place de l'écran, de la webcam @@ -219,6 +219,7 @@ libre, et les erreurs pointent ainsi les lignes que l'agent a écrites. | `ctx.footage_corners`, `ctx.footage_warp` | | Ce que lisent les deux fonctions suivantes : les coins TL, TR, BR, BL du métrage en uv de sortie, et son warp (1 homographie, 0 bilinéaire). | | `fx_to_footage(uv)` | `vec2` | Le point du plan de l'enregistrement sous `uv` : (0, 0) son coin haut-gauche, (1, 1) le bas-droit, prolongé au-delà de ses bords. Loin hors de 0..1 là où aucun point du plan ne tombe. | | `fx_from_footage(p)` | `vec2` | L'inverse : où tombe en uv de sortie le point `p` du plan. | +| `footage(p)` | `vec4` | L'enregistrement lui-même au point `p` de son plan (le repère de `fx_to_footage`), à sa propre résolution : sans curseur, webcam, cadre ni fond. Transparent hors de 0..1. | | `params.` | selon le type | Les paramètres déclarés par l'effet (C.3). | | `fx_px(n)` | `f32` | `n` pixels d'une sortie 1080p, en fraction de la hauteur. | | `fx_hash(p)` | `f32` | Hasard déterministe dans [0, 1) pour un `vec2`, identique sur tous les GPU (PCG). | @@ -269,7 +270,7 @@ comprend ce qu'il lit, et l'inspecteur sait quel contrôle afficher. ### C.4 Les règles -- **Pure.** Le résultat ne dépend que de `uv`, de `frame()`, de `ctx` et de `params`. Pas d'état +- **Pure.** Le résultat ne dépend que de `uv`, de `frame()`, de `footage()`, de `ctx` et de `params`. Pas d'état d'une frame à l'autre, pas d'horloge, pas de compteur de frames. La preview saute d'une frame à l'autre au seek, l'export les rend dans l'ordre, et les deux doivent donner le même pixel. - **Indépendante de la résolution.** La preview rend plus petit que l'export. Distances en uv ou @@ -334,9 +335,10 @@ Le coût d'un effet est estimé depuis son code, sans le faire tourner. Il ne d machine : le même code reçoit le même palier partout, et un effet haut de gamme n'est jamais refusé parce que la machine qui l'écrit est modeste. -- **L'estimation se fait sur l'IR de naga.** Elle compte les lectures de `frame()` par pixel, - multipliées par la borne des boucles qui les contiennent ; les fonctions appelées sont - dépliées. Les lectures de texture dominent le coût mesuré dans le spike : c'est la bande +- **L'estimation se fait sur l'IR de naga.** Elle compte les lectures de `frame()` et de + `footage()` par pixel, multipliées par la borne des boucles qui les contiennent ; les fonctions + appelées sont dépliées. Un effet qui lit `footage()` fait en plus peindre la copie de + l'enregistrement, une fois par frame (D.5). Les lectures de texture dominent le coût mesuré dans le spike : c'est la bande passante qui paie. - **Les paliers**, en lectures par pixel : @@ -453,6 +455,21 @@ Une seule source WGSL, traduite par naga au moment de compiler. | Copie du RT | `CopySubresourceRegion` vers `ann_copy` (fait) | blit vers `ann_copy` dans le command buffer de la frame (fait) | `copy_texture_to_texture` vers le niveau 0 d'`ann_copy` (fait) | | Insertion | fin de `compose_frame` (fait) | fin de `compose_frame`, avant `submit` (fait) | `image` entre la passe caméra et l'ann-pass, `text` et `both` après (fait) | | Uniforms | `Map(WRITE_DISCARD)` (fait) | `set_fragment_bytes` (fait) | `create_buffer_init(UNIFORM)` par passe (fait) | +| `footage()` | `fill_footage_copy`, liée en t4 | `fill_footage_copy`, liée en `texture(1)` | `encode_footage_copy`, binding 4 (une texture vide sinon) | + +**`footage()` : la copie de l'enregistrement** (ajoutée le 25/10/2026) +- **Pourquoi.** `frame()` porte l'image à la résolution de la sortie : un enregistrement montré + plus petit que ses pixels y a perdu son détail, et un effet qui grossit (une loupe, une lentille) + ne grossissait que du flou. C'est ce que seul le compositeur a, donc il entre au contrat (A.5). +- **Comment.** Chaque backend peint la coupe du métrage (`content_cut`, le repère de + `fx_to_footage`) dans une cible RGBA à la résolution de la texture décodeur, bornée à 4 096 de + côté (`shader_fx::footage_copy_px`), en UN draw du mode 0 : la même conversion YUV que l'écran + dessiné, sur le modèle du remplissage de la pyramide de profondeur de champ. +- **Seulement quand un effet la lit.** `analyze` dit si `fx_fs` utilise `fx_footage` (l'usage + des globales selon naga), `shader_fx::reads_footage` le mémorise par source, et la copie n'est + peinte qu'aux frames où un tel effet est actif. Un effet qui ne la lit pas ne coûte rien de plus. +- **Ce qu'elle ne contient pas.** Le curseur, la webcam et les annotations sont dessinés à part : + un effet qui doit les montrer les lit dans `frame()`. **macOS en détail** - **Activer `msl-out`.** Le dépôt l'obtient aujourd'hui par unification des features de wgpu ; le diff --git a/electron/ai-edition/deep-agent/service.ts b/electron/ai-edition/deep-agent/service.ts index 12d41fbcd2..332808a085 100644 --- a/electron/ai-edition/deep-agent/service.ts +++ b/electron/ai-edition/deep-agent/service.ts @@ -268,7 +268,7 @@ export const TOOL_DESCRIPTIONS: Record = { addCameraFullscreen: "Add a camera-fullscreen region over a span of the edited timeline (virtual seconds): the webcam fills the frame for that span. This only does something when the footage under that span comes from an asset with a linked webcam — check assets[].hasCameraTrack (or hasAnyCamera) in getCurrentDocument first. On footage with no camera the call is refused rather than storing a region that would render nothing; say so instead of retrying.", addEffect: - "Write a visual effect as a shader and pose it over a span of the edited timeline (virtual seconds), or pose one of this project's effects again (effectId). Use it for a look the settings do not offer. `effect.code` is WGSL that defines `fn effect(uv: vec2) -> vec4`, called for every pixel; uv is 0..1 with (0,0) top-left. Available inside it: `frame(uv)` samples the composed picture; `params.` reads each declared parameter; `ctx.time` is programme seconds; `ctx.region.x` goes 0 to 1 across the region (y its length in s, z seconds since it began); `ctx.resolution` and `ctx.aspect`; `ctx.cursor` (xy in uv, z = 1 when on screen); `ctx.clicks[0..3]`, the last four clicks, newest first (xy in uv where it happened, z seconds since, w = 1 when the slot holds one); `ctx.footage` and `ctx.camera`, the recording's and the webcam's rects (x, y, w, h in uv; camera zero-sized when absent) — all already placed through zoom and layout; `fx_to_footage(uv)` and `fx_from_footage(p)` map between output uv and the recording's own surface (0..1 across the recording) through the warp the compositor draws it with, zoom, layout and 3D camera included, and `ctx.footage_aspect` is its width over height: the effect works in whichever of the two spaces fits it; `fx_px(n)`, n pixels of a 1080p frame as a fraction of the height; `fx_hash(vec2)`, a random number in [0,1) identical on every GPU. Rules: the effect is a pure function of those inputs (no state between frames), so the preview and the export show the same pixels; reason in uv or fx_px, never raw pixels; every loop is a `for` with literal bounds (`for (var i = 0; i < 16; i++)`), at most 4096 iterations per pixel — the one hard rule. `params` declares up to 8 (recommended) parameters, each `{type: number|toggle|color|point, name, label, default}` (number also `min`, `max`); declare one only when moving it visibly changes the effect over its whole range. `target`: `image` (default: the picture under the annotations and captions), `text` (the annotations and captions alone, premultiplied alpha), `both` (everything); use text or both only when asked. One effect runs at a time: to combine two looks, write them in one shader. The result gives the cost tier, in frame() reads per pixel, with what a 1080p frame pays on a fast discrete GPU (RTX 4070 Ti) and on an integrated one (Apple M1): light ≤ 8, up to 0.06 ms and 1.3 ms; medium ≤ 32, 0.14 ms and 4.3 ms; heavy ≤ 128, 0.54 ms and 16 ms; very heavy beyond, 2 ms and 49 ms at 512 reads. It also gives a recommendation when the tier runs long: light anywhere, medium up to a minute, heavy up to 10 s, very heavy a few seconds. It is a recommendation, never a refusal. Code that does not compile is refused with the compiler's error at the line of your code: fix it and call again. Check the result with getFrames.", + "Write a visual effect as a shader and pose it over a span of the edited timeline (virtual seconds), or pose one of this project's effects again (effectId). Use it for a look the settings do not offer. `effect.code` is WGSL that defines `fn effect(uv: vec2) -> vec4`, called for every pixel; uv is 0..1 with (0,0) top-left. Available inside it: `frame(uv)` samples the composed picture; `footage(p)` samples the recording itself at its own resolution at point p of its surface (the space of `fx_to_footage`), transparent outside 0..1, with no cursor or webcam on it: read it for anything that magnifies, frame() for the rest; `params.` reads each declared parameter; `ctx.time` is programme seconds; `ctx.region.x` goes 0 to 1 across the region (y its length in s, z seconds since it began); `ctx.resolution` and `ctx.aspect`; `ctx.cursor` (xy in uv, z = 1 when on screen); `ctx.clicks[0..3]`, the last four clicks, newest first (xy in uv where it happened, z seconds since, w = 1 when the slot holds one); `ctx.footage` and `ctx.camera`, the recording's and the webcam's rects (x, y, w, h in uv; camera zero-sized when absent) — all already placed through zoom and layout; `fx_to_footage(uv)` and `fx_from_footage(p)` map between output uv and the recording's own surface (0..1 across the recording) through the warp the compositor draws it with, zoom, layout and 3D camera included, and `ctx.footage_aspect` is its width over height: the effect works in whichever of the two spaces fits it; `fx_px(n)`, n pixels of a 1080p frame as a fraction of the height; `fx_hash(vec2)`, a random number in [0,1) identical on every GPU. Rules: the effect is a pure function of those inputs (no state between frames), so the preview and the export show the same pixels; reason in uv or fx_px, never raw pixels; every loop is a `for` with literal bounds (`for (var i = 0; i < 16; i++)`), at most 4096 iterations per pixel — the one hard rule. `params` declares up to 8 (recommended) parameters, each `{type: number|toggle|color|point, name, label, default}` (number also `min`, `max`); declare one only when moving it visibly changes the effect over its whole range. `target`: `image` (default: the picture under the annotations and captions), `text` (the annotations and captions alone, premultiplied alpha), `both` (everything); use text or both only when asked. One effect runs at a time: to combine two looks, write them in one shader. The result gives the cost tier, in frame() and footage() reads per pixel, with what a 1080p frame pays on a fast discrete GPU (RTX 4070 Ti) and on an integrated one (Apple M1): light ≤ 8, up to 0.06 ms and 1.3 ms; medium ≤ 32, 0.14 ms and 4.3 ms; heavy ≤ 128, 0.54 ms and 16 ms; very heavy beyond, 2 ms and 49 ms at 512 reads. It also gives a recommendation when the tier runs long: light anywhere, medium up to a minute, heavy up to 10 s, very heavy a few seconds. It is a recommendation, never a refusal. Code that does not compile is refused with the compiler's error at the line of your code: fix it and call again. Check the result with getFrames.", setEffect: "Move, resize or re-tune an effect region by id (virtual seconds): its span, its parameter `values`, or its `target`. Only the fields you pass change; a value equal to the default is dropped. Refused if the new span runs into another effect: one runs at a time.", setEffectDefinition: diff --git a/electron/mcp/effects-contract.test.ts b/electron/mcp/effects-contract.test.ts index b8d54cbc87..94886eabf6 100644 --- a/electron/mcp/effects-contract.test.ts +++ b/electron/mcp/effects-contract.test.ts @@ -34,6 +34,7 @@ describe("the effect contract resource", () => { it("names every helper the prelude gives", () => { for (const helper of [ "frame(uv)", + "footage(p)", "fx_px(n)", "fx_hash(p)", "fx_to_footage(uv)", diff --git a/electron/mcp/effects-contract.ts b/electron/mcp/effects-contract.ts index 8da5f1e2bb..414cbbe236 100644 --- a/electron/mcp/effects-contract.ts +++ b/electron/mcp/effects-contract.ts @@ -25,6 +25,7 @@ added after your code, so compiler errors point at your lines. | Name | Type | Meaning | |---|---|---| | frame(uv) | vec4 | The composed picture at uv (bilinear, edges clamped). | +| footage(p) | vec4 | The recording itself at point p of its surface (the space of fx_to_footage), at the recording's own resolution: no cursor, webcam, frame or background on it. Transparent outside 0..1. | | ctx.resolution | vec2 | Output size in pixels. | | ctx.aspect | f32 | Width over height, for round distances in uv. | | ctx.time | f32 | Programme seconds, continuous through cuts, clips and speed changes. | @@ -50,6 +51,11 @@ layout and the 3D camera put it, through the same warp the compositor draws it w computed there moves, scales and tilts with the recording. Map into it with fx_to_footage, compute, and map back with fx_from_footage before calling frame(). +frame() holds the picture at the output's resolution: a recording shown smaller than its pixels has +lost detail there. footage() reads the recording at its own resolution, so an effect that magnifies +(a loupe, a lens, a close-up) reads footage(p) and stays sharp. footage() holds the recording only: +where the effect must show the cursor or the webcam too, it reads frame() for them. + ## Parameters Declare each one as \`{ type, name, label, default }\`; a number also takes \`min\` and \`max\`. @@ -66,7 +72,7 @@ changes the effect over its whole range: each one becomes a control in the edito ## Rules -- **Pure.** The result depends only on uv, frame(), ctx and params. No state between frames, no +- **Pure.** The result depends only on uv, frame(), footage(), ctx and params. No state between frames, no clock, no frame counter: the preview jumps between frames when seeking, the export renders them in order, and both must show the same picture. - **Resolution-free.** The preview renders smaller than the export: measure in uv or fx_px. @@ -92,8 +98,9 @@ parts. ## Cost -The result of addEffect gives the cost in frame() reads per pixel, counted from the code (loops -multiplied by their bound), and its tier. Per 1080p frame, measured on a fast discrete GPU (RTX +The result of addEffect gives the cost in frame() and footage() reads per pixel, counted from the +code (loops multiplied by their bound), and its tier. An effect that reads footage() also has the +recording copied once per frame at its own resolution, about one read more at 4K. Per 1080p frame, measured on a fast discrete GPU (RTX 4070 Ti) and on an integrated one (Apple M1): | Tier | Reads | Per frame, RTX 4070 Ti | Per frame, M1 | Recommended for |