Skip to main content

mcraw_tui/
thumbnail.rs

1use std::path::PathBuf;
2use std::sync::Mutex;
3use std::time::Instant;
4
5use anyhow::Result;
6use lru::LruCache;
7use std::num::NonZeroUsize;
8
9use crate::preview::pipeline::pipeline::{GpuPreviewPipeline, Ready};
10use crate::preview::pipeline::params::PreviewParams;
11use crate::preview::pipeline::params::{transfer_to_u32, color_space_to_u32};
12
13pub const THUMBNAIL_WIDTH: u32 = 320;
14pub const THUMBNAIL_HEIGHT: u32 = 180;
15pub const CACHE_MAX_ENTRIES: usize = 1000;
16pub const CACHE_MAX_BYTES: usize = 50 * 1024 * 1024;
17
18/// Compute aspect-ratio-preserving output dims that fit inside THUMBNAIL_WIDTH × THUMBNAIL_HEIGHT.
19/// This is the same logic as build_preview_params in app.rs — keep in sync.
20pub fn aspect_fit(raw_w: u32, raw_h: u32) -> (u32, u32) {
21    let raw_aspect = raw_w as f64 / raw_h as f64;
22    let target_aspect = THUMBNAIL_WIDTH as f64 / THUMBNAIL_HEIGHT as f64;
23    if raw_aspect > target_aspect {
24        let h = (THUMBNAIL_WIDTH as f64 / raw_aspect) as u32;
25        (THUMBNAIL_WIDTH, h.max(1))
26    } else {
27        let w = (THUMBNAIL_HEIGHT as f64 * raw_aspect) as u32;
28        (w.max(1), THUMBNAIL_HEIGHT)
29    }
30}
31
32fn build_params(
33    width: u32,
34    height: u32,
35    raw_width: u32,
36    raw_height: u32,
37    black_level: f32,
38    white_level: f32,
39    bayer_phase: u32,
40) -> PreviewParams {
41    PreviewParams {
42        width,
43        height,
44        bayer_width: raw_width,
45        bayer_height: raw_height,
46        black_level,
47        white_level,
48        exposure: 0.0,
49        wb_r: 1.0, wb_g: 1.0, wb_b: 1.0,
50        contrast: 1.0,
51        saturation: 1.0,
52        shadows: 0.0,
53        highlights: 0.0,
54        _align0: 0.0, _align1: 0.0,
55        ccm_row0: [1.0, 0.0, 0.0, 0.0],
56        ccm_row1: [0.0, 1.0, 0.0, 0.0],
57        ccm_row2: [0.0, 0.0, 1.0, 0.0],
58        color_space: color_space_to_u32(&crate::color::ColorSpace::Rec709),
59        transfer: transfer_to_u32(&crate::color::TransferFunction::Gamma24),
60        adjust_enabled: 0,
61        bayer_phase,
62        compute_histogram: 0,
63        _pad0: 0, _pad1: 0, _pad2: 0, _pad3: 0, _pad4: 0, _pad5: 0, _pad6: 0,
64    }
65}
66
67static FALLBACK_PLACEHOLDER: &[u8] = include_bytes!("../assets/placeholder.sixel");
68
69#[derive(Clone)]
70pub struct CachedThumbnail {
71    pub sixel: Vec<u8>,
72    pub width: u32,
73    pub height: u32,
74    pub encode_time: Instant,
75}
76
77impl CachedThumbnail {
78    pub fn byte_size(&self) -> usize {
79        self.sixel.len()
80    }
81}
82
83pub struct ThumbnailCache {
84    inner: Mutex<LruCache<PathBuf, CachedThumbnail>>,
85    current_bytes: std::sync::atomic::AtomicUsize,
86    pub placeholder: Vec<u8>,
87}
88
89impl ThumbnailCache {
90    pub fn new() -> Self {
91        Self::new_with_placeholder(None)
92    }
93
94    pub fn new_with_placeholder(custom_path: Option<&std::path::Path>) -> Self {
95        let placeholder = match custom_path {
96            Some(p) => match std::fs::read(p) {
97                Ok(data) => {
98                    tracing::info!("loaded custom placeholder from {}", p.display());
99                    data
100                }
101                Err(e) => {
102                    tracing::warn!("failed to load custom placeholder {}: {}; using bundled", p.display(), e);
103                    FALLBACK_PLACEHOLDER.to_vec()
104                }
105            }
106            None => FALLBACK_PLACEHOLDER.to_vec(),
107        };
108
109        Self {
110            inner: Mutex::new(LruCache::new(NonZeroUsize::new(CACHE_MAX_ENTRIES).unwrap())),
111            current_bytes: std::sync::atomic::AtomicUsize::new(0),
112            placeholder,
113        }
114    }
115
116    pub fn get(&self, path: &PathBuf) -> Option<CachedThumbnail> {
117        let mut cache = self.inner.lock().unwrap();
118        cache.get(path).cloned()
119    }
120
121    pub fn insert(&self, path: PathBuf, thumbnail: CachedThumbnail) {
122        let size = thumbnail.byte_size();
123        let mut cache = self.inner.lock().unwrap();
124
125        // Enforce byte cap: evict until we fit
126        let mut evict_bytes = 0usize;
127        while self.current_bytes.load(std::sync::atomic::Ordering::Relaxed) + size > CACHE_MAX_BYTES && !cache.is_empty() {
128            if let Some((_, evicted)) = cache.pop_lru() {
129                evict_bytes += evicted.byte_size();
130            }
131        }
132
133        if let Some(old) = cache.put(path, thumbnail) {
134            self.current_bytes.fetch_sub(old.byte_size(), std::sync::atomic::Ordering::Relaxed);
135        }
136
137        self.current_bytes.fetch_add(size, std::sync::atomic::Ordering::Relaxed);
138        if evict_bytes > 0 {
139            self.current_bytes.fetch_sub(evict_bytes, std::sync::atomic::Ordering::Relaxed);
140        }
141    }
142
143    pub fn clear(&self) {
144        let mut cache = self.inner.lock().unwrap();
145        cache.clear();
146        self.current_bytes.store(0, std::sync::atomic::Ordering::Relaxed);
147    }
148
149    pub fn len(&self) -> usize {
150        self.inner.lock().unwrap().len()
151    }
152}
153
154impl Default for ThumbnailCache {
155    fn default() -> Self {
156        Self::new()
157    }
158}
159
160pub fn compute_thumbnail(
161    pipeline: &mut GpuPreviewPipeline<Ready>,
162    bayer: &[u16],
163    raw_width: u32,
164    raw_height: u32,
165    black_level: f32,
166    white_level: f32,
167    bayer_phase: u32,
168) -> Result<CachedThumbnail> {
169    let (width, height) = aspect_fit(raw_width, raw_height);
170
171    let params = build_params(width, height, raw_width, raw_height, black_level, white_level, bayer_phase);
172
173    let (rgba, w, h) = pipeline.process_and_readback(bayer, &params)?;
174
175    let sixel = icy_sixel::sixel_encode(
176        &rgba,
177        w as usize,
178        h as usize,
179        &icy_sixel::EncodeOptions::default(),
180    ).map_err(|e| anyhow::anyhow!("sixel encode: {}", e))?;
181
182    Ok(CachedThumbnail {
183        sixel: sixel.into_bytes(),
184        width: w,
185        height: h,
186        encode_time: Instant::now(),
187    })
188}
189
190// ═══════════════════════════════════════════════════════════════════════════════
191// CPU thumbnail pipeline — pixel-exact port of shaders/preview.wgsl
192// ═══════════════════════════════════════════════════════════════════════════════
193
194fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 {
195    let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
196    t * t * (3.0 - 2.0 * t)
197}
198
199fn load_bayer(bayer: &[u16], raw_w: u32, x: i32, y: i32) -> f32 {
200    let cx = x.clamp(0, raw_w as i32 - 1);
201    let cy = y.clamp(0, (bayer.len() as u32 / raw_w).saturating_sub(1) as i32);
202    bayer[(cy as u32 * raw_w + cx as u32) as usize] as f32
203}
204
205fn bayer_color(x: i32, y: i32, phase: u32) -> i32 {
206    let even_row = (y & 1) == 0;
207    let even_col = (x & 1) == 0;
208    match phase {
209        0 => { // RGGB
210            if even_row { if even_col { 0 } else { 1 } }
211            else        { if even_col { 1 } else { 2 } }
212        }
213        1 => { // GRBG
214            if even_row { if even_col { 1 } else { 0 } }
215            else        { if even_col { 2 } else { 1 } }
216        }
217        2 => { // GBRG
218            if even_row { if even_col { 1 } else { 2 } }
219            else        { if even_col { 0 } else { 1 } }
220        }
221        _ => { // BGGR
222            if even_row { if even_col { 2 } else { 1 } }
223            else        { if even_col { 1 } else { 0 } }
224        }
225    }
226}
227
228fn demosaic_bilinear(bayer: &[u16], raw_w: u32, _raw_h: u32, phase: u32, x: i32, y: i32) -> [f32; 3] {
229    let c = bayer_color(x, y, phase);
230    let center = load_bayer(bayer, raw_w, x, y);
231    let n  = load_bayer(bayer, raw_w, x, y - 1);
232    let s  = load_bayer(bayer, raw_w, x, y + 1);
233    let w  = load_bayer(bayer, raw_w, x - 1, y);
234    let e  = load_bayer(bayer, raw_w, x + 1, y);
235    let nw = load_bayer(bayer, raw_w, x - 1, y - 1);
236    let ne = load_bayer(bayer, raw_w, x + 1, y - 1);
237    let sw = load_bayer(bayer, raw_w, x - 1, y + 1);
238    let se = load_bayer(bayer, raw_w, x + 1, y + 1);
239
240    let (r, g, b) = if c == 0 { // R site
241        (center, (n + s + w + e) * 0.25, (nw + ne + sw + se) * 0.25)
242    } else if c == 2 { // B site
243        ((nw + ne + sw + se) * 0.25, (n + s + w + e) * 0.25, center)
244    } else { // G site
245        let horiz_color = bayer_color(x - 1, y, phase);
246        let _vert_color = bayer_color(x, y - 1, phase);
247        if horiz_color == 0 { // R is horizontal
248            ((w + e) * 0.5, center, (n + s) * 0.5)
249        } else { // B is horizontal
250            ((n + s) * 0.5, center, (w + e) * 0.5)
251        }
252    };
253    [r, g, b]
254}
255
256fn apply_oetf(r: f32, g: f32, b: f32, tf: u32) -> [f32; 3] {
257    let oetf_ch = |x: f32| -> f32 {
258        match tf {
259            0 => x,
260            14 => x.max(0.0).powf(1.0 / 2.4),
261            1 => {
262                if x < 0.018 { 4.5 * x }
263                else { 1.099 * x.max(0.0).powf(0.45) - 0.099 }
264            }
265            2 => {
266                if x >= 0.01 { 0.432699 * (10.0 * x + 1.0).log10() + 0.037584 }
267                else { (x * 261.5 + 10.23) / 1023.0 }
268            }
269            3 => {
270                if x < 0.01 { 5.6 * x + 0.125 }
271                else { 0.241514 * (x + 0.00873).log10() + 0.598206 }
272            }
273            4 => {
274                if x > 0.010591 { 0.247190 * (5.555556 * x + 0.052272).log10() + 0.385537 }
275                else { 5.367655 * x + 0.092809 }
276            }
277            5 => {
278                let a: f32 = (262144.0 - 16.0) / 117.45;
279                let b_rev: f32 = (1023.0 - 95.0) / 1023.0;
280                let c_rev: f32 = 95.0 / 1023.0;
281                let s_rev = (7.0 * 0.6931471805599453 * f32::exp2(7.0 - 14.0 * c_rev / b_rev)) / (a * b_rev);
282                let t_rev = (f32::exp2(14.0 * (-c_rev / b_rev) + 6.0) - 64.0) / a;
283                if x >= t_rev {
284                    ((a * x + 64.0).log2() - 6.0) / 14.0 * b_rev + c_rev
285                } else {
286                    (x - t_rev) / s_rev
287                }
288            }
289            6 => {
290                let neg_graft = (0.097465473 - 0.12512219) / 1.9754798;
291                let pos_graft = (0.15277891 - 0.12512219) / 1.9754798;
292                if x < neg_graft {
293                    -0.36726845 * ((-x * 14.98325 + 1.0).max(1e-10)).log10() + 0.12783901
294                } else if x <= pos_graft {
295                    1.9754798 * x + 0.12512219
296                } else {
297                    0.36726845 * (x * 14.98325 + 1.0).log10() + 0.12240537
298                }
299            }
300            7 => {
301                if x >= 0.000889 { 0.245281 * (5.555556 * x + 0.064829).log10() + 0.384316 }
302                else { 8.799461 * x + 0.092864 }
303            }
304            8 | 9 => {
305                if x < -0.05641088 { 0.0 }
306                else if x < 0.01 { 47.28711236 * (x + 0.05641088) * (x + 0.05641088) }
307                else { 0.08550479 * (x + 0.00964052).log2() + 0.69336945 }
308            }
309            10 => {
310                if x > 0.0078125 { (x.log2() + 9.72) / 17.52 }
311                else { 10.5402377416545 * x + 0.0729055341958355 }
312            }
313            11 => {
314                let m1 = 0.1593017578125;
315                let m2 = 78.84375;
316                let c1 = 0.8359375;
317                let c2 = 18.8515625;
318                let c3 = 18.6875;
319                let x_m1 = x.max(0.0).powf(m1);
320                (c1 + c2 * x_m1).max(0.0) / (1.0 + c3 * x_m1).max(1e-10).powf(m2)
321            }
322            12 => {
323                if x < 1.0 / 12.0 { (3.0 * x.max(0.0)).sqrt() }
324                else { 0.17883277 * (12.0 * x - 0.28466892).max(1e-10).ln() + 0.55991073 }
325            }
326            13 => {
327                if x <= 0.00262409 { x * 10.44426855 }
328                else { 0.07329248 * ((x + 0.0075).log2() + 7.0) }
329            }
330            _ => x,
331        }
332    };
333    [oetf_ch(r), oetf_ch(g), oetf_ch(b)]
334}
335
336fn inverse_oetf(r: f32, g: f32, b: f32, tf: u32) -> [f32; 3] {
337    let inv_ch = |y: f32| -> f32 {
338        match tf {
339            0 => y,
340            14 => y.max(0.0).powf(2.4),
341            1 => {
342                if y < 0.081 { y / 4.5 }
343                else { ((y + 0.099) / 1.099).powf(1.0 / 0.45) }
344            }
345            2 => {
346                let knee_val = (0.01 * 261.5 + 10.23) / 1023.0;
347                if y >= knee_val { ((10.0f32).powf((y - 0.037584) / 0.432699) - 1.0) / 10.0 }
348                else { (y * 1023.0 - 10.23) / 261.5 }
349            }
350            3 => {
351                if y < 0.181 { (y - 0.125) / 5.6 }
352                else { (10.0f32).powf((y - 0.598206) / 0.241514) - 0.00873 }
353            }
354            4 => {
355                let knee_val = 5.367655 * 0.010591 + 0.092809;
356                if y >= knee_val { ((10.0f32).powf((y - 0.385537) / 0.247190) - 0.052272) / 5.555556 }
357                else { (y - 0.092809) / 5.367655 }
358            }
359            5 => {
360                let a: f32 = (262144.0 - 16.0) / 117.45;
361                let b_rev: f32 = (1023.0 - 95.0) / 1023.0;
362                let c_rev: f32 = 95.0 / 1023.0;
363                let s_rev = (7.0 * 0.6931471805599453 * f32::exp2(7.0 - 14.0 * c_rev / b_rev)) / (a * b_rev);
364                let t_rev = (f32::exp2(14.0 * (-c_rev / b_rev) + 6.0) - 64.0) / a;
365                if y >= 0.0 { (f32::exp2(14.0 * ((y - c_rev) / b_rev) + 6.0) - 64.0) / a }
366                else { y * s_rev + t_rev }
367            }
368            6 => {
369                let neg_graft = (0.097465473 - 0.12512219) / 1.9754798;
370                let pos_graft = (0.15277891 - 0.12512219) / 1.9754798;
371                let knee_lo = 0.12512219 + neg_graft * 1.9754798;
372                let knee_hi = 0.12512219 + pos_graft * 1.9754798;
373                if y < knee_lo {
374                    ((10.0f32).powf(-(y - 0.12783901) / 0.36726845) - 1.0) / (-14.98325)
375                } else if y <= knee_hi {
376                    (y - 0.12512219) / 1.9754798
377                } else {
378                    ((10.0f32).powf((y - 0.12240537) / 0.36726845) - 1.0) / 14.98325
379                }
380            }
381            7 => {
382                let knee_val = 8.799461 * 0.000889 + 0.092864;
383                if y >= knee_val { ((10.0f32).powf((y - 0.384316) / 0.245281) - 0.064829) / 5.555556 }
384                else { (y - 0.092864) / 8.799461 }
385            }
386            8 | 9 => {
387                if y <= 0.0 { -0.05641088 }
388                else {
389                    let knee_val = 47.28711236 * (0.01 + 0.05641088) * (0.01 + 0.05641088);
390                    if y < knee_val { (y / 47.28711236).sqrt() - 0.05641088 }
391                    else { (2.0f32).powf((y - 0.69336945) / 0.08550479) - 0.00964052 }
392                }
393            }
394            10 => {
395                let cutoff = 10.5402377416545 * 0.0078125 + 0.0729055341958355;
396                if y > cutoff { (2.0f32).powf(y * 17.52 - 9.72) }
397                else { (y - 0.0729055341958355) / 10.5402377416545 }
398            }
399            11 => {
400                let m1 = 0.1593017578125;
401                let m2 = 78.84375;
402                let c1 = 0.8359375;
403                let c2 = 18.8515625;
404                let c3 = 18.6875;
405                let v = y.max(0.0);
406                let v_m2 = v.powf(1.0 / m2);
407                let num = (v_m2 - c1).max(0.0);
408                let den = c2 - c3 * v_m2;
409                if den > 0.0 { (num / den).powf(1.0 / m1) }
410                else { 0.0 }
411            }
412            12 => {
413                let knee_out: f32 = f32::sqrt(3.0 / 12.0);
414                if y <= knee_out { y * y / 3.0 }
415                else { ((y - 0.55991073) / 0.17883277).exp() + 0.28466892 / 12.0 }
416            }
417            13 => {
418                let cut_out = 0.00262409 * 10.44426855;
419                if y <= cut_out { y / 10.44426855 }
420                else { (2.0f32).powf(y / 0.07329248 - 7.0) - 0.0075 }
421            }
422            _ => y,
423        }
424    };
425    [inv_ch(r), inv_ch(g), inv_ch(b)]
426}
427
428fn srgb_oetf(r: f32, g: f32, b: f32) -> [f32; 3] {
429    let srgb_ch = |x: f32| -> f32 {
430        if x <= 0.0031308 { x * 12.92 }
431        else { 1.055 * x.max(0.0).powf(1.0 / 2.4) - 0.055 }
432    };
433    [srgb_ch(r), srgb_ch(g), srgb_ch(b)]
434}
435
436fn apply_tone_curve(r: f32, g: f32, b: f32, shadows: f32, highlights: f32) -> [f32; 3] {
437    let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
438    let shadow_weight = 1.0 - smoothstep(0.0, 0.35, luma);
439    let mut rt = r + shadows * shadow_weight;
440    let mut gt = g + shadows * shadow_weight;
441    let mut bt = b + shadows * shadow_weight;
442    let hi_weight = smoothstep(0.5, 1.0, luma);
443    rt = rt + highlights * hi_weight * rt;
444    gt = gt + highlights * hi_weight * gt;
445    bt = bt + highlights * hi_weight * bt;
446    [rt.max(0.0), gt.max(0.0), bt.max(0.0)]
447}
448
449fn xyz_to_rec709(x: f32, y: f32, z: f32) -> [f32; 3] {
450    [
451        3.2404542 * x + -1.9692660 * y + 0.0556434 * z,
452        -1.5371385 * x + 1.8760108 * y + -0.2040259 * z,
453        -0.4985314 * x + 0.0415560 * y + 1.0572252 * z,
454    ]
455}
456
457fn working_to_xyz(r: f32, g: f32, b: f32, cs: u32) -> [f32; 3] {
458    match cs {
459        0 => [ // ACESAP1
460            0.6954522414 * r + 0.1406786965 * g + 0.1638690622 * b,
461            0.0447945634 * r + 0.8596711185 * g + 0.0955343182 * b,
462            -0.0055258826 * r + 0.0040252104 * g + 1.0015006723 * b,
463        ],
464        1 => [ // Apple Wide Gamut
465            1.99650669 * r + -0.04380294 * g + 0.04729625 * b,
466            0.50573456 * r + 0.86522867 * g + -0.37096323 * b,
467            0.00612684 * r + -0.00089651 * g + 0.99476967 * b,
468        ],
469        2 => [ // ARRIWideGamut3
470            0.688161 * r + 0.150181 * g + 0.161658 * b,
471            0.047434 * r + 0.807529 * g + 0.145037 * b,
472            -0.002103 * r + -0.004533 * g + 1.006636 * b,
473        ],
474        3 => [ // ARRIWideGamut4
475            0.732690 * r + 0.143327 * g + 0.123983 * b,
476            0.044200 * r + 0.878486 * g + 0.077314 * b,
477            -0.001988 * r + -0.003142 * g + 1.005130 * b,
478        ],
479        5 => [ // DaVinciWideGamut
480            0.8000 * r + 0.3130 * g + -0.1130 * b,
481            0.1682 * r + 0.9877 * g + -0.1559 * b,
482            0.0790 * r + -0.1155 * g + 1.0365 * b,
483        ],
484        6 | 7 => [ // DciP3 / DisplayP3
485            0.4865709 * r + 0.2656677 * g + 0.1982242 * b,
486            0.2289746 * r + 0.6917385 * g + 0.0792869 * b,
487            0.0 * r + 0.0451136 * g + 1.0439444 * b,
488        ],
489        11 => [ // Rec2020
490            0.6369580 * r + 0.1446169 * g + 0.1688810 * b,
491            0.2627002 * r + 0.6779981 * g + 0.0593017 * b,
492            0.0 * r + 0.0280727 * g + 1.0609052 * b,
493        ],
494        _ => [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)],
495    }
496}
497
498fn gamut_clip_to_srgb(r: f32, g: f32, b: f32, cs: u32) -> [f32; 3] {
499    if cs == 12 || cs == 15 { // Rec709 or Srgb
500        [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)]
501    } else {
502        let xyz = working_to_xyz(r, g, b, cs);
503        let srgb = xyz_to_rec709(xyz[0], xyz[1], xyz[2]);
504        [srgb[0].clamp(0.0, 1.0), srgb[1].clamp(0.0, 1.0), srgb[2].clamp(0.0, 1.0)]
505    }
506}
507
508/// Generate a thumbnail on the CPU, producing sixel-encoded RGBA bytes.
509/// This is a pixel-exact port of the WGSL preview shader — same pipeline order,
510/// same constants, same bilinear demosaic. Use for outputs ≤ 800×600 where the
511/// GPU dispatch + readback overhead dominates.
512pub fn cpu_thumbnail(
513    bayer: &[u16],
514    params: &PreviewParams,
515) -> Result<(Vec<u8>, u32, u32)> {
516    let out_w = params.width;
517    let out_h = params.height;
518    let raw_w = params.bayer_width;
519    let raw_h = params.bayer_height;
520    let black = params.black_level;
521    let range = (params.white_level - black).max(0.001);
522    let exp_gain = (2.0f32).powf(params.exposure);
523    let adjust = params.adjust_enabled != 0;
524    let phase = params.bayer_phase;
525
526    let mut rgba = vec![0u8; (out_w * out_h * 4) as usize];
527
528    // Single-pass: for each output pixel, map to bayer coordinate and process
529    // the full color pipeline in the exact same order as the WGSL shader.
530    for y in 0..out_h {
531        for x in 0..out_w {
532            let src_x = (x * raw_w) / out_w;
533            let src_y = (y * raw_h) / out_h;
534
535            // 1. Bilinear demosaic
536            let mut rgb = demosaic_bilinear(bayer, raw_w, raw_h, phase, src_x as i32, src_y as i32);
537
538            // 2. Normalize
539            rgb[0] = (rgb[0] - black) / range;
540            rgb[1] = (rgb[1] - black) / range;
541            rgb[2] = (rgb[2] - black) / range;
542
543            // 3. Exposure
544            rgb[0] *= exp_gain;
545            rgb[1] *= exp_gain;
546            rgb[2] *= exp_gain;
547
548            // 4. White balance
549            rgb[0] *= params.wb_r;
550            rgb[1] *= params.wb_g;
551            rgb[2] *= params.wb_b;
552
553            // 5. Camera Color Matrix (CCM)
554            // NOTE: WGSL mat3x3(ccm_row0, ccm_row1, ccm_row2) is column-major:
555            //   column 0 = ccm_row0, column 1 = ccm_row1, column 2 = ccm_row2
556            // So ccm_row0[0] * r + ccm_row1[0] * g + ccm_row2[0] * b
557            let (cr, cg, cb) = (rgb[0], rgb[1], rgb[2]);
558            rgb[0] = params.ccm_row0[0] * cr + params.ccm_row1[0] * cg + params.ccm_row2[0] * cb;
559            rgb[1] = params.ccm_row0[1] * cr + params.ccm_row1[1] * cg + params.ccm_row2[1] * cb;
560            rgb[2] = params.ccm_row0[2] * cr + params.ccm_row1[2] * cg + params.ccm_row2[2] * cb;
561
562            // 6. Grading adjustments (only if adjust_enabled)
563            if adjust {
564                rgb = apply_tone_curve(rgb[0], rgb[1], rgb[2], params.shadows, params.highlights);
565                // Contrast pivot at 0.18 mid-grey
566                rgb[0] = ((rgb[0] - 0.18) * params.contrast + 0.18).max(0.0);
567                rgb[1] = ((rgb[1] - 0.18) * params.contrast + 0.18).max(0.0);
568                rgb[2] = ((rgb[2] - 0.18) * params.contrast + 0.18).max(0.0);
569                // Saturation
570                let luma = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
571                rgb[0] = luma + (rgb[0] - luma) * params.saturation;
572                rgb[1] = luma + (rgb[1] - luma) * params.saturation;
573                rgb[2] = luma + (rgb[2] - luma) * params.saturation;
574            }
575
576            // 7. Apply selected OETF
577            let encoded = apply_oetf(rgb[0], rgb[1], rgb[2], params.transfer);
578
579            // 8. Display compensation: decode OETF → linear
580            let linear_for_display = inverse_oetf(encoded[0], encoded[1], encoded[2], params.transfer);
581
582            // 9. Gamut clip to sRGB
583            let srgb_linear = gamut_clip_to_srgb(linear_for_display[0], linear_for_display[1], linear_for_display[2], params.color_space);
584
585            // 10. sRGB OETF for display
586            let display = srgb_oetf(srgb_linear[0], srgb_linear[1], srgb_linear[2]);
587
588            // 11. Pack to RGBA8
589            let idx = ((y * out_w + x) * 4) as usize;
590            rgba[idx] = (display[0].clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
591            rgba[idx + 1] = (display[1].clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
592            rgba[idx + 2] = (display[2].clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
593            rgba[idx + 3] = 255;
594        }
595    }
596
597    // 12. Encode to sixel
598    let sixel = icy_sixel::sixel_encode(
599        &rgba,
600        out_w as usize,
601        out_h as usize,
602        &icy_sixel::EncodeOptions::default(),
603    ).map_err(|e| anyhow::anyhow!("sixel encode: {}", e))?;
604
605    Ok((sixel.into_bytes(), out_w, out_h))
606}