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        // `mode` + `invert` + the source size, then the rectangle as a `vec4<f32>`
34        // (16-byte aligned, hence the 32).
35        size: 32,
36        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
37        mapped_at_creation: false,
38    });
39    MaskPipeline {
40        render_pipeline,
41        bind_group_layout: bgl,
42        sampler,
43        uniform_buf,
44    }
45}
46
47/// The `MaskUniforms` block `mask.wgsl` reads, in its own layout order.
48#[cfg(feature = "wgpu")]
49#[derive(Default, Clone, Copy)]
50struct MaskUniforms {
51    /// 0 = `ShapeMask`, 1 = `LumaMask`, 2 = `AlphaMatte`.
52    mode: u32,
53    invert: u32,
54    /// The source frame's size, so `rect` can be given in its pixels.
55    src: (f32, f32),
56    /// `x, y, x_end, y_end` in source pixels. Unused outside `ShapeMask`.
57    rect: [f32; 4],
58}
59
60#[cfg(feature = "wgpu")]
61impl MaskUniforms {
62    /// The 32 bytes the shader expects. Written by hand rather than through
63    /// `bytemuck` because the struct is private and this is its only use.
64    fn to_bytes(self) -> [u8; 32] {
65        let mut out = [0u8; 32];
66        out[0..4].copy_from_slice(&self.mode.to_le_bytes());
67        out[4..8].copy_from_slice(&self.invert.to_le_bytes());
68        out[8..12].copy_from_slice(&self.src.0.to_le_bytes());
69        out[12..16].copy_from_slice(&self.src.1.to_le_bytes());
70        for (i, v) in self.rect.iter().enumerate() {
71            let at = 16 + i * 4;
72            out[at..at + 4].copy_from_slice(&v.to_le_bytes());
73        }
74        out
75    }
76}
77
78#[cfg(feature = "wgpu")]
79fn submit_mask_pass(
80    ctx: &crate::context::RenderContext,
81    pd: &MaskPipeline,
82    base_tex: &wgpu::Texture,
83    mask_tex: &wgpu::Texture,
84    output_tex: &wgpu::Texture,
85    uniforms: MaskUniforms,
86    label: &str,
87) {
88    ctx.queue
89        .write_buffer(&pd.uniform_buf, 0, &uniforms.to_bytes());
90
91    let base_view = base_tex.create_view(&wgpu::TextureViewDescriptor::default());
92    let mask_view = mask_tex.create_view(&wgpu::TextureViewDescriptor::default());
93    let out_view = output_tex.create_view(&wgpu::TextureViewDescriptor::default());
94
95    let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
96        label: Some(label),
97        layout: &pd.bind_group_layout,
98        entries: &[
99            wgpu::BindGroupEntry {
100                binding: 0,
101                resource: wgpu::BindingResource::TextureView(&base_view),
102            },
103            wgpu::BindGroupEntry {
104                binding: 1,
105                resource: wgpu::BindingResource::TextureView(&mask_view),
106            },
107            wgpu::BindGroupEntry {
108                binding: 2,
109                resource: wgpu::BindingResource::Sampler(&pd.sampler),
110            },
111            wgpu::BindGroupEntry {
112                binding: 3,
113                resource: pd.uniform_buf.as_entire_binding(),
114            },
115        ],
116    });
117    submit_render_pass(ctx, &pd.render_pipeline, &bind_group, &out_view, label);
118}
119
120// ShapeMaskNode
121
122/// Clear the alpha of `inputs[0]` outside a rectangle of the source frame.
123///
124/// Pixels inside the rectangle keep their alpha; all others are made fully
125/// transparent. The rectangle reaches the shader as a uniform, so the node holds no
126/// mask buffer and uploads nothing per frame (#1710).
127pub struct ShapeMaskNode {
128    /// `x, y, width, height` in source-frame pixels, and `invert`.
129    ///
130    /// A `Cell` so an animated rectangle can be applied to the live node
131    /// ([`NodeParam::ShapeMaskRect`](crate::NodeParam::ShapeMaskRect)) instead of
132    /// rebuilding the graph around it, which would recreate the render pipeline every
133    /// frame. `Cell` keeps the node `Send`, which is all `RenderNodeCpu` requires.
134    rect: std::cell::Cell<(u32, u32, u32, u32)>,
135    invert: std::cell::Cell<bool>,
136    #[cfg(feature = "wgpu")]
137    pipeline: std::sync::OnceLock<MaskPipeline>,
138}
139
140impl ShapeMaskNode {
141    /// Keeps the pixels inside `[x, x + width) x [y, y + height)` of the **source**
142    /// frame, or those outside it when `invert`.
143    #[must_use]
144    pub fn new(x: u32, y: u32, width: u32, height: u32, invert: bool) -> Self {
145        Self {
146            rect: std::cell::Cell::new((x, y, width, height)),
147            invert: std::cell::Cell::new(invert),
148            #[cfg(feature = "wgpu")]
149            pipeline: std::sync::OnceLock::new(),
150        }
151    }
152
153    /// Whether pixel `(px, py)` of the source frame is kept.
154    fn keeps(&self, px: u32, py: u32) -> bool {
155        let (x, y, width, height) = self.rect.get();
156        let inside =
157            px >= x && px < x.saturating_add(width) && py >= y && py < y.saturating_add(height);
158        inside != self.invert.get()
159    }
160}
161
162impl RenderNodeCpu for ShapeMaskNode {
163    /// Tests the rectangle against the coordinates of the buffer it is given.
164    ///
165    /// Those are the previous node's output pixels, where the GPU path evaluates the
166    /// rectangle in the *original* source frame's pixels. The two agree until a node
167    /// that resizes (a `ScaleNode`) runs in front of this one.
168    fn process_cpu(&self, rgba: &mut [u8], w: u32, _h: u32) {
169        if w == 0 {
170            return;
171        }
172        // Walked rather than derived from the index, so no `usize -> u32` cast is
173        // needed for a coordinate that is a `u32` by construction.
174        let (mut px, mut py) = (0u32, 0u32);
175        for base in rgba.as_chunks_mut::<4>().0 {
176            if !self.keeps(px, py) {
177                base[3] = 0;
178            }
179            px += 1;
180            if px == w {
181                px = 0;
182                py += 1;
183            }
184        }
185    }
186}
187
188#[cfg(feature = "wgpu")]
189impl ShapeMaskNode {
190    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &MaskPipeline {
191        self.pipeline.get_or_init(|| create_mask_pipeline(ctx))
192    }
193}
194
195#[cfg(feature = "wgpu")]
196impl crate::nodes::RenderNode for ShapeMaskNode {
197    fn input_count(&self) -> usize {
198        2
199    }
200
201    /// Takes [`NodeParam::ShapeMaskRect`](crate::NodeParam::ShapeMaskRect), so an
202    /// animated rectangle moves without the graph being rebuilt around it.
203    fn set_param(&self, param: crate::nodes::NodeParam) -> bool {
204        match param {
205            crate::nodes::NodeParam::ShapeMaskRect {
206                x,
207                y,
208                width,
209                height,
210                invert,
211            } => {
212                self.rect.set((x, y, width, height));
213                self.invert.set(invert);
214                true
215            }
216            crate::nodes::NodeParam::MotionBlurShutter(_) => false,
217        }
218    }
219
220    fn process(
221        &self,
222        inputs: &[&wgpu::Texture],
223        outputs: &[&wgpu::Texture],
224        ctx: &crate::context::RenderContext,
225    ) {
226        let Some(base_tex) = inputs.first() else {
227            log::warn!("ShapeMaskNode::process called with no inputs");
228            return;
229        };
230        let Some(output) = outputs.first() else {
231            log::warn!("ShapeMaskNode::process called with no outputs");
232            return;
233        };
234        // `inputs[1]` is the original source frame, which is the space the rectangle
235        // is expressed in. It is bound only to satisfy the shared bind-group layout:
236        // mode 0 evaluates the rectangle and never samples it.
237        //
238        // `input_count()` is 2, so the executor always supplies it. Falling back to the
239        // chained texture would put the rectangle in a different pixel space whenever a
240        // node resized in front of this one, so say it rather than absorb it silently.
241        let source = inputs.get(1).copied().unwrap_or_else(|| {
242            log::warn!("ShapeMaskNode::process called without the source frame");
243            base_tex
244        });
245        let pd = self.get_or_create_pipeline(ctx);
246        let (x, y, width, height) = self.rect.get();
247        #[allow(clippy::cast_precision_loss)]
248        let uniforms = MaskUniforms {
249            mode: 0,
250            invert: u32::from(self.invert.get()),
251            src: (source.width() as f32, source.height() as f32),
252            rect: [
253                x as f32,
254                y as f32,
255                x.saturating_add(width) as f32,
256                y.saturating_add(height) as f32,
257            ],
258        };
259        submit_mask_pass(ctx, pd, base_tex, source, output, uniforms, "ShapeMask BG");
260    }
261}
262
263// LumaMaskNode
264
265/// Mask `inputs[0]` using the BT.709 luma of the source frame (`inputs[1]`).
266///
267/// The luma (0.0–1.0) is multiplied into the base alpha channel. The source frame is
268/// sampled per frame rather than baked into the node (#1710), which is what lets an
269/// effect graph containing this node be cached across frames.
270pub struct LumaMaskNode {
271    /// Use `1 - luma` instead of `luma`.
272    invert: bool,
273    #[cfg(feature = "wgpu")]
274    pipeline: std::sync::OnceLock<MaskPipeline>,
275}
276
277impl LumaMaskNode {
278    /// Masks by the source frame's own BT.709 luma, or its complement when `invert`.
279    ///
280    /// The node holds no mask: the GPU path samples the source frame directly, so
281    /// there is nothing to build or upload per frame.
282    #[must_use]
283    pub fn new(invert: bool) -> Self {
284        Self {
285            invert,
286            #[cfg(feature = "wgpu")]
287            pipeline: std::sync::OnceLock::new(),
288        }
289    }
290}
291
292impl RenderNodeCpu for LumaMaskNode {
293    /// Masks by the luma of the buffer it is given.
294    ///
295    /// That buffer is the previous node's output, where the GPU path uses the
296    /// *original* source frame. The two agree when this node is first in the graph;
297    /// an effect in front of it makes them differ. The compositor drives only the GPU
298    /// path, and its own divergence from the CPU `geq` in that position is a known v1
299    /// limitation (see `avio::gpu_compositor`'s `LumaMask` arm).
300    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
301    fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
302        for base in rgba.as_chunks_mut::<4>().0 {
303            let mr = f32::from(base[0]) / 255.0;
304            let mg = f32::from(base[1]) / 255.0;
305            let mb = f32::from(base[2]) / 255.0;
306            let luma = bt709_luma(mr, mg, mb);
307            let opacity = if self.invert { 1.0 - luma } else { luma };
308            let ba = f32::from(base[3]) / 255.0;
309            base[3] = ((ba * opacity).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
310        }
311    }
312}
313
314#[cfg(feature = "wgpu")]
315impl LumaMaskNode {
316    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &MaskPipeline {
317        self.pipeline.get_or_init(|| create_mask_pipeline(ctx))
318    }
319}
320
321#[cfg(feature = "wgpu")]
322impl crate::nodes::RenderNode for LumaMaskNode {
323    fn input_count(&self) -> usize {
324        2
325    }
326
327    fn process(
328        &self,
329        inputs: &[&wgpu::Texture],
330        outputs: &[&wgpu::Texture],
331        ctx: &crate::context::RenderContext,
332    ) {
333        let Some(base_tex) = inputs.first() else {
334            log::warn!("LumaMaskNode::process called with no inputs");
335            return;
336        };
337        let Some(output) = outputs.first() else {
338            log::warn!("LumaMaskNode::process called with no outputs");
339            return;
340        };
341        // `inputs[1]` is the original source frame, which *is* the mask: the shader
342        // takes its BT.709 luma. Nothing is built or uploaded per frame.
343        //
344        // `input_count()` is 2, so the executor always supplies it; the fallback would
345        // mask by the chained frame instead of the source.
346        let source = inputs.get(1).copied().unwrap_or_else(|| {
347            log::warn!("LumaMaskNode::process called without the source frame");
348            base_tex
349        });
350        let pd = self.get_or_create_pipeline(ctx);
351        let uniforms = MaskUniforms {
352            mode: 1,
353            invert: u32::from(self.invert),
354            ..MaskUniforms::default()
355        };
356        submit_mask_pass(ctx, pd, base_tex, source, output, uniforms, "LumaMask BG");
357    }
358}
359
360// AlphaMatteNode
361
362/// Porter-Duff src-over: composite `inputs[0]` (foreground) over `inputs[1]`
363/// (background) using the foreground's own alpha channel.
364///
365/// For the CPU path the background data must be stored in `background_rgba`.
366pub struct AlphaMatteNode {
367    /// Background frame RGBA bytes (required for the CPU path).
368    pub background_rgba: Vec<u8>,
369    /// Width of `background_rgba`.
370    pub background_width: u32,
371    /// Height of `background_rgba`.
372    pub background_height: u32,
373    #[cfg(feature = "wgpu")]
374    pipeline: std::sync::OnceLock<MaskPipeline>,
375}
376
377impl AlphaMatteNode {
378    #[must_use]
379    pub fn new(background_rgba: Vec<u8>, background_width: u32, background_height: u32) -> Self {
380        Self {
381            background_rgba,
382            background_width,
383            background_height,
384            #[cfg(feature = "wgpu")]
385            pipeline: std::sync::OnceLock::new(),
386        }
387    }
388}
389
390impl RenderNodeCpu for AlphaMatteNode {
391    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
392    fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
393        if self.background_rgba.len() != rgba.len() {
394            return;
395        }
396        for (fg, bg) in rgba
397            .as_chunks_mut::<4>()
398            .0
399            .iter_mut()
400            .zip(self.background_rgba.as_chunks::<4>().0.iter())
401        {
402            let fa = f32::from(fg[3]) / 255.0;
403            let ba = f32::from(bg[3]) / 255.0;
404            for ch in 0..3 {
405                let fc = f32::from(fg[ch]) / 255.0;
406                let bc = f32::from(bg[ch]) / 255.0;
407                fg[ch] = ((fc * fa + bc * (1.0 - fa)).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
408            }
409            fg[3] = ((fa + ba * (1.0 - fa)).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
410        }
411    }
412}
413
414#[cfg(feature = "wgpu")]
415impl AlphaMatteNode {
416    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &MaskPipeline {
417        self.pipeline.get_or_init(|| create_mask_pipeline(ctx))
418    }
419}
420
421#[cfg(feature = "wgpu")]
422impl crate::nodes::RenderNode for AlphaMatteNode {
423    fn input_count(&self) -> usize {
424        2
425    }
426
427    fn process(
428        &self,
429        inputs: &[&wgpu::Texture],
430        outputs: &[&wgpu::Texture],
431        ctx: &crate::context::RenderContext,
432    ) {
433        let Some(fg_tex) = inputs.first() else {
434            log::warn!("AlphaMatteNode::process called with no inputs");
435            return;
436        };
437        let Some(output) = outputs.first() else {
438            log::warn!("AlphaMatteNode::process called with no outputs");
439            return;
440        };
441        let pd = self.get_or_create_pipeline(ctx);
442        let bg_tex = upload_rgba_texture(
443            ctx,
444            &self.background_rgba,
445            self.background_width,
446            self.background_height,
447            "AlphaMatte bg",
448        );
449        let uniforms = MaskUniforms {
450            mode: 2,
451            ..MaskUniforms::default()
452        };
453        submit_mask_pass(ctx, pd, fg_tex, &bg_tex, output, uniforms, "AlphaMatte BG");
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use crate::nodes::RenderNodeCpu;
461
462    #[test]
463    fn shape_mask_node_should_keep_a_pixel_inside_the_rectangle() {
464        // The rectangle covers the only pixel, so its alpha survives.
465        let node = ShapeMaskNode::new(0, 0, 1, 1, false);
466        let mut rgba = vec![128u8, 128, 128, 200];
467        node.process_cpu(&mut rgba, 1, 1);
468        assert!(
469            i32::from(rgba[3]).abs_diff(200) <= 1,
470            "a pixel inside the rectangle keeps its alpha"
471        );
472    }
473
474    #[test]
475    fn shape_mask_node_should_drop_a_pixel_outside_the_rectangle() {
476        // A rectangle of zero width covers nothing.
477        let node = ShapeMaskNode::new(0, 0, 0, 0, false);
478        let mut rgba = vec![128u8, 128, 128, 255];
479        node.process_cpu(&mut rgba, 1, 1);
480        assert_eq!(rgba[3], 0, "a pixel outside the rectangle is dropped");
481    }
482
483    #[test]
484    fn shape_mask_node_invert_should_swap_inside_for_outside() {
485        // The other half of the gate above, so the rectangle test is not read as a
486        // blanket keep-everything.
487        let node = ShapeMaskNode::new(0, 0, 1, 1, true);
488        let mut rgba = vec![128u8, 128, 128, 255];
489        node.process_cpu(&mut rgba, 1, 1);
490        assert_eq!(
491            rgba[3], 0,
492            "inverted, a pixel inside the rectangle is dropped"
493        );
494    }
495
496    /// A 4x2 opaque base. The rectangle below covers its left half, so a per-region
497    /// assertion pins spatial selection (a 1x1 test cannot).
498    fn tagged_base() -> (Vec<u8>, u32, u32) {
499        let (w, h) = (4u32, 2u32);
500        let mut base = Vec::with_capacity((w * h * 4) as usize);
501        for _ in 0..h {
502            for _ in 0..w {
503                base.extend_from_slice(&[100, 100, 100, 255]); // opaque grey
504            }
505        }
506        (base, w, h)
507    }
508
509    fn alpha_at(rgba: &[u8], w: u32, x: u32, y: u32) -> u8 {
510        rgba[((y * w + x) * 4 + 3) as usize]
511    }
512
513    #[test]
514    fn shape_mask_node_should_keep_masked_region_only() {
515        let (mut base, w, h) = tagged_base();
516        // x in [0, 2), all rows.
517        ShapeMaskNode::new(0, 0, 2, h, false).process_cpu(&mut base, w, h);
518        assert!(alpha_at(&base, w, 0, 0) > 200, "kept (0,0) preserves alpha");
519        assert!(alpha_at(&base, w, 1, 1) > 200, "kept (1,1) preserves alpha");
520        assert!(alpha_at(&base, w, 2, 0) < 30, "dropped (2,0) zeroes alpha");
521        assert!(alpha_at(&base, w, 3, 1) < 30, "dropped (3,1) zeroes alpha");
522    }
523
524    // LumaMaskNode
525
526    #[test]
527    fn luma_mask_node_white_should_preserve_alpha() {
528        // The mask is the frame's own luma, so a white pixel is fully opaque.
529        let node = LumaMaskNode::new(false);
530        let mut rgba = vec![255u8, 255, 255, 200];
531        node.process_cpu(&mut rgba, 1, 1);
532        assert!(
533            i32::from(rgba[3]).abs_diff(200) <= 2,
534            "white preserves alpha, got {}",
535            rgba[3]
536        );
537    }
538
539    #[test]
540    fn luma_mask_node_black_should_zero_alpha() {
541        let node = LumaMaskNode::new(false);
542        let mut rgba = vec![0u8, 0, 0, 255];
543        node.process_cpu(&mut rgba, 1, 1);
544        assert_eq!(rgba[3], 0, "black must zero out alpha");
545    }
546
547    #[test]
548    fn luma_mask_node_invert_should_swap_light_for_dark() {
549        // The other half of the two above: inverted, white is what disappears.
550        let node = LumaMaskNode::new(true);
551        let mut white = vec![255u8, 255, 255, 255];
552        node.process_cpu(&mut white, 1, 1);
553        assert_eq!(white[3], 0, "inverted, white zeroes alpha");
554        let node = LumaMaskNode::new(true);
555        let mut black = vec![0u8, 0, 0, 200];
556        node.process_cpu(&mut black, 1, 1);
557        assert!(
558            i32::from(black[3]).abs_diff(200) <= 2,
559            "inverted, black preserves alpha, got {}",
560            black[3]
561        );
562    }
563
564    #[test]
565    fn luma_mask_node_should_mask_by_region_luma() {
566        // White left half, black right half, all opaque.
567        let (w, h) = (4u32, 2u32);
568        let mut base = Vec::with_capacity((w * h * 4) as usize);
569        for _ in 0..h {
570            for x in 0..w {
571                let v = if x < 2 { 255 } else { 0 };
572                base.extend_from_slice(&[v, v, v, 255]);
573            }
574        }
575        LumaMaskNode::new(false).process_cpu(&mut base, w, h);
576        assert!(
577            alpha_at(&base, w, 0, 0) > 200,
578            "white (0,0) preserves alpha"
579        );
580        assert!(
581            alpha_at(&base, w, 1, 1) > 200,
582            "white (1,1) preserves alpha"
583        );
584        assert!(alpha_at(&base, w, 2, 0) < 30, "black (2,0) zeroes alpha");
585        assert!(alpha_at(&base, w, 3, 1) < 30, "black (3,1) zeroes alpha");
586    }
587
588    // AlphaMatteNode
589
590    #[test]
591    fn alpha_matte_node_opaque_fg_should_replace_background() {
592        let bg = vec![50u8, 50, 50, 255];
593        let node = AlphaMatteNode::new(bg, 1, 1);
594        let mut fg = vec![200u8, 100, 50, 255]; // fully opaque fg
595        node.process_cpu(&mut fg, 1, 1);
596        assert!(
597            (fg[0] as i32 - 200).abs() <= 1,
598            "opaque fg must dominate; got {}",
599            fg[0]
600        );
601    }
602
603    #[test]
604    fn alpha_matte_node_transparent_fg_should_show_background() {
605        let bg = vec![50u8, 80, 120, 255];
606        let node = AlphaMatteNode::new(bg, 1, 1);
607        let mut fg = vec![200u8, 200, 200, 0]; // fully transparent fg
608        node.process_cpu(&mut fg, 1, 1);
609        assert!(
610            (fg[0] as i32 - 50).abs() <= 1,
611            "transparent fg must show bg; got {}",
612            fg[0]
613        );
614    }
615}
616
617#[cfg(all(test, feature = "wgpu"))]
618mod gpu_tests {
619    use super::*;
620    use crate::context::RenderContext;
621    use crate::graph::RenderGraph;
622    use std::sync::Arc;
623
624    /// A headless GPU context, or `None` when no adapter is available (CI).
625    fn ctx() -> Option<Arc<RenderContext>> {
626        match futures::executor::block_on(RenderContext::init()) {
627            Ok(ctx) => Some(Arc::new(ctx)),
628            Err(_) => None,
629        }
630    }
631
632    /// An opaque base whose left half is white and right half black.
633    ///
634    /// The frame *is* the mask now: the shader reads the source frame the executor
635    /// binds as the second input rather than a buffer the node carries.
636    fn luma_tagged_frame() -> (Vec<u8>, u32, u32) {
637        let (w, h) = (4u32, 2u32);
638        let mut base = Vec::with_capacity((w * h * 4) as usize);
639        for _ in 0..h {
640            for x in 0..w {
641                if x < 2 {
642                    base.extend_from_slice(&[255, 255, 255, 255]);
643                } else {
644                    base.extend_from_slice(&[0, 0, 0, 255]);
645                }
646            }
647        }
648        (base, w, h)
649    }
650
651    fn alpha_at(rgba: &[u8], w: u32, x: u32, y: u32) -> u8 {
652        rgba[((y * w + x) * 4 + 3) as usize]
653    }
654
655    #[test]
656    fn luma_mask_gpu_should_mask_by_region_luma_on_tagged_fixture() {
657        let Some(ctx) = ctx() else {
658            return;
659        };
660        let (base, w, h) = luma_tagged_frame();
661        let out = RenderGraph::new(Arc::clone(&ctx))
662            .push(LumaMaskNode::new(false))
663            .process_gpu(&base, w, h)
664            .expect("gpu luma mask");
665
666        // Bright region -> alpha preserved; dark region -> zeroed (validates the
667        // mask.wgsl luma branch).
668        assert!(
669            alpha_at(&out, w, 0, 0) > 200,
670            "bright (0,0) preserves alpha on GPU"
671        );
672        assert!(
673            alpha_at(&out, w, 1, 1) > 200,
674            "bright (1,1) preserves alpha on GPU"
675        );
676        assert!(
677            alpha_at(&out, w, 2, 0) < 30,
678            "dark (2,0) zeroes alpha on GPU"
679        );
680        assert!(
681            alpha_at(&out, w, 3, 1) < 30,
682            "dark (3,1) zeroes alpha on GPU"
683        );
684    }
685
686    #[test]
687    fn luma_mask_gpu_inverted_should_keep_the_dark_region() {
688        let Some(ctx) = ctx() else {
689            return;
690        };
691        let (base, w, h) = luma_tagged_frame();
692        let out = RenderGraph::new(Arc::clone(&ctx))
693            .push(LumaMaskNode::new(true))
694            .process_gpu(&base, w, h)
695            .expect("gpu luma mask");
696
697        assert!(
698            alpha_at(&out, w, 0, 0) < 30,
699            "inverted: bright (0,0) is dropped on GPU"
700        );
701        assert!(
702            alpha_at(&out, w, 3, 1) > 200,
703            "inverted: dark (3,1) is kept on GPU"
704        );
705    }
706
707    /// The mask must come from the **source** frame, not from whatever the chain has
708    /// made of it by the time this node runs.
709    ///
710    /// That is the behaviour the baked mask had (it was built from the pre-graph
711    /// frame), so keeping it is what makes the shader a drop-in for it. A node bound to
712    /// `inputs[0]` instead of `inputs[1]` looks correct whenever the mask is the only
713    /// effect -- every parity test here is that shape -- so this one puts an effect in
714    /// front of it that destroys the luma.
715    #[test]
716    fn luma_mask_gpu_should_mask_by_the_source_frame_not_the_chained_one() {
717        let Some(ctx) = ctx() else {
718            return;
719        };
720        let (base, w, h) = luma_tagged_frame();
721        let out = RenderGraph::new(Arc::clone(&ctx))
722            // Brightness -1.0 drives every pixel to black, so a mask taken from the
723            // chained texture would hide the whole frame.
724            .push(crate::nodes::ColorGradeNode::new(-1.0, 1.0, 1.0, 0.0, 0.0))
725            .push(LumaMaskNode::new(false))
726            .process_gpu(&base, w, h)
727            .expect("gpu luma mask");
728
729        assert!(
730            alpha_at(&out, w, 0, 0) > 200,
731            "the source frame's bright half must still be kept, got {}",
732            alpha_at(&out, w, 0, 0)
733        );
734        assert!(
735            alpha_at(&out, w, 3, 1) < 30,
736            "the source frame's dark half must still be dropped, got {}",
737            alpha_at(&out, w, 3, 1)
738        );
739    }
740
741    #[test]
742    fn shape_mask_gpu_should_keep_the_rectangle_on_tagged_fixture() {
743        let Some(ctx) = ctx() else {
744            return;
745        };
746        let (w, h) = (4u32, 2u32);
747        let base: Vec<u8> = std::iter::repeat_n([100u8, 100, 100, 255], (w * h) as usize)
748            .flatten()
749            .collect();
750        let out = RenderGraph::new(Arc::clone(&ctx))
751            .push(ShapeMaskNode::new(0, 0, 2, h, false))
752            .process_gpu(&base, w, h)
753            .expect("gpu shape mask");
754
755        // Inside the rectangle -> alpha preserved; outside -> zeroed (validates the
756        // mask.wgsl shape branch).
757        assert!(
758            alpha_at(&out, w, 0, 0) > 200,
759            "kept (0,0) preserves alpha on GPU"
760        );
761        assert!(
762            alpha_at(&out, w, 1, 1) > 200,
763            "kept (1,1) preserves alpha on GPU"
764        );
765        assert!(
766            alpha_at(&out, w, 2, 0) < 30,
767            "dropped (2,0) zeroes alpha on GPU"
768        );
769        assert!(
770            alpha_at(&out, w, 3, 1) < 30,
771            "dropped (3,1) zeroes alpha on GPU"
772        );
773    }
774
775    #[test]
776    fn shape_mask_gpu_inverted_should_keep_the_outside() {
777        let Some(ctx) = ctx() else {
778            return;
779        };
780        let (w, h) = (4u32, 2u32);
781        let base: Vec<u8> = std::iter::repeat_n([100u8, 100, 100, 255], (w * h) as usize)
782            .flatten()
783            .collect();
784        let out = RenderGraph::new(Arc::clone(&ctx))
785            .push(ShapeMaskNode::new(0, 0, 2, h, true))
786            .process_gpu(&base, w, h)
787            .expect("gpu shape mask");
788
789        assert!(
790            alpha_at(&out, w, 0, 0) < 30,
791            "inverted: inside (0,0) is dropped on GPU"
792        );
793        assert!(
794            alpha_at(&out, w, 3, 1) > 200,
795            "inverted: outside (3,1) is kept on GPU"
796        );
797    }
798}