Skip to main content

proof_engine/editor/
color_grading.rs

1
2//! Color grading editor — LUT generation, HDR grading, wheels, color science.
3
4use glam::{Vec2, Vec3, Vec4};
5use std::collections::HashMap;
6
7// ---------------------------------------------------------------------------
8// Color spaces
9// ---------------------------------------------------------------------------
10
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub enum ColorSpace {
13    Linear,
14    Gamma22,
15    Gamma18,
16    AcesCg,
17    AcesAp0,
18    Rec709,
19    Rec2020,
20    DciP3,
21    DisplayP3,
22    SRgb,
23    LogC,
24    SLog3,
25    RedWideGamut,
26}
27
28impl ColorSpace {
29    pub fn label(self) -> &'static str {
30        match self {
31            ColorSpace::Linear => "Linear",
32            ColorSpace::Gamma22 => "Gamma 2.2",
33            ColorSpace::Gamma18 => "Gamma 1.8",
34            ColorSpace::AcesCg => "ACEScg",
35            ColorSpace::AcesAp0 => "ACES AP0",
36            ColorSpace::Rec709 => "Rec.709",
37            ColorSpace::Rec2020 => "Rec.2020",
38            ColorSpace::DciP3 => "DCI-P3",
39            ColorSpace::DisplayP3 => "Display P3",
40            ColorSpace::SRgb => "sRGB",
41            ColorSpace::LogC => "Log C",
42            ColorSpace::SLog3 => "S-Log3",
43            ColorSpace::RedWideGamut => "REDWideGamutRGB",
44        }
45    }
46
47    pub fn is_hdr(self) -> bool {
48        matches!(self, ColorSpace::AcesCg | ColorSpace::AcesAp0 | ColorSpace::Rec2020 | ColorSpace::LogC | ColorSpace::SLog3)
49    }
50
51    pub fn to_linear(self, v: f32) -> f32 {
52        match self {
53            ColorSpace::Linear => v,
54            ColorSpace::Gamma22 | ColorSpace::SRgb => v.powf(2.2),
55            ColorSpace::Gamma18 => v.powf(1.8),
56            _ => v,
57        }
58    }
59
60    pub fn from_linear(self, v: f32) -> f32 {
61        match self {
62            ColorSpace::Linear => v,
63            ColorSpace::Gamma22 | ColorSpace::SRgb => v.max(0.0).powf(1.0 / 2.2),
64            ColorSpace::Gamma18 => v.max(0.0).powf(1.0 / 1.8),
65            _ => v,
66        }
67    }
68}
69
70// ---------------------------------------------------------------------------
71// Tone mapping
72// ---------------------------------------------------------------------------
73
74#[derive(Debug, Clone, Copy, PartialEq)]
75pub enum ToneMappingMode {
76    None,
77    Aces,
78    FilmicAces,
79    Reinhard,
80    ReinhardExtended,
81    Uncharted2,
82    Hable,
83    CustomCurve,
84    AgX,
85    Tony,
86}
87
88impl ToneMappingMode {
89    pub fn label(self) -> &'static str {
90        match self {
91            ToneMappingMode::None => "None",
92            ToneMappingMode::Aces => "ACES",
93            ToneMappingMode::FilmicAces => "Filmic ACES",
94            ToneMappingMode::Reinhard => "Reinhard",
95            ToneMappingMode::ReinhardExtended => "Reinhard Extended",
96            ToneMappingMode::Uncharted2 => "Uncharted 2",
97            ToneMappingMode::Hable => "Hable",
98            ToneMappingMode::CustomCurve => "Custom Curve",
99            ToneMappingMode::AgX => "AgX",
100            ToneMappingMode::Tony => "Tony",
101        }
102    }
103
104    pub fn apply(self, x: f32) -> f32 {
105        match self {
106            ToneMappingMode::None => x,
107            ToneMappingMode::Reinhard => x / (1.0 + x),
108            ToneMappingMode::ReinhardExtended => {
109                let white = 4.0_f32;
110                x * (1.0 + x / (white * white)) / (1.0 + x)
111            }
112            ToneMappingMode::Aces => {
113                // ACES fitted approximation
114                let a = 2.51_f32;
115                let b = 0.03_f32;
116                let c = 2.43_f32;
117                let d = 0.59_f32;
118                let e = 0.14_f32;
119                ((x * (a * x + b)) / (x * (c * x + d) + e)).clamp(0.0, 1.0)
120            }
121            ToneMappingMode::Uncharted2 | ToneMappingMode::Hable => {
122                let a = 0.15_f32;
123                let b = 0.50_f32;
124                let c = 0.10_f32;
125                let d = 0.20_f32;
126                let e_val = 0.02_f32;
127                let f = 0.30_f32;
128                let w = 11.2_f32;
129                let tone = |v: f32| ((v * (a * v + c * b) + d * e_val) / (v * (a * v + b) + d * f)) - e_val / f;
130                let curr = tone(x * 2.0);
131                let white_scale = 1.0 / tone(w);
132                (curr * white_scale).clamp(0.0, 1.0)
133            }
134            _ => x / (1.0 + x),
135        }
136    }
137
138    pub fn apply_vec3(self, color: Vec3) -> Vec3 {
139        Vec3::new(self.apply(color.x), self.apply(color.y), self.apply(color.z))
140    }
141}
142
143// ---------------------------------------------------------------------------
144// Color wheel / lift-gamma-gain
145// ---------------------------------------------------------------------------
146
147#[derive(Debug, Clone, Copy)]
148pub struct ColorWheelValue {
149    pub hue_offset: f32,     // degrees
150    pub saturation: f32,     // 0..2
151    pub lightness: f32,      // -1..1
152    pub tint: Vec3,
153}
154
155impl Default for ColorWheelValue {
156    fn default() -> Self {
157        Self { hue_offset: 0.0, saturation: 1.0, lightness: 0.0, tint: Vec3::ZERO }
158    }
159}
160
161impl ColorWheelValue {
162    pub fn apply(&self, color: Vec3) -> Vec3 {
163        // Apply lightness
164        let c = color + Vec3::splat(self.lightness);
165        // Apply saturation
166        let lum = c.dot(Vec3::new(0.2126, 0.7152, 0.0722));
167        let c = Vec3::splat(lum).lerp(c, self.saturation);
168        // Apply hue rotation via RGB rotation matrix (approximate)
169        let h = self.hue_offset * std::f32::consts::PI / 180.0;
170        let cos_h = h.cos();
171        let sin_h = h.sin();
172        let u = Vec3::new(0.213, 0.715, 0.072);
173        let w = Vec3::new(0.143, -0.140, -0.283); // cross product like
174        let c = Vec3::new(
175            c.dot(u + Vec3::new(cos_h * (1.0 - u.x), -sin_h * w.z, sin_h * w.y)),
176            c.dot(u + Vec3::new(sin_h * w.z, cos_h * (1.0 - u.y), -sin_h * w.x)),
177            c.dot(u + Vec3::new(-sin_h * w.y, sin_h * w.x, cos_h * (1.0 - u.z))),
178        );
179        // Add tint
180        c + self.tint
181    }
182}
183
184#[derive(Debug, Clone, Copy)]
185pub struct LiftGammaGain {
186    pub lift: Vec4,   // xyz = RGB, w = master
187    pub gamma: Vec4,
188    pub gain: Vec4,
189}
190
191impl Default for LiftGammaGain {
192    fn default() -> Self {
193        Self {
194            lift: Vec4::new(0.0, 0.0, 0.0, 0.0),
195            gamma: Vec4::new(1.0, 1.0, 1.0, 1.0),
196            gain: Vec4::new(1.0, 1.0, 1.0, 1.0),
197        }
198    }
199}
200
201impl LiftGammaGain {
202    pub fn apply(&self, color: Vec3) -> Vec3 {
203        let lift = Vec3::new(self.lift.x + self.lift.w, self.lift.y + self.lift.w, self.lift.z + self.lift.w);
204        let gamma = Vec3::new(self.gamma.x * self.gamma.w, self.gamma.y * self.gamma.w, self.gamma.z * self.gamma.w);
205        let gain = Vec3::new(self.gain.x * self.gain.w, self.gain.y * self.gain.w, self.gain.z * self.gain.w);
206        let c = color * gain + lift;
207        Vec3::new(
208            c.x.max(0.0).powf(1.0 / gamma.x.max(0.001)),
209            c.y.max(0.0).powf(1.0 / gamma.y.max(0.001)),
210            c.z.max(0.0).powf(1.0 / gamma.z.max(0.001)),
211        )
212    }
213}
214
215// ---------------------------------------------------------------------------
216// Curve per-channel
217// ---------------------------------------------------------------------------
218
219#[derive(Debug, Clone)]
220pub struct ColorCurveChannel {
221    pub points: Vec<Vec2>,
222    pub enabled: bool,
223}
224
225impl ColorCurveChannel {
226    pub fn identity() -> Self {
227        Self {
228            points: vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 1.0)],
229            enabled: true,
230        }
231    }
232
233    pub fn evaluate(&self, t: f32) -> f32 {
234        if !self.enabled { return t; }
235        let t = t.clamp(0.0, 1.0);
236        if self.points.len() < 2 { return t; }
237        let i = self.points.partition_point(|p| p.x <= t);
238        if i == 0 { return self.points[0].y; }
239        if i >= self.points.len() { return self.points[self.points.len()-1].y; }
240        let a = self.points[i-1];
241        let b = self.points[i];
242        let u = (t - a.x) / (b.x - a.x).max(1e-6);
243        // Smooth cubic interpolation
244        let u = u * u * (3.0 - 2.0 * u);
245        a.y + (b.y - a.y) * u
246    }
247
248    pub fn add_point(&mut self, t: f32, v: f32) {
249        let i = self.points.partition_point(|p| p.x < t);
250        self.points.insert(i, Vec2::new(t, v));
251    }
252
253    pub fn remove_point(&mut self, idx: usize) {
254        if self.points.len() > 2 {
255            self.points.remove(idx);
256        }
257    }
258
259    pub fn reset(&mut self) {
260        self.points = vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 1.0)];
261    }
262}
263
264#[derive(Debug, Clone)]
265pub struct ColorCurves {
266    pub master: ColorCurveChannel,
267    pub red: ColorCurveChannel,
268    pub green: ColorCurveChannel,
269    pub blue: ColorCurveChannel,
270    pub hue_vs_hue: ColorCurveChannel,
271    pub hue_vs_sat: ColorCurveChannel,
272    pub hue_vs_lum: ColorCurveChannel,
273    pub lum_vs_sat: ColorCurveChannel,
274    pub sat_vs_sat: ColorCurveChannel,
275}
276
277impl Default for ColorCurves {
278    fn default() -> Self {
279        Self {
280            master: ColorCurveChannel::identity(),
281            red: ColorCurveChannel::identity(),
282            green: ColorCurveChannel::identity(),
283            blue: ColorCurveChannel::identity(),
284            hue_vs_hue: ColorCurveChannel::identity(),
285            hue_vs_sat: ColorCurveChannel::identity(),
286            hue_vs_lum: ColorCurveChannel::identity(),
287            lum_vs_sat: ColorCurveChannel::identity(),
288            sat_vs_sat: ColorCurveChannel::identity(),
289        }
290    }
291}
292
293impl ColorCurves {
294    pub fn apply(&self, color: Vec3) -> Vec3 {
295        Vec3::new(
296            self.red.evaluate(self.master.evaluate(color.x)),
297            self.green.evaluate(self.master.evaluate(color.y)),
298            self.blue.evaluate(self.master.evaluate(color.z)),
299        )
300    }
301}
302
303// ---------------------------------------------------------------------------
304// Shadow / midtone / highlight controls
305// ---------------------------------------------------------------------------
306
307#[derive(Debug, Clone, Copy, Default)]
308pub struct ShadowsMidtonesHighlights {
309    pub shadows: Vec4,     // rgb offset, w = weight
310    pub midtones: Vec4,
311    pub highlights: Vec4,
312    pub shadows_start: f32,
313    pub shadows_end: f32,
314    pub highlights_start: f32,
315    pub highlights_end: f32,
316}
317
318impl ShadowsMidtonesHighlights {
319    pub fn new() -> Self {
320        Self {
321            shadows: Vec4::new(0.0, 0.0, 0.0, 1.0),
322            midtones: Vec4::new(0.0, 0.0, 0.0, 1.0),
323            highlights: Vec4::new(0.0, 0.0, 0.0, 1.0),
324            shadows_start: 0.0,
325            shadows_end: 0.3,
326            highlights_start: 0.55,
327            highlights_end: 1.0,
328        }
329    }
330
331    pub fn apply(&self, color: Vec3) -> Vec3 {
332        let lum = color.dot(Vec3::new(0.2126, 0.7152, 0.0722));
333        let shadow_w = (1.0 - (lum - self.shadows_start) / (self.shadows_end - self.shadows_start).max(1e-6)).clamp(0.0, 1.0);
334        let highlight_w = ((lum - self.highlights_start) / (self.highlights_end - self.highlights_start).max(1e-6)).clamp(0.0, 1.0);
335        let midtone_w = (1.0 - shadow_w - highlight_w).clamp(0.0, 1.0);
336        let s = Vec3::new(self.shadows.x, self.shadows.y, self.shadows.z) * self.shadows.w;
337        let m = Vec3::new(self.midtones.x, self.midtones.y, self.midtones.z) * self.midtones.w;
338        let h = Vec3::new(self.highlights.x, self.highlights.y, self.highlights.z) * self.highlights.w;
339        color + s * shadow_w + m * midtone_w + h * highlight_w
340    }
341}
342
343// ---------------------------------------------------------------------------
344// 3D LUT
345// ---------------------------------------------------------------------------
346
347#[derive(Debug, Clone, Copy, PartialEq)]
348pub enum LutSize {
349    Lut16,
350    Lut32,
351    Lut48,
352    Lut64,
353}
354
355impl LutSize {
356    pub fn dim(self) -> usize {
357        match self {
358            LutSize::Lut16 => 16,
359            LutSize::Lut32 => 32,
360            LutSize::Lut48 => 48,
361            LutSize::Lut64 => 64,
362        }
363    }
364
365    pub fn byte_size(self) -> usize {
366        let d = self.dim();
367        d * d * d * 4 * 4 // RGBA f32
368    }
369}
370
371#[derive(Debug, Clone)]
372pub struct Lut3D {
373    pub size: LutSize,
374    pub data: Vec<Vec3>, // size^3 entries
375    pub name: String,
376    pub source_space: ColorSpace,
377    pub target_space: ColorSpace,
378}
379
380impl Lut3D {
381    pub fn identity(size: LutSize) -> Self {
382        let dim = size.dim();
383        let n = dim * dim * dim;
384        let mut data = Vec::with_capacity(n);
385        for b in 0..dim {
386            for g in 0..dim {
387                for r in 0..dim {
388                    data.push(Vec3::new(
389                        r as f32 / (dim - 1) as f32,
390                        g as f32 / (dim - 1) as f32,
391                        b as f32 / (dim - 1) as f32,
392                    ));
393                }
394            }
395        }
396        Self {
397            size,
398            data,
399            name: "Identity".into(),
400            source_space: ColorSpace::Linear,
401            target_space: ColorSpace::SRgb,
402        }
403    }
404
405    pub fn apply(&self, color: Vec3) -> Vec3 {
406        let dim = self.size.dim();
407        let c = color.clamp(Vec3::ZERO, Vec3::ONE);
408        let sc = c * (dim - 1) as f32;
409        let x0 = sc.x.floor() as usize;
410        let y0 = sc.y.floor() as usize;
411        let z0 = sc.z.floor() as usize;
412        let x1 = (x0 + 1).min(dim - 1);
413        let y1 = (y0 + 1).min(dim - 1);
414        let z1 = (z0 + 1).min(dim - 1);
415        let fx = sc.x.fract();
416        let fy = sc.y.fract();
417        let fz = sc.z.fract();
418        let idx = |r: usize, g: usize, b: usize| b * dim * dim + g * dim + r;
419        // Trilinear interpolation
420        let c000 = self.data[idx(x0, y0, z0)];
421        let c100 = self.data[idx(x1, y0, z0)];
422        let c010 = self.data[idx(x0, y1, z0)];
423        let c110 = self.data[idx(x1, y1, z0)];
424        let c001 = self.data[idx(x0, y0, z1)];
425        let c101 = self.data[idx(x1, y0, z1)];
426        let c011 = self.data[idx(x0, y1, z1)];
427        let c111 = self.data[idx(x1, y1, z1)];
428        let c00 = c000.lerp(c100, fx);
429        let c01 = c010.lerp(c110, fx);
430        let c10 = c001.lerp(c101, fx);
431        let c11 = c011.lerp(c111, fx);
432        let c0 = c00.lerp(c01, fy);
433        let c1 = c10.lerp(c11, fy);
434        c0.lerp(c1, fz)
435    }
436
437    pub fn bake_from_grade(&mut self, grade: &ColorGrade) {
438        let dim = self.size.dim();
439        for b in 0..dim {
440            for g in 0..dim {
441                for r in 0..dim {
442                    let input = Vec3::new(
443                        r as f32 / (dim - 1) as f32,
444                        g as f32 / (dim - 1) as f32,
445                        b as f32 / (dim - 1) as f32,
446                    );
447                    let output = grade.apply(input);
448                    self.data[b * dim * dim + g * dim + r] = output;
449                }
450            }
451        }
452    }
453
454    pub fn export_cube_format(&self) -> String {
455        let dim = self.size.dim();
456        let mut out = String::new();
457        out.push_str(&format!("LUT_3D_SIZE {}\n\n", dim));
458        for entry in &self.data {
459            out.push_str(&format!("{:.6} {:.6} {:.6}\n", entry.x, entry.y, entry.z));
460        }
461        out
462    }
463}
464
465// ---------------------------------------------------------------------------
466// Full color grade pipeline
467// ---------------------------------------------------------------------------
468
469#[derive(Debug, Clone)]
470pub struct ColorGrade {
471    pub exposure: f32,
472    pub contrast: f32,
473    pub brightness: f32,
474    pub saturation: f32,
475    pub hue_shift: f32,
476    pub temperature: f32,   // Kelvin offset
477    pub tint: f32,
478    pub tone_mapping: ToneMappingMode,
479    pub lift_gamma_gain: LiftGammaGain,
480    pub shadows_midtones_highlights: ShadowsMidtonesHighlights,
481    pub curves: ColorCurves,
482    pub color_filter: Vec3,
483    pub channel_mixer_r: Vec3,
484    pub channel_mixer_g: Vec3,
485    pub channel_mixer_b: Vec3,
486    pub color_wheels: [ColorWheelValue; 3], // shadows/midtones/highlights
487    pub lut_contribution: f32,
488    pub output_colorspace: ColorSpace,
489    pub post_exposure: f32,
490}
491
492impl Default for ColorGrade {
493    fn default() -> Self {
494        Self {
495            exposure: 0.0,
496            contrast: 0.0,
497            brightness: 0.0,
498            saturation: 1.0,
499            hue_shift: 0.0,
500            temperature: 0.0,
501            tint: 0.0,
502            tone_mapping: ToneMappingMode::Aces,
503            lift_gamma_gain: LiftGammaGain::default(),
504            shadows_midtones_highlights: ShadowsMidtonesHighlights::new(),
505            curves: ColorCurves::default(),
506            color_filter: Vec3::ONE,
507            channel_mixer_r: Vec3::new(1.0, 0.0, 0.0),
508            channel_mixer_g: Vec3::new(0.0, 1.0, 0.0),
509            channel_mixer_b: Vec3::new(0.0, 0.0, 1.0),
510            color_wheels: [ColorWheelValue::default(); 3],
511            lut_contribution: 1.0,
512            output_colorspace: ColorSpace::SRgb,
513            post_exposure: 0.0,
514        }
515    }
516}
517
518impl ColorGrade {
519    pub fn apply(&self, input: Vec3) -> Vec3 {
520        // 1. Exposure
521        let exposure_mult = 2.0_f32.powf(self.exposure);
522        let c = input * exposure_mult;
523        // 2. White balance (approximate temperature/tint)
524        let temp_k = self.temperature * 100.0;
525        let wb_r = 1.0 + temp_k * 0.0002;
526        let wb_b = 1.0 - temp_k * 0.0002;
527        let wb_g = 1.0 + self.tint * 0.001;
528        let c = Vec3::new(c.x * wb_r, c.y * wb_g, c.z * wb_b);
529        // 3. Contrast
530        let c = (c - Vec3::splat(0.5)) * (1.0 + self.contrast * 0.01) + Vec3::splat(0.5);
531        let c = c + Vec3::splat(self.brightness * 0.01);
532        // 4. Saturation
533        let lum = c.dot(Vec3::new(0.2126, 0.7152, 0.0722));
534        let c = Vec3::splat(lum).lerp(c, self.saturation);
535        // 5. Color filter
536        let c = c * self.color_filter;
537        // 6. Channel mixer
538        let r = c.dot(self.channel_mixer_r);
539        let g = c.dot(self.channel_mixer_g);
540        let b = c.dot(self.channel_mixer_b);
541        let c = Vec3::new(r, g, b);
542        // 7. Lift / Gamma / Gain
543        let c = self.lift_gamma_gain.apply(c);
544        // 8. Shadows/Midtones/Highlights
545        let c = self.shadows_midtones_highlights.apply(c);
546        // 9. Curves
547        let c = self.curves.apply(c);
548        // 10. Tone mapping
549        let c = self.tone_mapping.apply_vec3(c);
550        // 11. Post exposure
551        let c = c * 2.0_f32.powf(self.post_exposure);
552        // 12. Gamma
553        Vec3::new(
554            self.output_colorspace.from_linear(c.x),
555            self.output_colorspace.from_linear(c.y),
556            self.output_colorspace.from_linear(c.z),
557        ).max(Vec3::ZERO)
558    }
559}
560
561// ---------------------------------------------------------------------------
562// Presets
563// ---------------------------------------------------------------------------
564
565#[derive(Debug, Clone)]
566pub struct ColorGradePreset {
567    pub name: String,
568    pub category: String,
569    pub description: String,
570    pub grade: ColorGrade,
571    pub thumbnail: Option<Vec<Vec3>>,
572}
573
574impl ColorGradePreset {
575    pub fn cinematic() -> Self {
576        let mut grade = ColorGrade::default();
577        grade.contrast = 15.0;
578        grade.saturation = 0.85;
579        grade.temperature = -10.0;
580        grade.lift_gamma_gain.lift = Vec4::new(-0.02, -0.02, 0.02, 0.0);
581        grade.lift_gamma_gain.gain = Vec4::new(1.1, 1.05, 0.95, 1.0);
582        grade.tone_mapping = ToneMappingMode::FilmicAces;
583        Self {
584            name: "Cinematic".into(),
585            category: "Film".into(),
586            description: "Filmic contrast with desaturated cool tones".into(),
587            grade,
588            thumbnail: None,
589        }
590    }
591
592    pub fn vintage() -> Self {
593        let mut grade = ColorGrade::default();
594        grade.saturation = 0.7;
595        grade.temperature = 25.0;
596        grade.tint = 5.0;
597        grade.lift_gamma_gain.lift = Vec4::new(0.03, 0.02, 0.0, 0.0);
598        grade.lift_gamma_gain.gain = Vec4::new(1.0, 0.95, 0.85, 1.0);
599        grade.tone_mapping = ToneMappingMode::Hable;
600        Self {
601            name: "Vintage".into(),
602            category: "Stylistic".into(),
603            description: "Warm desaturated vintage look".into(),
604            grade,
605            thumbnail: None,
606        }
607    }
608
609    pub fn horror() -> Self {
610        let mut grade = ColorGrade::default();
611        grade.saturation = 0.3;
612        grade.contrast = 25.0;
613        grade.temperature = -30.0;
614        grade.lift_gamma_gain.lift = Vec4::new(0.0, -0.03, 0.0, -0.05);
615        grade.tone_mapping = ToneMappingMode::Reinhard;
616        Self {
617            name: "Horror".into(),
618            category: "Stylistic".into(),
619            description: "Desaturated cold high-contrast horror look".into(),
620            grade,
621            thumbnail: None,
622        }
623    }
624
625    pub fn neon_noir() -> Self {
626        let mut grade = ColorGrade::default();
627        grade.saturation = 1.4;
628        grade.contrast = 20.0;
629        grade.temperature = -15.0;
630        grade.lift_gamma_gain.lift = Vec4::new(-0.05, 0.0, 0.1, 0.0);
631        grade.lift_gamma_gain.gain = Vec4::new(0.9, 0.95, 1.15, 1.0);
632        grade.tone_mapping = ToneMappingMode::ReinhardExtended;
633        Self {
634            name: "Neon Noir".into(),
635            category: "Stylistic".into(),
636            description: "High-saturation cyberpunk neon look".into(),
637            grade,
638            thumbnail: None,
639        }
640    }
641
642    pub fn natural() -> Self {
643        Self {
644            name: "Natural".into(),
645            category: "Neutral".into(),
646            description: "Neutral natural grading".into(),
647            grade: ColorGrade::default(),
648            thumbnail: None,
649        }
650    }
651}
652
653// ---------------------------------------------------------------------------
654// Editor
655// ---------------------------------------------------------------------------
656
657#[derive(Debug, Clone, Copy, PartialEq)]
658pub enum ColorGradingTab {
659    Grading,
660    ToneMapping,
661    Curves,
662    Wheels,
663    Lut,
664    Presets,
665    Scopes,
666}
667
668#[derive(Debug, Clone, Copy, PartialEq)]
669pub enum ScopeMode {
670    Waveform,
671    Parade,
672    Histogram,
673    Vectorscope,
674}
675
676#[derive(Debug, Clone)]
677pub struct ColorGradingEditor {
678    pub grade: ColorGrade,
679    pub active_tab: ColorGradingTab,
680    pub active_lut: Option<Lut3D>,
681    pub lut_size: LutSize,
682    pub presets: Vec<ColorGradePreset>,
683    pub history: Vec<ColorGrade>,
684    pub history_pos: usize,
685    pub scope_mode: ScopeMode,
686    pub show_clipping: bool,
687    pub show_scopes: bool,
688    pub preview_split: bool,
689    pub split_position: f32,
690    pub input_space: ColorSpace,
691    pub working_space: ColorSpace,
692    pub output_space: ColorSpace,
693    pub preset_search: String,
694}
695
696impl ColorGradingEditor {
697    pub fn new() -> Self {
698        Self {
699            grade: ColorGrade::default(),
700            active_tab: ColorGradingTab::Grading,
701            active_lut: None,
702            lut_size: LutSize::Lut32,
703            presets: vec![
704                ColorGradePreset::natural(),
705                ColorGradePreset::cinematic(),
706                ColorGradePreset::vintage(),
707                ColorGradePreset::horror(),
708                ColorGradePreset::neon_noir(),
709            ],
710            history: Vec::new(),
711            history_pos: 0,
712            scope_mode: ScopeMode::Waveform,
713            show_clipping: false,
714            show_scopes: true,
715            preview_split: false,
716            split_position: 0.5,
717            input_space: ColorSpace::Linear,
718            working_space: ColorSpace::AcesCg,
719            output_space: ColorSpace::SRgb,
720            preset_search: String::new(),
721        }
722    }
723
724    pub fn snapshot(&mut self) {
725        self.history.truncate(self.history_pos);
726        self.history.push(self.grade.clone());
727        self.history_pos = self.history.len();
728    }
729
730    pub fn undo(&mut self) {
731        if self.history_pos > 1 {
732            self.history_pos -= 1;
733            self.grade = self.history[self.history_pos - 1].clone();
734        }
735    }
736
737    pub fn redo(&mut self) {
738        if self.history_pos < self.history.len() {
739            self.grade = self.history[self.history_pos].clone();
740            self.history_pos += 1;
741        }
742    }
743
744    pub fn apply_preset(&mut self, preset: &ColorGradePreset) {
745        self.snapshot();
746        self.grade = preset.grade.clone();
747    }
748
749    pub fn generate_lut(&mut self) {
750        let mut lut = Lut3D::identity(self.lut_size);
751        lut.bake_from_grade(&self.grade);
752        lut.name = "Baked Grade".into();
753        self.active_lut = Some(lut);
754    }
755
756    pub fn export_lut_cube(&self) -> Option<String> {
757        self.active_lut.as_ref().map(|l| l.export_cube_format())
758    }
759
760    pub fn search_presets(&self, query: &str) -> Vec<&ColorGradePreset> {
761        let q = query.to_lowercase();
762        self.presets.iter().filter(|p| {
763            p.name.to_lowercase().contains(&q) ||
764            p.category.to_lowercase().contains(&q) ||
765            p.description.to_lowercase().contains(&q)
766        }).collect()
767    }
768
769    pub fn grade_pixel(&self, input: Vec3) -> Vec3 {
770        let graded = self.grade.apply(input);
771        if let Some(lut) = &self.active_lut {
772            let from_lut = lut.apply(graded);
773            graded.lerp(from_lut, self.grade.lut_contribution)
774        } else {
775            graded
776        }
777    }
778
779    pub fn reset_to_identity(&mut self) {
780        self.snapshot();
781        self.grade = ColorGrade::default();
782    }
783}
784
785// ---------------------------------------------------------------------------
786// Tests
787// ---------------------------------------------------------------------------
788#[cfg(test)]
789mod tests {
790    use super::*;
791
792    #[test]
793    fn test_tone_mapping() {
794        let tm = ToneMappingMode::Aces;
795        let v = tm.apply(5.0);
796        assert!(v >= 0.0 && v <= 1.0);
797    }
798
799    #[test]
800    fn test_color_grade() {
801        let grade = ColorGrade::default();
802        let c = grade.apply(Vec3::new(0.5, 0.5, 0.5));
803        assert!(c.x >= 0.0 && c.x <= 1.0);
804    }
805
806    #[test]
807    fn test_lut_identity() {
808        let lut = Lut3D::identity(LutSize::Lut16);
809        let c = lut.apply(Vec3::new(0.5, 0.5, 0.5));
810        assert!((c.x - 0.5).abs() < 0.1);
811    }
812
813    #[test]
814    fn test_lut_bake() {
815        let mut lut = Lut3D::identity(LutSize::Lut16);
816        let grade = ColorGrade::default();
817        lut.bake_from_grade(&grade);
818        assert!(!lut.data.is_empty());
819    }
820
821    #[test]
822    fn test_editor() {
823        let mut ed = ColorGradingEditor::new();
824        ed.grade.exposure = 1.0;
825        ed.snapshot();
826        ed.undo();
827        assert!((ed.grade.exposure).abs() < 1e-5);
828        ed.generate_lut();
829        assert!(ed.active_lut.is_some());
830    }
831}