Skip to main content

blad_codec/
lib.rs

1//! Lossless codec backends.
2//!
3//! # Byte-oriented by design
4//!
5//! Encode and decode take and produce raw sample **bytes** in the source file's own byte
6//! order, and write through a [`Write`]. Materialising an image as `Vec<u16>` merely to
7//! change endianness duplicates the entire frame — ~300 MB on a 51MP file, twice per
8//! round trip.
9//!
10//! # Why JPEG XL
11//!
12//! Measured on real 16-bit files, not chosen by reputation:
13//!
14//! - `zstd -19` reaches only 0.72 on a Bayer mosaic; LZ looks for repeated byte
15//!   sequences and photographic data has 2D correlation instead.
16//! - AVIF and HEIC cannot participate at all — AV1 tops out at 12 bits per sample.
17//! - PNG handles 16-bit but is well behind JXL's modular mode.
18//!
19//! libjxl is vendored, so the binary links `libjxl.a` statically and depends on nothing
20//! beyond the platform C/C++ runtime. That also pins the codec version, which keeps
21//! benchmarks comparable over time instead of shifting under someone's package upgrade.
22//!
23//! # Effort is non-monotonic
24//!
25//! Default is 4, not 9. Measured: on Bayer planes effort 7 encoded *larger* than 4 and
26//! 3.8x slower; on a 51MP RGB frame effort 9 was larger than 7 and 36x slower than 4.
27//! Do not raise the default without measuring on both CFA and RGB content.
28
29use std::io::Write;
30
31#[derive(Debug, thiserror::Error)]
32pub enum Error {
33    #[error("io: {0}")]
34    Io(#[from] std::io::Error),
35    #[error("libjxl: {0}")]
36    Backend(String),
37    #[error("byte count mismatch: got {actual}, expected {expected}")]
38    SizeMismatch { expected: u64, actual: u64 },
39}
40
41pub type Result<T> = std::result::Result<T, Error>;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Channels {
45    Gray,
46    Rgb,
47}
48
49impl Channels {
50    pub fn count(self) -> u64 {
51        match self {
52            Channels::Gray => 1,
53            Channels::Rgb => 3,
54        }
55    }
56}
57
58/// Bits per sample. Real files mix depths — a 3FR holds a 16-bit sensor mosaic *and* an
59/// 8-bit embedded preview — so this cannot be assumed away.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum Depth {
62    Eight,
63    Sixteen,
64}
65
66impl Depth {
67    pub fn from_bits(bits: u16) -> Option<Self> {
68        match bits {
69            8 => Some(Depth::Eight),
70            16 => Some(Depth::Sixteen),
71            _ => None,
72        }
73    }
74    pub fn bytes(self) -> u64 {
75        match self {
76            Depth::Eight => 1,
77            Depth::Sixteen => 2,
78        }
79    }
80}
81
82/// Everything a codec needs to know about a rectangle of raw samples.
83///
84/// Encode and decode take the *same* descriptor, and that symmetry is the point: a
85/// round trip whose two halves disagree about any one field does not fail loudly, it
86/// silently produces wrong pixels. One value passed to both sides cannot disagree.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct Frame {
89    pub width: u32,
90    pub height: u32,
91    pub channels: Channels,
92    pub depth: Depth,
93    /// Byte order of the samples *as stored in the file*.
94    pub little_endian: bool,
95}
96
97impl Frame {
98    /// Raw byte length of the samples this frame describes.
99    pub fn byte_len(&self) -> u64 {
100        u64::from(self.width) * u64::from(self.height) * self.channels.count() * self.depth.bytes()
101    }
102}
103
104/// A lossless image codec operating on raw sample bytes.
105///
106/// Implementations must be exactly reversible. This is asserted end-to-end on every
107/// archive rather than trusted.
108pub trait Codec: Sync {
109    /// Short stable identifier recorded in the archive manifest, so a future version
110    /// knows which backend produced a payload.
111    fn id(&self) -> &'static str;
112
113    /// Human description of the exact encoder and settings, for the archive's record.
114    ///
115    /// Reported by the linked library rather than assumed from the crate version: what
116    /// matters to someone holding the archive in ten years is which encoder actually
117    /// produced those bytes, and a hardcoded string can drift from reality without
118    /// anyone noticing.
119    fn describe(&self) -> String {
120        self.id().to_string()
121    }
122
123    /// Encode `src` (raw samples in the file's byte order), writing the compressed
124    /// representation to `out`. Returns bytes written.
125    fn encode(&self, src: &[u8], frame: Frame, out: &mut dyn Write) -> Result<u64>;
126
127    /// Decode `data`, writing raw samples in the file's byte order to `out`.
128    /// Returns bytes written.
129    fn decode(&self, data: &[u8], frame: Frame, out: &mut dyn Write) -> Result<u64>;
130}
131
132/// JPEG XL via vendored libjxl, in process.
133#[derive(Debug, Clone)]
134pub struct Jxl {
135    /// 1-10, mapped to libjxl's speed tiers. See the module note on non-monotonicity
136    /// before changing this.
137    pub effort: u8,
138}
139
140impl Default for Jxl {
141    fn default() -> Self {
142        Self { effort: 4 }
143    }
144}
145
146impl Jxl {
147    fn speed(&self) -> jpegxl_rs::encode::EncoderSpeed {
148        use jpegxl_rs::encode::EncoderSpeed::*;
149        match self.effort.clamp(1, 10) {
150            1 => Lightning,
151            2 => Thunder,
152            3 => Falcon,
153            4 => Cheetah,
154            5 => Hare,
155            6 => Wombat,
156            7 => Squirrel,
157            8 => Kitten,
158            9 => Tortoise,
159            _ => Glacier,
160        }
161    }
162}
163
164/// Reinterpret raw bytes as 16-bit samples without copying.
165///
166/// Large allocations come back well-aligned from every allocator we care about, so the
167/// zero-copy path is the normal one; the copy is a correctness fallback, not the
168/// expected case.
169fn as_u16(src: &[u8]) -> std::borrow::Cow<'_, [u16]> {
170    match bytemuck::try_cast_slice::<u8, u16>(src) {
171        Ok(s) => std::borrow::Cow::Borrowed(s),
172        Err(_) => std::borrow::Cow::Owned(
173            src.chunks_exact(2)
174                .map(|c| u16::from_ne_bytes([c[0], c[1]]))
175                .collect(),
176        ),
177    }
178}
179
180fn endianness(little_endian: bool) -> jpegxl_rs::Endianness {
181    if little_endian {
182        jpegxl_rs::Endianness::Little
183    } else {
184        jpegxl_rs::Endianness::Big
185    }
186}
187
188/// libjxl's own version, e.g. `0.12.0`, from the linked library.
189pub fn libjxl_version() -> String {
190    // Encoded as major*1_000_000 + minor*1_000 + patch.
191    let v = unsafe { jpegxl_sys::encoder::encode::JxlEncoderVersion() };
192    format!("{}.{}.{}", v / 1_000_000, (v / 1_000) % 1_000, v % 1_000)
193}
194
195impl Codec for Jxl {
196    fn id(&self) -> &'static str {
197        "jxl"
198    }
199
200    fn describe(&self) -> String {
201        format!(
202            "libjxl {} effort {}",
203            libjxl_version(),
204            self.effort.clamp(1, 10)
205        )
206    }
207
208    fn encode(&self, src: &[u8], frame: Frame, out: &mut dyn Write) -> Result<u64> {
209        let Frame {
210            width,
211            height,
212            channels: ch,
213            depth,
214            little_endian,
215        } = frame;
216        let expected = frame.byte_len();
217        if src.len() as u64 != expected {
218            return Err(Error::SizeMismatch {
219                expected,
220                actual: src.len() as u64,
221            });
222        }
223
224        // The colour encoding must match the channel count or libjxl rejects the
225        // configuration outright. It is metadata only: lossless recovers every sample
226        // exactly regardless, and what these samples *mean* (sensor response, Adobe RGB,
227        // whatever) is defined by the container we preserve verbatim — never by this tag.
228        let color = match ch {
229            Channels::Gray => jpegxl_rs::encode::ColorEncoding::SrgbLuma,
230            Channels::Rgb => jpegxl_rs::encode::ColorEncoding::Srgb,
231        };
232        // Without an explicit runner libjxl encodes single-threaded. Measured cost of
233        // omitting it: 51-79% slower — enough to lose to spawning a subprocess.
234        let runner = jpegxl_rs::ThreadsRunner::default();
235        let mut enc = jpegxl_rs::encoder_builder()
236            .parallel_runner(&runner)
237            .lossless(true)
238            // libjxl rejects lossless unless the original profile is kept: lossless
239            // means the encoder must not transform the samples, and substituting a
240            // profile is exactly what would license it to.
241            .uses_original_profile(true)
242            .speed(self.speed())
243            .color_encoding(color)
244            .has_alpha(false)
245            .build()
246            .map_err(|e| Error::Backend(e.to_string()))?;
247
248        let nch = ch.count() as u32;
249        let encoded: Vec<u8> = match depth {
250            Depth::Eight => {
251                let ef = jpegxl_rs::encode::EncoderFrame::new(src)
252                    .num_channels(nch)
253                    .endianness(endianness(little_endian));
254                enc.encode_frame::<u8, u8>(&ef, width, height)
255                    .map_err(|e| Error::Backend(e.to_string()))?
256                    .data
257            }
258            Depth::Sixteen => {
259                let samples = as_u16(src);
260                let ef = jpegxl_rs::encode::EncoderFrame::new(samples.as_ref())
261                    .num_channels(nch)
262                    .endianness(endianness(little_endian));
263                enc.encode_frame::<u16, u16>(&ef, width, height)
264                    .map_err(|e| Error::Backend(e.to_string()))?
265                    .data
266            }
267        };
268
269        out.write_all(&encoded)?;
270        Ok(encoded.len() as u64)
271    }
272
273    fn decode(&self, data: &[u8], frame: Frame, out: &mut dyn Write) -> Result<u64> {
274        let Frame {
275            depth,
276            little_endian,
277            ..
278        } = frame;
279        let runner = jpegxl_rs::ThreadsRunner::default();
280        let dec = jpegxl_rs::decoder_builder()
281            .parallel_runner(&runner)
282            .build()
283            .map_err(|e| Error::Backend(e.to_string()))?;
284        let expected = frame.byte_len();
285
286        let bytes: Vec<u8> = match depth {
287            Depth::Eight => {
288                dec.decode_with::<u8>(data)
289                    .map_err(|e| Error::Backend(e.to_string()))?
290                    .1
291            }
292            Depth::Sixteen => {
293                let (_, px) = dec
294                    .decode_with::<u16>(data)
295                    .map_err(|e| Error::Backend(e.to_string()))?;
296                // libjxl hands back native-order samples; write them in the file's order.
297                let mut v = Vec::with_capacity(px.len() * 2);
298                if little_endian {
299                    for s in &px {
300                        v.extend_from_slice(&s.to_le_bytes());
301                    }
302                } else {
303                    for s in &px {
304                        v.extend_from_slice(&s.to_be_bytes());
305                    }
306                }
307                v
308            }
309        };
310
311        if bytes.len() as u64 != expected {
312            return Err(Error::SizeMismatch {
313                expected,
314                actual: bytes.len() as u64,
315            });
316        }
317        out.write_all(&bytes)?;
318        Ok(bytes.len() as u64)
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    /// Deterministic pseudo-random bytes; no dev-dependency needed.
327    fn noise(n: usize, seed: u64) -> Vec<u8> {
328        let mut s = seed | 1;
329        (0..n)
330            .map(|_| {
331                s ^= s << 13;
332                s ^= s >> 7;
333                s ^= s << 17;
334                (s >> 24) as u8
335            })
336            .collect()
337    }
338
339    fn gray16(w: u32, h: u32, le: bool) -> Frame {
340        Frame {
341            width: w,
342            height: h,
343            channels: Channels::Gray,
344            depth: Depth::Sixteen,
345            little_endian: le,
346        }
347    }
348
349    fn round_trip(w: u32, h: u32, ch: Channels, depth: Depth, le: bool) {
350        let c = Jxl { effort: 1 };
351        let f = Frame {
352            width: w,
353            height: h,
354            channels: ch,
355            depth,
356            little_endian: le,
357        };
358        let src = noise(f.byte_len() as usize, 99);
359        let mut enc = Vec::new();
360        c.encode(&src, f, &mut enc).unwrap();
361        let mut dec = Vec::new();
362        c.decode(&enc, f, &mut dec).unwrap();
363        assert_eq!(src, dec, "{w}x{h} {ch:?} {depth:?} le={le}");
364    }
365
366    /// The whole archive guarantee rests on this: exactly reversible for every shape
367    /// and byte order we hand it.
368    #[test]
369    fn round_trips_losslessly() {
370        round_trip(64, 48, Channels::Gray, Depth::Sixteen, true);
371        round_trip(64, 48, Channels::Gray, Depth::Sixteen, false);
372        round_trip(40, 24, Channels::Rgb, Depth::Eight, true);
373        round_trip(33, 17, Channels::Rgb, Depth::Sixteen, true);
374        round_trip(33, 17, Channels::Rgb, Depth::Sixteen, false);
375    }
376
377    /// Odd dimensions have no clean 2x2 CFA split, so the archive layer falls back to
378    /// whole-frame encoding. That path must still be exact.
379    #[test]
380    fn handles_odd_dimensions() {
381        round_trip(1, 1, Channels::Gray, Depth::Sixteen, true);
382        round_trip(7, 3, Channels::Rgb, Depth::Eight, false);
383    }
384
385    #[test]
386    fn encode_rejects_wrong_byte_count() {
387        let c = Jxl::default();
388        assert!(matches!(
389            c.encode(&[0u8; 10], gray16(4, 4, false), &mut Vec::new()),
390            Err(Error::SizeMismatch { .. })
391        ));
392    }
393
394    /// Byte order must survive the round trip in both directions.
395    #[test]
396    fn byte_order_is_preserved() {
397        let c = Jxl { effort: 1 };
398        let src = noise(16 * 16 * 2, 3);
399        for le in [true, false] {
400            let f = gray16(16, 16, le);
401            let mut enc = Vec::new();
402            c.encode(&src, f, &mut enc).unwrap();
403            let mut out = Vec::new();
404            c.decode(&enc, f, &mut out).unwrap();
405            assert_eq!(src, out, "le={le}");
406        }
407    }
408}
409
410#[cfg(test)]
411mod version_tests {
412    /// The version must come from the linked library, so that an archive's record of
413    /// how it was made cannot drift from what actually made it.
414    #[test]
415    fn reports_the_linked_libjxl_version() {
416        let v = super::libjxl_version();
417        assert!(
418            v.starts_with("0.") || v.starts_with("1."),
419            "odd version {v}"
420        );
421        assert_eq!(
422            v.matches('.').count(),
423            2,
424            "expected major.minor.patch, got {v}"
425        );
426        use super::Codec;
427        assert!(super::Jxl::default().describe().contains(&v));
428    }
429}