pub const BT2020_TO_BT709: [[f32; 3]; 3] = [
[1.6605, -0.5876, -0.0728],
[-0.1246, 1.1329, -0.0083],
[-0.0182, -0.1006, 1.1187],
];
pub const BT709_TO_BT2020: [[f32; 3]; 3] = [
[0.6274, 0.3293, 0.0433],
[0.0691, 0.9195, 0.0114],
[0.0164, 0.0880, 0.8956],
];
pub const P3_TO_BT709: [[f32; 3]; 3] = [
[1.2249, -0.2247, 0.0],
[-0.0420, 1.0419, 0.0],
[-0.0197, -0.0786, 1.0979],
];
pub const BT709_TO_P3: [[f32; 3]; 3] = [
[0.8225, 0.1774, 0.0],
[0.0332, 0.9669, 0.0],
[0.0171, 0.0724, 0.9108],
];
pub const BT2020_TO_P3: [[f32; 3]; 3] = [
[1.3435, -0.2822, -0.0613],
[-0.0653, 1.0758, -0.0105],
[-0.0028, -0.0196, 1.0219],
];
pub const P3_TO_BT2020: [[f32; 3]; 3] = [
[0.7539, 0.1986, 0.0476],
[0.0457, 0.9418, 0.0125],
[0.0012, 0.0176, 0.9811],
];
#[inline]
pub fn apply_matrix(m: &[[f32; 3]; 3], rgb: [f32; 3]) -> [f32; 3] {
[
m[0][0] * rgb[0] + m[0][1] * rgb[1] + m[0][2] * rgb[2],
m[1][0] * rgb[0] + m[1][1] * rgb[1] + m[1][2] * rgb[2],
m[2][0] * rgb[0] + m[2][1] * rgb[1] + m[2][2] * rgb[2],
]
}
pub fn apply_matrix_row(m: &[[f32; 3]; 3], row: &mut [f32], channels: usize) {
debug_assert!(channels == 3 || channels == 4);
for chunk in row.chunks_exact_mut(channels) {
let rgb = [chunk[0], chunk[1], chunk[2]];
let out = apply_matrix(m, rgb);
chunk[0] = out[0];
chunk[1] = out[1];
chunk[2] = out[2];
}
}
#[inline]
pub fn is_out_of_gamut(rgb: [f32; 3]) -> bool {
rgb[0] < 0.0 || rgb[0] > 1.0 || rgb[1] < 0.0 || rgb[1] > 1.0 || rgb[2] < 0.0 || rgb[2] > 1.0
}
#[inline]
pub fn soft_clip(rgb: [f32; 3]) -> [f32; 3] {
let [mut r, mut g, mut b] = rgb;
r = r.max(0.0);
g = g.max(0.0);
b = b.max(0.0);
if r <= 1.0 && g <= 1.0 && b <= 1.0 {
return [r, g, b];
}
if r >= g {
if g > b {
clip_sorted(&mut r, &mut g, &mut b);
} else if b > r {
clip_sorted(&mut b, &mut r, &mut g);
} else if b > g {
clip_sorted(&mut r, &mut b, &mut g);
} else {
clip_sorted(&mut r, &mut g, &mut b);
}
} else if r >= b {
clip_sorted(&mut g, &mut r, &mut b);
} else if b > g {
clip_sorted(&mut b, &mut g, &mut r);
} else {
clip_sorted(&mut g, &mut b, &mut r);
}
[r, g, b]
}
#[inline(always)]
fn clip_sorted(hi: &mut f32, mid: &mut f32, lo: &mut f32) {
let new_hi = hi.min(1.0);
let new_lo = lo.min(1.0);
if *hi != *lo {
*mid = new_lo + (new_hi - new_lo) * (*mid - *lo) / (*hi - *lo);
} else {
*mid = new_hi;
}
*hi = new_hi;
*lo = new_lo;
}
#[inline]
pub fn apply_matrix_row_simd(matrix: &[[f32; 3]; 3], row: &mut [[f32; 3]]) {
archmage::incant!(
crate::simd::blocks::apply_matrix_rgb_tier(matrix, row),
[v4, v3, neon, wasm128, scalar]
);
}
#[inline]
pub fn apply_matrix_row_simd_rgba(matrix: &[[f32; 3]; 3], row: &mut [[f32; 4]]) {
archmage::incant!(
crate::simd::blocks::apply_matrix_rgba_tier(matrix, row),
[v4, v3, neon, wasm128, scalar]
);
}
#[inline]
pub fn soft_clip_row_simd(row: &mut [[f32; 3]]) {
archmage::incant!(
crate::simd::blocks::soft_clip_tier(row),
[v4, v3, neon, wasm128, scalar]
);
}
#[inline]
pub fn is_out_of_gamut_mask_simd(row: &[[f32; 3]], out: &mut [f32]) {
assert_eq!(
row.len(),
out.len(),
"is_out_of_gamut_mask_simd: row and out length must match"
);
archmage::incant!(
crate::simd::blocks::is_out_of_gamut_mask_tier(row, out),
[v4, v3, neon, wasm128, scalar]
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bt709_bt2020_roundtrip() {
let rgb = [0.5_f32, 0.3, 0.8];
let bt2020 = apply_matrix(&BT709_TO_BT2020, rgb);
let back = apply_matrix(&BT2020_TO_BT709, bt2020);
for i in 0..3 {
assert!(
(back[i] - rgb[i]).abs() < 1e-3,
"BT.709↔BT.2020 roundtrip[{i}]: {:.5} vs {:.5}",
back[i],
rgb[i]
);
}
}
#[test]
fn bt709_p3_roundtrip() {
let rgb = [0.5_f32, 0.3, 0.8];
let p3 = apply_matrix(&BT709_TO_P3, rgb);
let back = apply_matrix(&P3_TO_BT709, p3);
for i in 0..3 {
assert!(
(back[i] - rgb[i]).abs() < 1e-3,
"BT.709↔P3 roundtrip[{i}]: {:.5} vs {:.5}",
back[i],
rgb[i]
);
}
}
#[test]
fn bt2020_p3_roundtrip() {
let rgb = [0.5_f32, 0.3, 0.8];
let p3 = apply_matrix(&BT2020_TO_P3, rgb);
let back = apply_matrix(&P3_TO_BT2020, p3);
for i in 0..3 {
assert!(
(back[i] - rgb[i]).abs() < 1e-3,
"BT.2020↔P3 roundtrip[{i}]: {:.5} vs {:.5}",
back[i],
rgb[i]
);
}
}
#[test]
fn neutral_gray_preserved() {
let gray = [0.5_f32, 0.5, 0.5];
for (name, m) in [
("709→2020", &BT709_TO_BT2020),
("2020→709", &BT2020_TO_BT709),
("709→P3", &BT709_TO_P3),
("P3→709", &P3_TO_BT709),
] {
let out = apply_matrix(m, gray);
for (i, &c) in out.iter().enumerate() {
assert!((c - 0.5).abs() < 0.01, "{name}: gray[{i}] = {c:.5}",);
}
}
}
#[test]
fn row_preserves_alpha() {
let mut row = [0.5_f32, 0.3, 0.8, 0.42];
apply_matrix_row(&BT709_TO_BT2020, &mut row, 4);
assert!((row[3] - 0.42).abs() < 1e-6);
}
#[test]
fn soft_clip_in_gamut_is_identity() {
let rgb = [0.5, 0.3, 0.8];
let clipped = soft_clip(rgb);
for i in 0..3 {
assert!(
(clipped[i] - rgb[i]).abs() < 1e-7,
"in-gamut soft_clip changed channel {i}"
);
}
}
#[test]
fn soft_clip_clamps_to_unit_range() {
let bt2020_green = [0.0, 1.0, 0.0];
let bt709 = apply_matrix(&BT2020_TO_BT709, bt2020_green);
assert!(
is_out_of_gamut(bt709),
"BT.2020 pure green should be out of BT.709 gamut"
);
let clipped = soft_clip(bt709);
for (i, &c) in clipped.iter().enumerate() {
assert!(
(0.0..=1.0).contains(&c),
"soft_clip[{i}] = {c} out of [0,1]"
);
}
}
#[test]
fn soft_clip_preserves_hue_better_than_hard_clamp() {
let rgb = [0.8, 1.3, 1.1];
let clipped = soft_clip(rgb);
let clamped = [
rgb[0].clamp(0.0, 1.0),
rgb[1].clamp(0.0, 1.0),
rgb[2].clamp(0.0, 1.0),
];
for &c in &clipped {
assert!((0.0..=1.0).contains(&c));
}
assert!(clipped[1] >= clipped[2], "soft_clip: g should be >= b");
assert!(clipped[2] >= clipped[0], "soft_clip: b should be >= r");
assert!(
(clipped[1] - clipped[2]).abs() > 0.01,
"soft_clip should maintain g-b difference, got g={} b={}",
clipped[1],
clipped[2]
);
assert!(
(clamped[1] - clamped[2]).abs() < 1e-6,
"hard clamp should collapse g and b"
);
}
#[test]
fn soft_clip_bt2020_saturated() {
let colors = [
[1.0_f32, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 1.0, 0.0], [0.0, 1.0, 1.0], ];
for color in &colors {
let out = soft_clip(apply_matrix(&BT2020_TO_BT709, *color));
for (i, &c) in out.iter().enumerate() {
assert!(
(0.0..=1.0).contains(&c),
"BT.2020 {color:?} → BT.709 clip[{i}] = {c}"
);
}
}
}
}