use super::{clamp_u8, validate_buffer, PixelFormat, VideoResult};
#[derive(Debug, Clone, Copy)]
pub struct ColorVec {
pub r: f32,
pub g: f32,
pub b: f32,
}
impl ColorVec {
#[must_use]
pub const fn new(r: f32, g: f32, b: f32) -> Self {
Self { r, g, b }
}
#[must_use]
pub const fn neutral_lift() -> Self {
Self::new(0.0, 0.0, 0.0)
}
#[must_use]
pub const fn neutral_gamma() -> Self {
Self::new(1.0, 1.0, 1.0)
}
#[must_use]
pub const fn neutral_gain() -> Self {
Self::new(1.0, 1.0, 1.0)
}
}
impl Default for ColorVec {
fn default() -> Self {
Self::neutral_gamma()
}
}
#[derive(Debug, Clone, Copy)]
pub struct LiftGammaGain {
pub lift: ColorVec,
pub gamma: ColorVec,
pub gain: ColorVec,
}
impl Default for LiftGammaGain {
fn default() -> Self {
Self {
lift: ColorVec::neutral_lift(),
gamma: ColorVec::neutral_gamma(),
gain: ColorVec::neutral_gain(),
}
}
}
impl LiftGammaGain {
#[inline]
#[must_use]
pub fn apply_channel(val: f32, lift: f32, gamma: f32, gain: f32) -> f32 {
let lifted = val * (1.0 - lift) + lift;
let gained = lifted * gain;
let gamma_clamped = gamma.max(0.001);
gained.max(0.0).powf(1.0 / gamma_clamped)
}
}
#[derive(Debug, Clone)]
pub struct ColorGradeConfig {
pub lgg: LiftGammaGain,
pub saturation: f32,
pub contrast: f32,
pub brightness: f32,
}
impl Default for ColorGradeConfig {
fn default() -> Self {
Self {
lgg: LiftGammaGain::default(),
saturation: 1.0,
contrast: 1.0,
brightness: 0.0,
}
}
}
impl ColorGradeConfig {
#[must_use]
pub fn warm_cinematic() -> Self {
Self {
lgg: LiftGammaGain {
lift: ColorVec::new(0.05, 0.03, 0.0),
gamma: ColorVec::new(1.05, 1.0, 0.95),
gain: ColorVec::new(1.1, 1.05, 0.9),
},
saturation: 1.1,
contrast: 1.05,
brightness: 0.0,
}
}
#[must_use]
pub fn teal_orange() -> Self {
Self {
lgg: LiftGammaGain {
lift: ColorVec::new(0.0, 0.05, 0.08),
gamma: ColorVec::new(1.05, 1.0, 0.95),
gain: ColorVec::new(1.15, 1.0, 0.85),
},
saturation: 1.2,
contrast: 1.1,
brightness: 0.0,
}
}
#[must_use]
pub fn bleach_bypass() -> Self {
Self {
lgg: LiftGammaGain::default(),
saturation: 0.4,
contrast: 1.4,
brightness: 0.0,
}
}
}
pub struct ColorGrade {
config: ColorGradeConfig,
}
impl ColorGrade {
#[must_use]
pub fn new(config: ColorGradeConfig) -> Self {
Self { config }
}
pub fn apply(
&self,
data: &mut [u8],
width: usize,
height: usize,
format: PixelFormat,
) -> VideoResult<()> {
validate_buffer(data, width, height, format)?;
let bpp = format.bytes_per_pixel();
let cfg = &self.config;
let lgg = &cfg.lgg;
for py in 0..height {
for px in 0..width {
let idx = (py * width + px) * bpp;
let r_in = f32::from(data[idx]) / 255.0;
let g_in = f32::from(data[idx + 1]) / 255.0;
let b_in = f32::from(data[idx + 2]) / 255.0;
let r = LiftGammaGain::apply_channel(r_in, lgg.lift.r, lgg.gamma.r, lgg.gain.r);
let g = LiftGammaGain::apply_channel(g_in, lgg.lift.g, lgg.gamma.g, lgg.gain.g);
let b = LiftGammaGain::apply_channel(b_in, lgg.lift.b, lgg.gamma.b, lgg.gain.b);
let r = (r + cfg.brightness).clamp(0.0, 1.0);
let g = (g + cfg.brightness).clamp(0.0, 1.0);
let b = (b + cfg.brightness).clamp(0.0, 1.0);
let c = cfg.contrast;
let r = ((r - 0.5) * c + 0.5).clamp(0.0, 1.0);
let g = ((g - 0.5) * c + 0.5).clamp(0.0, 1.0);
let b = ((b - 0.5) * c + 0.5).clamp(0.0, 1.0);
let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
let r = (luma + (r - luma) * cfg.saturation).clamp(0.0, 1.0);
let g = (luma + (g - luma) * cfg.saturation).clamp(0.0, 1.0);
let b = (luma + (b - luma) * cfg.saturation).clamp(0.0, 1.0);
data[idx] = clamp_u8(r * 255.0);
data[idx + 1] = clamp_u8(g * 255.0);
data[idx + 2] = clamp_u8(b * 255.0);
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn gray_ramp(w: usize, h: usize) -> Vec<u8> {
let mut buf = vec![0u8; w * h * 3];
for py in 0..h {
for px in 0..w {
let val = (px * 255 / w) as u8;
let idx = (py * w + px) * 3;
buf[idx] = val;
buf[idx + 1] = val;
buf[idx + 2] = val;
}
}
buf
}
#[test]
fn test_color_grade_identity() {
let orig = gray_ramp(32, 32);
let mut buf = orig.clone();
let cg = ColorGrade::new(ColorGradeConfig::default());
cg.apply(&mut buf, 32, 32, PixelFormat::Rgb)
.expect("apply should succeed");
for (a, b) in buf.iter().zip(orig.iter()) {
assert!(
(*a as i32 - *b as i32).abs() <= 1,
"Identity grade should not change image"
);
}
}
#[test]
fn test_color_grade_warm_cinematic() {
let mut buf = gray_ramp(32, 32);
let cg = ColorGrade::new(ColorGradeConfig::warm_cinematic());
assert!(cg.apply(&mut buf, 32, 32, PixelFormat::Rgb).is_ok());
}
#[test]
fn test_color_grade_teal_orange() {
let mut buf = gray_ramp(32, 32);
let cg = ColorGrade::new(ColorGradeConfig::teal_orange());
assert!(cg.apply(&mut buf, 32, 32, PixelFormat::Rgb).is_ok());
}
#[test]
fn test_color_grade_bleach_bypass() {
let mut buf = gray_ramp(32, 32);
let cg = ColorGrade::new(ColorGradeConfig::bleach_bypass());
cg.apply(&mut buf, 32, 32, PixelFormat::Rgb)
.expect("apply should succeed");
for px in buf.chunks_exact(3) {
assert!((px[0] as i32 - px[1] as i32).abs() <= 2);
}
}
#[test]
fn test_color_grade_lift_brightens_shadows() {
let black_px = vec![0u8; 1 * 1 * 3];
let mut buf = black_px.clone();
let cfg = ColorGradeConfig {
lgg: LiftGammaGain {
lift: ColorVec::new(0.2, 0.2, 0.2),
..Default::default()
},
..Default::default()
};
let cg = ColorGrade::new(cfg);
cg.apply(&mut buf, 1, 1, PixelFormat::Rgb)
.expect("apply should succeed");
assert!(buf[0] > 0, "Lift should raise black level");
}
#[test]
fn test_color_grade_gain_brightens_highlights() {
let white_px = vec![200u8; 1 * 1 * 3];
let mut buf = white_px.clone();
let cfg = ColorGradeConfig {
lgg: LiftGammaGain {
gain: ColorVec::new(1.5, 1.5, 1.5),
..Default::default()
},
..Default::default()
};
let cg = ColorGrade::new(cfg);
cg.apply(&mut buf, 1, 1, PixelFormat::Rgb)
.expect("apply should succeed");
assert!(buf[0] >= 200, "Gain should not reduce highlights");
}
#[test]
fn test_color_grade_desaturation() {
let mut buf = vec![200u8, 100u8, 50u8];
let cfg = ColorGradeConfig {
saturation: 0.0,
..Default::default()
};
let cg = ColorGrade::new(cfg);
cg.apply(&mut buf, 1, 1, PixelFormat::Rgb)
.expect("apply should succeed");
assert!((buf[0] as i32 - buf[1] as i32).abs() <= 1);
assert!((buf[1] as i32 - buf[2] as i32).abs() <= 1);
}
#[test]
fn test_color_grade_rgba() {
let mut buf = vec![128u8; 32 * 32 * 4];
let cg = ColorGrade::new(ColorGradeConfig::default());
assert!(cg.apply(&mut buf, 32, 32, PixelFormat::Rgba).is_ok());
}
#[test]
fn test_color_grade_wrong_size_err() {
let mut buf = vec![0u8; 5];
let cg = ColorGrade::new(ColorGradeConfig::default());
assert!(cg.apply(&mut buf, 32, 32, PixelFormat::Rgb).is_err());
}
#[test]
fn test_lift_gamma_gain_apply_channel() {
let out = LiftGammaGain::apply_channel(0.5, 0.0, 1.0, 1.0);
assert!((out - 0.5).abs() < 0.001);
let out = LiftGammaGain::apply_channel(0.0, 0.2, 1.0, 1.0);
assert!((out - 0.2).abs() < 0.001);
let out_bright = LiftGammaGain::apply_channel(0.5, 0.0, 2.0, 1.0);
assert!(
out_bright > 0.5,
"gamma=2 should brighten midtones (sqrt curve)"
);
let out_dark = LiftGammaGain::apply_channel(0.5, 0.0, 0.5, 1.0);
assert!(
out_dark < 0.5,
"gamma=0.5 should darken midtones (square curve)"
);
}
}