Skip to main content

ff_render/nodes/
composite.rs

1use super::RenderNodeCpu;
2
3// ── BlendMode ─────────────────────────────────────────────────────────────────
4
5/// Photoshop-compatible blend modes.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
7#[repr(u32)]
8pub enum BlendMode {
9    /// Overlay replaces base.
10    #[default]
11    Normal = 0,
12    /// base × overlay.
13    Multiply = 1,
14    /// 1 − (1−base)(1−overlay).
15    Screen = 2,
16    /// Multiply below 50% grey, Screen above.
17    Overlay = 3,
18    /// Soft light — W3C formula.
19    SoftLight = 4,
20    /// Hard light — Overlay with base/overlay swapped.
21    HardLight = 5,
22    /// base / (1 − overlay).
23    ColorDodge = 6,
24    /// 1 − (1−base) / overlay.
25    ColorBurn = 7,
26    /// |base − overlay|.
27    Difference = 8,
28    /// base + overlay − 2·base·overlay.
29    Exclusion = 9,
30    /// clamp(base + overlay, 0, 1).
31    Add = 10,
32    /// clamp(base − overlay, 0, 1).
33    Subtract = 11,
34    /// min(base, overlay).
35    Darken = 12,
36    /// max(base, overlay).
37    Lighten = 13,
38    /// Overlay hue + base saturation + base lightness.
39    Hue = 14,
40    /// Base hue + overlay saturation + base lightness.
41    Saturation = 15,
42    /// Overlay hue + overlay saturation + base lightness.
43    Color = 16,
44    /// Base hue + base saturation + overlay lightness.
45    Luminosity = 17,
46}
47
48// ── BlendModeNode ─────────────────────────────────────────────────────────────
49
50#[cfg(feature = "wgpu")]
51struct BlendPipeline {
52    render_pipeline: wgpu::RenderPipeline,
53    bind_group_layout: wgpu::BindGroupLayout,
54    sampler: wgpu::Sampler,
55    uniform_buf: wgpu::Buffer,
56}
57
58/// Apply a Photoshop-compatible blend mode to two input textures.
59///
60/// `input_count() = 2` — `inputs[0]` is the base layer, `inputs[1]` is the
61/// overlay.  The `opacity` field attenuates the overlay's contribution.
62///
63/// For the CPU path the overlay data must be stored in `overlay_rgba`.
64pub struct BlendModeNode {
65    /// Blend algorithm.
66    pub mode: BlendMode,
67    /// Overlay opacity (0.0 = invisible, 1.0 = fully applied).
68    pub opacity: f32,
69    /// Overlay frame as RGBA bytes (required for CPU path).
70    pub overlay_rgba: Vec<u8>,
71    /// Width of `overlay_rgba`.
72    pub overlay_width: u32,
73    /// Height of `overlay_rgba`.
74    pub overlay_height: u32,
75    #[cfg(feature = "wgpu")]
76    pipeline: std::sync::OnceLock<BlendPipeline>,
77}
78
79impl BlendModeNode {
80    #[must_use]
81    pub fn new(
82        mode: BlendMode,
83        opacity: f32,
84        overlay_rgba: Vec<u8>,
85        overlay_width: u32,
86        overlay_height: u32,
87    ) -> Self {
88        Self {
89            mode,
90            opacity,
91            overlay_rgba,
92            overlay_width,
93            overlay_height,
94            #[cfg(feature = "wgpu")]
95            pipeline: std::sync::OnceLock::new(),
96        }
97    }
98}
99
100// ── CPU helpers ───────────────────────────────────────────────────────────────
101
102#[allow(clippy::many_single_char_names, clippy::float_cmp)]
103fn rgb_to_hsl(r: f32, g: f32, b: f32) -> [f32; 3] {
104    let max_c = r.max(g).max(b);
105    let min_c = r.min(g).min(b);
106    let l = (max_c + min_c) * 0.5;
107    if (max_c - min_c).abs() < 1e-6 {
108        return [0.0, 0.0, l];
109    }
110    let delta = max_c - min_c;
111    let s = if l < 0.5 {
112        delta / (max_c + min_c)
113    } else {
114        delta / (2.0 - max_c - min_c)
115    };
116    let h = if max_c == r {
117        let raw = (g - b) / delta;
118        if g >= b { raw } else { raw + 6.0 }
119    } else if max_c == g {
120        (b - r) / delta + 2.0
121    } else {
122        (r - g) / delta + 4.0
123    } / 6.0;
124    [h, s, l]
125}
126
127fn hue_to_rgb_cpu(p: f32, q: f32, t_in: f32) -> f32 {
128    let t = t_in.rem_euclid(1.0);
129    if t < 1.0 / 6.0 {
130        return p + (q - p) * 6.0 * t;
131    }
132    if t < 0.5 {
133        return q;
134    }
135    if t < 2.0 / 3.0 {
136        return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
137    }
138    p
139}
140
141#[allow(clippy::many_single_char_names)]
142fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [f32; 3] {
143    if s.abs() < 1e-6 {
144        return [l, l, l];
145    }
146    let q = if l < 0.5 {
147        l * (1.0 + s)
148    } else {
149        l + s - l * s
150    };
151    let p = 2.0 * l - q;
152    [
153        hue_to_rgb_cpu(p, q, h + 1.0 / 3.0),
154        hue_to_rgb_cpu(p, q, h),
155        hue_to_rgb_cpu(p, q, h - 1.0 / 3.0),
156    ]
157}
158
159fn overlay_ch(b: f32, o: f32) -> f32 {
160    if b < 0.5 {
161        2.0 * b * o
162    } else {
163        1.0 - 2.0 * (1.0 - b) * (1.0 - o)
164    }
165}
166
167fn soft_light_d(b: f32) -> f32 {
168    if b <= 0.25 {
169        ((16.0 * b - 12.0) * b + 4.0) * b
170    } else {
171        b.sqrt()
172    }
173}
174
175fn soft_light_ch(b: f32, o: f32) -> f32 {
176    if o <= 0.5 {
177        b - (1.0 - 2.0 * o) * b * (1.0 - b)
178    } else {
179        b + (2.0 * o - 1.0) * (soft_light_d(b) - b)
180    }
181}
182
183#[allow(clippy::many_single_char_names)]
184fn blend_rgb(mode: BlendMode, base: [f32; 3], ov: [f32; 3]) -> [f32; 3] {
185    let [br, bg, bb] = base;
186    let [or, og, ob] = ov;
187    match mode {
188        BlendMode::Normal => ov,
189        BlendMode::Multiply => [br * or, bg * og, bb * ob],
190        BlendMode::Screen => [
191            1.0 - (1.0 - br) * (1.0 - or),
192            1.0 - (1.0 - bg) * (1.0 - og),
193            1.0 - (1.0 - bb) * (1.0 - ob),
194        ],
195        BlendMode::Overlay => [overlay_ch(br, or), overlay_ch(bg, og), overlay_ch(bb, ob)],
196        BlendMode::SoftLight => [
197            soft_light_ch(br, or),
198            soft_light_ch(bg, og),
199            soft_light_ch(bb, ob),
200        ],
201        BlendMode::HardLight => [overlay_ch(or, br), overlay_ch(og, bg), overlay_ch(ob, bb)],
202        BlendMode::ColorDodge => [
203            (br / (1.0 - or + 1e-4)).clamp(0.0, 1.0),
204            (bg / (1.0 - og + 1e-4)).clamp(0.0, 1.0),
205            (bb / (1.0 - ob + 1e-4)).clamp(0.0, 1.0),
206        ],
207        BlendMode::ColorBurn => [
208            (1.0 - (1.0 - br) / (or + 1e-4)).clamp(0.0, 1.0),
209            (1.0 - (1.0 - bg) / (og + 1e-4)).clamp(0.0, 1.0),
210            (1.0 - (1.0 - bb) / (ob + 1e-4)).clamp(0.0, 1.0),
211        ],
212        BlendMode::Difference => [(br - or).abs(), (bg - og).abs(), (bb - ob).abs()],
213        BlendMode::Exclusion => [
214            br + or - 2.0 * br * or,
215            bg + og - 2.0 * bg * og,
216            bb + ob - 2.0 * bb * ob,
217        ],
218        BlendMode::Add => [
219            (br + or).clamp(0.0, 1.0),
220            (bg + og).clamp(0.0, 1.0),
221            (bb + ob).clamp(0.0, 1.0),
222        ],
223        BlendMode::Subtract => [
224            (br - or).clamp(0.0, 1.0),
225            (bg - og).clamp(0.0, 1.0),
226            (bb - ob).clamp(0.0, 1.0),
227        ],
228        BlendMode::Darken => [br.min(or), bg.min(og), bb.min(ob)],
229        BlendMode::Lighten => [br.max(or), bg.max(og), bb.max(ob)],
230        BlendMode::Hue => {
231            let [_bh, bs, bl] = rgb_to_hsl(br, bg, bb);
232            let [oh, _, _] = rgb_to_hsl(or, og, ob);
233            hsl_to_rgb(oh, bs, bl)
234        }
235        BlendMode::Saturation => {
236            let [bh, bs, bl] = rgb_to_hsl(br, bg, bb);
237            let [_, os, _] = rgb_to_hsl(or, og, ob);
238            let _ = bs;
239            hsl_to_rgb(bh, os, bl)
240        }
241        BlendMode::Color => {
242            let [_, _, bl] = rgb_to_hsl(br, bg, bb);
243            let [oh, os, _] = rgb_to_hsl(or, og, ob);
244            hsl_to_rgb(oh, os, bl)
245        }
246        BlendMode::Luminosity => {
247            let [bh, bs, _] = rgb_to_hsl(br, bg, bb);
248            let [_, _, ol] = rgb_to_hsl(or, og, ob);
249            hsl_to_rgb(bh, bs, ol)
250        }
251    }
252}
253
254impl RenderNodeCpu for BlendModeNode {
255    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
256    fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
257        if self.overlay_rgba.len() != rgba.len() {
258            log::warn!(
259                "BlendModeNode::process_cpu skipped: size mismatch base={} overlay={}",
260                rgba.len(),
261                self.overlay_rgba.len()
262            );
263            return;
264        }
265        for (base, ov) in rgba
266            .chunks_exact_mut(4)
267            .zip(self.overlay_rgba.chunks_exact(4))
268        {
269            let br = f32::from(base[0]) / 255.0;
270            let bg = f32::from(base[1]) / 255.0;
271            let bb = f32::from(base[2]) / 255.0;
272            let or = f32::from(ov[0]) / 255.0;
273            let og = f32::from(ov[1]) / 255.0;
274            let ob = f32::from(ov[2]) / 255.0;
275            let oa = f32::from(ov[3]) / 255.0;
276
277            let [rr, rg, rb] = blend_rgb(self.mode, [br, bg, bb], [or, og, ob]);
278            let eff_alpha = oa * self.opacity;
279            let out_r = (br + (rr - br) * eff_alpha).clamp(0.0, 1.0);
280            let out_g = (bg + (rg - bg) * eff_alpha).clamp(0.0, 1.0);
281            let out_b = (bb + (rb - bb) * eff_alpha).clamp(0.0, 1.0);
282            base[0] = (out_r * 255.0 + 0.5) as u8;
283            base[1] = (out_g * 255.0 + 0.5) as u8;
284            base[2] = (out_b * 255.0 + 0.5) as u8;
285        }
286    }
287}
288
289// ── GPU: BlendModeNode ────────────────────────────────────────────────────────
290
291#[cfg(feature = "wgpu")]
292impl BlendModeNode {
293    #[allow(clippy::too_many_lines)]
294    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &BlendPipeline {
295        self.pipeline.get_or_init(|| {
296            let device = &ctx.device;
297            let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
298                label: Some("Blend shader"),
299                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/blend.wgsl").into()),
300            });
301            let bgl = two_tex_sampler_uniform_bgl(device, "Blend");
302            let render_pipeline = fullscreen_pipeline(device, &shader, "Blend", &bgl);
303            let sampler = linear_sampler(device, "Blend");
304            let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
305                label: Some("Blend uniforms"),
306                size: 16,
307                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
308                mapped_at_creation: false,
309            });
310            BlendPipeline {
311                render_pipeline,
312                bind_group_layout: bgl,
313                sampler,
314                uniform_buf,
315            }
316        })
317    }
318}
319
320#[cfg(feature = "wgpu")]
321impl super::RenderNode for BlendModeNode {
322    fn input_count(&self) -> usize {
323        2
324    }
325
326    fn process(
327        &self,
328        inputs: &[&wgpu::Texture],
329        outputs: &[&wgpu::Texture],
330        ctx: &crate::context::RenderContext,
331    ) {
332        let Some(tex_base) = inputs.first() else {
333            log::warn!("BlendModeNode::process called with no inputs");
334            return;
335        };
336        let Some(output) = outputs.first() else {
337            log::warn!("BlendModeNode::process called with no outputs");
338            return;
339        };
340        let pd = self.get_or_create_pipeline(ctx);
341
342        // Upload overlay frame.
343        let ov_tex = upload_rgba_texture(
344            ctx,
345            &self.overlay_rgba,
346            self.overlay_width,
347            self.overlay_height,
348            "Blend overlay",
349        );
350
351        // Write uniforms: [mode_u32, opacity_f32, pad, pad] = 16 bytes.
352        let mode_bytes = (self.mode as u32).to_le_bytes();
353        let opac_bytes = self.opacity.to_le_bytes();
354        let uniforms: [u8; 16] = [
355            mode_bytes[0],
356            mode_bytes[1],
357            mode_bytes[2],
358            mode_bytes[3],
359            opac_bytes[0],
360            opac_bytes[1],
361            opac_bytes[2],
362            opac_bytes[3],
363            0,
364            0,
365            0,
366            0,
367            0,
368            0,
369            0,
370            0,
371        ];
372        ctx.queue.write_buffer(&pd.uniform_buf, 0, &uniforms);
373
374        let base_view = tex_base.create_view(&wgpu::TextureViewDescriptor::default());
375        let ov_view = ov_tex.create_view(&wgpu::TextureViewDescriptor::default());
376        let out_view = output.create_view(&wgpu::TextureViewDescriptor::default());
377
378        let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
379            label: Some("Blend BG"),
380            layout: &pd.bind_group_layout,
381            entries: &[
382                wgpu::BindGroupEntry {
383                    binding: 0,
384                    resource: wgpu::BindingResource::TextureView(&base_view),
385                },
386                wgpu::BindGroupEntry {
387                    binding: 1,
388                    resource: wgpu::BindingResource::TextureView(&ov_view),
389                },
390                wgpu::BindGroupEntry {
391                    binding: 2,
392                    resource: wgpu::BindingResource::Sampler(&pd.sampler),
393                },
394                wgpu::BindGroupEntry {
395                    binding: 3,
396                    resource: pd.uniform_buf.as_entire_binding(),
397                },
398            ],
399        });
400
401        submit_render_pass(ctx, &pd.render_pipeline, &bind_group, &out_view, "Blend");
402    }
403}
404
405// ── TransformNode ─────────────────────────────────────────────────────────────
406
407#[cfg(feature = "wgpu")]
408struct TransformPipeline {
409    render_pipeline: wgpu::RenderPipeline,
410    bind_group_layout: wgpu::BindGroupLayout,
411    sampler: wgpu::Sampler,
412    uniform_buf: wgpu::Buffer,
413}
414
415/// Apply a 2D affine transform (translate, rotate, scale) to a texture.
416///
417/// Pixels that fall outside the [0, 1] UV range after the inverse transform
418/// are rendered as fully transparent.
419///
420/// The CPU path is a no-op (passthrough); use the GPU path for actual
421/// transformation.
422pub struct TransformNode {
423    /// UV-space translation (positive = shift right/down).
424    pub translate: [f32; 2],
425    /// Counter-clockwise rotation in radians.
426    pub rotate: f32,
427    /// Scale factors (1.0 = no change; > 1.0 = zoom in).
428    pub scale: [f32; 2],
429    #[cfg(feature = "wgpu")]
430    pipeline: std::sync::OnceLock<TransformPipeline>,
431}
432
433impl TransformNode {
434    #[must_use]
435    pub fn new(translate: [f32; 2], rotate: f32, scale: [f32; 2]) -> Self {
436        Self {
437            translate,
438            rotate,
439            scale,
440            #[cfg(feature = "wgpu")]
441            pipeline: std::sync::OnceLock::new(),
442        }
443    }
444}
445
446impl Default for TransformNode {
447    fn default() -> Self {
448        Self::new([0.0, 0.0], 0.0, [1.0, 1.0])
449    }
450}
451
452impl RenderNodeCpu for TransformNode {
453    fn process_cpu(&self, _rgba: &mut [u8], _w: u32, _h: u32) {
454        // Affine transform is not implemented in the CPU fallback path.
455    }
456}
457
458#[cfg(feature = "wgpu")]
459impl TransformNode {
460    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &TransformPipeline {
461        self.pipeline.get_or_init(|| {
462            let device = &ctx.device;
463            let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
464                label: Some("Transform shader"),
465                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/transform.wgsl").into()),
466            });
467            let bgl = one_tex_sampler_uniform_bgl(device, "Transform");
468            let render_pipeline = fullscreen_pipeline(device, &shader, "Transform", &bgl);
469            let sampler = linear_sampler(device, "Transform");
470            // Uniform: translate[2], rotate, _pad, scale[2], _pad, _pad = 32 bytes.
471            let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
472                label: Some("Transform uniforms"),
473                size: 32,
474                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
475                mapped_at_creation: false,
476            });
477            TransformPipeline {
478                render_pipeline,
479                bind_group_layout: bgl,
480                sampler,
481                uniform_buf,
482            }
483        })
484    }
485}
486
487#[cfg(feature = "wgpu")]
488impl super::RenderNode for TransformNode {
489    fn process(
490        &self,
491        inputs: &[&wgpu::Texture],
492        outputs: &[&wgpu::Texture],
493        ctx: &crate::context::RenderContext,
494    ) {
495        let Some(input) = inputs.first() else {
496            log::warn!("TransformNode::process called with no inputs");
497            return;
498        };
499        let Some(output) = outputs.first() else {
500            log::warn!("TransformNode::process called with no outputs");
501            return;
502        };
503        let pd = self.get_or_create_pipeline(ctx);
504
505        // Pack uniforms: translate(2), rotate(1), pad(1), scale(2), pad(2) → 8×f32 = 32 bytes.
506        let uniforms = pack_f32(&[
507            self.translate[0],
508            self.translate[1],
509            self.rotate,
510            0.0,
511            self.scale[0],
512            self.scale[1],
513            0.0,
514            0.0,
515        ]);
516        ctx.queue.write_buffer(&pd.uniform_buf, 0, &uniforms);
517
518        let in_view = input.create_view(&wgpu::TextureViewDescriptor::default());
519        let out_view = output.create_view(&wgpu::TextureViewDescriptor::default());
520
521        let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
522            label: Some("Transform BG"),
523            layout: &pd.bind_group_layout,
524            entries: &[
525                wgpu::BindGroupEntry {
526                    binding: 0,
527                    resource: wgpu::BindingResource::TextureView(&in_view),
528                },
529                wgpu::BindGroupEntry {
530                    binding: 1,
531                    resource: wgpu::BindingResource::Sampler(&pd.sampler),
532                },
533                wgpu::BindGroupEntry {
534                    binding: 2,
535                    resource: pd.uniform_buf.as_entire_binding(),
536                },
537            ],
538        });
539        submit_render_pass(
540            ctx,
541            &pd.render_pipeline,
542            &bind_group,
543            &out_view,
544            "Transform",
545        );
546    }
547}
548
549// ── ChromaKeyNode ─────────────────────────────────────────────────────────────
550
551#[cfg(feature = "wgpu")]
552struct ChromaKeyPipeline {
553    render_pipeline: wgpu::RenderPipeline,
554    bind_group_layout: wgpu::BindGroupLayout,
555    sampler: wgpu::Sampler,
556    uniform_buf: wgpu::Buffer,
557}
558
559/// Remove a solid colour from a texture by chroma distance, producing alpha.
560///
561/// The algorithm computes the Euclidean distance between the pixel's chroma
562/// vector (RGB − luma) and the key colour's chroma vector, then applies a soft
563/// threshold to set the alpha channel.  Pixels that match `key_color` within
564/// `tolerance` become fully transparent; pixels further than `tolerance +
565/// softness` stay fully opaque.
566pub struct ChromaKeyNode {
567    /// Key colour in linear RGB [0.0, 1.0].
568    pub key_color: [f32; 3],
569    /// Chroma distance threshold (0.0–1.0).
570    pub tolerance: f32,
571    /// Edge feather width (0.0–1.0).
572    pub softness: f32,
573    #[cfg(feature = "wgpu")]
574    pipeline: std::sync::OnceLock<ChromaKeyPipeline>,
575}
576
577impl ChromaKeyNode {
578    #[must_use]
579    pub fn new(key_color: [f32; 3], tolerance: f32, softness: f32) -> Self {
580        Self {
581            key_color,
582            tolerance,
583            softness,
584            #[cfg(feature = "wgpu")]
585            pipeline: std::sync::OnceLock::new(),
586        }
587    }
588}
589
590// ── CPU helpers ───────────────────────────────────────────────────────────────
591
592fn bt709_luma(r: f32, g: f32, b: f32) -> f32 {
593    0.2126 * r + 0.7152 * g + 0.0722 * b
594}
595
596fn chroma_dist_cpu(pixel: [f32; 3], key: [f32; 3]) -> f32 {
597    let pl = bt709_luma(pixel[0], pixel[1], pixel[2]);
598    let kl = bt709_luma(key[0], key[1], key[2]);
599    let dp = [pixel[0] - pl, pixel[1] - pl, pixel[2] - pl];
600    let dk = [key[0] - kl, key[1] - kl, key[2] - kl];
601    let d = [dp[0] - dk[0], dp[1] - dk[1], dp[2] - dk[2]];
602    (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
603}
604
605fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 {
606    let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
607    t * t * (3.0 - 2.0 * t)
608}
609
610impl RenderNodeCpu for ChromaKeyNode {
611    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
612    fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
613        for pixel in rgba.chunks_exact_mut(4) {
614            let r = f32::from(pixel[0]) / 255.0;
615            let g = f32::from(pixel[1]) / 255.0;
616            let b = f32::from(pixel[2]) / 255.0;
617            let a = f32::from(pixel[3]) / 255.0;
618            let dist = chroma_dist_cpu([r, g, b], self.key_color);
619            let alpha_factor = smoothstep(
620                self.tolerance - self.softness,
621                self.tolerance + self.softness,
622                dist,
623            );
624            pixel[3] = ((a * alpha_factor).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
625        }
626    }
627}
628
629#[cfg(feature = "wgpu")]
630impl ChromaKeyNode {
631    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &ChromaKeyPipeline {
632        self.pipeline.get_or_init(|| {
633            let device = &ctx.device;
634            let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
635                label: Some("ChromaKey shader"),
636                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/chroma_key.wgsl").into()),
637            });
638            let bgl = one_tex_sampler_uniform_bgl(device, "ChromaKey");
639            let render_pipeline = fullscreen_pipeline(device, &shader, "ChromaKey", &bgl);
640            let sampler = linear_sampler(device, "ChromaKey");
641            // Uniform: key_color(3) + tolerance(1) + softness(1) + pad(3) = 8×f32 = 32 bytes.
642            let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
643                label: Some("ChromaKey uniforms"),
644                size: 32,
645                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
646                mapped_at_creation: false,
647            });
648            ChromaKeyPipeline {
649                render_pipeline,
650                bind_group_layout: bgl,
651                sampler,
652                uniform_buf,
653            }
654        })
655    }
656}
657
658#[cfg(feature = "wgpu")]
659impl super::RenderNode for ChromaKeyNode {
660    fn process(
661        &self,
662        inputs: &[&wgpu::Texture],
663        outputs: &[&wgpu::Texture],
664        ctx: &crate::context::RenderContext,
665    ) {
666        let Some(input) = inputs.first() else {
667            log::warn!("ChromaKeyNode::process called with no inputs");
668            return;
669        };
670        let Some(output) = outputs.first() else {
671            log::warn!("ChromaKeyNode::process called with no outputs");
672            return;
673        };
674        let pd = self.get_or_create_pipeline(ctx);
675
676        let uniforms = pack_f32(&[
677            self.key_color[0],
678            self.key_color[1],
679            self.key_color[2],
680            self.tolerance,
681            self.softness,
682            0.0,
683            0.0,
684            0.0,
685        ]);
686        ctx.queue.write_buffer(&pd.uniform_buf, 0, &uniforms);
687
688        let in_view = input.create_view(&wgpu::TextureViewDescriptor::default());
689        let out_view = output.create_view(&wgpu::TextureViewDescriptor::default());
690
691        let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
692            label: Some("ChromaKey BG"),
693            layout: &pd.bind_group_layout,
694            entries: &[
695                wgpu::BindGroupEntry {
696                    binding: 0,
697                    resource: wgpu::BindingResource::TextureView(&in_view),
698                },
699                wgpu::BindGroupEntry {
700                    binding: 1,
701                    resource: wgpu::BindingResource::Sampler(&pd.sampler),
702                },
703                wgpu::BindGroupEntry {
704                    binding: 2,
705                    resource: pd.uniform_buf.as_entire_binding(),
706                },
707            ],
708        });
709        submit_render_pass(
710            ctx,
711            &pd.render_pipeline,
712            &bind_group,
713            &out_view,
714            "ChromaKey",
715        );
716    }
717}
718
719// ── Shared mask pipeline ──────────────────────────────────────────────────────
720
721#[cfg(feature = "wgpu")]
722struct MaskPipeline {
723    render_pipeline: wgpu::RenderPipeline,
724    bind_group_layout: wgpu::BindGroupLayout,
725    sampler: wgpu::Sampler,
726    uniform_buf: wgpu::Buffer,
727}
728
729#[cfg(feature = "wgpu")]
730fn create_mask_pipeline(ctx: &crate::context::RenderContext) -> MaskPipeline {
731    let device = &ctx.device;
732    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
733        label: Some("Mask shader"),
734        source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/mask.wgsl").into()),
735    });
736    let bgl = two_tex_sampler_uniform_bgl(device, "Mask");
737    let render_pipeline = fullscreen_pipeline(device, &shader, "Mask", &bgl);
738    let sampler = linear_sampler(device, "Mask");
739    let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
740        label: Some("Mask uniforms"),
741        size: 16,
742        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
743        mapped_at_creation: false,
744    });
745    MaskPipeline {
746        render_pipeline,
747        bind_group_layout: bgl,
748        sampler,
749        uniform_buf,
750    }
751}
752
753#[cfg(feature = "wgpu")]
754fn submit_mask_pass(
755    ctx: &crate::context::RenderContext,
756    pd: &MaskPipeline,
757    base_tex: &wgpu::Texture,
758    mask_tex: &wgpu::Texture,
759    output_tex: &wgpu::Texture,
760    mode: u32,
761    label: &str,
762) {
763    let mode_bytes = mode.to_le_bytes();
764    let uniforms: [u8; 16] = [
765        mode_bytes[0],
766        mode_bytes[1],
767        mode_bytes[2],
768        mode_bytes[3],
769        0,
770        0,
771        0,
772        0,
773        0,
774        0,
775        0,
776        0,
777        0,
778        0,
779        0,
780        0,
781    ];
782    ctx.queue.write_buffer(&pd.uniform_buf, 0, &uniforms);
783
784    let base_view = base_tex.create_view(&wgpu::TextureViewDescriptor::default());
785    let mask_view = mask_tex.create_view(&wgpu::TextureViewDescriptor::default());
786    let out_view = output_tex.create_view(&wgpu::TextureViewDescriptor::default());
787
788    let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
789        label: Some(label),
790        layout: &pd.bind_group_layout,
791        entries: &[
792            wgpu::BindGroupEntry {
793                binding: 0,
794                resource: wgpu::BindingResource::TextureView(&base_view),
795            },
796            wgpu::BindGroupEntry {
797                binding: 1,
798                resource: wgpu::BindingResource::TextureView(&mask_view),
799            },
800            wgpu::BindGroupEntry {
801                binding: 2,
802                resource: wgpu::BindingResource::Sampler(&pd.sampler),
803            },
804            wgpu::BindGroupEntry {
805                binding: 3,
806                resource: pd.uniform_buf.as_entire_binding(),
807            },
808        ],
809    });
810    submit_render_pass(ctx, &pd.render_pipeline, &bind_group, &out_view, label);
811}
812
813// ── ShapeMaskNode ─────────────────────────────────────────────────────────────
814
815/// Mask `inputs[0]` using the alpha channel of `inputs[1]` (or `mask_rgba`).
816///
817/// Pixels where the mask alpha is > 0 are kept opaque; all others are made
818/// fully transparent (hard threshold at ~1/255).
819pub struct ShapeMaskNode {
820    /// Mask frame RGBA bytes (required for the CPU path).
821    pub mask_rgba: Vec<u8>,
822    /// Width of `mask_rgba`.
823    pub mask_width: u32,
824    /// Height of `mask_rgba`.
825    pub mask_height: u32,
826    #[cfg(feature = "wgpu")]
827    pipeline: std::sync::OnceLock<MaskPipeline>,
828}
829
830impl ShapeMaskNode {
831    #[must_use]
832    pub fn new(mask_rgba: Vec<u8>, mask_width: u32, mask_height: u32) -> Self {
833        Self {
834            mask_rgba,
835            mask_width,
836            mask_height,
837            #[cfg(feature = "wgpu")]
838            pipeline: std::sync::OnceLock::new(),
839        }
840    }
841}
842
843impl RenderNodeCpu for ShapeMaskNode {
844    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
845    fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
846        if self.mask_rgba.len() != rgba.len() {
847            return;
848        }
849        for (base, mask) in rgba.chunks_exact_mut(4).zip(self.mask_rgba.chunks_exact(4)) {
850            let keep = if mask[3] > 1 { 1.0_f32 } else { 0.0_f32 };
851            let a = f32::from(base[3]) / 255.0;
852            base[3] = ((a * keep).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
853        }
854    }
855}
856
857#[cfg(feature = "wgpu")]
858impl ShapeMaskNode {
859    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &MaskPipeline {
860        self.pipeline.get_or_init(|| create_mask_pipeline(ctx))
861    }
862}
863
864#[cfg(feature = "wgpu")]
865impl super::RenderNode for ShapeMaskNode {
866    fn input_count(&self) -> usize {
867        2
868    }
869
870    fn process(
871        &self,
872        inputs: &[&wgpu::Texture],
873        outputs: &[&wgpu::Texture],
874        ctx: &crate::context::RenderContext,
875    ) {
876        let Some(base_tex) = inputs.first() else {
877            log::warn!("ShapeMaskNode::process called with no inputs");
878            return;
879        };
880        let Some(output) = outputs.first() else {
881            log::warn!("ShapeMaskNode::process called with no outputs");
882            return;
883        };
884        let pd = self.get_or_create_pipeline(ctx);
885        let mask_tex = upload_rgba_texture(
886            ctx,
887            &self.mask_rgba,
888            self.mask_width,
889            self.mask_height,
890            "ShapeMask mask",
891        );
892        submit_mask_pass(ctx, pd, base_tex, &mask_tex, output, 0, "ShapeMask BG");
893    }
894}
895
896// ── LumaMaskNode ──────────────────────────────────────────────────────────────
897
898/// Mask `inputs[0]` using the BT.709 luma of `inputs[1]` (or `mask_rgba`).
899///
900/// The mask luma (0.0–1.0) is multiplied into the base alpha channel.
901pub struct LumaMaskNode {
902    /// Mask frame RGBA bytes (required for the CPU path).
903    pub mask_rgba: Vec<u8>,
904    /// Width of `mask_rgba`.
905    pub mask_width: u32,
906    /// Height of `mask_rgba`.
907    pub mask_height: u32,
908    #[cfg(feature = "wgpu")]
909    pipeline: std::sync::OnceLock<MaskPipeline>,
910}
911
912impl LumaMaskNode {
913    #[must_use]
914    pub fn new(mask_rgba: Vec<u8>, mask_width: u32, mask_height: u32) -> Self {
915        Self {
916            mask_rgba,
917            mask_width,
918            mask_height,
919            #[cfg(feature = "wgpu")]
920            pipeline: std::sync::OnceLock::new(),
921        }
922    }
923}
924
925impl RenderNodeCpu for LumaMaskNode {
926    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
927    fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
928        if self.mask_rgba.len() != rgba.len() {
929            return;
930        }
931        for (base, mask) in rgba.chunks_exact_mut(4).zip(self.mask_rgba.chunks_exact(4)) {
932            let mr = f32::from(mask[0]) / 255.0;
933            let mg = f32::from(mask[1]) / 255.0;
934            let mb = f32::from(mask[2]) / 255.0;
935            let luma = bt709_luma(mr, mg, mb);
936            let ba = f32::from(base[3]) / 255.0;
937            base[3] = ((ba * luma).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
938        }
939    }
940}
941
942#[cfg(feature = "wgpu")]
943impl LumaMaskNode {
944    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &MaskPipeline {
945        self.pipeline.get_or_init(|| create_mask_pipeline(ctx))
946    }
947}
948
949#[cfg(feature = "wgpu")]
950impl super::RenderNode for LumaMaskNode {
951    fn input_count(&self) -> usize {
952        2
953    }
954
955    fn process(
956        &self,
957        inputs: &[&wgpu::Texture],
958        outputs: &[&wgpu::Texture],
959        ctx: &crate::context::RenderContext,
960    ) {
961        let Some(base_tex) = inputs.first() else {
962            log::warn!("LumaMaskNode::process called with no inputs");
963            return;
964        };
965        let Some(output) = outputs.first() else {
966            log::warn!("LumaMaskNode::process called with no outputs");
967            return;
968        };
969        let pd = self.get_or_create_pipeline(ctx);
970        let mask_tex = upload_rgba_texture(
971            ctx,
972            &self.mask_rgba,
973            self.mask_width,
974            self.mask_height,
975            "LumaMask mask",
976        );
977        submit_mask_pass(ctx, pd, base_tex, &mask_tex, output, 1, "LumaMask BG");
978    }
979}
980
981// ── AlphaMatteNode ────────────────────────────────────────────────────────────
982
983/// Porter-Duff src-over: composite `inputs[0]` (foreground) over `inputs[1]`
984/// (background) using the foreground's own alpha channel.
985///
986/// For the CPU path the background data must be stored in `background_rgba`.
987pub struct AlphaMatteNode {
988    /// Background frame RGBA bytes (required for the CPU path).
989    pub background_rgba: Vec<u8>,
990    /// Width of `background_rgba`.
991    pub background_width: u32,
992    /// Height of `background_rgba`.
993    pub background_height: u32,
994    #[cfg(feature = "wgpu")]
995    pipeline: std::sync::OnceLock<MaskPipeline>,
996}
997
998impl AlphaMatteNode {
999    #[must_use]
1000    pub fn new(background_rgba: Vec<u8>, background_width: u32, background_height: u32) -> Self {
1001        Self {
1002            background_rgba,
1003            background_width,
1004            background_height,
1005            #[cfg(feature = "wgpu")]
1006            pipeline: std::sync::OnceLock::new(),
1007        }
1008    }
1009}
1010
1011impl RenderNodeCpu for AlphaMatteNode {
1012    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1013    fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
1014        if self.background_rgba.len() != rgba.len() {
1015            return;
1016        }
1017        for (fg, bg) in rgba
1018            .chunks_exact_mut(4)
1019            .zip(self.background_rgba.chunks_exact(4))
1020        {
1021            let fa = f32::from(fg[3]) / 255.0;
1022            let ba = f32::from(bg[3]) / 255.0;
1023            for ch in 0..3 {
1024                let fc = f32::from(fg[ch]) / 255.0;
1025                let bc = f32::from(bg[ch]) / 255.0;
1026                fg[ch] = ((fc * fa + bc * (1.0 - fa)).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
1027            }
1028            fg[3] = ((fa + ba * (1.0 - fa)).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
1029        }
1030    }
1031}
1032
1033#[cfg(feature = "wgpu")]
1034impl AlphaMatteNode {
1035    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &MaskPipeline {
1036        self.pipeline.get_or_init(|| create_mask_pipeline(ctx))
1037    }
1038}
1039
1040#[cfg(feature = "wgpu")]
1041impl super::RenderNode for AlphaMatteNode {
1042    fn input_count(&self) -> usize {
1043        2
1044    }
1045
1046    fn process(
1047        &self,
1048        inputs: &[&wgpu::Texture],
1049        outputs: &[&wgpu::Texture],
1050        ctx: &crate::context::RenderContext,
1051    ) {
1052        let Some(fg_tex) = inputs.first() else {
1053            log::warn!("AlphaMatteNode::process called with no inputs");
1054            return;
1055        };
1056        let Some(output) = outputs.first() else {
1057            log::warn!("AlphaMatteNode::process called with no outputs");
1058            return;
1059        };
1060        let pd = self.get_or_create_pipeline(ctx);
1061        let bg_tex = upload_rgba_texture(
1062            ctx,
1063            &self.background_rgba,
1064            self.background_width,
1065            self.background_height,
1066            "AlphaMatte bg",
1067        );
1068        submit_mask_pass(ctx, pd, fg_tex, &bg_tex, output, 2, "AlphaMatte BG");
1069    }
1070}
1071
1072// ── Tests ─────────────────────────────────────────────────────────────────────
1073
1074#[cfg(test)]
1075mod tests {
1076    use super::*;
1077
1078    // ── BlendModeNode ─────────────────────────────────────────────────────────
1079
1080    #[test]
1081    fn blend_mode_multiply_should_produce_product_of_base_and_overlay() {
1082        // 50% grey × 50% grey = 25% grey (pixel-exact per acceptance criteria).
1083        let grey50 = vec![128u8, 128, 128, 255];
1084        let node = BlendModeNode::new(BlendMode::Multiply, 1.0, grey50.clone(), 1, 1);
1085        let mut rgba = grey50;
1086        node.process_cpu(&mut rgba, 1, 1);
1087        // 128/255 * 128/255 * 255 ≈ 64.25 → 64 or 65.
1088        let expected = (128.0_f32 / 255.0 * 128.0 / 255.0 * 255.0 + 0.5) as u8;
1089        let diff = (rgba[0] as i32 - expected as i32).abs();
1090        assert!(
1091            diff <= 1,
1092            "Multiply 50%×50% grey: expected ~{expected}, got {}",
1093            rgba[0]
1094        );
1095    }
1096
1097    #[test]
1098    fn blend_mode_screen_should_be_brighter_than_either_input() {
1099        let base = vec![100u8, 100, 100, 255];
1100        let overlay = vec![150u8, 150, 150, 255];
1101        let node = BlendModeNode::new(BlendMode::Screen, 1.0, overlay, 1, 1);
1102        let mut rgba = base;
1103        node.process_cpu(&mut rgba, 1, 1);
1104        assert!(
1105            rgba[0] > 150,
1106            "Screen must be brighter than max input; got {}",
1107            rgba[0]
1108        );
1109    }
1110
1111    #[test]
1112    fn blend_mode_normal_at_full_opacity_should_replace_base_with_overlay() {
1113        let base = vec![50u8, 50, 50, 255];
1114        let overlay = vec![200u8, 100, 50, 255];
1115        let node = BlendModeNode::new(BlendMode::Normal, 1.0, overlay, 1, 1);
1116        let mut rgba = base;
1117        node.process_cpu(&mut rgba, 1, 1);
1118        assert!(
1119            (rgba[0] as i32 - 200).abs() <= 1,
1120            "R should match overlay; got {}",
1121            rgba[0]
1122        );
1123        assert!(
1124            (rgba[1] as i32 - 100).abs() <= 1,
1125            "G should match overlay; got {}",
1126            rgba[1]
1127        );
1128    }
1129
1130    #[test]
1131    fn blend_mode_normal_at_zero_opacity_should_leave_base_unchanged() {
1132        let base = vec![50u8, 80, 120, 255];
1133        let overlay = vec![200u8, 200, 200, 255];
1134        let node = BlendModeNode::new(BlendMode::Normal, 0.0, overlay, 1, 1);
1135        let mut rgba = base.clone();
1136        node.process_cpu(&mut rgba, 1, 1);
1137        assert!(
1138            (rgba[0] as i32 - 50).abs() <= 1,
1139            "R should match base; got {}",
1140            rgba[0]
1141        );
1142    }
1143
1144    #[test]
1145    fn blend_mode_difference_of_equal_pixels_should_be_black() {
1146        let grey = vec![128u8, 128, 128, 255];
1147        let node = BlendModeNode::new(BlendMode::Difference, 1.0, grey.clone(), 1, 1);
1148        let mut rgba = grey;
1149        node.process_cpu(&mut rgba, 1, 1);
1150        assert!(
1151            rgba[0] <= 1,
1152            "Difference of same pixel must be ~black; got {}",
1153            rgba[0]
1154        );
1155    }
1156
1157    #[test]
1158    fn blend_mode_add_should_clamp_at_white() {
1159        let bright = vec![200u8, 200, 200, 255];
1160        let node = BlendModeNode::new(BlendMode::Add, 1.0, bright.clone(), 1, 1);
1161        let mut rgba = bright;
1162        node.process_cpu(&mut rgba, 1, 1);
1163        assert_eq!(rgba[0], 255, "Add of two bright values must clamp to 255");
1164    }
1165
1166    #[test]
1167    fn blend_mode_darken_should_return_minimum_channel() {
1168        let base = vec![100u8, 200, 50, 255];
1169        let overlay = vec![150u8, 50, 100, 255];
1170        let node = BlendModeNode::new(BlendMode::Darken, 1.0, overlay, 1, 1);
1171        let mut rgba = base;
1172        node.process_cpu(&mut rgba, 1, 1);
1173        assert!(
1174            (rgba[0] as i32 - 100).abs() <= 1,
1175            "Darken R: min(100,150)=100; got {}",
1176            rgba[0]
1177        );
1178        assert!(
1179            (rgba[1] as i32 - 50).abs() <= 1,
1180            "Darken G: min(200,50)=50; got {}",
1181            rgba[1]
1182        );
1183        assert!(
1184            (rgba[2] as i32 - 50).abs() <= 1,
1185            "Darken B: min(50,100)=50; got {}",
1186            rgba[2]
1187        );
1188    }
1189
1190    #[test]
1191    fn blend_mode_size_mismatch_should_be_noop() {
1192        let overlay = vec![200u8; 8];
1193        let node = BlendModeNode::new(BlendMode::Normal, 1.0, overlay, 2, 1);
1194        let original = vec![50u8, 80, 120, 255];
1195        let mut rgba = original.clone();
1196        node.process_cpu(&mut rgba, 1, 1);
1197        assert_eq!(rgba, original, "size mismatch must leave base unchanged");
1198    }
1199
1200    // ── TransformNode ─────────────────────────────────────────────────────────
1201
1202    #[test]
1203    fn transform_node_cpu_path_should_be_passthrough() {
1204        let node = TransformNode::new([0.1, 0.0], 0.0, [2.0, 2.0]);
1205        let original = vec![10u8, 20, 30, 255];
1206        let mut rgba = original.clone();
1207        node.process_cpu(&mut rgba, 1, 1);
1208        assert_eq!(rgba, original, "TransformNode CPU must be a no-op");
1209    }
1210
1211    #[test]
1212    fn transform_node_default_should_be_identity() {
1213        let node = TransformNode::default();
1214        assert_eq!(node.translate, [0.0, 0.0]);
1215        assert_eq!(node.rotate, 0.0);
1216        assert_eq!(node.scale, [1.0, 1.0]);
1217    }
1218
1219    // ── ChromaKeyNode ─────────────────────────────────────────────────────────
1220
1221    #[test]
1222    fn chroma_key_node_pure_green_should_become_transparent() {
1223        let mut rgba = vec![0u8, 255, 0, 255]; // pure green
1224        let node = ChromaKeyNode::new([0.0, 1.0, 0.0], 0.1, 0.05);
1225        node.process_cpu(&mut rgba, 1, 1);
1226        assert_eq!(
1227            rgba[3], 0,
1228            "pure green key must produce fully transparent alpha"
1229        );
1230    }
1231
1232    #[test]
1233    fn chroma_key_node_non_key_colour_should_stay_opaque() {
1234        let mut rgba = vec![255u8, 0, 0, 255]; // pure red
1235        let node = ChromaKeyNode::new([0.0, 1.0, 0.0], 0.1, 0.05);
1236        node.process_cpu(&mut rgba, 1, 1);
1237        assert!(
1238            rgba[3] > 200,
1239            "non-key colour must stay opaque; got alpha={}",
1240            rgba[3]
1241        );
1242    }
1243
1244    #[test]
1245    fn chroma_key_node_tolerances_should_control_threshold() {
1246        // A dark green should be keyed with a generous tolerance but not with a tight one.
1247        let mut rgba_tight = vec![0u8, 100, 0, 255]; // dark green
1248        let mut rgba_loose = rgba_tight.clone();
1249        let node_tight = ChromaKeyNode::new([0.0, 1.0, 0.0], 0.05, 0.01);
1250        let node_loose = ChromaKeyNode::new([0.0, 1.0, 0.0], 0.8, 0.1);
1251        node_tight.process_cpu(&mut rgba_tight, 1, 1);
1252        node_loose.process_cpu(&mut rgba_loose, 1, 1);
1253        assert!(
1254            rgba_loose[3] < rgba_tight[3],
1255            "loose tolerance must key more aggressively than tight"
1256        );
1257    }
1258
1259    // ── ShapeMaskNode ─────────────────────────────────────────────────────────
1260
1261    #[test]
1262    fn shape_mask_node_opaque_mask_should_keep_base_alpha() {
1263        let mask = vec![0u8, 0, 0, 255]; // fully opaque mask
1264        let node = ShapeMaskNode::new(mask, 1, 1);
1265        let mut rgba = vec![128u8, 128, 128, 200];
1266        node.process_cpu(&mut rgba, 1, 1);
1267        assert!(
1268            (rgba[3] as i32 - 200).abs() <= 1,
1269            "opaque mask must preserve base alpha"
1270        );
1271    }
1272
1273    #[test]
1274    fn shape_mask_node_transparent_mask_should_zero_alpha() {
1275        let mask = vec![255u8, 255, 255, 0]; // fully transparent mask
1276        let node = ShapeMaskNode::new(mask, 1, 1);
1277        let mut rgba = vec![128u8, 128, 128, 255];
1278        node.process_cpu(&mut rgba, 1, 1);
1279        assert_eq!(rgba[3], 0, "transparent mask must produce zero alpha");
1280    }
1281
1282    // ── LumaMaskNode ─────────────────────────────────────────────────────────
1283
1284    #[test]
1285    fn luma_mask_node_white_mask_should_preserve_alpha() {
1286        let mask = vec![255u8, 255, 255, 255]; // white → luma = 1.0
1287        let node = LumaMaskNode::new(mask, 1, 1);
1288        let mut rgba = vec![100u8, 100, 100, 200];
1289        node.process_cpu(&mut rgba, 1, 1);
1290        assert!(
1291            (rgba[3] as i32 - 200).abs() <= 2,
1292            "white mask preserves alpha"
1293        );
1294    }
1295
1296    #[test]
1297    fn luma_mask_node_black_mask_should_zero_alpha() {
1298        let mask = vec![0u8, 0, 0, 255]; // black → luma = 0.0
1299        let node = LumaMaskNode::new(mask, 1, 1);
1300        let mut rgba = vec![100u8, 100, 100, 255];
1301        node.process_cpu(&mut rgba, 1, 1);
1302        assert_eq!(rgba[3], 0, "black mask must zero out alpha");
1303    }
1304
1305    // ── AlphaMatteNode ───────────────────────────────────────────────────────
1306
1307    #[test]
1308    fn alpha_matte_node_opaque_fg_should_replace_background() {
1309        let bg = vec![50u8, 50, 50, 255];
1310        let node = AlphaMatteNode::new(bg, 1, 1);
1311        let mut fg = vec![200u8, 100, 50, 255]; // fully opaque fg
1312        node.process_cpu(&mut fg, 1, 1);
1313        assert!(
1314            (fg[0] as i32 - 200).abs() <= 1,
1315            "opaque fg must dominate; got {}",
1316            fg[0]
1317        );
1318    }
1319
1320    #[test]
1321    fn alpha_matte_node_transparent_fg_should_show_background() {
1322        let bg = vec![50u8, 80, 120, 255];
1323        let node = AlphaMatteNode::new(bg, 1, 1);
1324        let mut fg = vec![200u8, 200, 200, 0]; // fully transparent fg
1325        node.process_cpu(&mut fg, 1, 1);
1326        assert!(
1327            (fg[0] as i32 - 50).abs() <= 1,
1328            "transparent fg must show bg; got {}",
1329            fg[0]
1330        );
1331    }
1332
1333    // ── Type-check ────────────────────────────────────────────────────────────
1334
1335    #[test]
1336    fn all_blend_mode_variants_should_compile() {
1337        let modes = [
1338            BlendMode::Normal,
1339            BlendMode::Multiply,
1340            BlendMode::Screen,
1341            BlendMode::Overlay,
1342            BlendMode::SoftLight,
1343            BlendMode::HardLight,
1344            BlendMode::ColorDodge,
1345            BlendMode::ColorBurn,
1346            BlendMode::Difference,
1347            BlendMode::Exclusion,
1348            BlendMode::Add,
1349            BlendMode::Subtract,
1350            BlendMode::Darken,
1351            BlendMode::Lighten,
1352            BlendMode::Hue,
1353            BlendMode::Saturation,
1354            BlendMode::Color,
1355            BlendMode::Luminosity,
1356        ];
1357        assert_eq!(modes.len(), 18);
1358    }
1359}
1360
1361// ── GPU helpers (shared) ──────────────────────────────────────────────────────
1362
1363#[cfg(feature = "wgpu")]
1364pub(crate) fn linear_sampler(device: &wgpu::Device, label: &str) -> wgpu::Sampler {
1365    device.create_sampler(&wgpu::SamplerDescriptor {
1366        label: Some(&format!("{label} sampler")),
1367        address_mode_u: wgpu::AddressMode::ClampToEdge,
1368        address_mode_v: wgpu::AddressMode::ClampToEdge,
1369        mag_filter: wgpu::FilterMode::Linear,
1370        min_filter: wgpu::FilterMode::Linear,
1371        ..Default::default()
1372    })
1373}
1374
1375/// Build a BGL with one texture + one sampler + one uniform buffer.
1376#[cfg(feature = "wgpu")]
1377pub(crate) fn one_tex_sampler_uniform_bgl(
1378    device: &wgpu::Device,
1379    label: &str,
1380) -> wgpu::BindGroupLayout {
1381    device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1382        label: Some(&format!("{label} BGL")),
1383        entries: &[
1384            wgpu::BindGroupLayoutEntry {
1385                binding: 0,
1386                visibility: wgpu::ShaderStages::FRAGMENT,
1387                ty: wgpu::BindingType::Texture {
1388                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
1389                    view_dimension: wgpu::TextureViewDimension::D2,
1390                    multisampled: false,
1391                },
1392                count: None,
1393            },
1394            wgpu::BindGroupLayoutEntry {
1395                binding: 1,
1396                visibility: wgpu::ShaderStages::FRAGMENT,
1397                ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
1398                count: None,
1399            },
1400            wgpu::BindGroupLayoutEntry {
1401                binding: 2,
1402                visibility: wgpu::ShaderStages::FRAGMENT,
1403                ty: wgpu::BindingType::Buffer {
1404                    ty: wgpu::BufferBindingType::Uniform,
1405                    has_dynamic_offset: false,
1406                    min_binding_size: None,
1407                },
1408                count: None,
1409            },
1410        ],
1411    })
1412}
1413
1414/// Build a BGL with two textures + one sampler + one uniform buffer.
1415#[cfg(feature = "wgpu")]
1416pub(crate) fn two_tex_sampler_uniform_bgl(
1417    device: &wgpu::Device,
1418    label: &str,
1419) -> wgpu::BindGroupLayout {
1420    device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1421        label: Some(&format!("{label} BGL")),
1422        entries: &[
1423            wgpu::BindGroupLayoutEntry {
1424                binding: 0,
1425                visibility: wgpu::ShaderStages::FRAGMENT,
1426                ty: wgpu::BindingType::Texture {
1427                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
1428                    view_dimension: wgpu::TextureViewDimension::D2,
1429                    multisampled: false,
1430                },
1431                count: None,
1432            },
1433            wgpu::BindGroupLayoutEntry {
1434                binding: 1,
1435                visibility: wgpu::ShaderStages::FRAGMENT,
1436                ty: wgpu::BindingType::Texture {
1437                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
1438                    view_dimension: wgpu::TextureViewDimension::D2,
1439                    multisampled: false,
1440                },
1441                count: None,
1442            },
1443            wgpu::BindGroupLayoutEntry {
1444                binding: 2,
1445                visibility: wgpu::ShaderStages::FRAGMENT,
1446                ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
1447                count: None,
1448            },
1449            wgpu::BindGroupLayoutEntry {
1450                binding: 3,
1451                visibility: wgpu::ShaderStages::FRAGMENT,
1452                ty: wgpu::BindingType::Buffer {
1453                    ty: wgpu::BufferBindingType::Uniform,
1454                    has_dynamic_offset: false,
1455                    min_binding_size: None,
1456                },
1457                count: None,
1458            },
1459        ],
1460    })
1461}
1462
1463#[cfg(feature = "wgpu")]
1464pub(crate) fn fullscreen_pipeline(
1465    device: &wgpu::Device,
1466    shader: &wgpu::ShaderModule,
1467    label: &str,
1468    bgl: &wgpu::BindGroupLayout,
1469) -> wgpu::RenderPipeline {
1470    let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1471        label: Some(&format!("{label} layout")),
1472        bind_group_layouts: &[Some(bgl)],
1473        immediate_size: 0,
1474    });
1475    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1476        label: Some(&format!("{label} pipeline")),
1477        layout: Some(&layout),
1478        vertex: wgpu::VertexState {
1479            module: shader,
1480            entry_point: Some("vs_main"),
1481            buffers: &[],
1482            compilation_options: wgpu::PipelineCompilationOptions::default(),
1483        },
1484        fragment: Some(wgpu::FragmentState {
1485            module: shader,
1486            entry_point: Some("fs_main"),
1487            targets: &[Some(wgpu::ColorTargetState {
1488                format: wgpu::TextureFormat::Rgba8Unorm,
1489                blend: None,
1490                write_mask: wgpu::ColorWrites::ALL,
1491            })],
1492            compilation_options: wgpu::PipelineCompilationOptions::default(),
1493        }),
1494        primitive: wgpu::PrimitiveState::default(),
1495        depth_stencil: None,
1496        multisample: wgpu::MultisampleState::default(),
1497        multiview_mask: None,
1498        cache: None,
1499    })
1500}
1501
1502#[cfg(feature = "wgpu")]
1503pub(crate) fn upload_rgba_texture(
1504    ctx: &crate::context::RenderContext,
1505    data: &[u8],
1506    width: u32,
1507    height: u32,
1508    label: &str,
1509) -> wgpu::Texture {
1510    let tex = ctx.device.create_texture(&wgpu::TextureDescriptor {
1511        label: Some(label),
1512        size: wgpu::Extent3d {
1513            width,
1514            height,
1515            depth_or_array_layers: 1,
1516        },
1517        mip_level_count: 1,
1518        sample_count: 1,
1519        dimension: wgpu::TextureDimension::D2,
1520        format: wgpu::TextureFormat::Rgba8Unorm,
1521        usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
1522        view_formats: &[],
1523    });
1524    ctx.queue.write_texture(
1525        wgpu::TexelCopyTextureInfo {
1526            texture: &tex,
1527            mip_level: 0,
1528            origin: wgpu::Origin3d::ZERO,
1529            aspect: wgpu::TextureAspect::All,
1530        },
1531        data,
1532        wgpu::TexelCopyBufferLayout {
1533            offset: 0,
1534            bytes_per_row: Some(width * 4),
1535            rows_per_image: None,
1536        },
1537        wgpu::Extent3d {
1538            width,
1539            height,
1540            depth_or_array_layers: 1,
1541        },
1542    );
1543    tex
1544}
1545
1546#[cfg(feature = "wgpu")]
1547pub(crate) fn submit_render_pass(
1548    ctx: &crate::context::RenderContext,
1549    pipeline: &wgpu::RenderPipeline,
1550    bind_group: &wgpu::BindGroup,
1551    out_view: &wgpu::TextureView,
1552    label: &str,
1553) {
1554    let mut encoder = ctx
1555        .device
1556        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1557            label: Some(&format!("{label} encoder")),
1558        });
1559    {
1560        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1561            label: Some(&format!("{label} pass")),
1562            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1563                view: out_view,
1564                resolve_target: None,
1565                depth_slice: None,
1566                ops: wgpu::Operations {
1567                    load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
1568                    store: wgpu::StoreOp::Store,
1569                },
1570            })],
1571            depth_stencil_attachment: None,
1572            timestamp_writes: None,
1573            occlusion_query_set: None,
1574            multiview_mask: None,
1575        });
1576        pass.set_pipeline(pipeline);
1577        pass.set_bind_group(0, bind_group, &[]);
1578        pass.draw(0..6, 0..1);
1579    }
1580    ctx.queue.submit(std::iter::once(encoder.finish()));
1581}
1582
1583#[cfg(feature = "wgpu")]
1584fn pack_f32(values: &[f32]) -> Vec<u8> {
1585    values.iter().flat_map(|f| f.to_le_bytes()).collect()
1586}