1use 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#[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: 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#[cfg(feature = "wgpu")]
49#[derive(Default, Clone, Copy)]
50struct MaskUniforms {
51 mode: u32,
53 invert: u32,
54 src: (f32, f32),
56 rect: [f32; 4],
58}
59
60#[cfg(feature = "wgpu")]
61impl MaskUniforms {
62 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
120pub struct ShapeMaskNode {
128 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 #[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 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 fn process_cpu(&self, rgba: &mut [u8], w: u32, _h: u32) {
169 if w == 0 {
170 return;
171 }
172 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 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 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
263pub struct LumaMaskNode {
271 invert: bool,
273 #[cfg(feature = "wgpu")]
274 pipeline: std::sync::OnceLock<MaskPipeline>,
275}
276
277impl LumaMaskNode {
278 #[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 #[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 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
360pub struct AlphaMatteNode {
367 pub background_rgba: Vec<u8>,
369 pub background_width: u32,
371 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 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 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 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 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]); }
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 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 #[test]
527 fn luma_mask_node_white_should_preserve_alpha() {
528 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 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 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 #[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]; 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]; 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 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 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 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 #[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 .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 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}