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: 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
105pub struct ShapeMaskNode {
112 pub mask_rgba: Vec<u8>,
114 pub mask_width: u32,
116 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
193pub struct LumaMaskNode {
199 pub mask_rgba: Vec<u8>,
201 pub mask_width: u32,
203 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
283pub struct AlphaMatteNode {
290 pub background_rgba: Vec<u8>,
292 pub background_width: u32,
294 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]; 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]; 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 #[test]
405 fn luma_mask_node_white_mask_should_preserve_alpha() {
406 let mask = vec![255u8, 255, 255, 255]; 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]; 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 #[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]; 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]; 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}