Skip to main content

oximedia_gpu/
blend_kernel.rs

1//! GPU blend kernels (CPU simulation via Rayon).
2//!
3//! Provides parallel image compositing and blending operations that simulate
4//! GPU compute-shader dispatch semantics.
5//!
6//! # Supported blend modes
7//!
8//! | Mode | Description |
9//! |------|-------------|
10//! | [`BlendMode::AlphaComposite`] | Porter-Duff "over" compositing |
11//! | [`BlendMode::Additive`] | src + dst, clamped to 255 |
12//! | [`BlendMode::Multiply`] | src * dst / 255 |
13//! | [`BlendMode::Screen`] | 1 - (1-src)*(1-dst) |
14//! | [`BlendMode::Overlay`] | Photoshop-style overlay |
15//! | [`BlendMode::SoftLight`] | Pegtop soft light formula |
16//! | [`BlendMode::Difference`] | abs(src - dst) |
17//! | [`BlendMode::Dissolve`] | Random per-pixel src/dst selection by opacity |
18//!
19//! # Example
20//!
21//! ```rust
22//! use oximedia_gpu::blend_kernel::{BlendKernel, BlendMode};
23//!
24//! let src = vec![200u8, 100, 50, 255];   // opaque orange
25//! let mut dst = vec![0u8, 128, 255, 255]; // opaque blue
26//! BlendKernel::blend(&src, &mut dst, 1, 1, BlendMode::AlphaComposite, 255)?;
27//! # Ok::<(), oximedia_gpu::blend_kernel::BlendError>(())
28//! ```
29
30use rayon::prelude::*;
31use thiserror::Error;
32
33// ─── Error ────────────────────────────────────────────────────────────────────
34
35/// Errors returned by blend kernel operations.
36#[derive(Debug, Clone, PartialEq, Error)]
37pub enum BlendError {
38    /// Source or destination buffer has incorrect length.
39    #[error("Buffer size mismatch: expected {expected}, got {actual}")]
40    BufferSizeMismatch { expected: usize, actual: usize },
41    /// Image dimensions are zero or invalid.
42    #[error("Invalid dimensions: {width}x{height}")]
43    InvalidDimensions { width: u32, height: u32 },
44    /// Pixel count overflow.
45    #[error("Pixel count overflow for {width}x{height}")]
46    PixelCountOverflow { width: u32, height: u32 },
47    /// Mask length doesn't match pixel count.
48    #[error("Mask length mismatch: expected {expected}, got {actual}")]
49    MaskLengthMismatch { expected: usize, actual: usize },
50}
51
52// ─── BlendMode ───────────────────────────────────────────────────────────────
53
54/// Compositing / blend mode.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56pub enum BlendMode {
57    /// Porter-Duff "over" — alpha-premultiplied compositing.
58    AlphaComposite,
59    /// Linear additive blend, clamped to 255.
60    Additive,
61    /// Photographic multiply (darkens).
62    Multiply,
63    /// Screen blend (lightens).
64    Screen,
65    /// Overlay (contrast boost).
66    Overlay,
67    /// Pegtop soft-light formula.
68    SoftLight,
69    /// Absolute difference (subtract with abs).
70    Difference,
71    /// Stochastic dissolve controlled by global opacity.
72    Dissolve,
73}
74
75impl BlendMode {
76    /// Human-readable label.
77    #[must_use]
78    pub fn label(self) -> &'static str {
79        match self {
80            Self::AlphaComposite => "alpha_composite",
81            Self::Additive => "additive",
82            Self::Multiply => "multiply",
83            Self::Screen => "screen",
84            Self::Overlay => "overlay",
85            Self::SoftLight => "soft_light",
86            Self::Difference => "difference",
87            Self::Dissolve => "dissolve",
88        }
89    }
90}
91
92// ─── BlendStats ──────────────────────────────────────────────────────────────
93
94/// Statistics from a blend operation.
95#[derive(Debug, Clone, Default)]
96pub struct BlendStats {
97    /// Total destination pixels modified.
98    pub pixels_blended: u64,
99    /// Blend mode used.
100    pub mode: Option<BlendMode>,
101    /// Global opacity applied (0–255).
102    pub opacity: u8,
103}
104
105// ─── BlendKernel ─────────────────────────────────────────────────────────────
106
107/// GPU-style image blending kernel (CPU simulation via Rayon).
108///
109/// Operates on packed RGBA (4 bytes / pixel) buffers.
110/// `dst` is modified in place (it acts as both the base and the output).
111#[derive(Debug, Clone, Default)]
112pub struct BlendKernel;
113
114impl BlendKernel {
115    // ── Validation helpers ────────────────────────────────────────────────────
116
117    fn validate_rgba(buf: &[u8], width: u32, height: u32) -> Result<usize, BlendError> {
118        if width == 0 || height == 0 {
119            return Err(BlendError::InvalidDimensions { width, height });
120        }
121        let pixels = (width as usize)
122            .checked_mul(height as usize)
123            .ok_or(BlendError::PixelCountOverflow { width, height })?;
124        let expected = pixels * 4;
125        if buf.len() != expected {
126            return Err(BlendError::BufferSizeMismatch {
127                expected,
128                actual: buf.len(),
129            });
130        }
131        Ok(pixels)
132    }
133
134    // ── Public API ────────────────────────────────────────────────────────────
135
136    /// Blend `src` over `dst` using the specified blend mode and global opacity.
137    ///
138    /// `opacity` controls how much of `src` is mixed in (0 = invisible, 255 = fully opaque).
139    /// The per-pixel alpha in `src` is also respected by all modes.
140    ///
141    /// # Errors
142    ///
143    /// Returns [`BlendError`] if buffers or dimensions are invalid.
144    pub fn blend(
145        src: &[u8],
146        dst: &mut [u8],
147        width: u32,
148        height: u32,
149        mode: BlendMode,
150        opacity: u8,
151    ) -> Result<BlendStats, BlendError> {
152        Self::validate_rgba(src, width, height)?;
153        let pixels = Self::validate_rgba(dst, width, height)?;
154        let op = opacity as f32 / 255.0;
155
156        src.par_chunks(4)
157            .zip(dst.par_chunks_mut(4))
158            .for_each(|(s, d)| {
159                blend_pixel(s, d, mode, op);
160            });
161
162        Ok(BlendStats {
163            pixels_blended: pixels as u64,
164            mode: Some(mode),
165            opacity,
166        })
167    }
168
169    /// Blend with a per-pixel opacity mask (single channel, one byte per pixel).
170    ///
171    /// Each pixel's effective opacity is `global_opacity * mask[i] / 255`.
172    ///
173    /// # Errors
174    ///
175    /// Returns [`BlendError`] if any buffer or mask length is invalid.
176    pub fn blend_masked(
177        src: &[u8],
178        dst: &mut [u8],
179        mask: &[u8],
180        width: u32,
181        height: u32,
182        mode: BlendMode,
183        global_opacity: u8,
184    ) -> Result<BlendStats, BlendError> {
185        Self::validate_rgba(src, width, height)?;
186        let pixels = Self::validate_rgba(dst, width, height)?;
187        if mask.len() != pixels {
188            return Err(BlendError::MaskLengthMismatch {
189                expected: pixels,
190                actual: mask.len(),
191            });
192        }
193
194        let go = global_opacity as f32 / 255.0;
195
196        src.par_chunks(4)
197            .zip(dst.par_chunks_mut(4))
198            .zip(mask.par_iter())
199            .for_each(|((s, d), &m)| {
200                let op = go * (m as f32 / 255.0);
201                blend_pixel(s, d, mode, op);
202            });
203
204        Ok(BlendStats {
205            pixels_blended: pixels as u64,
206            mode: Some(mode),
207            opacity: global_opacity,
208        })
209    }
210
211    /// Composite multiple layers using Porter-Duff "over" operator.
212    ///
213    /// Layers are composited from bottom to top (index 0 is the base).
214    /// Each `(buffer, opacity)` entry is blended onto the accumulator.
215    ///
216    /// # Errors
217    ///
218    /// Returns [`BlendError`] if any layer has incorrect dimensions.
219    pub fn composite_layers(
220        layers: &[(&[u8], u8)],
221        width: u32,
222        height: u32,
223    ) -> Result<Vec<u8>, BlendError> {
224        if width == 0 || height == 0 {
225            return Err(BlendError::InvalidDimensions { width, height });
226        }
227        let pixels = (width as usize)
228            .checked_mul(height as usize)
229            .ok_or(BlendError::PixelCountOverflow { width, height })?;
230        let buf_size = pixels * 4;
231
232        for (i, (layer, _)) in layers.iter().enumerate() {
233            if layer.len() != buf_size {
234                return Err(BlendError::BufferSizeMismatch {
235                    expected: buf_size,
236                    actual: layer.len(),
237                });
238            }
239            let _ = i;
240        }
241
242        if layers.is_empty() {
243            return Ok(vec![0u8; buf_size]);
244        }
245
246        // Start with a transparent black canvas.
247        let mut acc = vec![0u8; buf_size];
248        for (layer, opacity) in layers {
249            let op = *opacity as f32 / 255.0;
250            layer
251                .par_chunks(4)
252                .zip(acc.par_chunks_mut(4))
253                .for_each(|(s, d)| {
254                    blend_pixel(s, d, BlendMode::AlphaComposite, op);
255                });
256        }
257        Ok(acc)
258    }
259
260    /// Apply a constant color tint (multiply by a solid RGBA color).
261    ///
262    /// Each pixel is multiplied component-wise by `tint / 255`.
263    ///
264    /// # Errors
265    ///
266    /// Returns [`BlendError`] if buffer or dimensions are invalid.
267    pub fn apply_tint(
268        src: &[u8],
269        dst: &mut [u8],
270        width: u32,
271        height: u32,
272        tint: [u8; 4],
273    ) -> Result<(), BlendError> {
274        Self::validate_rgba(src, width, height)?;
275        Self::validate_rgba(dst, width, height)?;
276
277        src.par_chunks(4)
278            .zip(dst.par_chunks_mut(4))
279            .for_each(|(s, d)| {
280                for c in 0..4 {
281                    let v = (s[c] as u32 * tint[c] as u32 + 127) / 255;
282                    d[c] = v.min(255) as u8;
283                }
284            });
285        Ok(())
286    }
287
288    /// Premultiply RGB channels by the alpha channel in place.
289    ///
290    /// Input: `[R, G, B, A]` straight alpha.
291    /// Output: `[R*A/255, G*A/255, B*A/255, A]` premultiplied.
292    ///
293    /// # Errors
294    ///
295    /// Returns [`BlendError`] on dimension or buffer mismatch.
296    pub fn premultiply_alpha(buf: &mut [u8], width: u32, height: u32) -> Result<(), BlendError> {
297        Self::validate_rgba(buf, width, height)?;
298        buf.par_chunks_mut(4).for_each(|px| {
299            let a = px[3] as u32;
300            for c in 0..3 {
301                px[c] = ((px[c] as u32 * a + 127) / 255) as u8;
302            }
303        });
304        Ok(())
305    }
306
307    /// Un-premultiply alpha (divide RGB by alpha).
308    ///
309    /// Pixels with `A = 0` are left as `[0, 0, 0, 0]`.
310    ///
311    /// # Errors
312    ///
313    /// Returns [`BlendError`] on dimension or buffer mismatch.
314    pub fn unpremultiply_alpha(buf: &mut [u8], width: u32, height: u32) -> Result<(), BlendError> {
315        Self::validate_rgba(buf, width, height)?;
316        buf.par_chunks_mut(4).for_each(|px| {
317            let a = px[3] as f32;
318            if a > 0.0 {
319                for c in 0..3 {
320                    px[c] = (px[c] as f32 / a * 255.0).round().clamp(0.0, 255.0) as u8;
321                }
322            }
323        });
324        Ok(())
325    }
326}
327
328// ─── Pixel-level blend functions ──────────────────────────────────────────────
329
330/// Apply one blend operation to a single `[R,G,B,A]` pixel pair.
331///
332/// `s` is the source pixel, `d` is the destination (modified in place).
333/// `opacity` is the global layer opacity in `[0.0, 1.0]`.
334fn blend_pixel(s: &[u8], d: &mut [u8], mode: BlendMode, opacity: f32) {
335    let sa = (s[3] as f32 / 255.0) * opacity;
336    match mode {
337        BlendMode::AlphaComposite => alpha_composite(s, d, sa),
338        BlendMode::Additive => additive(s, d, sa),
339        BlendMode::Multiply => multiply(s, d, sa),
340        BlendMode::Screen => screen(s, d, sa),
341        BlendMode::Overlay => overlay(s, d, sa),
342        BlendMode::SoftLight => soft_light(s, d, sa),
343        BlendMode::Difference => difference(s, d, sa),
344        BlendMode::Dissolve => dissolve(s, d, sa),
345    }
346}
347
348/// Porter-Duff "over": `Cout = Cs * As + Cd * Ad * (1 - As)`.
349fn alpha_composite(s: &[u8], d: &mut [u8], sa: f32) {
350    let da = d[3] as f32 / 255.0;
351    let out_a = sa + da * (1.0 - sa);
352    if out_a < 1e-9 {
353        d[0] = 0;
354        d[1] = 0;
355        d[2] = 0;
356        d[3] = 0;
357        return;
358    }
359    for c in 0..3 {
360        let sc = s[c] as f32 / 255.0;
361        let dc = d[c] as f32 / 255.0;
362        let out_c = (sc * sa + dc * da * (1.0 - sa)) / out_a;
363        d[c] = (out_c * 255.0).round().clamp(0.0, 255.0) as u8;
364    }
365    d[3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
366}
367
368/// Additive blend: `Cout = clamp(Cd + Cs * Sa, 0, 255)`.
369fn additive(s: &[u8], d: &mut [u8], sa: f32) {
370    for c in 0..3 {
371        let v = d[c] as f32 + s[c] as f32 * sa;
372        d[c] = v.round().clamp(0.0, 255.0) as u8;
373    }
374    // Alpha: additive does not change destination alpha.
375}
376
377/// Multiply blend: `Cout = lerp(Cd, Cd * Cs / 255, Sa)`.
378fn multiply(s: &[u8], d: &mut [u8], sa: f32) {
379    for c in 0..3 {
380        let dc = d[c] as f32;
381        let sc = s[c] as f32;
382        let blended = dc * sc / 255.0;
383        d[c] = lerp_channel(dc, blended, sa);
384    }
385}
386
387/// Screen blend: `Cout = lerp(Cd, 255 - (255-Cd)*(255-Cs)/255, Sa)`.
388fn screen(s: &[u8], d: &mut [u8], sa: f32) {
389    for c in 0..3 {
390        let dc = d[c] as f32;
391        let sc = s[c] as f32;
392        let blended = 255.0 - (255.0 - dc) * (255.0 - sc) / 255.0;
393        d[c] = lerp_channel(dc, blended, sa);
394    }
395}
396
397/// Overlay blend: multiply if dst < 0.5, screen otherwise.
398fn overlay(s: &[u8], d: &mut [u8], sa: f32) {
399    for c in 0..3 {
400        let dc = d[c] as f32 / 255.0;
401        let sc = s[c] as f32 / 255.0;
402        let blended = if dc < 0.5 {
403            2.0 * dc * sc
404        } else {
405            1.0 - 2.0 * (1.0 - dc) * (1.0 - sc)
406        };
407        d[c] = lerp_channel(d[c] as f32, blended * 255.0, sa);
408    }
409}
410
411/// Pegtop soft-light: `2*Cd*Cs + Cd²*(1-2*Cs)` (all in [0,1]).
412fn soft_light(s: &[u8], d: &mut [u8], sa: f32) {
413    for c in 0..3 {
414        let dc = d[c] as f32 / 255.0;
415        let sc = s[c] as f32 / 255.0;
416        let blended = 2.0 * dc * sc + dc * dc * (1.0 - 2.0 * sc);
417        d[c] = lerp_channel(d[c] as f32, blended * 255.0, sa);
418    }
419}
420
421/// Difference blend: `Cout = lerp(Cd, abs(Cd - Cs), Sa)`.
422fn difference(s: &[u8], d: &mut [u8], sa: f32) {
423    for c in 0..3 {
424        let dc = d[c] as f32;
425        let sc = s[c] as f32;
426        let blended = (dc - sc).abs();
427        d[c] = lerp_channel(dc, blended, sa);
428    }
429}
430
431/// Dissolve: use source pixel when opacity threshold is met.
432///
433/// Deterministic per-pixel: uses a hash of the channel index to simulate
434/// stochastic per-pixel selection without an RNG.
435fn dissolve(s: &[u8], d: &mut [u8], sa: f32) {
436    // Deterministic "random" threshold using a simple fixed pattern.
437    // In real GPU shaders this uses a noise texture; here we use a xor-shift
438    // of the combined src/dst byte values to avoid needing a true RNG.
439    let hash =
440        xorshift32(s[0] as u32 ^ (s[1] as u32 * 17) ^ (d[0] as u32 * 31) ^ (d[1] as u32 * 7));
441    let threshold = (hash & 0xFF) as f32 / 255.0;
442    if sa > threshold {
443        // Use source pixel
444        for c in 0..3 {
445            d[c] = s[c];
446        }
447        d[3] = s[3];
448    }
449    // else keep destination unchanged
450}
451
452// ─── Private helpers ──────────────────────────────────────────────────────────
453
454/// Linear interpolation: `a + (b - a) * t`, returns u8.
455#[inline]
456fn lerp_channel(a: f32, b: f32, t: f32) -> u8 {
457    (a + (b - a) * t).round().clamp(0.0, 255.0) as u8
458}
459
460/// Simple xorshift32 for deterministic dissolve without RNG.
461#[inline]
462fn xorshift32(mut x: u32) -> u32 {
463    x ^= x << 13;
464    x ^= x >> 17;
465    x ^= x << 5;
466    x
467}
468
469// ─── Tests ───────────────────────────────────────────────────────────────────
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    // ── BlendMode ─────────────────────────────────────────────────────────────
476
477    #[test]
478    fn test_blend_mode_labels() {
479        assert_eq!(BlendMode::AlphaComposite.label(), "alpha_composite");
480        assert_eq!(BlendMode::Additive.label(), "additive");
481        assert_eq!(BlendMode::Multiply.label(), "multiply");
482        assert_eq!(BlendMode::Screen.label(), "screen");
483        assert_eq!(BlendMode::Overlay.label(), "overlay");
484        assert_eq!(BlendMode::SoftLight.label(), "soft_light");
485        assert_eq!(BlendMode::Difference.label(), "difference");
486        assert_eq!(BlendMode::Dissolve.label(), "dissolve");
487    }
488
489    // ── Error handling ────────────────────────────────────────────────────────
490
491    #[test]
492    fn test_blend_invalid_dims() {
493        let src = vec![0u8; 4];
494        let mut dst = vec![0u8; 4];
495        let err = BlendKernel::blend(&src, &mut dst, 0, 1, BlendMode::Additive, 255);
496        assert!(matches!(err, Err(BlendError::InvalidDimensions { .. })));
497    }
498
499    #[test]
500    fn test_blend_buffer_mismatch() {
501        let src = vec![0u8; 8]; // wrong: 1×1 = 4
502        let mut dst = vec![0u8; 4];
503        let err = BlendKernel::blend(&src, &mut dst, 1, 1, BlendMode::Additive, 255);
504        assert!(matches!(err, Err(BlendError::BufferSizeMismatch { .. })));
505    }
506
507    #[test]
508    fn test_blend_masked_mask_mismatch() {
509        let src = vec![255u8; 4 * 4 * 4];
510        let mut dst = vec![0u8; 4 * 4 * 4];
511        let mask = vec![255u8; 10]; // wrong size
512        let err = BlendKernel::blend_masked(&src, &mut dst, &mask, 4, 4, BlendMode::Multiply, 255);
513        assert!(matches!(err, Err(BlendError::MaskLengthMismatch { .. })));
514    }
515
516    // ── Opacity=0 preserves destination ──────────────────────────────────────
517
518    #[test]
519    fn test_opacity_zero_preserves_dst() -> Result<(), BlendError> {
520        let src: Vec<u8> = vec![255, 0, 0, 255]; // opaque red
521        let original_dst = vec![0u8, 128, 255, 255]; // opaque blue
522        let mut dst = original_dst.clone();
523        BlendKernel::blend(&src, &mut dst, 1, 1, BlendMode::AlphaComposite, 0)?;
524        // With opacity=0 the src contributes nothing; dst should be almost unchanged.
525        for (orig, &out) in original_dst.iter().zip(dst.iter()) {
526            let diff = (*orig as i16 - out as i16).abs();
527            assert!(diff <= 1, "channel diff={diff}");
528        }
529        Ok(())
530    }
531
532    // ── Opacity=255 opaque source covers destination (AlphaComposite) ─────────
533
534    #[test]
535    fn test_alpha_composite_fully_opaque_src() -> Result<(), BlendError> {
536        let src = vec![200u8, 100, 50, 255]; // fully opaque
537        let mut dst = vec![0u8, 0, 0, 255];
538        BlendKernel::blend(&src, &mut dst, 1, 1, BlendMode::AlphaComposite, 255)?;
539        assert_eq!(dst[0], 200);
540        assert_eq!(dst[1], 100);
541        assert_eq!(dst[2], 50);
542        assert_eq!(dst[3], 255);
543        Ok(())
544    }
545
546    // ── Additive ──────────────────────────────────────────────────────────────
547
548    #[test]
549    fn test_additive_blend_clamps_to_255() -> Result<(), BlendError> {
550        let src = vec![200u8, 200, 200, 255];
551        let mut dst = vec![100u8, 100, 100, 255];
552        BlendKernel::blend(&src, &mut dst, 1, 1, BlendMode::Additive, 255)?;
553        assert_eq!(dst[0], 255, "200+100=300 → clamp to 255");
554        Ok(())
555    }
556
557    #[test]
558    fn test_additive_blend_zero_src() -> Result<(), BlendError> {
559        let src = vec![0u8, 0, 0, 255];
560        let original = vec![100u8, 150, 200, 255];
561        let mut dst = original.clone();
562        BlendKernel::blend(&src, &mut dst, 1, 1, BlendMode::Additive, 255)?;
563        assert_eq!(dst[..3], original[..3]);
564        Ok(())
565    }
566
567    // ── Multiply ──────────────────────────────────────────────────────────────
568
569    #[test]
570    fn test_multiply_with_white_src_unchanged() -> Result<(), BlendError> {
571        let src = vec![255u8, 255, 255, 255]; // white multiplier
572        let original = vec![100u8, 150, 200, 255];
573        let mut dst = original.clone();
574        BlendKernel::blend(&src, &mut dst, 1, 1, BlendMode::Multiply, 255)?;
575        // Multiply with white → dst * 255/255 = dst
576        for c in 0..3 {
577            let diff = (original[c] as i16 - dst[c] as i16).abs();
578            assert!(diff <= 1, "channel {c}: diff={diff}");
579        }
580        Ok(())
581    }
582
583    #[test]
584    fn test_multiply_with_black_src_yields_zero() -> Result<(), BlendError> {
585        let src = vec![0u8, 0, 0, 255]; // black multiplier
586        let mut dst = vec![200u8, 150, 100, 255];
587        BlendKernel::blend(&src, &mut dst, 1, 1, BlendMode::Multiply, 255)?;
588        // Multiply with black → 0
589        for c in 0..3 {
590            assert_eq!(dst[c], 0, "channel {c} should be 0");
591        }
592        Ok(())
593    }
594
595    // ── Screen ────────────────────────────────────────────────────────────────
596
597    #[test]
598    fn test_screen_with_black_src_unchanged() -> Result<(), BlendError> {
599        let src = vec![0u8, 0, 0, 255]; // black screen → no change
600        let original = vec![100u8, 150, 200, 255];
601        let mut dst = original.clone();
602        BlendKernel::blend(&src, &mut dst, 1, 1, BlendMode::Screen, 255)?;
603        for c in 0..3 {
604            let diff = (original[c] as i16 - dst[c] as i16).abs();
605            assert!(diff <= 1, "channel {c}: diff={diff}");
606        }
607        Ok(())
608    }
609
610    #[test]
611    fn test_screen_with_white_src_yields_white() -> Result<(), BlendError> {
612        let src = vec![255u8, 255, 255, 255]; // white screen → white
613        let mut dst = vec![100u8, 150, 200, 255];
614        BlendKernel::blend(&src, &mut dst, 1, 1, BlendMode::Screen, 255)?;
615        for c in 0..3 {
616            assert_eq!(
617                dst[c], 255,
618                "channel {c} should be 255 after screen with white"
619            );
620        }
621        Ok(())
622    }
623
624    // ── Difference ────────────────────────────────────────────────────────────
625
626    #[test]
627    fn test_difference_with_same_src_dst_yields_black() -> Result<(), BlendError> {
628        let src = vec![100u8, 150, 200, 255];
629        let mut dst = vec![100u8, 150, 200, 255];
630        BlendKernel::blend(&src, &mut dst, 1, 1, BlendMode::Difference, 255)?;
631        for c in 0..3 {
632            assert_eq!(dst[c], 0, "difference of equal values should be 0");
633        }
634        Ok(())
635    }
636
637    // ── Masked blend ──────────────────────────────────────────────────────────
638
639    #[test]
640    fn test_masked_blend_all_opaque() -> Result<(), BlendError> {
641        let w = 2u32;
642        let h = 2u32;
643        let src = vec![255u8; (w * h * 4) as usize];
644        let mut dst = vec![0u8; (w * h * 4) as usize];
645        let mask = vec![255u8; (w * h) as usize]; // fully opaque mask
646        BlendKernel::blend_masked(&src, &mut dst, &mask, w, h, BlendMode::AlphaComposite, 255)?;
647        // All dst pixels should become white (src is all 255 opaque)
648        for &v in &dst {
649            assert_eq!(v, 255);
650        }
651        Ok(())
652    }
653
654    #[test]
655    fn test_masked_blend_all_transparent_preserves_dst() -> Result<(), BlendError> {
656        let w = 2u32;
657        let h = 2u32;
658        let src = vec![255u8; (w * h * 4) as usize];
659        let original_dst = vec![100u8; (w * h * 4) as usize];
660        let mut dst = original_dst.clone();
661        let mask = vec![0u8; (w * h) as usize]; // fully transparent mask
662        BlendKernel::blend_masked(&src, &mut dst, &mask, w, h, BlendMode::AlphaComposite, 255)?;
663        // With mask=0, alpha=0, dst should be unchanged
664        assert_eq!(dst, original_dst);
665        Ok(())
666    }
667
668    // ── Layer compositing ─────────────────────────────────────────────────────
669
670    #[test]
671    fn test_composite_layers_empty_returns_transparent() -> Result<(), BlendError> {
672        let result = BlendKernel::composite_layers(&[], 4, 4)?;
673        assert_eq!(result.len(), 4 * 4 * 4);
674        assert!(result.iter().all(|&v| v == 0));
675        Ok(())
676    }
677
678    #[test]
679    fn test_composite_layers_single_opaque() -> Result<(), BlendError> {
680        let layer = vec![200u8, 100, 50, 255]; // 1×1 opaque orange
681        let result = BlendKernel::composite_layers(&[(&layer, 255)], 1, 1)?;
682        assert_eq!(result.len(), 4);
683        // Should match the layer
684        assert_eq!(result[0], 200);
685        assert_eq!(result[1], 100);
686        assert_eq!(result[2], 50);
687        Ok(())
688    }
689
690    // ── Tint ──────────────────────────────────────────────────────────────────
691
692    #[test]
693    fn test_apply_tint_white_tint_unchanged() -> Result<(), BlendError> {
694        let src = vec![100u8, 150, 200, 255];
695        let mut dst = vec![0u8; 4];
696        BlendKernel::apply_tint(&src, &mut dst, 1, 1, [255, 255, 255, 255])?;
697        for c in 0..3 {
698            let diff = (src[c] as i16 - dst[c] as i16).abs();
699            assert!(diff <= 1, "channel {c}: diff={diff}");
700        }
701        Ok(())
702    }
703
704    #[test]
705    fn test_apply_tint_black_tint_yields_black() -> Result<(), BlendError> {
706        let src = vec![200u8, 150, 100, 255];
707        let mut dst = vec![0u8; 4];
708        BlendKernel::apply_tint(&src, &mut dst, 1, 1, [0, 0, 0, 0])?;
709        assert_eq!(dst[0], 0);
710        assert_eq!(dst[1], 0);
711        assert_eq!(dst[2], 0);
712        Ok(())
713    }
714
715    // ── Premultiply / unpremultiply ───────────────────────────────────────────
716
717    #[test]
718    fn test_premultiply_alpha_full_opaque() -> Result<(), BlendError> {
719        let mut buf = vec![200u8, 100, 50, 255];
720        BlendKernel::premultiply_alpha(&mut buf, 1, 1)?;
721        // With alpha=255, channels stay the same
722        assert_eq!(buf[0], 200);
723        assert_eq!(buf[1], 100);
724        assert_eq!(buf[2], 50);
725        Ok(())
726    }
727
728    #[test]
729    fn test_premultiply_alpha_half_opacity() -> Result<(), BlendError> {
730        let mut buf = vec![200u8, 200, 200, 128];
731        BlendKernel::premultiply_alpha(&mut buf, 1, 1)?;
732        // ~200 * 128 / 255 ≈ 100
733        let expected = (200u32 * 128 + 127) / 255;
734        let diff = (buf[0] as i32 - expected as i32).abs();
735        assert!(
736            diff <= 1,
737            "premultiplied R: got {}, expected ~{}",
738            buf[0],
739            expected
740        );
741        Ok(())
742    }
743
744    #[test]
745    fn test_unpremultiply_alpha_zero_alpha() -> Result<(), BlendError> {
746        let mut buf = vec![100u8, 100, 100, 0]; // fully transparent
747        BlendKernel::unpremultiply_alpha(&mut buf, 1, 1)?;
748        // alpha=0 → no change (stays 0)
749        assert_eq!(buf[0], 100); // left unchanged when alpha=0
750        Ok(())
751    }
752
753    #[test]
754    fn test_premultiply_unpremultiply_roundtrip() -> Result<(), BlendError> {
755        let original = vec![200u8, 150, 100, 200];
756        let mut buf = original.clone();
757        BlendKernel::premultiply_alpha(&mut buf, 1, 1)?;
758        BlendKernel::unpremultiply_alpha(&mut buf, 1, 1)?;
759        for c in 0..3 {
760            let diff = (original[c] as i16 - buf[c] as i16).abs();
761            assert!(
762                diff <= 2,
763                "channel {c}: orig={} back={} diff={diff}",
764                original[c],
765                buf[c]
766            );
767        }
768        Ok(())
769    }
770
771    // ── Stats ─────────────────────────────────────────────────────────────────
772
773    #[test]
774    fn test_blend_stats_returned() -> Result<(), BlendError> {
775        let src = vec![0u8; 4 * 4 * 4];
776        let mut dst = vec![0u8; 4 * 4 * 4];
777        let stats = BlendKernel::blend(&src, &mut dst, 4, 4, BlendMode::Screen, 200)?;
778        assert_eq!(stats.pixels_blended, 16);
779        assert_eq!(stats.mode, Some(BlendMode::Screen));
780        assert_eq!(stats.opacity, 200);
781        Ok(())
782    }
783}