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    /// Encode `src` (raw samples in the file's byte order), writing the compressed
114    /// representation to `out`. Returns bytes written.
115    fn encode(&self, src: &[u8], frame: Frame, out: &mut dyn Write) -> Result<u64>;
116
117    /// Decode `data`, writing raw samples in the file's byte order to `out`.
118    /// Returns bytes written.
119    fn decode(&self, data: &[u8], frame: Frame, out: &mut dyn Write) -> Result<u64>;
120}
121
122/// JPEG XL via vendored libjxl, in process.
123#[derive(Debug, Clone)]
124pub struct Jxl {
125    /// 1-10, mapped to libjxl's speed tiers. See the module note on non-monotonicity
126    /// before changing this.
127    pub effort: u8,
128}
129
130impl Default for Jxl {
131    fn default() -> Self {
132        Self { effort: 4 }
133    }
134}
135
136impl Jxl {
137    fn speed(&self) -> jpegxl_rs::encode::EncoderSpeed {
138        use jpegxl_rs::encode::EncoderSpeed::*;
139        match self.effort.clamp(1, 10) {
140            1 => Lightning,
141            2 => Thunder,
142            3 => Falcon,
143            4 => Cheetah,
144            5 => Hare,
145            6 => Wombat,
146            7 => Squirrel,
147            8 => Kitten,
148            9 => Tortoise,
149            _ => Glacier,
150        }
151    }
152}
153
154/// Reinterpret raw bytes as 16-bit samples without copying.
155///
156/// Large allocations come back well-aligned from every allocator we care about, so the
157/// zero-copy path is the normal one; the copy is a correctness fallback, not the
158/// expected case.
159fn as_u16(src: &[u8]) -> std::borrow::Cow<'_, [u16]> {
160    match bytemuck::try_cast_slice::<u8, u16>(src) {
161        Ok(s) => std::borrow::Cow::Borrowed(s),
162        Err(_) => std::borrow::Cow::Owned(
163            src.chunks_exact(2)
164                .map(|c| u16::from_ne_bytes([c[0], c[1]]))
165                .collect(),
166        ),
167    }
168}
169
170fn endianness(little_endian: bool) -> jpegxl_rs::Endianness {
171    if little_endian {
172        jpegxl_rs::Endianness::Little
173    } else {
174        jpegxl_rs::Endianness::Big
175    }
176}
177
178impl Codec for Jxl {
179    fn id(&self) -> &'static str {
180        "jxl"
181    }
182
183    fn encode(&self, src: &[u8], frame: Frame, out: &mut dyn Write) -> Result<u64> {
184        let Frame {
185            width,
186            height,
187            channels: ch,
188            depth,
189            little_endian,
190        } = frame;
191        let expected = frame.byte_len();
192        if src.len() as u64 != expected {
193            return Err(Error::SizeMismatch {
194                expected,
195                actual: src.len() as u64,
196            });
197        }
198
199        // The colour encoding must match the channel count or libjxl rejects the
200        // configuration outright. It is metadata only: lossless recovers every sample
201        // exactly regardless, and what these samples *mean* (sensor response, Adobe RGB,
202        // whatever) is defined by the container we preserve verbatim — never by this tag.
203        let color = match ch {
204            Channels::Gray => jpegxl_rs::encode::ColorEncoding::SrgbLuma,
205            Channels::Rgb => jpegxl_rs::encode::ColorEncoding::Srgb,
206        };
207        // Without an explicit runner libjxl encodes single-threaded. Measured cost of
208        // omitting it: 51-79% slower — enough to lose to spawning a subprocess.
209        let runner = jpegxl_rs::ThreadsRunner::default();
210        let mut enc = jpegxl_rs::encoder_builder()
211            .parallel_runner(&runner)
212            .lossless(true)
213            // libjxl rejects lossless unless the original profile is kept: lossless
214            // means the encoder must not transform the samples, and substituting a
215            // profile is exactly what would license it to.
216            .uses_original_profile(true)
217            .speed(self.speed())
218            .color_encoding(color)
219            .has_alpha(false)
220            .build()
221            .map_err(|e| Error::Backend(e.to_string()))?;
222
223        let nch = ch.count() as u32;
224        let encoded: Vec<u8> = match depth {
225            Depth::Eight => {
226                let ef = jpegxl_rs::encode::EncoderFrame::new(src)
227                    .num_channels(nch)
228                    .endianness(endianness(little_endian));
229                enc.encode_frame::<u8, u8>(&ef, width, height)
230                    .map_err(|e| Error::Backend(e.to_string()))?
231                    .data
232            }
233            Depth::Sixteen => {
234                let samples = as_u16(src);
235                let ef = jpegxl_rs::encode::EncoderFrame::new(samples.as_ref())
236                    .num_channels(nch)
237                    .endianness(endianness(little_endian));
238                enc.encode_frame::<u16, u16>(&ef, width, height)
239                    .map_err(|e| Error::Backend(e.to_string()))?
240                    .data
241            }
242        };
243
244        out.write_all(&encoded)?;
245        Ok(encoded.len() as u64)
246    }
247
248    fn decode(&self, data: &[u8], frame: Frame, out: &mut dyn Write) -> Result<u64> {
249        let Frame {
250            depth,
251            little_endian,
252            ..
253        } = frame;
254        let runner = jpegxl_rs::ThreadsRunner::default();
255        let dec = jpegxl_rs::decoder_builder()
256            .parallel_runner(&runner)
257            .build()
258            .map_err(|e| Error::Backend(e.to_string()))?;
259        let expected = frame.byte_len();
260
261        let bytes: Vec<u8> = match depth {
262            Depth::Eight => {
263                dec.decode_with::<u8>(data)
264                    .map_err(|e| Error::Backend(e.to_string()))?
265                    .1
266            }
267            Depth::Sixteen => {
268                let (_, px) = dec
269                    .decode_with::<u16>(data)
270                    .map_err(|e| Error::Backend(e.to_string()))?;
271                // libjxl hands back native-order samples; write them in the file's order.
272                let mut v = Vec::with_capacity(px.len() * 2);
273                if little_endian {
274                    for s in &px {
275                        v.extend_from_slice(&s.to_le_bytes());
276                    }
277                } else {
278                    for s in &px {
279                        v.extend_from_slice(&s.to_be_bytes());
280                    }
281                }
282                v
283            }
284        };
285
286        if bytes.len() as u64 != expected {
287            return Err(Error::SizeMismatch {
288                expected,
289                actual: bytes.len() as u64,
290            });
291        }
292        out.write_all(&bytes)?;
293        Ok(bytes.len() as u64)
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    /// Deterministic pseudo-random bytes; no dev-dependency needed.
302    fn noise(n: usize, seed: u64) -> Vec<u8> {
303        let mut s = seed | 1;
304        (0..n)
305            .map(|_| {
306                s ^= s << 13;
307                s ^= s >> 7;
308                s ^= s << 17;
309                (s >> 24) as u8
310            })
311            .collect()
312    }
313
314    fn gray16(w: u32, h: u32, le: bool) -> Frame {
315        Frame {
316            width: w,
317            height: h,
318            channels: Channels::Gray,
319            depth: Depth::Sixteen,
320            little_endian: le,
321        }
322    }
323
324    fn round_trip(w: u32, h: u32, ch: Channels, depth: Depth, le: bool) {
325        let c = Jxl { effort: 1 };
326        let f = Frame {
327            width: w,
328            height: h,
329            channels: ch,
330            depth,
331            little_endian: le,
332        };
333        let src = noise(f.byte_len() as usize, 99);
334        let mut enc = Vec::new();
335        c.encode(&src, f, &mut enc).unwrap();
336        let mut dec = Vec::new();
337        c.decode(&enc, f, &mut dec).unwrap();
338        assert_eq!(src, dec, "{w}x{h} {ch:?} {depth:?} le={le}");
339    }
340
341    /// The whole archive guarantee rests on this: exactly reversible for every shape
342    /// and byte order we hand it.
343    #[test]
344    fn round_trips_losslessly() {
345        round_trip(64, 48, Channels::Gray, Depth::Sixteen, true);
346        round_trip(64, 48, Channels::Gray, Depth::Sixteen, false);
347        round_trip(40, 24, Channels::Rgb, Depth::Eight, true);
348        round_trip(33, 17, Channels::Rgb, Depth::Sixteen, true);
349        round_trip(33, 17, Channels::Rgb, Depth::Sixteen, false);
350    }
351
352    /// Odd dimensions have no clean 2x2 CFA split, so the archive layer falls back to
353    /// whole-frame encoding. That path must still be exact.
354    #[test]
355    fn handles_odd_dimensions() {
356        round_trip(1, 1, Channels::Gray, Depth::Sixteen, true);
357        round_trip(7, 3, Channels::Rgb, Depth::Eight, false);
358    }
359
360    #[test]
361    fn encode_rejects_wrong_byte_count() {
362        let c = Jxl::default();
363        assert!(matches!(
364            c.encode(&[0u8; 10], gray16(4, 4, false), &mut Vec::new()),
365            Err(Error::SizeMismatch { .. })
366        ));
367    }
368
369    /// Byte order must survive the round trip in both directions.
370    #[test]
371    fn byte_order_is_preserved() {
372        let c = Jxl { effort: 1 };
373        let src = noise(16 * 16 * 2, 3);
374        for le in [true, false] {
375            let f = gray16(16, 16, le);
376            let mut enc = Vec::new();
377            c.encode(&src, f, &mut enc).unwrap();
378            let mut out = Vec::new();
379            c.decode(&enc, f, &mut out).unwrap();
380            assert_eq!(src, out, "le={le}");
381        }
382    }
383}