1#![forbid(unsafe_code)]
38
39use crate::error::{CodecError, CodecResult};
40use crate::gif::encoder::{DitheringMethod, GifEncoderConfig, QuantizationMethod};
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
48pub enum QualityTier {
49 Low,
51 Medium,
53 High,
55}
56
57impl QualityTier {
58 pub fn from_quality(quality: f32) -> CodecResult<Self> {
64 if !(0.0..=1.0).contains(&quality) {
65 return Err(CodecError::InvalidParameter(format!(
66 "quality must be in [0.0, 1.0], got {quality}"
67 )));
68 }
69 Ok(if quality < 0.334 {
70 QualityTier::Low
71 } else if quality < 0.667 {
72 QualityTier::Medium
73 } else {
74 QualityTier::High
75 })
76 }
77
78 pub fn description(self) -> &'static str {
80 match self {
81 QualityTier::Low => "low (2–64 colors, no dithering)",
82 QualityTier::Medium => "medium (64–128 colors, Floyd-Steinberg)",
83 QualityTier::High => "high (128–256 colors, Floyd-Steinberg + Octree)",
84 }
85 }
86}
87
88#[derive(Clone, Debug)]
97pub struct PerceptualMetrics {
98 pub chromatic_complexity: f32,
100 pub edge_density: f32,
102 pub luma_variance: f32,
104 pub colour_richness: f32,
106}
107
108impl PerceptualMetrics {
109 pub fn analyse(data: &[u8], width: usize, height: usize) -> CodecResult<Self> {
119 let expected = width * height * 4;
120 if data.len() != expected {
121 return Err(CodecError::InvalidParameter(format!(
122 "expected {expected} bytes for {width}x{height} RGBA, got {}",
123 data.len()
124 )));
125 }
126
127 let pixels = width * height;
128 if pixels == 0 {
129 return Ok(Self {
130 chromatic_complexity: 0.0,
131 edge_density: 0.0,
132 luma_variance: 0.0,
133 colour_richness: 0.0,
134 });
135 }
136
137 let mut luma_sum: f64 = 0.0;
139 let mut luma_sq_sum: f64 = 0.0;
140 let mut chroma_sum: f64 = 0.0;
141
142 let mut luma_hist = [0u32; 256];
144
145 for chunk in data.chunks_exact(4) {
146 let r = chunk[0] as f64;
147 let g = chunk[1] as f64;
148 let b = chunk[2] as f64;
149
150 let y = 0.299 * r + 0.587 * g + 0.114 * b;
152 luma_sum += y;
153 luma_sq_sum += y * y;
154 luma_hist[y as usize] += 1;
155
156 let cmax = r.max(g).max(b);
158 let cmin = r.min(g).min(b);
159 chroma_sum += cmax - cmin;
160 }
161
162 let n = pixels as f64;
163 let mean_luma = luma_sum / n;
164 let variance = (luma_sq_sum / n) - mean_luma * mean_luma;
165 let luma_variance = (variance / (255.0 * 255.0 / 4.0)) as f32; let chromatic_complexity = (chroma_sum / (n * 255.0)) as f32;
168
169 let non_empty = luma_hist.iter().filter(|&&c| c > 0).count();
171 let colour_richness = non_empty as f32 / 256.0;
172
173 let edge_density = compute_edge_density(data, width, height);
175
176 Ok(Self {
177 chromatic_complexity: chromatic_complexity.clamp(0.0, 1.0),
178 edge_density: edge_density.clamp(0.0, 1.0),
179 luma_variance: luma_variance.clamp(0.0, 1.0),
180 colour_richness: colour_richness.clamp(0.0, 1.0),
181 })
182 }
183
184 pub fn complexity_score(&self) -> f32 {
188 (0.35 * self.chromatic_complexity
189 + 0.30 * self.edge_density
190 + 0.20 * self.luma_variance
191 + 0.15 * self.colour_richness)
192 .clamp(0.0, 1.0)
193 }
194}
195
196fn compute_edge_density(rgba: &[u8], width: usize, height: usize) -> f32 {
198 if width < 3 || height < 3 {
199 return 0.0;
200 }
201
202 let luma: Vec<f32> = rgba
204 .chunks_exact(4)
205 .map(|p| 0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32)
206 .collect();
207
208 let mut total_gradient: f64 = 0.0;
209 let checked_pixels = (width - 2) * (height - 2);
210
211 for y in 1..height - 1 {
212 for x in 1..width - 1 {
213 let gx = (-luma[(y - 1) * width + (x - 1)] + luma[(y - 1) * width + (x + 1)]
214 - 2.0 * luma[y * width + (x - 1)]
215 + 2.0 * luma[y * width + (x + 1)]
216 - luma[(y + 1) * width + (x - 1)]
217 + luma[(y + 1) * width + (x + 1)]) as f64;
218
219 let gy = (-luma[(y - 1) * width + (x - 1)]
220 - 2.0 * luma[(y - 1) * width + x]
221 - luma[(y - 1) * width + (x + 1)]
222 + luma[(y + 1) * width + (x - 1)]
223 + 2.0 * luma[(y + 1) * width + x]
224 + luma[(y + 1) * width + (x + 1)]) as f64;
225
226 total_gradient += (gx * gx + gy * gy).sqrt();
227 }
228 }
229
230 if checked_pixels == 0 {
231 return 0.0;
232 }
233
234 let max_gradient = 4.0 * 255.0 * std::f64::consts::SQRT_2;
236 (total_gradient / (checked_pixels as f64 * max_gradient)) as f32
237}
238
239#[derive(Clone, Debug)]
249pub struct ConstantQualityConfig {
250 pub quality: f32,
252 pub content_adaptive: bool,
254 pub min_colors: usize,
256 pub max_colors: usize,
258 pub loop_count: u16,
260}
261
262impl ConstantQualityConfig {
263 pub fn new(quality: f32) -> Self {
270 Self {
271 quality,
272 content_adaptive: true,
273 min_colors: 2,
274 max_colors: 256,
275 loop_count: 0,
276 }
277 }
278
279 pub fn non_adaptive(mut self) -> Self {
281 self.content_adaptive = false;
282 self
283 }
284
285 pub fn with_min_colors(mut self, min: usize) -> Self {
287 self.min_colors = min.clamp(2, 256);
288 self
289 }
290
291 pub fn with_max_colors(mut self, max: usize) -> Self {
293 self.max_colors = max.clamp(2, 256);
294 self
295 }
296}
297
298#[derive(Clone, Debug)]
307pub struct ResolvedGifParams {
308 pub config: GifEncoderConfig,
310 pub tier: QualityTier,
312 pub complexity_score: Option<f32>,
314}
315
316impl ResolvedGifParams {
317 pub fn colors(&self) -> usize {
319 self.config.colors
320 }
321
322 pub fn dithering(&self) -> DitheringMethod {
324 self.config.dithering
325 }
326
327 pub fn quantization(&self) -> QuantizationMethod {
329 self.config.quantization
330 }
331}
332
333#[derive(Clone, Debug)]
354pub struct ConstantQualityGifEncoder {
355 config: ConstantQualityConfig,
356 tier: QualityTier,
357}
358
359impl ConstantQualityGifEncoder {
360 pub fn new(config: ConstantQualityConfig) -> CodecResult<Self> {
367 let tier = QualityTier::from_quality(config.quality)?;
368 if config.min_colors > config.max_colors {
369 return Err(CodecError::InvalidParameter(format!(
370 "min_colors ({}) > max_colors ({})",
371 config.min_colors, config.max_colors
372 )));
373 }
374 Ok(Self { config, tier })
375 }
376
377 pub fn resolve(&self) -> ResolvedGifParams {
379 let (colors, dithering, quantization) = self.derive_params(self.config.quality, None);
380 ResolvedGifParams {
381 config: GifEncoderConfig {
382 colors,
383 dithering,
384 quantization,
385 transparent_index: None,
386 loop_count: self.config.loop_count,
387 },
388 tier: self.tier,
389 complexity_score: None,
390 }
391 }
392
393 pub fn resolve_with_metrics(&self, metrics: &PerceptualMetrics) -> ResolvedGifParams {
397 if !self.config.content_adaptive {
398 return self.resolve();
399 }
400
401 let score = metrics.complexity_score();
402 let effective_quality = (self.config.quality * 0.7 + score * 0.3).clamp(0.0, 1.0);
404 let (colors, dithering, quantization) =
405 self.derive_params(effective_quality, Some(metrics));
406
407 ResolvedGifParams {
408 config: GifEncoderConfig {
409 colors,
410 dithering,
411 quantization,
412 transparent_index: None,
413 loop_count: self.config.loop_count,
414 },
415 tier: self.tier,
416 complexity_score: Some(score),
417 }
418 }
419
420 pub fn analyse_and_resolve(
426 &self,
427 rgba: &[u8],
428 width: usize,
429 height: usize,
430 ) -> CodecResult<ResolvedGifParams> {
431 if !self.config.content_adaptive {
432 return Ok(self.resolve());
433 }
434 let metrics = PerceptualMetrics::analyse(rgba, width, height)?;
435 Ok(self.resolve_with_metrics(&metrics))
436 }
437
438 pub fn tier(&self) -> QualityTier {
440 self.tier
441 }
442
443 fn derive_params(
447 &self,
448 quality: f32,
449 metrics: Option<&PerceptualMetrics>,
450 ) -> (usize, DitheringMethod, QuantizationMethod) {
451 let raw_colors = self.config.min_colors as f32
455 + quality * (self.config.max_colors - self.config.min_colors) as f32;
456 let colors = clamp_to_valid_color_count(raw_colors as usize);
457
458 let dithering = if quality < 0.25 {
460 DitheringMethod::None
461 } else if quality < 0.60 {
462 match metrics {
465 Some(m) if m.chromatic_complexity > 0.5 => DitheringMethod::FloydSteinberg,
466 _ => DitheringMethod::Ordered,
467 }
468 } else {
469 DitheringMethod::FloydSteinberg
470 };
471
472 let quantization = if quality >= 0.667 {
476 QuantizationMethod::Octree
477 } else {
478 QuantizationMethod::MedianCut
479 };
480
481 (colors, dithering, quantization)
482 }
483}
484
485fn clamp_to_valid_color_count(n: usize) -> usize {
493 const VALID: [usize; 8] = [2, 4, 8, 16, 32, 64, 128, 256];
494 let clamped = n.clamp(2, 256);
495 let mut best = 2usize;
497 for &v in &VALID {
498 if v <= clamped {
499 best = v;
500 } else {
501 break;
502 }
503 }
504 best
505}
506
507#[cfg(test)]
512mod tests {
513 use super::*;
514
515 #[test]
518 fn test_quality_tier_low() {
519 assert_eq!(QualityTier::from_quality(0.0).unwrap(), QualityTier::Low);
520 assert_eq!(QualityTier::from_quality(0.1).unwrap(), QualityTier::Low);
521 assert_eq!(QualityTier::from_quality(0.333).unwrap(), QualityTier::Low);
522 }
523
524 #[test]
525 fn test_quality_tier_medium() {
526 assert_eq!(QualityTier::from_quality(0.5).unwrap(), QualityTier::Medium);
527 assert_eq!(
528 QualityTier::from_quality(0.334).unwrap(),
529 QualityTier::Medium
530 );
531 }
532
533 #[test]
534 fn test_quality_tier_high() {
535 assert_eq!(QualityTier::from_quality(0.8).unwrap(), QualityTier::High);
536 assert_eq!(QualityTier::from_quality(1.0).unwrap(), QualityTier::High);
537 }
538
539 #[test]
540 fn test_quality_tier_invalid() {
541 assert!(QualityTier::from_quality(-0.1).is_err());
542 assert!(QualityTier::from_quality(1.1).is_err());
543 assert!(QualityTier::from_quality(f32::NAN).is_err());
544 }
545
546 #[test]
547 fn test_quality_tier_description() {
548 assert!(!QualityTier::Low.description().is_empty());
549 assert!(!QualityTier::Medium.description().is_empty());
550 assert!(!QualityTier::High.description().is_empty());
551 }
552
553 #[test]
556 fn test_clamp_to_valid_color_count() {
557 assert_eq!(clamp_to_valid_color_count(1), 2);
559 assert_eq!(clamp_to_valid_color_count(3), 2);
561 assert_eq!(clamp_to_valid_color_count(9), 8);
562 assert_eq!(clamp_to_valid_color_count(64), 64);
563 assert_eq!(clamp_to_valid_color_count(200), 128);
564 assert_eq!(clamp_to_valid_color_count(256), 256);
565 assert_eq!(clamp_to_valid_color_count(128), 128);
567 assert_eq!(clamp_to_valid_color_count(16), 16);
568 }
569
570 #[test]
573 fn test_resolve_low_quality() {
574 let cfg = ConstantQualityConfig::new(0.1).non_adaptive();
575 let enc = ConstantQualityGifEncoder::new(cfg).unwrap();
576 let params = enc.resolve();
577
578 assert!(params.colors() <= 16, "low quality should have few colors");
579 assert_eq!(params.dithering(), DitheringMethod::None);
580 assert_eq!(params.tier, QualityTier::Low);
581 assert!(params.complexity_score.is_none());
582 }
583
584 #[test]
585 fn test_resolve_high_quality() {
586 let cfg = ConstantQualityConfig::new(0.9).non_adaptive();
587 let enc = ConstantQualityGifEncoder::new(cfg).unwrap();
588 let params = enc.resolve();
589
590 assert!(
591 params.colors() >= 128,
592 "high quality should have many colors"
593 );
594 assert_eq!(params.dithering(), DitheringMethod::FloydSteinberg);
595 assert_eq!(params.quantization(), QuantizationMethod::Octree);
596 assert_eq!(params.tier, QualityTier::High);
597 }
598
599 #[test]
600 fn test_resolve_medium_quality() {
601 let cfg = ConstantQualityConfig::new(0.5).non_adaptive();
602 let enc = ConstantQualityGifEncoder::new(cfg).unwrap();
603 let params = enc.resolve();
604
605 assert!(params.colors() >= 64 && params.colors() <= 128);
606 assert_eq!(params.tier, QualityTier::Medium);
607 }
608
609 #[test]
610 fn test_invalid_quality_rejected() {
611 assert!(ConstantQualityGifEncoder::new(ConstantQualityConfig::new(1.5)).is_err());
612 assert!(ConstantQualityGifEncoder::new(ConstantQualityConfig::new(-0.1)).is_err());
613 }
614
615 #[test]
616 fn test_min_colors_gt_max_colors_rejected() {
617 let cfg = ConstantQualityConfig::new(0.5)
618 .with_min_colors(200)
619 .with_max_colors(64);
620 assert!(ConstantQualityGifEncoder::new(cfg).is_err());
621 }
622
623 #[test]
626 fn test_perceptual_metrics_flat_image() {
627 let rgba: Vec<u8> = vec![128u8, 64, 32, 255].repeat(8 * 8);
629 let m = PerceptualMetrics::analyse(&rgba, 8, 8).unwrap();
630
631 assert!(
632 m.edge_density < 0.01,
633 "flat image should have near-zero edge density"
634 );
635 assert!(m.complexity_score() < 0.4);
636 }
637
638 #[test]
639 fn test_perceptual_metrics_wrong_size() {
640 let rgba = vec![0u8; 10]; assert!(PerceptualMetrics::analyse(&rgba, 2, 2).is_err());
642 }
643
644 #[test]
645 fn test_perceptual_metrics_zero_size() {
646 let m = PerceptualMetrics::analyse(&[], 0, 0).unwrap();
647 assert_eq!(m.complexity_score(), 0.0);
648 }
649
650 #[test]
653 fn test_analyse_and_resolve_returns_ok() {
654 let rgba: Vec<u8> = vec![0u8, 0, 255, 255].repeat(4 * 4);
656 let cfg = ConstantQualityConfig::new(0.7);
657 let enc = ConstantQualityGifEncoder::new(cfg).unwrap();
658 let params = enc.analyse_and_resolve(&rgba, 4, 4).unwrap();
659
660 assert!(params.complexity_score.is_some());
661 assert!(params.colors() >= 2);
662 }
663
664 #[test]
665 fn test_analyse_and_resolve_nonadaptive_ignores_content() {
666 let rgba: Vec<u8> = vec![255u8, 0, 0, 255].repeat(4 * 4);
667 let cfg = ConstantQualityConfig::new(0.6).non_adaptive();
668 let enc = ConstantQualityGifEncoder::new(cfg).unwrap();
669 let params = enc.analyse_and_resolve(&rgba, 4, 4).unwrap();
670
671 assert!(params.complexity_score.is_none());
672 }
673
674 #[test]
675 fn test_loop_count_propagated() {
676 let mut cfg = ConstantQualityConfig::new(0.5);
677 cfg.loop_count = 3;
678 let enc = ConstantQualityGifEncoder::new(cfg).unwrap();
679 let params = enc.resolve();
680 assert_eq!(params.config.loop_count, 3);
681 }
682}