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