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 |