Skip to main content

jxl_encoder/
validation.rs

1// Copyright (c) Imazen LLC.
2// Licensed under AGPL-3.0-or-later. Commercial licenses at https://www.imazen.io/pricing
3//
4//! Fail-fast validation for public `Config` types.
5//!
6//! Existing encode paths keep clamping out-of-range values (the historical
7//! behaviour callers may rely on). Batch-job callers who would rather see
8//! an error than have their input silently massaged can call
9//! [`crate::api::LossyConfig::validate`] /
10//! [`crate::api::LosslessConfig::validate`] (and, with the `__expert` cargo
11//! feature, [`crate::effort::LossyInternalParams::validate`] /
12//! [`crate::effort::LosslessInternalParams::validate`]) before invoking
13//! the encoder.
14//!
15//! Validation is conservative: ranges either come from libjxl reference
16//! caps (verified against the consuming code path under
17//! `src/vardct/`, `src/modular/`, `src/effort.rs`) or are wide enough to
18//! accept anything the encoder will actually accept without panicking.
19//! Nonsensical-but-safe values (e.g. `tree_max_buckets = u16::MAX`) are
20//! left to the encoder to clamp.
21
22use core::ops::RangeInclusive;
23
24/// Errors produced by `validate()` on the public config types.
25///
26/// `#[non_exhaustive]` so new variants can land additively as we discover
27/// further invariants worth surfacing.
28#[non_exhaustive]
29#[derive(Debug, Clone, thiserror::Error)]
30pub enum ValidationError {
31    // ── Lossy / Lossless shared knobs ──────────────────────────────────
32    /// Butteraugli distance is outside the libjxl-supported range.
33    /// libjxl rejects distances `<= 0.0` (for lossy) and clamps the upper
34    /// end to `25.0`. `0.0` is mathematically lossless and is **not**
35    /// accepted on `LossyConfig`; use `LosslessConfig` instead.
36    #[error("distance {value} out of valid range {valid:?}")]
37    DistanceOutOfRange {
38        value: f32,
39        valid: RangeInclusive<f32>,
40    },
41    /// Distance was non-finite (NaN or infinity).
42    #[error("distance must be finite, got {value}")]
43    DistanceNotFinite { value: f32 },
44    /// Effort level outside `1..=10`.
45    /// (`EffortProfile::lossy` / `lossless` clamp internally; this surfaces
46    /// the violation up front instead of silently coercing.)
47    #[error("effort {value} out of valid range {valid:?}")]
48    EffortOutOfRange {
49        value: u8,
50        valid: RangeInclusive<u8>,
51    },
52
53    // ── LossyConfig (quality loops) ────────────────────────────────────
54    /// A quality-loop iteration count exceeds the encoder's reasonable cap.
55    /// libjxl uses up to 4 butteraugli iterations at kTortoise; we accept up
56    /// to 16 across all loops to leave headroom for the tuning harness.
57    #[error("{name} iter count {value} out of valid range {valid:?}")]
58    IterCountOutOfRange {
59        name: &'static str,
60        value: u32,
61        valid: RangeInclusive<u32>,
62    },
63    /// Two or more quality loops are simultaneously requested. The lossy
64    /// encoder runs at most one quality loop per encode (butteraugli, ssim2,
65    /// or zensim) — picking which is the caller's choice. Stacking is not a
66    /// supported configuration.
67    #[error("mutually exclusive quality loops: {first} and {second} both have nonzero iter count")]
68    QualityLoopMutuallyExclusive {
69        first: &'static str,
70        second: &'static str,
71    },
72
73    // ── LossyInternalParams numeric ranges ─────────────────────────────
74    /// `fine_grained_step` outside `1..=8`. `0` would cause the AC strategy
75    /// search loop's `step_by(0)` to panic.
76    #[error("fine_grained_step {value} out of valid range {valid:?}")]
77    FineGrainedStepOutOfRange {
78        value: u8,
79        valid: RangeInclusive<u8>,
80    },
81    /// `k_info_loss_mul_base` is non-finite or non-positive. The encoder
82    /// multiplies pixel-domain error terms by this; non-positive values
83    /// invert the cost model.
84    #[error("k_info_loss_mul_base {value} must be finite and > 0.0")]
85    KInfoLossMulBaseInvalid { value: f32 },
86    /// `k_ac_quant` is non-finite or non-positive. Used as the
87    /// quantization-cost constant when materializing the initial quant field;
88    /// non-positive values produce a zero/negative initial quant.
89    #[error("k_ac_quant {value} must be finite and > 0.0")]
90    KAcQuantInvalid { value: f32 },
91
92    // ── LosslessInternalParams numeric ranges ──────────────────────────
93    /// `nb_rcts_to_try` exceeds libjxl's documented kTortoise schedule (19).
94    #[error("nb_rcts_to_try {value} out of valid range {valid:?}")]
95    NbRctsToTryOutOfRange {
96        value: u8,
97        valid: RangeInclusive<u8>,
98    },
99    /// `wp_num_param_sets` exceeds the maximum number of WP modes the
100    /// encoder iterates over (5).
101    #[error("wp_num_param_sets {value} out of valid range {valid:?}")]
102    WpNumParamSetsOutOfRange {
103        value: u8,
104        valid: RangeInclusive<u8>,
105    },
106    /// `tree_max_buckets` is zero — the histogram quantizer needs at least
107    /// one bucket per property.
108    #[error("tree_max_buckets must be > 0, got 0")]
109    TreeMaxBucketsZero,
110    /// `tree_num_properties` exceeds the property-order length (16, the size
111    /// of `PROP_ORDER_NO_SQUEEZE` / `PROP_ORDER_SQUEEZE` in
112    /// `src/modular/tree_learn.rs`).
113    #[error("tree_num_properties {value} out of valid range {valid:?}")]
114    TreeNumPropertiesOutOfRange {
115        value: u8,
116        valid: RangeInclusive<u8>,
117    },
118    /// `tree_threshold_base` is non-finite or negative. libjxl's formula is
119    /// `75 + 14 * speed_tier`; negative thresholds would accept every split.
120    #[error("tree_threshold_base {value} must be finite and >= 0.0")]
121    TreeThresholdBaseInvalid { value: f32 },
122    /// `tree_sample_fraction` is non-finite or outside `0.0..=1.0`. It is a
123    /// pixel-fraction sampler ratio.
124    #[error("tree_sample_fraction {value} out of valid range {valid:?}")]
125    TreeSampleFractionOutOfRange {
126        value: f32,
127        valid: RangeInclusive<f32>,
128    },
129}
130
131// ── Range constants ────────────────────────────────────────────────────
132
133/// libjxl's documented butteraugli distance range.
134/// `cjxl --distance` accepts `[0.0, 25.0]`; we reject `0.0` for lossy and
135/// require lossless instead, so the lossy validator uses an open lower bound.
136pub(crate) const DISTANCE_MAX: f32 = 25.0;
137pub(crate) const EFFORT_RANGE: RangeInclusive<u8> = 1..=10;
138/// Cap on quality-loop iter counts. libjxl's kTortoise butteraugli runs 4
139/// passes; 16 leaves room for sweep harnesses without inviting absurd values.
140#[cfg(any(
141    feature = "butteraugli-loop",
142    feature = "ssim2-loop",
143    feature = "zensim-loop"
144))]
145pub(crate) const ITER_MAX: u32 = 16;
146#[cfg(feature = "__expert")]
147pub(crate) const FINE_GRAINED_STEP_RANGE: RangeInclusive<u8> = 1..=8;
148/// libjxl's kTortoise `nb_rcts_to_try` schedule peaks at 19.
149#[cfg(feature = "__expert")]
150pub(crate) const NB_RCTS_RANGE: RangeInclusive<u8> = 0..=19;
151/// `find_best_wp_params` iterates up to 5 modes (`mode 0..5`).
152#[cfg(feature = "__expert")]
153pub(crate) const WP_NUM_PARAM_SETS_RANGE: RangeInclusive<u8> = 0..=5;
154/// `PROP_ORDER_NO_SQUEEZE` / `PROP_ORDER_SQUEEZE` are 16 entries; values
155/// above are silently clamped by `from_profile_impl`.
156#[cfg(feature = "__expert")]
157pub(crate) const TREE_NUM_PROPERTIES_RANGE: RangeInclusive<u8> = 0..=16;
158#[cfg(feature = "__expert")]
159pub(crate) const TREE_SAMPLE_FRACTION_RANGE: RangeInclusive<f32> = 0.0..=1.0;
160
161// ── Helpers ────────────────────────────────────────────────────────────
162
163#[inline]
164fn check_effort(effort: u8) -> Result<(), ValidationError> {
165    if EFFORT_RANGE.contains(&effort) {
166        Ok(())
167    } else {
168        Err(ValidationError::EffortOutOfRange {
169            value: effort,
170            valid: EFFORT_RANGE,
171        })
172    }
173}
174
175#[cfg(any(
176    feature = "butteraugli-loop",
177    feature = "ssim2-loop",
178    feature = "zensim-loop"
179))]
180#[inline]
181fn check_iter(name: &'static str, value: u32) -> Result<(), ValidationError> {
182    let valid = 0..=ITER_MAX;
183    if valid.contains(&value) {
184        Ok(())
185    } else {
186        Err(ValidationError::IterCountOutOfRange { name, value, valid })
187    }
188}
189
190/// Validate the per-knob ranges of a resolved [`crate::effort::EffortProfile`]
191/// for the fields that [`crate::effort::LossyInternalParams`] exposes.
192#[cfg(feature = "__expert")]
193pub(crate) fn validate_lossy_profile_overrides(
194    profile: &crate::effort::EffortProfile,
195) -> Result<(), ValidationError> {
196    if !FINE_GRAINED_STEP_RANGE.contains(&profile.fine_grained_step) {
197        return Err(ValidationError::FineGrainedStepOutOfRange {
198            value: profile.fine_grained_step,
199            valid: FINE_GRAINED_STEP_RANGE,
200        });
201    }
202    if !profile.k_info_loss_mul_base.is_finite() || profile.k_info_loss_mul_base <= 0.0 {
203        return Err(ValidationError::KInfoLossMulBaseInvalid {
204            value: profile.k_info_loss_mul_base,
205        });
206    }
207    if !profile.k_ac_quant.is_finite() || profile.k_ac_quant <= 0.0 {
208        return Err(ValidationError::KAcQuantInvalid {
209            value: profile.k_ac_quant,
210        });
211    }
212    Ok(())
213}
214
215/// Validate the per-knob ranges of a resolved [`crate::effort::EffortProfile`]
216/// for the fields that [`crate::effort::LosslessInternalParams`] exposes.
217#[cfg(feature = "__expert")]
218pub(crate) fn validate_lossless_profile_overrides(
219    profile: &crate::effort::EffortProfile,
220) -> Result<(), ValidationError> {
221    if !NB_RCTS_RANGE.contains(&profile.nb_rcts_to_try) {
222        return Err(ValidationError::NbRctsToTryOutOfRange {
223            value: profile.nb_rcts_to_try,
224            valid: NB_RCTS_RANGE,
225        });
226    }
227    if !WP_NUM_PARAM_SETS_RANGE.contains(&profile.wp_num_param_sets) {
228        return Err(ValidationError::WpNumParamSetsOutOfRange {
229            value: profile.wp_num_param_sets,
230            valid: WP_NUM_PARAM_SETS_RANGE,
231        });
232    }
233    if profile.tree_max_buckets == 0 {
234        return Err(ValidationError::TreeMaxBucketsZero);
235    }
236    if !TREE_NUM_PROPERTIES_RANGE.contains(&profile.tree_num_properties) {
237        return Err(ValidationError::TreeNumPropertiesOutOfRange {
238            value: profile.tree_num_properties,
239            valid: TREE_NUM_PROPERTIES_RANGE,
240        });
241    }
242    if !profile.tree_threshold_base.is_finite() || profile.tree_threshold_base < 0.0 {
243        return Err(ValidationError::TreeThresholdBaseInvalid {
244            value: profile.tree_threshold_base,
245        });
246    }
247    if !profile.tree_sample_fraction.is_finite()
248        || !TREE_SAMPLE_FRACTION_RANGE.contains(&profile.tree_sample_fraction)
249    {
250        return Err(ValidationError::TreeSampleFractionOutOfRange {
251            value: profile.tree_sample_fraction,
252            valid: TREE_SAMPLE_FRACTION_RANGE,
253        });
254    }
255    // tree_max_samples_fixed: any u32 is fine (0 = "use fraction", any other
256    // value is a hard sample cap).
257    Ok(())
258}
259
260// ── Public validate() impls ─────────────────────────────────────────────
261
262impl crate::api::LossyConfig {
263    /// Validate that every parameter on this config is within the encoder's
264    /// supported range.
265    ///
266    /// `LossyConfig` setters intentionally accept and clamp out-of-range
267    /// values for backwards-compat — `with_distance(50.0).with_effort(15)`
268    /// returns a config the encoder happily runs (clamped to 25.0 / 10).
269    /// Batch-job callers who want a fail-fast escape can call this method
270    /// before invoking the encoder.
271    ///
272    /// Returns the **first** violation encountered; ordering of the checks
273    /// is an implementation detail.
274    ///
275    /// When `__expert` is enabled and a `profile_override` has been applied
276    /// via [`Self::with_internal_params`], the resolved profile's fields are
277    /// also checked against the same ranges
278    /// [`crate::effort::LossyInternalParams::validate`] would enforce.
279    pub fn validate(&self) -> Result<(), ValidationError> {
280        let d = self.distance();
281        if !d.is_finite() {
282            return Err(ValidationError::DistanceNotFinite { value: d });
283        }
284        // Lossy distance must be > 0; 0.0 means lossless and is rejected by
285        // `Quality::to_distance` already, but `LossyConfig::new` accepts any
286        // f32. Use an open lower bound by checking explicitly.
287        if d <= 0.0 || d > DISTANCE_MAX {
288            return Err(ValidationError::DistanceOutOfRange {
289                value: d,
290                valid: 0.0..=DISTANCE_MAX,
291            });
292        }
293        check_effort(self.effort())?;
294
295        // Quality-loop iter counts and exclusivity.
296        #[cfg(feature = "butteraugli-loop")]
297        let bi = self.butteraugli_iters();
298        #[cfg(not(feature = "butteraugli-loop"))]
299        let bi = 0u32;
300        #[cfg(feature = "butteraugli-loop")]
301        check_iter("butteraugli_iters", bi)?;
302
303        #[cfg(feature = "ssim2-loop")]
304        let si = self.ssim2_iters_value();
305        #[cfg(not(feature = "ssim2-loop"))]
306        let si = 0u32;
307        #[cfg(feature = "ssim2-loop")]
308        check_iter("ssim2_iters", si)?;
309
310        #[cfg(feature = "zensim-loop")]
311        let zi = self.zensim_iters_value();
312        #[cfg(not(feature = "zensim-loop"))]
313        let zi = 0u32;
314        #[cfg(feature = "zensim-loop")]
315        check_iter("zensim_iters", zi)?;
316
317        // Mutual exclusivity. The encoder dispatches to a single quality
318        // loop per encode; stacking two is not supported.
319        let active: &[(&'static str, u32)] = &[
320            ("butteraugli_iters", bi),
321            ("ssim2_iters", si),
322            ("zensim_iters", zi),
323        ];
324        let mut first_active: Option<&'static str> = None;
325        for &(name, val) in active {
326            if val > 0 {
327                if let Some(prev) = first_active {
328                    return Err(ValidationError::QualityLoopMutuallyExclusive {
329                        first: prev,
330                        second: name,
331                    });
332                }
333                first_active = Some(name);
334            }
335        }
336
337        // Validate the resolved internal-params profile if one was set.
338        #[cfg(feature = "__expert")]
339        if let Some(profile) = self.profile_override_ref() {
340            validate_lossy_profile_overrides(profile)?;
341        }
342
343        Ok(())
344    }
345}
346
347impl crate::api::LosslessConfig {
348    /// Validate that every parameter on this config is within the encoder's
349    /// supported range.
350    ///
351    /// See [`crate::api::LossyConfig::validate`] for the contract.
352    pub fn validate(&self) -> Result<(), ValidationError> {
353        check_effort(self.effort())?;
354
355        #[cfg(feature = "__expert")]
356        if let Some(profile) = self.profile_override_ref() {
357            validate_lossless_profile_overrides(profile)?;
358        }
359        Ok(())
360    }
361}
362
363#[cfg(feature = "__expert")]
364impl crate::effort::LossyInternalParams {
365    /// Validate every `Some(_)` field against the same ranges
366    /// [`crate::api::LossyConfig::validate`] enforces on the resolved
367    /// profile. Use this to fail fast on a freshly-constructed
368    /// `LossyInternalParams` before passing it to
369    /// [`crate::api::LossyConfig::with_internal_params`].
370    pub fn validate(&self) -> Result<(), ValidationError> {
371        if let Some(step) = self.fine_grained_step
372            && !FINE_GRAINED_STEP_RANGE.contains(&step)
373        {
374            return Err(ValidationError::FineGrainedStepOutOfRange {
375                value: step,
376                valid: FINE_GRAINED_STEP_RANGE,
377            });
378        }
379        if let Some(v) = self.k_info_loss_mul_base
380            && (!v.is_finite() || v <= 0.0)
381        {
382            return Err(ValidationError::KInfoLossMulBaseInvalid { value: v });
383        }
384        if let Some(v) = self.k_ac_quant
385            && (!v.is_finite() || v <= 0.0)
386        {
387            return Err(ValidationError::KAcQuantInvalid { value: v });
388        }
389        // try_dct16/32/64/4x8_afv, cfl_two_pass, chromacity_adjustment,
390        // patch_ref_tree_learning, non_aligned_eval,
391        // enhanced_clustering_vardct: all bool — well-formed by typing.
392        // entropy_mul_table: well-formed by constructor (well-formed enum
393        // variants only); no field-level checks beyond what the type itself
394        // enforces.
395        Ok(())
396    }
397}
398
399#[cfg(feature = "__expert")]
400impl crate::effort::LosslessInternalParams {
401    /// Validate every `Some(_)` field against the same ranges
402    /// [`crate::api::LosslessConfig::validate`] enforces on the resolved
403    /// profile.
404    pub fn validate(&self) -> Result<(), ValidationError> {
405        if let Some(v) = self.nb_rcts_to_try
406            && !NB_RCTS_RANGE.contains(&v)
407        {
408            return Err(ValidationError::NbRctsToTryOutOfRange {
409                value: v,
410                valid: NB_RCTS_RANGE,
411            });
412        }
413        if let Some(v) = self.wp_num_param_sets
414            && !WP_NUM_PARAM_SETS_RANGE.contains(&v)
415        {
416            return Err(ValidationError::WpNumParamSetsOutOfRange {
417                value: v,
418                valid: WP_NUM_PARAM_SETS_RANGE,
419            });
420        }
421        if let Some(0) = self.tree_max_buckets {
422            return Err(ValidationError::TreeMaxBucketsZero);
423        }
424        if let Some(v) = self.tree_num_properties
425            && !TREE_NUM_PROPERTIES_RANGE.contains(&v)
426        {
427            return Err(ValidationError::TreeNumPropertiesOutOfRange {
428                value: v,
429                valid: TREE_NUM_PROPERTIES_RANGE,
430            });
431        }
432        if let Some(v) = self.tree_threshold_base
433            && (!v.is_finite() || v < 0.0)
434        {
435            return Err(ValidationError::TreeThresholdBaseInvalid { value: v });
436        }
437        if let Some(v) = self.tree_sample_fraction
438            && (!v.is_finite() || !TREE_SAMPLE_FRACTION_RANGE.contains(&v))
439        {
440            return Err(ValidationError::TreeSampleFractionOutOfRange {
441                value: v,
442                valid: TREE_SAMPLE_FRACTION_RANGE,
443            });
444        }
445        // tree_max_samples_fixed: any u32 is acceptable.
446        Ok(())
447    }
448}