Skip to main content

oximedia_codec/gif/
quality.rs

1//! Constant-quality mode for GIF encoding.
2//!
3//! This module implements a perceptual quality-driven GIF encoding pipeline that
4//! automatically selects:
5//!
6//! - **Color count** (2–256) — more colors → sharper palette, larger file.
7//! - **Dithering strategy** — none / Floyd-Steinberg / Bayer ordered.
8//! - **Quantization algorithm** — median-cut vs octree based on image complexity.
9//!
10//! Quality is expressed as a value in `[0.0, 1.0]` where `1.0` is best visual
11//! fidelity (256 colors, full dithering, octree quantization) and `0.0` is
12//! minimum file size (2 colors, no dithering, median-cut).
13//!
14//! # Design
15//!
16//! The quality parameter drives a three-tier decision:
17//!
18//! | Quality range | Colors  | Dithering        | Quantizer   |
19//! |---------------|---------|------------------|-------------|
20//! | 0.00 – 0.33   | 2–64    | None             | Median Cut  |
21//! | 0.33 – 0.66   | 64–128  | Floyd-Steinberg  | Median Cut  |
22//! | 0.66 – 1.00   | 128–256 | Floyd-Steinberg  | Octree      |
23//!
24//! # Example
25//!
26//! ```
27//! use oximedia_codec::gif::quality::{ConstantQualityConfig, ConstantQualityGifEncoder};
28//!
29//! let cfg = ConstantQualityConfig::new(0.75); // high quality
30//! let encoder = ConstantQualityGifEncoder::new(cfg).expect("valid quality");
31//!
32//! // Resolve the concrete GIF encoder parameters
33//! let gif_params = encoder.resolve();
34//! assert!(gif_params.colors() >= 64);
35//! ```
36
37#![forbid(unsafe_code)]
38
39use crate::error::{CodecError, CodecResult};
40use crate::gif::encoder::{DitheringMethod, GifEncoderConfig, QuantizationMethod};
41
42// ============================================================================
43// Quality Level Enum
44// ============================================================================
45
46/// Coarse quality tier, derived from the floating-point quality parameter.
47#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
48pub enum QualityTier {
49    /// Low quality — minimum file size, reduced palette.
50    Low,
51    /// Medium quality — balanced palette and dithering.
52    Medium,
53    /// High quality — maximum palette depth and full dithering.
54    High,
55}
56
57impl QualityTier {
58    /// Derive a quality tier from a `[0.0, 1.0]` quality value.
59    ///
60    /// # Errors
61    ///
62    /// Returns `CodecError::InvalidParameter` when `quality` is outside `[0.0, 1.0]`.
63    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    /// Returns a human-readable description of the tier.
79    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// ============================================================================
89// Perceptual Metrics
90// ============================================================================
91
92/// Perceptual complexity metrics computed from raw RGBA pixel data.
93///
94/// These metrics guide the automatic selection of quantizer and dithering
95/// strategies within a given quality tier.
96#[derive(Clone, Debug)]
97pub struct PerceptualMetrics {
98    /// Estimate of unique hue regions (0.0 = monochrome, 1.0 = highly chromatic).
99    pub chromatic_complexity: f32,
100    /// Spatial edge density (0.0 = flat, 1.0 = highly detailed).
101    pub edge_density: f32,
102    /// Brightness variance normalised to [0.0, 1.0].
103    pub luma_variance: f32,
104    /// Number of distinct colours sampled (normalised to [0.0, 1.0] against 256).
105    pub colour_richness: f32,
106}
107
108impl PerceptualMetrics {
109    /// Analyse an RGBA pixel buffer.
110    ///
111    /// `data` must be a flat `[R, G, B, A, ...]` slice with
112    /// exactly `width * height * 4` bytes.
113    ///
114    /// # Errors
115    ///
116    /// Returns `CodecError::InvalidParameter` when the slice length does not match
117    /// `width * height * 4`.
118    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        // ── Luma statistics ──────────────────────────────────────────────────
138        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        // Use a fixed-size histogram over 256 luma buckets for colour richness.
143        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            // BT.601 luma
151            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            // Chroma saturation estimate: max(r,g,b) – min(r,g,b)
157            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; // normalise
166
167        let chromatic_complexity = (chroma_sum / (n * 255.0)) as f32;
168
169        // Colour richness: number of non-empty luma buckets / 256
170        let non_empty = luma_hist.iter().filter(|&&c| c > 0).count();
171        let colour_richness = non_empty as f32 / 256.0;
172
173        // ── Edge density via 3×3 Sobel on luma ──────────────────────────────
174        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    /// Aggregate complexity score in `[0.0, 1.0]`.
185    ///
186    /// Weights: 35% chromatic + 30% edge + 20% luma variance + 15% richness.
187    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
196/// Compute a normalised edge density using a simplified Sobel operator on luma.
197fn compute_edge_density(rgba: &[u8], width: usize, height: usize) -> f32 {
198    if width < 3 || height < 3 {
199        return 0.0;
200    }
201
202    // Extract luma plane
203    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    // Maximum possible gradient per pixel is 4 * 255 * sqrt(2) ≈ 1442.2
235    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// ============================================================================
240// ConstantQualityConfig
241// ============================================================================
242
243/// Configuration for constant-quality GIF encoding.
244///
245/// The quality parameter drives all encoder decisions: colour depth, dithering,
246/// and quantisation algorithm.  Content-adaptive overrides can be applied by
247/// calling [`ConstantQualityGifEncoder::resolve_with_metrics`].
248#[derive(Clone, Debug)]
249pub struct ConstantQualityConfig {
250    /// Target quality in `[0.0, 1.0]` (0 = smallest file, 1 = best fidelity).
251    pub quality: f32,
252    /// When `true`, run perceptual analysis and adapt parameters to image content.
253    pub content_adaptive: bool,
254    /// Minimum palette size (overrides quality-derived minimum).
255    pub min_colors: usize,
256    /// Maximum palette size (overrides quality-derived maximum).
257    pub max_colors: usize,
258    /// Animation loop count (0 = infinite).
259    pub loop_count: u16,
260}
261
262impl ConstantQualityConfig {
263    /// Create a new constant-quality configuration.
264    ///
265    /// # Panics
266    ///
267    /// Does not panic; invalid quality values produce a validation error in
268    /// [`ConstantQualityGifEncoder::new`].
269    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    /// Disable content-adaptive analysis (fixed parameters from quality alone).
280    pub fn non_adaptive(mut self) -> Self {
281        self.content_adaptive = false;
282        self
283    }
284
285    /// Override minimum colour count.
286    pub fn with_min_colors(mut self, min: usize) -> Self {
287        self.min_colors = min.clamp(2, 256);
288        self
289    }
290
291    /// Override maximum colour count.
292    pub fn with_max_colors(mut self, max: usize) -> Self {
293        self.max_colors = max.clamp(2, 256);
294        self
295    }
296}
297
298// ============================================================================
299// ResolvedGifParams — the encoder parameter set
300// ============================================================================
301
302/// Fully resolved GIF encoder parameters derived from a [`ConstantQualityConfig`].
303///
304/// This is a thin wrapper over [`GifEncoderConfig`] that exposes the quality
305/// tier and perceptual metrics used to derive the parameters.
306#[derive(Clone, Debug)]
307pub struct ResolvedGifParams {
308    /// The GIF encoder configuration ready for use.
309    pub config: GifEncoderConfig,
310    /// Quality tier that produced this configuration.
311    pub tier: QualityTier,
312    /// Content-complexity score (0 = flat, 1 = highly complex), if analysis ran.
313    pub complexity_score: Option<f32>,
314}
315
316impl ResolvedGifParams {
317    /// Returns the number of colours in the resolved palette.
318    pub fn colors(&self) -> usize {
319        self.config.colors
320    }
321
322    /// Returns the dithering method.
323    pub fn dithering(&self) -> DitheringMethod {
324        self.config.dithering
325    }
326
327    /// Returns the quantisation algorithm.
328    pub fn quantization(&self) -> QuantizationMethod {
329        self.config.quantization
330    }
331}
332
333// ============================================================================
334// ConstantQualityGifEncoder
335// ============================================================================
336
337/// Constant-quality GIF encoder.
338///
339/// Translates a floating-point quality value into concrete [`GifEncoderConfig`]
340/// parameters using a combination of quality tiers and, optionally, content-
341/// adaptive perceptual analysis.
342///
343/// # Example
344///
345/// ```
346/// use oximedia_codec::gif::quality::{ConstantQualityConfig, ConstantQualityGifEncoder};
347///
348/// let cfg = ConstantQualityConfig::new(0.8).non_adaptive();
349/// let enc = ConstantQualityGifEncoder::new(cfg).expect("valid quality");
350/// let params = enc.resolve();
351/// assert!(params.colors() >= 128);
352/// ```
353#[derive(Clone, Debug)]
354pub struct ConstantQualityGifEncoder {
355    config: ConstantQualityConfig,
356    tier: QualityTier,
357}
358
359impl ConstantQualityGifEncoder {
360    /// Create a new constant-quality GIF encoder.
361    ///
362    /// # Errors
363    ///
364    /// Returns `CodecError::InvalidParameter` when `quality` is outside `[0.0, 1.0]`
365    /// or `min_colors` > `max_colors`.
366    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    /// Resolve encoder parameters from quality alone (no content analysis).
378    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    /// Resolve encoder parameters using pre-computed [`PerceptualMetrics`].
394    ///
395    /// When `config.content_adaptive` is `false` this is identical to [`Self::resolve`].
396    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        // Blend base quality with content complexity
403        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    /// Analyse an RGBA pixel buffer and resolve parameters content-adaptively.
421    ///
422    /// # Errors
423    ///
424    /// Propagates errors from [`PerceptualMetrics::analyse`].
425    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    /// Quality tier this encoder was created with.
439    pub fn tier(&self) -> QualityTier {
440        self.tier
441    }
442
443    // ── Internal helpers ────────────────────────────────────────────────────
444
445    /// Derive (colors, dithering, quantization) from an effective quality value.
446    fn derive_params(
447        &self,
448        quality: f32,
449        metrics: Option<&PerceptualMetrics>,
450    ) -> (usize, DitheringMethod, QuantizationMethod) {
451        // ── Color count ──────────────────────────────────────────────────────
452        // Map quality linearly into [min_colors, max_colors], rounded to nearest
453        // power of two in [2, 256] to comply with GIF colour-table restrictions.
454        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        // ── Dithering ────────────────────────────────────────────────────────
459        let dithering = if quality < 0.25 {
460            DitheringMethod::None
461        } else if quality < 0.60 {
462            // Prefer ordered dithering for medium quality (less banding)
463            // but switch to Floyd-Steinberg when content is highly chromatic.
464            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        // ── Quantization ─────────────────────────────────────────────────────
473        // High quality (≥ 0.667): octree preserves local colour clusters better.
474        // Medium/low quality: median-cut is faster and sufficient.
475        let quantization = if quality >= 0.667 {
476            QuantizationMethod::Octree
477        } else {
478            QuantizationMethod::MedianCut
479        };
480
481        (colors, dithering, quantization)
482    }
483}
484
485/// Round a colour count to the nearest valid GIF power-of-two palette size.
486///
487/// GIF colour tables must have `2^(n+1)` entries where `n ∈ [0, 7]`, giving
488/// sizes: 2, 4, 8, 16, 32, 64, 128, 256.
489///
490/// Values are rounded DOWN to the nearest valid size (conservative — prefer
491/// fewer colours over more at any given quality level).
492fn 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    // Find the largest valid size that is <= clamped (round down).
496    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// ============================================================================
508// Tests
509// ============================================================================
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    // ── QualityTier ──────────────────────────────────────────────────────────
516
517    #[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    // ── clamp_to_valid_color_count ───────────────────────────────────────────
554
555    #[test]
556    fn test_clamp_to_valid_color_count() {
557        // Values below minimum clamp to 2
558        assert_eq!(clamp_to_valid_color_count(1), 2);
559        // Round DOWN to nearest valid size
560        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        // Exact matches
566        assert_eq!(clamp_to_valid_color_count(128), 128);
567        assert_eq!(clamp_to_valid_color_count(16), 16);
568    }
569
570    // ── ConstantQualityGifEncoder::resolve ───────────────────────────────────
571
572    #[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    // ── PerceptualMetrics ────────────────────────────────────────────────────
624
625    #[test]
626    fn test_perceptual_metrics_flat_image() {
627        // All pixels identical → zero complexity
628        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]; // wrong size for 2×2
641        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    // ── Content-adaptive path ────────────────────────────────────────────────
651
652    #[test]
653    fn test_analyse_and_resolve_returns_ok() {
654        // 4×4 solid blue image — simple content
655        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}