mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 21:16:52 -06:00
Measured against After Effects at 32 bpc: - Classic Color Dodge, Classic Color Burn and Classic Difference at partial opacity: AE fades the source toward the mode's neutral colour (0 for Dodge and Difference, 1 for Burn) by the opacity and applies the classic formula at full strength (b / (1 − k·o), |b − k·o|, 1 − (1 − b) / (1 − k·(1 − o))). We mixed the full-strength result by opacity: up to 45/255 off on ordinary 0–1 footage. - Overlay, Exclusion, Luminosity, Color Burn and Classic Color Burn keep their plain formula for values above 1 (HDR passes). They were missing from `supports_hdr`, so their inputs were clamped to 0–1 (Exclusion was up to 208/255 off); Classic Color Burn also clamped its result at 0, where AE keeps negative values. The CPU blend (color::blend_pixel) and the GPU kernel (common.wgsl) change together. Tests: the reporter's formulas for each case (blend.rs); the CPU/GPU parity test of every blend mode at every bit depth (over-range values, 70 % opacity) passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
539 lines
19 KiB
Rust
539 lines
19 KiB
Rust
//! Layer blend modes.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::luminance;
|
|
|
|
/// The blend modes of the Layer ▸ Blending Mode menu, in menu order.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum BlendMode {
|
|
#[default]
|
|
Normal,
|
|
Dissolve,
|
|
DancingDissolve,
|
|
Darken,
|
|
Multiply,
|
|
ColorBurn,
|
|
ClassicColorBurn,
|
|
LinearBurn,
|
|
DarkerColor,
|
|
Add,
|
|
Lighten,
|
|
Screen,
|
|
ColorDodge,
|
|
ClassicColorDodge,
|
|
LinearDodge,
|
|
LighterColor,
|
|
Overlay,
|
|
SoftLight,
|
|
HardLight,
|
|
LinearLight,
|
|
VividLight,
|
|
PinLight,
|
|
HardMix,
|
|
Difference,
|
|
ClassicDifference,
|
|
Exclusion,
|
|
Subtract,
|
|
Divide,
|
|
Hue,
|
|
Saturation,
|
|
Color,
|
|
Luminosity,
|
|
StencilAlpha,
|
|
StencilLuma,
|
|
SilhouetteAlpha,
|
|
SilhouetteLuma,
|
|
AlphaAdd,
|
|
LuminescentPremul,
|
|
}
|
|
|
|
impl BlendMode {
|
|
pub const ALL: [BlendMode; 38] = [
|
|
BlendMode::Normal,
|
|
BlendMode::Dissolve,
|
|
BlendMode::DancingDissolve,
|
|
BlendMode::Darken,
|
|
BlendMode::Multiply,
|
|
BlendMode::ColorBurn,
|
|
BlendMode::ClassicColorBurn,
|
|
BlendMode::LinearBurn,
|
|
BlendMode::DarkerColor,
|
|
BlendMode::Add,
|
|
BlendMode::Lighten,
|
|
BlendMode::Screen,
|
|
BlendMode::ColorDodge,
|
|
BlendMode::ClassicColorDodge,
|
|
BlendMode::LinearDodge,
|
|
BlendMode::LighterColor,
|
|
BlendMode::Overlay,
|
|
BlendMode::SoftLight,
|
|
BlendMode::HardLight,
|
|
BlendMode::LinearLight,
|
|
BlendMode::VividLight,
|
|
BlendMode::PinLight,
|
|
BlendMode::HardMix,
|
|
BlendMode::Difference,
|
|
BlendMode::ClassicDifference,
|
|
BlendMode::Exclusion,
|
|
BlendMode::Subtract,
|
|
BlendMode::Divide,
|
|
BlendMode::Hue,
|
|
BlendMode::Saturation,
|
|
BlendMode::Color,
|
|
BlendMode::Luminosity,
|
|
BlendMode::StencilAlpha,
|
|
BlendMode::StencilLuma,
|
|
BlendMode::SilhouetteAlpha,
|
|
BlendMode::SilhouetteLuma,
|
|
BlendMode::AlphaAdd,
|
|
BlendMode::LuminescentPremul,
|
|
];
|
|
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
BlendMode::Normal => "Normal",
|
|
BlendMode::Dissolve => "Dissolve",
|
|
BlendMode::DancingDissolve => "Dancing Dissolve",
|
|
BlendMode::Darken => "Darken",
|
|
BlendMode::Multiply => "Multiply",
|
|
BlendMode::ColorBurn => "Color Burn",
|
|
BlendMode::ClassicColorBurn => "Classic Color Burn",
|
|
BlendMode::LinearBurn => "Linear Burn",
|
|
BlendMode::DarkerColor => "Darker Color",
|
|
BlendMode::Add => "Add",
|
|
BlendMode::Lighten => "Lighten",
|
|
BlendMode::Screen => "Screen",
|
|
BlendMode::ColorDodge => "Color Dodge",
|
|
BlendMode::ClassicColorDodge => "Classic Color Dodge",
|
|
BlendMode::LinearDodge => "Linear Dodge",
|
|
BlendMode::LighterColor => "Lighter Color",
|
|
BlendMode::Overlay => "Overlay",
|
|
BlendMode::SoftLight => "Soft Light",
|
|
BlendMode::HardLight => "Hard Light",
|
|
BlendMode::LinearLight => "Linear Light",
|
|
BlendMode::VividLight => "Vivid Light",
|
|
BlendMode::PinLight => "Pin Light",
|
|
BlendMode::HardMix => "Hard Mix",
|
|
BlendMode::Difference => "Difference",
|
|
BlendMode::ClassicDifference => "Classic Difference",
|
|
BlendMode::Exclusion => "Exclusion",
|
|
BlendMode::Subtract => "Subtract",
|
|
BlendMode::Divide => "Divide",
|
|
BlendMode::Hue => "Hue",
|
|
BlendMode::Saturation => "Saturation",
|
|
BlendMode::Color => "Color",
|
|
BlendMode::Luminosity => "Luminosity",
|
|
BlendMode::StencilAlpha => "Stencil Alpha",
|
|
BlendMode::StencilLuma => "Stencil Luma",
|
|
BlendMode::SilhouetteAlpha => "Silhouette Alpha",
|
|
BlendMode::SilhouetteLuma => "Silhouette Luma",
|
|
BlendMode::AlphaAdd => "Alpha Add",
|
|
BlendMode::LuminescentPremul => "Luminescent Premul",
|
|
}
|
|
}
|
|
|
|
pub fn from_name(s: &str) -> Option<BlendMode> {
|
|
let k: String = s.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_ascii_lowercase();
|
|
BlendMode::ALL.into_iter().find(|m| m.label().chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_ascii_lowercase() == k)
|
|
}
|
|
|
|
/// Menu groups (a separator follows the last mode of each group).
|
|
pub fn ends_group(self) -> bool {
|
|
matches!(
|
|
self,
|
|
BlendMode::DancingDissolve
|
|
| BlendMode::DarkerColor
|
|
| BlendMode::LighterColor
|
|
| BlendMode::HardMix
|
|
| BlendMode::Divide
|
|
| BlendMode::Luminosity
|
|
| BlendMode::SilhouetteLuma
|
|
)
|
|
}
|
|
|
|
/// Modes that act on everything below (the accumulated comp) rather than compositing on top.
|
|
pub fn is_stencil(self) -> bool {
|
|
matches!(self, BlendMode::StencilAlpha | BlendMode::StencilLuma | BlendMode::SilhouetteAlpha | BlendMode::SilhouetteLuma)
|
|
}
|
|
|
|
/// Modes whose formulas are defined for over-range (HDR, 32 bpc) values. The others clamp
|
|
/// their inputs to 0..1 before blending, as After Effects documents for 32 bpc projects
|
|
/// (in 8/16 bpc everything is already in range). Overlay, Exclusion, Luminosity and the
|
|
/// Color Burns keep their plain formula above 1 in After Effects (#494).
|
|
pub fn supports_hdr(self) -> bool {
|
|
matches!(
|
|
self,
|
|
BlendMode::Normal
|
|
| BlendMode::Dissolve
|
|
| BlendMode::DancingDissolve
|
|
| BlendMode::Darken
|
|
| BlendMode::Multiply
|
|
| BlendMode::ColorBurn
|
|
| BlendMode::ClassicColorBurn
|
|
| BlendMode::LinearBurn
|
|
| BlendMode::DarkerColor
|
|
| BlendMode::Add
|
|
| BlendMode::Lighten
|
|
| BlendMode::Screen
|
|
| BlendMode::LinearDodge
|
|
| BlendMode::LighterColor
|
|
| BlendMode::Difference
|
|
| BlendMode::ClassicDifference
|
|
| BlendMode::Overlay
|
|
| BlendMode::Exclusion
|
|
| BlendMode::Luminosity
|
|
| BlendMode::Subtract
|
|
| BlendMode::Divide
|
|
| BlendMode::StencilAlpha
|
|
| BlendMode::StencilLuma
|
|
| BlendMode::SilhouetteAlpha
|
|
| BlendMode::SilhouetteLuma
|
|
| BlendMode::AlphaAdd
|
|
| BlendMode::LuminescentPremul
|
|
)
|
|
}
|
|
|
|
/// The source colour a classic mode leaves the backdrop unchanged with. After Effects fades
|
|
/// the source toward it by the layer's opacity before applying the classic formula at full
|
|
/// strength, rather than mixing the full-strength result by opacity (#494).
|
|
fn classic_neutral(self) -> Option<f32> {
|
|
match self {
|
|
BlendMode::ClassicColorDodge | BlendMode::ClassicDifference => Some(0.0),
|
|
BlendMode::ClassicColorBurn => Some(1.0),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn next(self) -> BlendMode {
|
|
let i = BlendMode::ALL.iter().position(|m| *m == self).unwrap_or(0);
|
|
BlendMode::ALL[(i + 1) % BlendMode::ALL.len()]
|
|
}
|
|
pub fn previous(self) -> BlendMode {
|
|
let i = BlendMode::ALL.iter().position(|m| *m == self).unwrap_or(0);
|
|
BlendMode::ALL[(i + BlendMode::ALL.len() - 1) % BlendMode::ALL.len()]
|
|
}
|
|
}
|
|
|
|
fn burn(cb: f32, cs: f32) -> f32 {
|
|
if cb >= 1.0 {
|
|
1.0
|
|
} else if cs <= 0.0 {
|
|
0.0
|
|
} else {
|
|
1.0 - ((1.0 - cb) / cs).min(1.0)
|
|
}
|
|
}
|
|
fn dodge(cb: f32, cs: f32) -> f32 {
|
|
if cb <= 0.0 {
|
|
0.0
|
|
} else if cs >= 1.0 {
|
|
1.0
|
|
} else {
|
|
(cb / (1.0 - cs)).min(1.0)
|
|
}
|
|
}
|
|
fn hard_light(cb: f32, cs: f32) -> f32 {
|
|
if cs <= 0.5 {
|
|
cb * 2.0 * cs
|
|
} else {
|
|
let s = 2.0 * cs - 1.0;
|
|
cb + s - cb * s
|
|
}
|
|
}
|
|
fn soft_light(cb: f32, cs: f32) -> f32 {
|
|
if cs <= 0.5 {
|
|
cb - (1.0 - 2.0 * cs) * cb * (1.0 - cb)
|
|
} else {
|
|
let d = if cb <= 0.25 { ((16.0 * cb - 12.0) * cb + 4.0) * cb } else { cb.max(0.0).sqrt() };
|
|
cb + (2.0 * cs - 1.0) * (d - cb)
|
|
}
|
|
}
|
|
fn vivid(cb: f32, cs: f32) -> f32 {
|
|
if cs <= 0.5 { burn(cb, 2.0 * cs) } else { dodge(cb, 2.0 * (cs - 0.5)) }
|
|
}
|
|
|
|
fn separable(mode: BlendMode, cb: f32, cs: f32) -> f32 {
|
|
match mode {
|
|
BlendMode::Darken => cb.min(cs),
|
|
BlendMode::Multiply => cb * cs,
|
|
BlendMode::ColorBurn => burn(cb, cs),
|
|
BlendMode::ClassicColorBurn => {
|
|
// (negative results are kept, as in After Effects)
|
|
if cs <= 0.0 { 0.0 } else { 1.0 - (1.0 - cb) / cs }
|
|
}
|
|
BlendMode::LinearBurn => (cb + cs - 1.0).max(0.0),
|
|
BlendMode::Add | BlendMode::LinearDodge => cb + cs,
|
|
BlendMode::Lighten => cb.max(cs),
|
|
BlendMode::Screen => cb + cs - cb * cs,
|
|
BlendMode::ColorDodge => dodge(cb, cs),
|
|
BlendMode::ClassicColorDodge => {
|
|
if cs >= 1.0 {
|
|
1.0
|
|
} else {
|
|
(cb / (1.0 - cs)).min(1.0)
|
|
}
|
|
}
|
|
BlendMode::Overlay => hard_light(cs, cb),
|
|
BlendMode::SoftLight => soft_light(cb, cs),
|
|
BlendMode::HardLight => hard_light(cb, cs),
|
|
BlendMode::LinearLight => (cb + 2.0 * cs - 1.0).clamp(0.0, 1.0),
|
|
BlendMode::VividLight => vivid(cb, cs),
|
|
BlendMode::PinLight => {
|
|
if cs <= 0.5 {
|
|
cb.min(2.0 * cs)
|
|
} else {
|
|
cb.max(2.0 * cs - 1.0)
|
|
}
|
|
}
|
|
BlendMode::HardMix => {
|
|
if cb + cs >= 1.0 {
|
|
1.0
|
|
} else {
|
|
0.0
|
|
}
|
|
}
|
|
BlendMode::Difference | BlendMode::ClassicDifference => (cb - cs).abs(),
|
|
BlendMode::Exclusion => cb + cs - 2.0 * cb * cs,
|
|
BlendMode::Subtract => (cb - cs).max(0.0),
|
|
BlendMode::Divide => {
|
|
if cs <= 0.0 {
|
|
if cb > 0.0 { 1.0 } else { 0.0 }
|
|
} else {
|
|
cb / cs
|
|
}
|
|
}
|
|
_ => cs,
|
|
}
|
|
}
|
|
|
|
fn lum(c: [f32; 3]) -> f32 {
|
|
0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
|
|
}
|
|
fn clip_color(c: [f32; 3]) -> [f32; 3] {
|
|
let l = lum(c);
|
|
let n = c[0].min(c[1]).min(c[2]);
|
|
let x = c[0].max(c[1]).max(c[2]);
|
|
let mut o = c;
|
|
if n < 0.0 {
|
|
for v in &mut o {
|
|
*v = l + (*v - l) * l / (l - n).max(1e-9);
|
|
}
|
|
}
|
|
if x > 1.0 {
|
|
for v in &mut o {
|
|
*v = l + (*v - l) * (1.0 - l) / (x - l).max(1e-9);
|
|
}
|
|
}
|
|
o
|
|
}
|
|
fn set_lum(c: [f32; 3], l: f32) -> [f32; 3] {
|
|
let d = l - lum(c);
|
|
clip_color([c[0] + d, c[1] + d, c[2] + d])
|
|
}
|
|
fn sat(c: [f32; 3]) -> f32 {
|
|
c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
|
|
}
|
|
fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] {
|
|
let mx = c[0].max(c[1]).max(c[2]);
|
|
let mn = c[0].min(c[1]).min(c[2]);
|
|
if mx - mn <= 1e-9 {
|
|
return [0.0; 3];
|
|
}
|
|
let f = |v: f32| (v - mn) * s / (mx - mn);
|
|
[f(c[0]), f(c[1]), f(c[2])]
|
|
}
|
|
|
|
fn non_separable(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] {
|
|
match mode {
|
|
BlendMode::Hue => set_lum(set_sat(cs, sat(cb)), lum(cb)),
|
|
BlendMode::Saturation => set_lum(set_sat(cb, sat(cs)), lum(cb)),
|
|
BlendMode::Color => set_lum(cs, lum(cb)),
|
|
BlendMode::Luminosity => set_lum(cb, lum(cs)),
|
|
BlendMode::DarkerColor => {
|
|
if luminance(cs[0], cs[1], cs[2]) < luminance(cb[0], cb[1], cb[2]) {
|
|
cs
|
|
} else {
|
|
cb
|
|
}
|
|
}
|
|
BlendMode::LighterColor => {
|
|
if luminance(cs[0], cs[1], cs[2]) > luminance(cb[0], cb[1], cb[2]) {
|
|
cs
|
|
} else {
|
|
cb
|
|
}
|
|
}
|
|
_ => [separable(mode, cb[0], cs[0]), separable(mode, cb[1], cs[1]), separable(mode, cb[2], cs[2])],
|
|
}
|
|
}
|
|
|
|
/// Composite one premultiplied source pixel over a premultiplied destination pixel.
|
|
///
|
|
/// `src` must already include layer opacity. `noise` (0..1) is a per-pixel random value used by
|
|
/// Dissolve modes. Stencil/Silhouette modes return the destination modified by the source.
|
|
#[inline]
|
|
pub fn blend_pixel(mode: BlendMode, dst: [f32; 4], src: [f32; 4], noise: f32) -> [f32; 4] {
|
|
let sa = src[3];
|
|
let da = dst[3];
|
|
match mode {
|
|
BlendMode::Normal => {
|
|
let k = 1.0 - sa;
|
|
return [src[0] + dst[0] * k, src[1] + dst[1] * k, src[2] + dst[2] * k, sa + da * k];
|
|
}
|
|
BlendMode::Dissolve | BlendMode::DancingDissolve => {
|
|
if sa <= 0.0 || noise >= sa {
|
|
return dst;
|
|
}
|
|
let inv = 1.0 / sa;
|
|
return [src[0] * inv, src[1] * inv, src[2] * inv, 1.0];
|
|
}
|
|
BlendMode::StencilAlpha => return dst.map(|v| v * sa),
|
|
BlendMode::SilhouetteAlpha => return dst.map(|v| v * (1.0 - sa)),
|
|
BlendMode::StencilLuma | BlendMode::SilhouetteLuma => {
|
|
let l = luminance(src[0], src[1], src[2]);
|
|
let k = if mode == BlendMode::StencilLuma { l } else { 1.0 - l };
|
|
return dst.map(|v| v * k);
|
|
}
|
|
BlendMode::AlphaAdd => {
|
|
return [src[0] + dst[0] * (1.0 - sa), src[1] + dst[1] * (1.0 - sa), src[2] + dst[2] * (1.0 - sa), (sa + da).min(1.0)];
|
|
}
|
|
BlendMode::LuminescentPremul => {
|
|
return [src[0] + dst[0] * (1.0 - sa), src[1] + dst[1] * (1.0 - sa), src[2] + dst[2] * (1.0 - sa), sa + da - sa * da];
|
|
}
|
|
_ => {}
|
|
}
|
|
if sa <= 0.0 {
|
|
return dst;
|
|
}
|
|
if da <= 0.0 {
|
|
return src;
|
|
}
|
|
let mut cs = [src[0] / sa, src[1] / sa, src[2] / sa];
|
|
let mut cb = [dst[0] / da, dst[1] / da, dst[2] / da];
|
|
if !mode.supports_hdr() {
|
|
cs = cs.map(|v| v.clamp(0.0, 1.0));
|
|
cb = cb.map(|v| v.clamp(0.0, 1.0));
|
|
}
|
|
let ao = sa + da - sa * da;
|
|
let mut o = [0.0f32; 4];
|
|
if let Some(neutral) = mode.classic_neutral() {
|
|
// Fade the source toward the neutral colour by its coverage, then apply the formula.
|
|
let b = non_separable(mode, cb, cs.map(|v| neutral + sa * (v - neutral)));
|
|
for i in 0..3 {
|
|
o[i] = (1.0 - da) * src[i] + da * b[i];
|
|
}
|
|
} else {
|
|
let b = non_separable(mode, cb, cs);
|
|
for i in 0..3 {
|
|
o[i] = (1.0 - da) * src[i] + (1.0 - sa) * dst[i] + sa * da * b[i];
|
|
}
|
|
}
|
|
o[3] = ao;
|
|
o
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn opaque(v: f32) -> [f32; 4] {
|
|
[v, v, v, 1.0]
|
|
}
|
|
|
|
#[test]
|
|
fn opaque_formulas() {
|
|
let d = opaque(0.6);
|
|
let s = opaque(0.3);
|
|
let r = |m| blend_pixel(m, d, s, 0.5)[0];
|
|
assert!((r(BlendMode::Normal) - 0.3).abs() < 1e-6);
|
|
assert!((r(BlendMode::Multiply) - 0.18).abs() < 1e-6);
|
|
assert!((r(BlendMode::Screen) - (0.6 + 0.3 - 0.18)).abs() < 1e-6);
|
|
assert!((r(BlendMode::Add) - 0.9).abs() < 1e-6);
|
|
assert!((r(BlendMode::Difference) - 0.3).abs() < 1e-6);
|
|
assert!((r(BlendMode::Darken) - 0.3).abs() < 1e-6);
|
|
assert!((r(BlendMode::Lighten) - 0.6).abs() < 1e-6);
|
|
assert!((r(BlendMode::Subtract) - 0.3).abs() < 1e-6);
|
|
assert!((r(BlendMode::Divide) - 1.0).abs() < 1e-6 || r(BlendMode::Divide) > 1.0);
|
|
assert!((r(BlendMode::LinearBurn) - 0.0).abs() < 1e-6);
|
|
assert!((r(BlendMode::Exclusion) - (0.9 - 0.36)).abs() < 1e-6);
|
|
// Overlay with base 0.6 (>0.5): screen(0.3, 2*0.6-1)
|
|
assert!((r(BlendMode::Overlay) - (0.3 + 0.2 - 0.06)).abs() < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn transparent_source_keeps_destination() {
|
|
let d = [0.2, 0.3, 0.4, 0.8];
|
|
for m in BlendMode::ALL {
|
|
if m.is_stencil() {
|
|
continue;
|
|
}
|
|
let o = blend_pixel(m, d, [0.0; 4], 0.5);
|
|
assert!((0..4).all(|i| (o[i] - d[i]).abs() < 1e-6), "{m:?} {o:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn source_over_transparent_destination() {
|
|
let s = [0.25, 0.1, 0.05, 0.5];
|
|
for m in [BlendMode::Normal, BlendMode::Multiply, BlendMode::Screen, BlendMode::Hue, BlendMode::Overlay] {
|
|
let o = blend_pixel(m, [0.0; 4], s, 0.9);
|
|
assert!((0..4).all(|i| (o[i] - s[i]).abs() < 1e-6), "{m:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn stencil() {
|
|
let d = [0.5, 0.5, 0.5, 1.0];
|
|
assert_eq!(blend_pixel(BlendMode::StencilAlpha, d, [0.0, 0.0, 0.0, 0.0], 0.0), [0.0; 4]);
|
|
assert_eq!(blend_pixel(BlendMode::SilhouetteAlpha, d, [0.0, 0.0, 0.0, 0.0], 0.0), d);
|
|
}
|
|
|
|
#[test]
|
|
fn hdr_modes() {
|
|
// Add and Screen keep over-range values (32 bpc); Soft Light clamps its inputs.
|
|
let d = [2.0, 2.0, 2.0, 1.0];
|
|
let s = [0.5, 0.5, 0.5, 1.0];
|
|
assert!((blend_pixel(BlendMode::Add, d, s, 0.5)[0] - 2.5).abs() < 1e-6);
|
|
assert!((blend_pixel(BlendMode::Screen, d, s, 0.5)[0] - 1.5).abs() < 1e-6);
|
|
assert!(blend_pixel(BlendMode::SoftLight, d, s, 0.5)[0] <= 1.0);
|
|
}
|
|
|
|
/// #494: After Effects' 32 bpc formulas, measured by the reporter. b = bottom, o = top,
|
|
/// k = opacity (premultiplied into the source, as the compositor passes it).
|
|
#[test]
|
|
fn after_effects_32bpc_formulas() {
|
|
let at = |m, b: f32, o: f32, k: f32| blend_pixel(m, opaque(b), [o * k, o * k, o * k, k], 0.5)[0];
|
|
let close = |a: f32, e: f32| (a - e).abs() < 1e-5;
|
|
// Classic modes fade the source toward their neutral colour first.
|
|
for (b, o) in [(0.4, 0.5), (0.8, 0.9), (0.1, 0.3)] {
|
|
let k = 0.15;
|
|
assert!(close(at(BlendMode::ClassicColorDodge, b, o, k), (b / (1.0 - k * o)).min(1.0)));
|
|
assert!(close(at(BlendMode::ClassicDifference, b, o, k), (b - k * o).abs()));
|
|
assert!(close(at(BlendMode::ClassicColorBurn, b, o, k), 1.0 - (1.0 - b) / (1.0 - k * (1.0 - o))));
|
|
}
|
|
// At full opacity the classic formulas are unchanged.
|
|
assert!(close(at(BlendMode::ClassicDifference, 0.6, 0.3, 1.0), 0.3));
|
|
// Over-range top layers keep the plain formulas.
|
|
let (b, o) = (0.7, 3.0);
|
|
assert!(close(at(BlendMode::Exclusion, b, o, 1.0), b + o - 2.0 * b * o));
|
|
let s = 2.0 * b - 1.0;
|
|
assert!(close(at(BlendMode::Overlay, b, o, 1.0), o + s - o * s));
|
|
assert!(close(at(BlendMode::ColorBurn, 0.0, 1.35, 0.15), 0.15 * (1.0 - 1.0 / 1.35)));
|
|
assert!(close(at(BlendMode::ClassicColorBurn, 0.0, 0.1, 1.0), -9.0), "negative results are kept");
|
|
// Luminosity: SetLum + ClipColor on the unclamped top layer.
|
|
let l = blend_pixel(BlendMode::Luminosity, [0.2, 0.4, 0.6, 1.0], [2.0, 2.0, 2.0, 1.0], 0.5);
|
|
let e = set_lum([0.2, 0.4, 0.6], 2.0);
|
|
assert!((0..3).all(|i| close(l[i], e[i])), "{l:?} {e:?}");
|
|
assert!((l[0] - 1.0).abs() > 0.1, "not the white of a top layer clamped to 1: {l:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn names_roundtrip() {
|
|
for m in BlendMode::ALL {
|
|
assert_eq!(BlendMode::from_name(m.label()), Some(m));
|
|
}
|
|
assert_eq!(BlendMode::from_name("soft-light"), Some(BlendMode::SoftLight));
|
|
}
|
|
}
|