Skip to main content

blazediff_shared/
lib.rs

1//! The primitives every BlazeDiff crate sits on: the [`Image`] buffer, [`yiq`]
2//! color math, and RGBA8 decode/encode for PNG, JPEG and QOI.
3//!
4//! It exists because the crates above it form a chain — `blazediff-interpret`
5//! depends on both `blazediff` and `blazediff-ssim`, which know nothing about
6//! each other — so anything two of them share has to live below all of them.
7//! Every decoder normalizes to [`Image`], so the compute crates never see a
8//! codec; PNG goes through vendored libspng, JPEG through vendored
9//! libjpeg-turbo, QOI through `qoi-rust`.
10//!
11//! ```no_run
12//! use blazediff_shared::{load_image_pair, save_image, ImageFormat};
13//!
14//! let (a, b) = load_image_pair("a.png", "b.jpg").unwrap();
15//! assert_eq!(ImageFormat::from_path("a.png"), Some(ImageFormat::Png));
16//! ```
17//!
18//! Without the default `codecs` feature the crate is pure Rust and compiles to
19//! wasm32: [`Image`], [`ImageError`], [`ImageFormat`] and [`yiq`] remain, which
20//! is all the wasm build of `blazediff` needs.
21
22#[cfg(feature = "codecs")]
23pub mod jpeg_io;
24#[cfg(feature = "codecs")]
25pub mod png_io;
26#[cfg(feature = "codecs")]
27pub mod qoi_io;
28#[cfg(feature = "codecs")]
29#[allow(
30    non_upper_case_globals,
31    non_camel_case_types,
32    non_snake_case,
33    dead_code
34)]
35pub mod spng_ffi;
36#[cfg(feature = "codecs")]
37#[allow(
38    non_upper_case_globals,
39    non_camel_case_types,
40    non_snake_case,
41    dead_code
42)]
43mod turbojpeg_ffi;
44pub mod yiq;
45
46#[cfg(feature = "codecs")]
47use rayon::prelude::*;
48use std::path::Path;
49
50#[cfg(feature = "codecs")]
51pub use jpeg_io::{decode_jpeg, load_jpeg, load_jpegs, save_jpeg};
52#[cfg(feature = "codecs")]
53pub use png_io::{
54    decode_png, encode_png, load_png, load_pngs, save_png, save_png_with_compression,
55};
56#[cfg(feature = "codecs")]
57pub use qoi_io::{decode_qoi, load_qoi, load_qois, save_qoi};
58
59/// A decoded image: RGBA, 4 bytes per pixel, row-major.
60pub struct Image {
61    pub data: Vec<u8>, // RGBA, 4 bytes/pixel
62    pub width: u32,
63    pub height: u32,
64}
65
66impl Image {
67    pub fn new(width: u32, height: u32) -> Self {
68        Self {
69            data: vec![0u8; (width * height * 4) as usize],
70            width,
71            height,
72        }
73    }
74
75    /// Create an image whose pixel data is left uninitialized.
76    ///
77    /// Use this for diff-output buffers that the diff pipeline fully
78    /// overwrites — either by `clear_transparent` (diff_mask mode), by the
79    /// deferred gray-fill pass (when at least one block differs and diff_mask
80    /// is off), or by the hot pass writing changed blocks. The only call path
81    /// that does not overwrite is the early "identical" short-circuit: in that
82    /// case `result.identical` is true and every shipping front-end (CLI,
83    /// N-API, Python, the WASM in-place copy) already skips reading the output
84    /// buffer.
85    ///
86    /// Avoiding `vec![0u8; ...]` here saves a 64 MB memset per 4K-image diff.
87    /// The OS's allocator typically hands us freshly-cleared pages for big
88    /// allocations anyway, but tiny diffs (a few hundred KB) reuse arena memory
89    /// whose contents are whatever the last call left behind — fine here,
90    /// because the callers above respect the "identical → don't read" contract.
91    pub fn new_uninit(width: u32, height: u32) -> Self {
92        let size = (width as usize) * (height as usize) * 4;
93        let mut data: Vec<u8> = Vec::with_capacity(size);
94        // SAFETY: the new length matches the capacity we just reserved, the
95        // element type (`u8`) has no validity requirements, and every byte
96        // of this allocation is overwritten before being read on every
97        // non-identical diff path. Identical-input callers skip the read.
98        unsafe {
99            data.set_len(size);
100        }
101        Self {
102            data,
103            width,
104            height,
105        }
106    }
107
108    #[inline]
109    pub fn as_u32(&self) -> &[u32] {
110        bytemuck::cast_slice(&self.data)
111    }
112
113    #[inline]
114    pub fn as_u32_mut(&mut self) -> &mut [u32] {
115        bytemuck::cast_slice_mut(&mut self.data)
116    }
117
118    #[inline]
119    pub fn get_pixel(&self, x: u32, y: u32) -> u32 {
120        let idx = (y * self.width + x) as usize;
121        self.as_u32()[idx]
122    }
123
124    #[inline]
125    pub fn set_pixel(&mut self, x: u32, y: u32, pixel: u32) {
126        let idx = (y * self.width + x) as usize;
127        self.as_u32_mut()[idx] = pixel;
128    }
129}
130
131/// Anything that can go wrong reading or writing an image.
132///
133/// The `Display` strings are part of the contract: the CLI, the N-API binding,
134/// the Python extension and the JS wrappers all surface them verbatim, and
135/// `@blazediff/core-native` pattern-matches on them to tell a missing file from
136/// a malformed one.
137#[derive(Debug)]
138pub enum ImageError {
139    Io(std::io::Error),
140    Png(String),
141    Jpeg(String),
142    Qoi(String),
143    UnsupportedFormat(String),
144}
145
146impl std::fmt::Display for ImageError {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        match self {
149            ImageError::Io(e) => write!(f, "IO error: {}", e),
150            ImageError::Png(e) => write!(f, "PNG error: {}", e),
151            ImageError::Jpeg(e) => write!(f, "JPEG error: {}", e),
152            ImageError::Qoi(e) => write!(f, "QOI error: {}", e),
153            ImageError::UnsupportedFormat(e) => write!(f, "Unsupported format: {}", e),
154        }
155    }
156}
157
158impl std::error::Error for ImageError {}
159
160impl From<std::io::Error> for ImageError {
161    fn from(e: std::io::Error) -> Self {
162        ImageError::Io(e)
163    }
164}
165
166/// The container formats BlazeDiff reads and writes.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum ImageFormat {
169    Png,
170    Jpeg,
171    Qoi,
172}
173
174impl ImageFormat {
175    /// Detect from a file extension, case-insensitively.
176    pub fn from_path<P: AsRef<Path>>(path: P) -> Option<Self> {
177        let ext = path.as_ref().extension()?.to_str()?.to_lowercase();
178        match ext.as_str() {
179            "png" => Some(ImageFormat::Png),
180            "jpg" | "jpeg" => Some(ImageFormat::Jpeg),
181            "qoi" => Some(ImageFormat::Qoi),
182            _ => None,
183        }
184    }
185
186    /// Detect from the leading magic bytes of an encoded buffer.
187    pub fn from_bytes(data: &[u8]) -> Option<Self> {
188        if data.starts_with(b"\x89PNG\r\n\x1a\n") {
189            return Some(ImageFormat::Png);
190        }
191        if data.starts_with(&[0xff, 0xd8, 0xff]) {
192            return Some(ImageFormat::Jpeg);
193        }
194        if data.starts_with(b"qoif") {
195            return Some(ImageFormat::Qoi);
196        }
197        None
198    }
199
200    pub fn as_str(&self) -> &'static str {
201        match self {
202            ImageFormat::Png => "png",
203            ImageFormat::Jpeg => "jpeg",
204            ImageFormat::Qoi => "qoi",
205        }
206    }
207}
208
209/// Whether the in-house [`blazediff_png`] codec takes the PNG paths.
210///
211/// Opt in by setting `BLAZEDIFF_PNG_ENABLED` to a truthy value
212/// (`1`/`true`/`yes`/`on`, any case); unset or anything else keeps PNG I/O
213/// entirely on spng. Read once per process, so changing the variable after the
214/// first PNG has been touched has no effect.
215///
216/// This is a property of the codec layer rather than of any one front-end, so
217/// every caller of this crate inherits it: the `blazediff` CLI, its N-API and
218/// Python bindings, and `@blazediff/ssim-native`.
219///
220/// Without the `codecs` feature there is no PNG path to route, so this is
221/// always `false`.
222#[cfg(feature = "codecs")]
223pub fn blazediff_png_enabled() -> bool {
224    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
225    *ENABLED.get_or_init(|| {
226        std::env::var("BLAZEDIFF_PNG_ENABLED").is_ok_and(|v| {
227            matches!(
228                v.trim().to_ascii_lowercase().as_str(),
229                "1" | "true" | "yes" | "on"
230            )
231        })
232    })
233}
234
235#[cfg(not(feature = "codecs"))]
236pub fn blazediff_png_enabled() -> bool {
237    false
238}
239
240/// The payload is the path alone — `Display` supplies the "Unsupported format:"
241/// prefix. All three front-ends used to pass a pre-prefixed string here and
242/// print it doubled.
243#[cfg(feature = "codecs")]
244fn unsupported_path<P: AsRef<Path>>(path: P) -> ImageError {
245    ImageError::UnsupportedFormat(path.as_ref().display().to_string())
246}
247
248/// Load one image, picking the decoder from the file extension.
249#[cfg(feature = "codecs")]
250pub fn load_image<P: AsRef<Path>>(path: P) -> Result<Image, ImageError> {
251    match ImageFormat::from_path(&path).ok_or_else(|| unsupported_path(&path))? {
252        ImageFormat::Png => load_png(path),
253        ImageFormat::Jpeg => load_jpeg(path),
254        ImageFormat::Qoi => load_qoi(path),
255    }
256}
257
258/// Load two images in parallel, picking each decoder from its file extension.
259///
260/// When both share a format this delegates to that codec's paired loader, which
261/// is a plain `rayon::join` — cheaper than the iterator machinery below for a
262/// two-task workload.
263#[cfg(feature = "codecs")]
264pub fn load_image_pair<P1: AsRef<Path> + Sync, P2: AsRef<Path> + Sync>(
265    path1: P1,
266    path2: P2,
267) -> Result<(Image, Image), ImageError> {
268    let fmt1 = ImageFormat::from_path(&path1).ok_or_else(|| unsupported_path(&path1))?;
269    let fmt2 = ImageFormat::from_path(&path2).ok_or_else(|| unsupported_path(&path2))?;
270
271    if fmt1 == fmt2 {
272        return match fmt1 {
273            ImageFormat::Png => load_pngs(&path1, &path2),
274            ImageFormat::Jpeg => load_jpegs(&path1, &path2),
275            ImageFormat::Qoi => load_qois(&path1, &path2),
276        };
277    }
278
279    // Mixed formats: still load in parallel.
280    let results: Vec<Result<Image, ImageError>> = [
281        (path1.as_ref().to_path_buf(), fmt1),
282        (path2.as_ref().to_path_buf(), fmt2),
283    ]
284    .par_iter()
285    .map(|(path, fmt)| match fmt {
286        ImageFormat::Png => load_png(path),
287        ImageFormat::Jpeg => load_jpeg(path),
288        ImageFormat::Qoi => load_qoi(path),
289    })
290    .collect();
291
292    let mut iter = results.into_iter();
293    Ok((iter.next().unwrap()?, iter.next().unwrap()?))
294}
295
296/// Decode an encoded buffer, picking the decoder from its magic bytes.
297#[cfg(feature = "codecs")]
298pub fn decode_image(data: &[u8]) -> Result<Image, ImageError> {
299    match ImageFormat::from_bytes(data) {
300        Some(ImageFormat::Png) => decode_png(data),
301        Some(ImageFormat::Jpeg) => decode_jpeg(data),
302        Some(ImageFormat::Qoi) => decode_qoi(data),
303        None => Err(ImageError::UnsupportedFormat(
304            "unrecognized image buffer, expected PNG, JPEG or QOI".to_string(),
305        )),
306    }
307}
308
309/// Decode two encoded buffers in parallel.
310#[cfg(feature = "codecs")]
311pub fn decode_image_pair(image1: &[u8], image2: &[u8]) -> Result<(Image, Image), ImageError> {
312    let (result1, result2) = rayon::join(|| decode_image(image1), || decode_image(image2));
313    Ok((result1?, result2?))
314}
315
316/// Write an image, picking the encoder from the file extension.
317///
318/// `compression` is the PNG level (0-9) and `quality` the JPEG quality (1-100);
319/// each is ignored by the formats it does not apply to.
320#[cfg(feature = "codecs")]
321pub fn save_image<P: AsRef<Path>>(
322    image: &Image,
323    path: P,
324    compression: u8,
325    quality: u8,
326) -> Result<(), ImageError> {
327    match ImageFormat::from_path(&path).ok_or_else(|| unsupported_path(&path))? {
328        ImageFormat::Png => save_png_with_compression(image, path, compression),
329        ImageFormat::Jpeg => save_jpeg(image, path, quality),
330        ImageFormat::Qoi => save_qoi(image, path),
331    }
332}
333
334/// Fuzzing-only oracle: exposes the spng reference decoder so the
335/// `blazediff_png` differential tests can check their decode against it.
336#[cfg(feature = "fuzzing")]
337#[doc(hidden)]
338pub fn decode_spng_reference(data: &[u8]) -> Result<Image, ImageError> {
339    png_io::decode_spng(data)
340}
341
342/// Fuzzing-only oracle: decode through spng at an arbitrary `SPNG_FMT_*` and
343/// decode-flags combination, for `blazediff_png`'s format-parity tests.
344/// Returns `(width, height, color_type, bit_depth, bytes)`.
345#[cfg(feature = "fuzzing")]
346#[doc(hidden)]
347pub fn decode_spng_reference_fmt(
348    data: &[u8],
349    fmt: std::os::raw::c_int,
350    flags: std::os::raw::c_int,
351) -> Result<(u32, u32, u8, u8, Vec<u8>), ImageError> {
352    png_io::decode_spng_fmt(data, fmt, flags)
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn detects_format_from_extension() {
361        assert_eq!(ImageFormat::from_path("a.PNG"), Some(ImageFormat::Png));
362        assert_eq!(ImageFormat::from_path("a.jpg"), Some(ImageFormat::Jpeg));
363        assert_eq!(ImageFormat::from_path("a.jpeg"), Some(ImageFormat::Jpeg));
364        assert_eq!(ImageFormat::from_path("a.qoi"), Some(ImageFormat::Qoi));
365        assert_eq!(ImageFormat::from_path("a.webp"), None);
366        assert_eq!(ImageFormat::from_path("noext"), None);
367    }
368
369    #[test]
370    fn detects_format_from_magic_bytes() {
371        assert_eq!(
372            ImageFormat::from_bytes(b"\x89PNG\r\n\x1a\n\x00"),
373            Some(ImageFormat::Png)
374        );
375        assert_eq!(
376            ImageFormat::from_bytes(&[0xff, 0xd8, 0xff, 0xe0]),
377            Some(ImageFormat::Jpeg)
378        );
379        assert_eq!(ImageFormat::from_bytes(b"qoifXXXX"), Some(ImageFormat::Qoi));
380        assert_eq!(ImageFormat::from_bytes(b"RIFF"), None);
381        assert_eq!(ImageFormat::from_bytes(b""), None);
382    }
383
384    #[test]
385    fn error_display_strings_are_stable() {
386        assert_eq!(
387            ImageError::Png("boom".into()).to_string(),
388            "PNG error: boom"
389        );
390        assert_eq!(
391            ImageError::Jpeg("boom".into()).to_string(),
392            "JPEG error: boom"
393        );
394        assert_eq!(
395            ImageError::Qoi("boom".into()).to_string(),
396            "QOI error: boom"
397        );
398        assert_eq!(
399            ImageError::UnsupportedFormat("a.webp".into()).to_string(),
400            "Unsupported format: a.webp"
401        );
402    }
403}