Skip to main content

oximedia_gpu/ops/
composite.rs

1//! GPU-accelerated image compositing with alpha blending and blend modes.
2//!
3//! Implements four standard Porter-Duff / Photoshop-style blend modes:
4//!
5//! - **Normal** – alpha-compositing straight over operation.
6//! - **Multiply** – each channel is multiplied together.
7//! - **Screen** – inverse-multiply: `1 - (1-a)(1-b)`.
8//! - **Overlay** – hard-light combination of multiply/screen.
9//!
10//! All operations run on CPU with rayon SIMD-friendly parallel processing as
11//! a CPU fallback path (GPU compute shader path can be wired in later via
12//! the existing `GpuDevice` infrastructure).
13
14use crate::{GpuDevice, GpuError, Result};
15use rayon::prelude::*;
16
17use super::utils;
18
19// ─────────────────────────────────────────────────────────────────────────────
20// Public API types
21// ─────────────────────────────────────────────────────────────────────────────
22
23/// Blend mode for layer compositing.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum BlendMode {
26    /// Standard alpha-compositing (Porter-Duff over).
27    Normal,
28    /// Multiply blend: `dst * src` per channel.
29    Multiply,
30    /// Screen blend: `1 - (1 - dst) * (1 - src)` per channel.
31    Screen,
32    /// Overlay: multiply for dark areas, screen for light areas.
33    Overlay,
34    /// Hard Light: overlay with source and destination roles swapped.
35    /// Multiply if src < 0.5, screen otherwise.
36    HardLight,
37    /// Color Dodge: brightens the base colour to reflect the blend colour.
38    /// `dst / (1 - src)`, clamped to 1.
39    ColorDodge,
40    /// Color Burn: darkens the base colour to reflect the blend colour.
41    /// `1 - (1 - dst) / src`, clamped to 0.
42    ColorBurn,
43    /// Linear Burn: `dst + src - 1`, clamped to 0.
44    LinearBurn,
45    /// Exclusion: `dst + src - 2 * dst * src`.
46    Exclusion,
47}
48
49impl Default for BlendMode {
50    fn default() -> Self {
51        Self::Normal
52    }
53}
54
55/// A single compositing layer.
56///
57/// The pixel data must be RGBA (4 bytes per pixel), packed row-major.
58#[derive(Debug, Clone)]
59pub struct BlendLayer<'a> {
60    /// Raw RGBA pixel data.
61    pub data: &'a [u8],
62    /// Layer width in pixels.
63    pub width: u32,
64    /// Layer height in pixels.
65    pub height: u32,
66    /// Global opacity in `[0.0, 1.0]` (multiplied into the alpha channel).
67    pub opacity: f32,
68    /// Blend mode applied when compositing this layer onto the accumulator.
69    pub blend_mode: BlendMode,
70}
71
72impl<'a> BlendLayer<'a> {
73    /// Create a new layer with the given parameters.
74    ///
75    /// # Errors
76    ///
77    /// Returns an error if `opacity` is outside `[0.0, 1.0]` or if the
78    /// buffer is too small for the given dimensions.
79    pub fn new(
80        data: &'a [u8],
81        width: u32,
82        height: u32,
83        opacity: f32,
84        blend_mode: BlendMode,
85    ) -> Result<Self> {
86        if !(0.0..=1.0).contains(&opacity) {
87            return Err(GpuError::Internal(format!(
88                "Layer opacity {opacity} is outside [0,1]"
89            )));
90        }
91        utils::validate_buffer_size(data, width, height, 4)?;
92        Ok(Self {
93            data,
94            width,
95            height,
96            opacity,
97            blend_mode,
98        })
99    }
100}
101
102// ─────────────────────────────────────────────────────────────────────────────
103// LayerCompositor
104// ─────────────────────────────────────────────────────────────────────────────
105
106/// Composites a stack of [`BlendLayer`]s onto a destination buffer.
107pub struct LayerCompositor;
108
109impl LayerCompositor {
110    /// Composite `layers` (bottom-to-top order) into `output`.
111    ///
112    /// * `output` must be `width * height * 4` bytes and is pre-cleared to
113    ///   transparent black before compositing begins.
114    /// * All layers must have the same `width` × `height` dimensions as the
115    ///   output buffer.
116    ///
117    /// `device` is kept as a parameter for future GPU compute shader dispatch;
118    /// the current implementation is a CPU-parallel fallback.
119    ///
120    /// # Errors
121    ///
122    /// Returns an error if:
123    /// - `output` is not `width * height * 4` bytes.
124    /// - Any layer has mismatched dimensions.
125    /// - Dimensions are zero or exceed 16 384.
126    pub fn blend_layers(
127        _device: &GpuDevice,
128        layers: &[BlendLayer<'_>],
129        output: &mut [u8],
130        width: u32,
131        height: u32,
132    ) -> Result<()> {
133        Self::blend_layers_cpu(layers, output, width, height)
134    }
135
136    /// CPU-only variant — useful for unit tests and CPU fallback paths.
137    ///
138    /// # Errors
139    ///
140    /// Same conditions as `blend_layers`.
141    pub fn blend_layers_cpu(
142        layers: &[BlendLayer<'_>],
143        output: &mut [u8],
144        width: u32,
145        height: u32,
146    ) -> Result<()> {
147        utils::validate_dimensions(width, height)?;
148        let expected = (width * height * 4) as usize;
149        if output.len() < expected {
150            return Err(GpuError::InvalidBufferSize {
151                expected,
152                actual: output.len(),
153            });
154        }
155
156        // Validate every layer dimensions up-front.
157        for (idx, layer) in layers.iter().enumerate() {
158            if layer.width != width || layer.height != height {
159                return Err(GpuError::Internal(format!(
160                    "Layer {idx} dimensions {}×{} do not match output {}×{}",
161                    layer.width, layer.height, width, height
162                )));
163            }
164        }
165
166        // Start with transparent black.
167        output[..expected].fill(0);
168
169        // Composite layers bottom-to-top.
170        for layer in layers {
171            Self::composite_layer(layer, output, width, height)?;
172        }
173
174        Ok(())
175    }
176
177    /// Composite a single layer onto the running accumulator.
178    fn composite_layer(
179        layer: &BlendLayer<'_>,
180        acc: &mut [u8],
181        width: u32,
182        height: u32,
183    ) -> Result<()> {
184        let n_pixels = (width * height) as usize;
185        let opacity = layer.opacity;
186        let mode = layer.blend_mode;
187
188        acc.par_chunks_exact_mut(4)
189            .zip(layer.data.par_chunks_exact(4))
190            .take(n_pixels)
191            .for_each(|(dst, src)| {
192                // Normalise to [0,1].
193                let dr = dst[0] as f32 / 255.0;
194                let dg = dst[1] as f32 / 255.0;
195                let db = dst[2] as f32 / 255.0;
196                let da = dst[3] as f32 / 255.0;
197
198                let sr = src[0] as f32 / 255.0;
199                let sg = src[1] as f32 / 255.0;
200                let sb = src[2] as f32 / 255.0;
201                let sa = (src[3] as f32 / 255.0) * opacity;
202
203                // Apply blend function to colour channels.
204                let (br, bg, bb) = apply_blend(mode, sr, sg, sb, dr, dg, db);
205
206                // Porter-Duff over composite using blended colour.
207                let out_a = sa + da * (1.0 - sa);
208                let (or, og, ob) = if out_a > 1e-6 {
209                    (
210                        (br * sa + dr * da * (1.0 - sa)) / out_a,
211                        (bg * sa + dg * da * (1.0 - sa)) / out_a,
212                        (bb * sa + db * da * (1.0 - sa)) / out_a,
213                    )
214                } else {
215                    (0.0, 0.0, 0.0)
216                };
217
218                dst[0] = (or.clamp(0.0, 1.0) * 255.0).round() as u8;
219                dst[1] = (og.clamp(0.0, 1.0) * 255.0).round() as u8;
220                dst[2] = (ob.clamp(0.0, 1.0) * 255.0).round() as u8;
221                dst[3] = (out_a.clamp(0.0, 1.0) * 255.0).round() as u8;
222            });
223
224        Ok(())
225    }
226}
227
228// ─────────────────────────────────────────────────────────────────────────────
229// Blend mode arithmetic
230// ─────────────────────────────────────────────────────────────────────────────
231
232/// Apply a blend mode to a single colour channel (src over dst).
233/// Returns (r, g, b) after blending — alpha compositing is handled by the caller.
234#[inline(always)]
235fn apply_blend(
236    mode: BlendMode,
237    sr: f32,
238    sg: f32,
239    sb: f32,
240    dr: f32,
241    dg: f32,
242    db: f32,
243) -> (f32, f32, f32) {
244    match mode {
245        BlendMode::Normal => (sr, sg, sb),
246        BlendMode::Multiply => (sr * dr, sg * dg, sb * db),
247        BlendMode::Screen => (screen(sr, dr), screen(sg, dg), screen(sb, db)),
248        BlendMode::Overlay => (overlay(dr, sr), overlay(dg, sg), overlay(db, sb)),
249        BlendMode::HardLight => (hard_light(sr, dr), hard_light(sg, dg), hard_light(sb, db)),
250        BlendMode::ColorDodge => (
251            color_dodge(sr, dr),
252            color_dodge(sg, dg),
253            color_dodge(sb, db),
254        ),
255        BlendMode::ColorBurn => (color_burn(sr, dr), color_burn(sg, dg), color_burn(sb, db)),
256        BlendMode::LinearBurn => (
257            linear_burn(sr, dr),
258            linear_burn(sg, dg),
259            linear_burn(sb, db),
260        ),
261        BlendMode::Exclusion => (exclusion(sr, dr), exclusion(sg, dg), exclusion(sb, db)),
262    }
263}
264
265/// Screen blend for a single channel.
266#[inline(always)]
267fn screen(a: f32, b: f32) -> f32 {
268    1.0 - (1.0 - a) * (1.0 - b)
269}
270
271/// Overlay blend for a single channel (base = dst, blend = src).
272#[inline(always)]
273fn overlay(base: f32, blend: f32) -> f32 {
274    if base < 0.5 {
275        2.0 * base * blend
276    } else {
277        1.0 - 2.0 * (1.0 - base) * (1.0 - blend)
278    }
279}
280
281/// Hard Light blend: overlay with src/dst roles swapped.
282/// Multiply when src < 0.5; screen otherwise.
283#[inline(always)]
284fn hard_light(src: f32, dst: f32) -> f32 {
285    if src < 0.5 {
286        2.0 * src * dst
287    } else {
288        1.0 - 2.0 * (1.0 - src) * (1.0 - dst)
289    }
290}
291
292/// Color Dodge: `dst / (1 - src)` — brightens the destination.
293#[inline(always)]
294fn color_dodge(src: f32, dst: f32) -> f32 {
295    if src >= 1.0 {
296        1.0
297    } else {
298        (dst / (1.0 - src)).min(1.0)
299    }
300}
301
302/// Color Burn: `1 - (1 - dst) / src` — darkens the destination.
303#[inline(always)]
304fn color_burn(src: f32, dst: f32) -> f32 {
305    if src <= 0.0 {
306        0.0
307    } else {
308        (1.0 - (1.0 - dst) / src).max(0.0)
309    }
310}
311
312/// Linear Burn: `dst + src - 1`, clamped to 0.
313#[inline(always)]
314fn linear_burn(src: f32, dst: f32) -> f32 {
315    (dst + src - 1.0).max(0.0)
316}
317
318/// Exclusion: `dst + src - 2 * dst * src`.
319#[inline(always)]
320fn exclusion(src: f32, dst: f32) -> f32 {
321    dst + src - 2.0 * dst * src
322}
323
324// ─────────────────────────────────────────────────────────────────────────────
325// Tests
326// ─────────────────────────────────────────────────────────────────────────────
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    fn solid_rgba(w: u32, h: u32, r: u8, g: u8, b: u8, a: u8) -> Vec<u8> {
333        let mut v = vec![0u8; (w * h * 4) as usize];
334        for px in v.chunks_exact_mut(4) {
335            px[0] = r;
336            px[1] = g;
337            px[2] = b;
338            px[3] = a;
339        }
340        v
341    }
342
343    // ── blend mode functions ─────────────────────────────────────────────────
344
345    #[test]
346    fn test_screen_blend_identity() {
347        // screen(0, x) == x
348        assert!((screen(0.0, 0.7) - 0.7).abs() < 1e-6);
349        // screen(1, x) == 1
350        assert!((screen(1.0, 0.5) - 1.0).abs() < 1e-6);
351    }
352
353    #[test]
354    fn test_multiply_blend_zero() {
355        let (r, _g, b) = apply_blend(BlendMode::Multiply, 0.0, 0.5, 1.0, 0.5, 0.5, 0.5);
356        assert!((r - 0.0).abs() < 1e-6);
357        assert!((b - 0.5).abs() < 1e-6);
358    }
359
360    #[test]
361    fn test_overlay_midpoint() {
362        // overlay(0.5, 0.5) should be 0.5
363        let v = overlay(0.5, 0.5);
364        assert!((v - 0.5).abs() < 1e-6, "overlay midpoint: {v}");
365    }
366
367    // ── LayerCompositor::blend_layers ────────────────────────────────────────
368
369    #[test]
370    fn test_blend_zero_layers_produces_black() {
371        let w = 4u32;
372        let h = 4u32;
373        let mut output = solid_rgba(w, h, 255, 255, 255, 255);
374        // Overwrite with some non-zero data to confirm clearing happens.
375        let result = LayerCompositor::blend_layers_cpu(&[], &mut output, w, h);
376        assert!(result.is_ok());
377        // Should have been cleared to transparent black.
378        for &v in &output {
379            assert_eq!(v, 0, "zero layers should produce transparent black");
380        }
381    }
382
383    #[test]
384    fn test_blend_single_fully_opaque_layer() {
385        let w = 4u32;
386        let h = 4u32;
387        let src = solid_rgba(w, h, 200, 100, 50, 255);
388        let layer =
389            BlendLayer::new(&src, w, h, 1.0, BlendMode::Normal).expect("create blend layer");
390        let mut out = vec![0u8; (w * h * 4) as usize];
391        LayerCompositor::blend_layers_cpu(&[layer], &mut out, w, h).expect("blend single layer");
392        for i in 0..(w * h) as usize {
393            assert_eq!(out[i * 4], 200, "red mismatch at pixel {i}");
394            assert_eq!(out[i * 4 + 1], 100, "green mismatch at pixel {i}");
395            assert_eq!(out[i * 4 + 2], 50, "blue mismatch at pixel {i}");
396            assert_eq!(out[i * 4 + 3], 255, "alpha mismatch at pixel {i}");
397        }
398    }
399
400    #[test]
401    fn test_blend_two_layers_normal_over() {
402        let w = 4u32;
403        let h = 4u32;
404        let bg = solid_rgba(w, h, 0, 0, 255, 255); // solid blue
405        let fg = solid_rgba(w, h, 255, 0, 0, 128); // semi-transparent red
406        let layers = [
407            BlendLayer::new(&bg, w, h, 1.0, BlendMode::Normal).expect("create bg layer"),
408            BlendLayer::new(&fg, w, h, 1.0, BlendMode::Normal).expect("create fg layer"),
409        ];
410        let mut out = vec![0u8; (w * h * 4) as usize];
411        LayerCompositor::blend_layers_cpu(&layers, &mut out, w, h).expect("blend two layers");
412        // Output alpha should be fully opaque (255 + semi over solid).
413        for i in 0..(w * h) as usize {
414            assert_eq!(out[i * 4 + 3], 255, "composite alpha should be 255");
415            // Red channel > 0 from the foreground layer.
416            assert!(out[i * 4] > 0, "red should be present");
417        }
418    }
419
420    #[test]
421    fn test_blend_multiply_two_identical_layers() {
422        let w = 4u32;
423        let h = 4u32;
424        // Both layers are 0.5 grey (128), multiply → 0.25 (64) expected.
425        let layer_data = solid_rgba(w, h, 128, 128, 128, 255);
426        let layers = [
427            BlendLayer::new(&layer_data, w, h, 1.0, BlendMode::Normal)
428                .expect("create normal layer"),
429            BlendLayer::new(&layer_data, w, h, 1.0, BlendMode::Multiply)
430                .expect("create multiply layer"),
431        ];
432        let mut out = vec![0u8; (w * h * 4) as usize];
433        LayerCompositor::blend_layers_cpu(&layers, &mut out, w, h).expect("blend multiply layers");
434        // Multiply of ~128/255 ≈ 0.502 with itself → ~0.252 → ~64.
435        for i in 0..(w * h) as usize {
436            let r = out[i * 4];
437            assert!(
438                r >= 60 && r <= 68,
439                "multiply result {r} out of expected range [60,68]"
440            );
441        }
442    }
443
444    #[test]
445    fn test_blend_layer_dimension_mismatch() {
446        let w = 4u32;
447        let h = 4u32;
448        let small = solid_rgba(2, 2, 255, 0, 0, 255);
449        let layer = BlendLayer {
450            data: &small,
451            width: 2,
452            height: 2,
453            opacity: 1.0,
454            blend_mode: BlendMode::Normal,
455        };
456        let mut out = vec![0u8; (w * h * 4) as usize];
457        let result = LayerCompositor::blend_layers_cpu(&[layer], &mut out, w, h);
458        assert!(result.is_err(), "mismatched dimensions should error");
459    }
460
461    #[test]
462    fn test_blend_layer_invalid_opacity() {
463        let data = solid_rgba(4, 4, 0, 0, 0, 255);
464        let result = BlendLayer::new(&data, 4, 4, 1.5, BlendMode::Normal);
465        assert!(result.is_err(), "opacity > 1.0 should error");
466    }
467
468    #[test]
469    fn test_blend_screen_mode() {
470        let w = 4u32;
471        let h = 4u32;
472        let bg = solid_rgba(w, h, 128, 128, 128, 255);
473        let fg = solid_rgba(w, h, 128, 128, 128, 255);
474        let layers = [
475            BlendLayer::new(&bg, w, h, 1.0, BlendMode::Normal).expect("create bg layer"),
476            BlendLayer::new(&fg, w, h, 1.0, BlendMode::Screen).expect("create screen fg layer"),
477        ];
478        let mut out = vec![0u8; (w * h * 4) as usize];
479        LayerCompositor::blend_layers_cpu(&layers, &mut out, w, h).expect("blend screen layers");
480        // Screen of ~0.5 with ~0.5 → 1-(0.5*0.5) = 0.75 → ~191.
481        for i in 0..(w * h) as usize {
482            let r = out[i * 4];
483            assert!(
484                r >= 185 && r <= 197,
485                "screen result {r} out of expected range [185,197]"
486            );
487        }
488    }
489
490    #[test]
491    fn test_blend_overlay_mode() {
492        let w = 4u32;
493        let h = 4u32;
494        let bg = solid_rgba(w, h, 100, 200, 50, 255);
495        let fg = solid_rgba(w, h, 200, 100, 150, 255);
496        let layers = [
497            BlendLayer::new(&bg, w, h, 1.0, BlendMode::Normal).expect("create bg layer"),
498            BlendLayer::new(&fg, w, h, 1.0, BlendMode::Overlay).expect("create overlay fg layer"),
499        ];
500        let mut out = vec![0u8; (w * h * 4) as usize];
501        let result = LayerCompositor::blend_layers_cpu(&layers, &mut out, w, h);
502        assert!(result.is_ok());
503    }
504
505    // ── New blend-mode tests ──────────────────────────────────────────────────
506
507    #[test]
508    fn test_hard_light_with_mid_grey_src() {
509        // hard_light(0.5, x) = overlay(x, 0.5) which at 0.5 gives 0.5
510        let v = hard_light(0.5, 0.5);
511        assert!(
512            (v - 0.5).abs() < 1e-5,
513            "hard_light(0.5,0.5) should be 0.5, got {v}"
514        );
515    }
516
517    #[test]
518    fn test_hard_light_black_src_yields_zero() {
519        // hard_light(0, x) = 2*0*x = 0
520        assert!((hard_light(0.0, 0.8) - 0.0).abs() < 1e-6);
521    }
522
523    #[test]
524    fn test_hard_light_white_src_yields_screen_like_max() {
525        // hard_light(1, x) = 1 - 2*(1-1)*(1-x) = 1
526        let v = hard_light(1.0, 0.3);
527        assert!(
528            (v - 1.0).abs() < 1e-6,
529            "hard_light(1, x) should be 1, got {v}"
530        );
531    }
532
533    #[test]
534    fn test_color_dodge_white_src_yields_one() {
535        // color_dodge(1.0, x) = 1 (src >= 1 clamp)
536        assert!((color_dodge(1.0, 0.5) - 1.0).abs() < 1e-6);
537    }
538
539    #[test]
540    fn test_color_dodge_black_src_yields_dst() {
541        // color_dodge(0, x) = x / 1 = x
542        let dst = 0.6;
543        let v = color_dodge(0.0, dst);
544        assert!(
545            (v - dst).abs() < 1e-6,
546            "color_dodge(0, {dst}) should be {dst}, got {v}"
547        );
548    }
549
550    #[test]
551    fn test_color_burn_white_src_yields_dst() {
552        // color_burn(1, x) = 1 - (1-x)/1 = x
553        let dst = 0.4;
554        let v = color_burn(1.0, dst);
555        assert!(
556            (v - dst).abs() < 1e-6,
557            "color_burn(1, {dst}) should be {dst}, got {v}"
558        );
559    }
560
561    #[test]
562    fn test_color_burn_black_src_yields_zero() {
563        // color_burn(0, x) = 0 (clamped)
564        assert!((color_burn(0.0, 0.8) - 0.0).abs() < 1e-6);
565    }
566
567    #[test]
568    fn test_linear_burn_saturates_to_zero() {
569        // linear_burn(0.3, 0.5) = 0.3+0.5-1 = -0.2 → clamped to 0
570        assert!((linear_burn(0.3, 0.5) - 0.0).abs() < 1e-6);
571    }
572
573    #[test]
574    fn test_linear_burn_normal_case() {
575        // linear_burn(0.9, 0.8) = 0.9+0.8-1 = 0.7
576        let v = linear_burn(0.9, 0.8);
577        assert!((v - 0.7).abs() < 1e-5, "expected 0.7, got {v}");
578    }
579
580    #[test]
581    fn test_exclusion_with_black_src_yields_dst() {
582        // exclusion(0, x) = x + 0 - 0 = x
583        let dst = 0.65;
584        let v = exclusion(0.0, dst);
585        assert!(
586            (v - dst).abs() < 1e-6,
587            "exclusion(0, {dst}) should be {dst}, got {v}"
588        );
589    }
590
591    #[test]
592    fn test_exclusion_midpoint_yields_zero_five() {
593        // exclusion(0.5, 0.5) = 0.5+0.5-2*0.5*0.5 = 1-0.5 = 0.5
594        let v = exclusion(0.5, 0.5);
595        assert!(
596            (v - 0.5).abs() < 1e-5,
597            "exclusion midpoint should be 0.5, got {v}"
598        );
599    }
600
601    #[test]
602    fn test_hard_light_blend_layers_round_trip() {
603        let w = 4u32;
604        let h = 4u32;
605        let bg = solid_rgba(w, h, 64, 64, 64, 255);
606        let fg = solid_rgba(w, h, 64, 64, 64, 255);
607        let layers = [
608            BlendLayer::new(&bg, w, h, 1.0, BlendMode::Normal).expect("create bg layer"),
609            BlendLayer::new(&fg, w, h, 1.0, BlendMode::HardLight).expect("create hard light layer"),
610        ];
611        let mut out = vec![0u8; (w * h * 4) as usize];
612        LayerCompositor::blend_layers_cpu(&layers, &mut out, w, h).expect("blend hard light");
613        // hard_light(64/255, 64/255) ≈ 2*(64/255)^2 ≈ 0.126 → ~32
614        for i in 0..(w * h) as usize {
615            let r = out[i * 4];
616            assert!(r < 64, "hard light with dark src should darken; got {r}");
617        }
618    }
619
620    #[test]
621    fn test_color_dodge_blend_layers_brightens() {
622        let w = 4u32;
623        let h = 4u32;
624        // bg = mid grey, fg = mid grey (almost 0.5 → dodge brightens significantly)
625        let bg = solid_rgba(w, h, 100, 100, 100, 255);
626        let fg = solid_rgba(w, h, 128, 128, 128, 255);
627        let layers = [
628            BlendLayer::new(&bg, w, h, 1.0, BlendMode::Normal).expect("create bg layer"),
629            BlendLayer::new(&fg, w, h, 1.0, BlendMode::ColorDodge).expect("create dodge layer"),
630        ];
631        let mut out = vec![0u8; (w * h * 4) as usize];
632        LayerCompositor::blend_layers_cpu(&layers, &mut out, w, h).expect("blend color dodge");
633        // Color dodge should produce brighter result than the background alone.
634        for i in 0..(w * h) as usize {
635            assert!(
636                out[i * 4] >= 100,
637                "color dodge should not darken; got {}",
638                out[i * 4]
639            );
640        }
641    }
642
643    #[test]
644    fn test_exclusion_blend_layers_round_trip() {
645        let w = 4u32;
646        let h = 4u32;
647        let bg = solid_rgba(w, h, 128, 128, 128, 255);
648        let fg = solid_rgba(w, h, 128, 128, 128, 255);
649        let layers = [
650            BlendLayer::new(&bg, w, h, 1.0, BlendMode::Normal).expect("create bg layer"),
651            BlendLayer::new(&fg, w, h, 1.0, BlendMode::Exclusion).expect("create exclusion layer"),
652        ];
653        let mut out = vec![0u8; (w * h * 4) as usize];
654        LayerCompositor::blend_layers_cpu(&layers, &mut out, w, h).expect("blend exclusion");
655        // exclusion(0.5, 0.5) = 0.5 → ~128
656        for i in 0..(w * h) as usize {
657            let r = out[i * 4];
658            assert!(
659                r >= 120 && r <= 136,
660                "exclusion(0.5,0.5) should be ~128; got {r}"
661            );
662        }
663    }
664}