Skip to main content

ff_render/nodes/composite/
masks.rs

1//! Mask nodes: `ShapeMaskNode`, `LumaMaskNode`, `AlphaMatteNode` + shared pipeline.
2
3use super::chroma_key::bt709_luma;
4#[cfg(feature = "wgpu")]
5use super::helpers::{
6    fullscreen_pipeline, linear_sampler, submit_render_pass, two_tex_sampler_uniform_bgl,
7    upload_rgba_texture,
8};
9use crate::nodes::RenderNodeCpu;
10
11// Shared mask pipeline
12
13#[cfg(feature = "wgpu")]
14struct MaskPipeline {
15    render_pipeline: wgpu::RenderPipeline,
16    bind_group_layout: wgpu::BindGroupLayout,
17    sampler: wgpu::Sampler,
18    uniform_buf: wgpu::Buffer,
19}
20
21#[cfg(feature = "wgpu")]
22fn create_mask_pipeline(ctx: &crate::context::RenderContext) -> MaskPipeline {
23    let device = &ctx.device;
24    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
25        label: Some("Mask shader"),
26        source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/mask.wgsl").into()),
27    });
28    let bgl = two_tex_sampler_uniform_bgl(device, "Mask");
29    let render_pipeline = fullscreen_pipeline(device, &shader, "Mask", &bgl);
30    let sampler = linear_sampler(device, "Mask");
31    let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
32        label: Some("Mask uniforms"),
33        size: 16,
34        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
35        mapped_at_creation: false,
36    });
37    MaskPipeline {
38        render_pipeline,
39        bind_group_layout: bgl,
40        sampler,
41        uniform_buf,
42    }
43}
44
45#[cfg(feature = "wgpu")]
46fn submit_mask_pass(
47    ctx: &crate::context::RenderContext,
48    pd: &MaskPipeline,
49    base_tex: &wgpu::Texture,
50    mask_tex: &wgpu::Texture,
51    output_tex: &wgpu::Texture,
52    mode: u32,
53    label: &str,
54) {
55    let mode_bytes = mode.to_le_bytes();
56    let uniforms: [u8; 16] = [
57        mode_bytes[0],
58        mode_bytes[1],
59        mode_bytes[2],
60        mode_bytes[3],
61        0,
62        0,
63        0,
64        0,
65        0,
66        0,
67        0,
68        0,
69        0,
70        0,
71        0,
72        0,
73    ];
74    ctx.queue.write_buffer(&pd.uniform_buf, 0, &uniforms);
75
76    let base_view = base_tex.create_view(&wgpu::TextureViewDescriptor::default());
77    let mask_view = mask_tex.create_view(&wgpu::TextureViewDescriptor::default());
78    let out_view = output_tex.create_view(&wgpu::TextureViewDescriptor::default());
79
80    let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
81        label: Some(label),
82        layout: &pd.bind_group_layout,
83        entries: &[
84            wgpu::BindGroupEntry {
85                binding: 0,
86                resource: wgpu::BindingResource::TextureView(&base_view),
87            },
88            wgpu::BindGroupEntry {
89                binding: 1,
90                resource: wgpu::BindingResource::TextureView(&mask_view),
91            },
92            wgpu::BindGroupEntry {
93                binding: 2,
94                resource: wgpu::BindingResource::Sampler(&pd.sampler),
95            },
96            wgpu::BindGroupEntry {
97                binding: 3,
98                resource: pd.uniform_buf.as_entire_binding(),
99            },
100        ],
101    });
102    submit_render_pass(ctx, &pd.render_pipeline, &bind_group, &out_view, label);
103}
104
105// ShapeMaskNode
106
107/// Mask `inputs[0]` using the alpha channel of `inputs[1]` (or `mask_rgba`).
108///
109/// Pixels where the mask alpha is > 0 are kept opaque; all others are made
110/// fully transparent (hard threshold at ~1/255).
111pub struct ShapeMaskNode {
112    /// Mask frame RGBA bytes (required for the CPU path).
113    pub mask_rgba: Vec<u8>,
114    /// Width of `mask_rgba`.
115    pub mask_width: u32,
116    /// Height of `mask_rgba`.
117    pub mask_height: u32,
118    #[cfg(feature = "wgpu")]
119    pipeline: std::sync::OnceLock<MaskPipeline>,
120}
121
122impl ShapeMaskNode {
123    #[must_use]
124    pub fn new(mask_rgba: Vec<u8>, mask_width: u32, mask_height: u32) -> Self {
125        Self {
126            mask_rgba,
127            mask_width,
128            mask_height,
129            #[cfg(feature = "wgpu")]
130            pipeline: std::sync::OnceLock::new(),
131        }
132    }
133}
134
135impl RenderNodeCpu for ShapeMaskNode {
136    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
137    fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
138        if self.mask_rgba.len() != rgba.len() {
139            return;
140        }
141        for (base, mask) in rgba
142            .as_chunks_mut::<4>()
143            .0
144            .iter_mut()
145            .zip(self.mask_rgba.as_chunks::<4>().0.iter())
146        {
147            let keep = if mask[3] > 1 { 1.0_f32 } else { 0.0_f32 };
148            let a = f32::from(base[3]) / 255.0;
149            base[3] = ((a * keep).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
150        }
151    }
152}
153
154#[cfg(feature = "wgpu")]
155impl ShapeMaskNode {
156    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &MaskPipeline {
157        self.pipeline.get_or_init(|| create_mask_pipeline(ctx))
158    }
159}
160
161#[cfg(feature = "wgpu")]
162impl crate::nodes::RenderNode for ShapeMaskNode {
163    fn input_count(&self) -> usize {
164        2
165    }
166
167    fn process(
168        &self,
169        inputs: &[&wgpu::Texture],
170        outputs: &[&wgpu::Texture],
171        ctx: &crate::context::RenderContext,
172    ) {
173        let Some(base_tex) = inputs.first() else {
174            log::warn!("ShapeMaskNode::process called with no inputs");
175            return;
176        };
177        let Some(output) = outputs.first() else {
178            log::warn!("ShapeMaskNode::process called with no outputs");
179            return;
180        };
181        let pd = self.get_or_create_pipeline(ctx);
182        let mask_tex = upload_rgba_texture(
183            ctx,
184            &self.mask_rgba,
185            self.mask_width,
186            self.mask_height,
187            "ShapeMask mask",
188        );
189        submit_mask_pass(ctx, pd, base_tex, &mask_tex, output, 0, "ShapeMask BG");
190    }
191}
192
193// LumaMaskNode
194
195/// Mask `inputs[0]` using the BT.709 luma of `inputs[1]` (or `mask_rgba`).
196///
197/// The mask luma (0.0–1.0) is multiplied into the base alpha channel.
198pub struct LumaMaskNode {
199    /// Mask frame RGBA bytes (required for the CPU path).
200    pub mask_rgba: Vec<u8>,
201    /// Width of `mask_rgba`.
202    pub mask_width: u32,
203    /// Height of `mask_rgba`.
204    pub mask_height: u32,
205    #[cfg(feature = "wgpu")]
206    pipeline: std::sync::OnceLock<MaskPipeline>,
207}
208
209impl LumaMaskNode {
210    #[must_use]
211    pub fn new(mask_rgba: Vec<u8>, mask_width: u32, mask_height: u32) -> Self {
212        Self {
213            mask_rgba,
214            mask_width,
215            mask_height,
216            #[cfg(feature = "wgpu")]
217            pipeline: std::sync::OnceLock::new(),
218        }
219    }
220}
221
222impl RenderNodeCpu for LumaMaskNode {
223    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
224    fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
225        if self.mask_rgba.len() != rgba.len() {
226            return;
227        }
228        for (base, mask) in rgba
229            .as_chunks_mut::<4>()
230            .0
231            .iter_mut()
232            .zip(self.mask_rgba.as_chunks::<4>().0.iter())
233        {
234            let mr = f32::from(mask[0]) / 255.0;
235            let mg = f32::from(mask[1]) / 255.0;
236            let mb = f32::from(mask[2]) / 255.0;
237            let luma = bt709_luma(mr, mg, mb);
238            let ba = f32::from(base[3]) / 255.0;
239            base[3] = ((ba * luma).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
240        }
241    }
242}
243
244#[cfg(feature = "wgpu")]
245impl LumaMaskNode {
246    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &MaskPipeline {
247        self.pipeline.get_or_init(|| create_mask_pipeline(ctx))
248    }
249}
250
251#[cfg(feature = "wgpu")]
252impl crate::nodes::RenderNode for LumaMaskNode {
253    fn input_count(&self) -> usize {
254        2
255    }
256
257    fn process(
258        &self,
259        inputs: &[&wgpu::Texture],
260        outputs: &[&wgpu::Texture],
261        ctx: &crate::context::RenderContext,
262    ) {
263        let Some(base_tex) = inputs.first() else {
264            log::warn!("LumaMaskNode::process called with no inputs");
265            return;
266        };
267        let Some(output) = outputs.first() else {
268            log::warn!("LumaMaskNode::process called with no outputs");
269            return;
270        };
271        let pd = self.get_or_create_pipeline(ctx);
272        let mask_tex = upload_rgba_texture(
273            ctx,
274            &self.mask_rgba,
275            self.mask_width,
276            self.mask_height,
277            "LumaMask mask",
278        );
279        submit_mask_pass(ctx, pd, base_tex, &mask_tex, output, 1, "LumaMask BG");
280    }
281}
282
283// AlphaMatteNode
284
285/// Porter-Duff src-over: composite `inputs[0]` (foreground) over `inputs[1]`
286/// (background) using the foreground's own alpha channel.
287///
288/// For the CPU path the background data must be stored in `background_rgba`.
289pub struct AlphaMatteNode {
290    /// Background frame RGBA bytes (required for the CPU path).
291    pub background_rgba: Vec<u8>,
292    /// Width of `background_rgba`.
293    pub background_width: u32,
294    /// Height of `background_rgba`.
295    pub background_height: u32,
296    #[cfg(feature = "wgpu")]
297    pipeline: std::sync::OnceLock<MaskPipeline>,
298}
299
300impl AlphaMatteNode {
301    #[must_use]
302    pub fn new(background_rgba: Vec<u8>, background_width: u32, background_height: u32) -> Self {
303        Self {
304            background_rgba,
305            background_width,
306            background_height,
307            #[cfg(feature = "wgpu")]
308            pipeline: std::sync::OnceLock::new(),
309        }
310    }
311}
312
313impl RenderNodeCpu for AlphaMatteNode {
314    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
315    fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
316        if self.background_rgba.len() != rgba.len() {
317            return;
318        }
319        for (fg, bg) in rgba
320            .as_chunks_mut::<4>()
321            .0
322            .iter_mut()
323            .zip(self.background_rgba.as_chunks::<4>().0.iter())
324        {
325            let fa = f32::from(fg[3]) / 255.0;
326            let ba = f32::from(bg[3]) / 255.0;
327            for ch in 0..3 {
328                let fc = f32::from(fg[ch]) / 255.0;
329                let bc = f32::from(bg[ch]) / 255.0;
330                fg[ch] = ((fc * fa + bc * (1.0 - fa)).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
331            }
332            fg[3] = ((fa + ba * (1.0 - fa)).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
333        }
334    }
335}
336
337#[cfg(feature = "wgpu")]
338impl AlphaMatteNode {
339    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &MaskPipeline {
340        self.pipeline.get_or_init(|| create_mask_pipeline(ctx))
341    }
342}
343
344#[cfg(feature = "wgpu")]
345impl crate::nodes::RenderNode for AlphaMatteNode {
346    fn input_count(&self) -> usize {
347        2
348    }
349
350    fn process(
351        &self,
352        inputs: &[&wgpu::Texture],
353        outputs: &[&wgpu::Texture],
354        ctx: &crate::context::RenderContext,
355    ) {
356        let Some(fg_tex) = inputs.first() else {
357            log::warn!("AlphaMatteNode::process called with no inputs");
358            return;
359        };
360        let Some(output) = outputs.first() else {
361            log::warn!("AlphaMatteNode::process called with no outputs");
362            return;
363        };
364        let pd = self.get_or_create_pipeline(ctx);
365        let bg_tex = upload_rgba_texture(
366            ctx,
367            &self.background_rgba,
368            self.background_width,
369            self.background_height,
370            "AlphaMatte bg",
371        );
372        submit_mask_pass(ctx, pd, fg_tex, &bg_tex, output, 2, "AlphaMatte BG");
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use crate::nodes::RenderNodeCpu;
380
381    #[test]
382    fn shape_mask_node_opaque_mask_should_keep_base_alpha() {
383        let mask = vec![0u8, 0, 0, 255]; // fully opaque mask
384        let node = ShapeMaskNode::new(mask, 1, 1);
385        let mut rgba = vec![128u8, 128, 128, 200];
386        node.process_cpu(&mut rgba, 1, 1);
387        assert!(
388            (rgba[3] as i32 - 200).abs() <= 1,
389            "opaque mask must preserve base alpha"
390        );
391    }
392
393    #[test]
394    fn shape_mask_node_transparent_mask_should_zero_alpha() {
395        let mask = vec![255u8, 255, 255, 0]; // fully transparent mask
396        let node = ShapeMaskNode::new(mask, 1, 1);
397        let mut rgba = vec![128u8, 128, 128, 255];
398        node.process_cpu(&mut rgba, 1, 1);
399        assert_eq!(rgba[3], 0, "transparent mask must produce zero alpha");
400    }
401
402    // LumaMaskNode
403
404    #[test]
405    fn luma_mask_node_white_mask_should_preserve_alpha() {
406        let mask = vec![255u8, 255, 255, 255]; // white → luma = 1.0
407        let node = LumaMaskNode::new(mask, 1, 1);
408        let mut rgba = vec![100u8, 100, 100, 200];
409        node.process_cpu(&mut rgba, 1, 1);
410        assert!(
411            (rgba[3] as i32 - 200).abs() <= 2,
412            "white mask preserves alpha"
413        );
414    }
415
416    #[test]
417    fn luma_mask_node_black_mask_should_zero_alpha() {
418        let mask = vec![0u8, 0, 0, 255]; // black → luma = 0.0
419        let node = LumaMaskNode::new(mask, 1, 1);
420        let mut rgba = vec![100u8, 100, 100, 255];
421        node.process_cpu(&mut rgba, 1, 1);
422        assert_eq!(rgba[3], 0, "black mask must zero out alpha");
423    }
424
425    // AlphaMatteNode
426
427    #[test]
428    fn alpha_matte_node_opaque_fg_should_replace_background() {
429        let bg = vec![50u8, 50, 50, 255];
430        let node = AlphaMatteNode::new(bg, 1, 1);
431        let mut fg = vec![200u8, 100, 50, 255]; // fully opaque fg
432        node.process_cpu(&mut fg, 1, 1);
433        assert!(
434            (fg[0] as i32 - 200).abs() <= 1,
435            "opaque fg must dominate; got {}",
436            fg[0]
437        );
438    }
439
440    #[test]
441    fn alpha_matte_node_transparent_fg_should_show_background() {
442        let bg = vec![50u8, 80, 120, 255];
443        let node = AlphaMatteNode::new(bg, 1, 1);
444        let mut fg = vec![200u8, 200, 200, 0]; // fully transparent fg
445        node.process_cpu(&mut fg, 1, 1);
446        assert!(
447            (fg[0] as i32 - 50).abs() <= 1,
448            "transparent fg must show bg; got {}",
449            fg[0]
450        );
451    }
452}