Files
lightcraft/crates/codecs/src/lib.rs
Claude f6f5e6f1b7 Part of #367: probes read image headers instead of decoding the pixels
The import probe of a non-raw image decoded it (at a 64 px fit, which for
JPEG still entropy-decodes every block and for PNG inflates everything)
only to learn its stored size and orientation: ~95 % of import CPU.

lightcraft-codecs gets `read_header(bytes) -> Header { format, width,
height, orientation }`: JPEG from its markers (EXIF orientation), PNG from
its chunks (IHDR, eXIf anywhere), TIFF from the first IFD (tiff crate,
no strips read), WebP from its RIFF chunks / bitstream header (image-webp),
PSD from its header and image resources. GIF, BMP and JPEG XL keep the
small decode. `files::probe_bytes` uses it; raw handling is unchanged.
The TIFF colour-layout check is shared with the decoder (`layout`).

Measured (release, 4 cores, throwaway benchmark, per file, bytes in
memory): 200 generated 4000x3000 JPEGs (3.6 MB): 72.7 ms -> 2.1 ms
(read_header alone 1.05 ms; the rest is the content hash and metadata);
20 PNGs (30.8 MB): 236-265 ms -> 9.0-9.6 ms.

Corrupt files (behaviour note): the header readers also refuse what a
decode refuses before the pixels (unsupported layout, compression, frame
type, bad IHDR, ...) and truncated files: a JPEG whose markers don't reach
its EOI, a PNG cut before the chunk after its IDAT run, TIFF strips/tiles
that start past the end, a WebP image chunk or PSD merged data past the
end. So truncated files (interrupted copies, partial downloads) are still
marked `error` in the import review, as before; tests check that whenever
the old decode accepted a (cut or byte-mutated) file the header agrees.
Changed: damage *inside* compressed data that is all present (flipped or
garbled bytes) was refused at scan and is now imported, and shows as
unreadable ("!" in the grid) when rendered. Also: tiny JPEGs (< 128 px)
were decoded leniently by the old probe, so a truncated one imported; it
is now refused. A TIFF truncated inside its last strip is not detected
(StripByteCounts are often overstated, so only strip starts are checked).

Tests: header readers vs decode on generated JPEG/PNG/TIFF/WebP/PSD/BMP
with and without EXIF/TIFF orientation; truncations and random mutations
agree with decode; hostile headers (zero/absurd sizes, bad lengths,
truncation, garbage) are errors; read_header added to the fuzz tests;
probe_bytes oriented dims match the decode-based values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 22:09:30 +00:00

316 lines
13 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Standard image codecs for LightCraft (layer L1).
//!
//! - [`sniff`] detects the container format (and flags camera raws for `lightcraft-raw`).
//! - [`decode`] decodes JPEG, PNG, TIFF, WebP, GIF, BMP, PSD (merged composite) and JPEG XL into
//! **linear-light** [`Rgb32f`] in the source's own primaries, described by [`SourceSpace`]; ICC
//! profiles (matrix/TRC exactly, LUT/CMYK via the `moxcms` CMS), 16-bit and float precision are
//! preserved. EXIF/XMP/ICC blobs are passed through untouched; orientation is reported, never applied.
//! - [`to_working`] converts a decode result to linear Rec.2020 D65 (the pipeline working space).
//! - [`read_header`] reads a file's stored dimensions and orientation from its headers, without
//! decoding pixels (import probes).
//! - [`decode_thumbnail`] is the fast path for grid thumbnails (EXIF thumbnail or DCT-scaled decode).
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and (native, feature `avif`) AVIF, embedding
//! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export.
//!
//! No decoder panics on malformed input (property-tested).
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod convert;
pub mod encode;
pub mod exif;
pub mod icc;
mod jpeg;
pub mod jpeg_par;
#[cfg(feature = "jxl")]
mod jxl;
mod other;
mod png_codec;
mod psd;
mod sniff;
pub mod space;
mod tiff_codec;
mod webp;
pub use encode::{
ChromaSubsampling, EncodeImage, EncodeMeta, Samples, TiffCompression, encode_avif, encode_jpeg, encode_png, encode_tiff, encode_webp_lossless,
};
pub use sniff::{Format, sniff};
pub use space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
use lightcraft_raster::{Plane, Rgb32f, Rgba8};
use serde::{Deserialize, Serialize};
/// Codec errors.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("unrecognised image format")]
UnknownFormat,
#[error("{0:?} decoding is not supported: {1}")]
Unsupported(Format, &'static str),
#[error("malformed {0:?} data: {1}")]
Malformed(Format, String),
#[error("image too large: {0}×{1}")]
TooLarge(u64, u64),
#[error("encode error: {0}")]
Encode(String),
}
pub type Result<T> = std::result::Result<T, Error>;
/// Decode options.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct DecodeOptions {
/// Fit the result within this box (never upscales). JPEG uses DCT-domain scaling (1/2, 1/4,
/// 1/8) before a final linear-light resample; other formats decode fully then resample.
pub max_size: Option<(u32, u32)>,
/// Refuse images with more pixels than this (default 1 Gpx).
pub max_pixels: u64,
}
impl Default for DecodeOptions {
fn default() -> Self {
DecodeOptions { max_size: None, max_pixels: 1 << 30 }
}
}
impl DecodeOptions {
pub fn fit(w: u32, h: u32) -> Self {
DecodeOptions { max_size: Some((w, h)), ..Default::default() }
}
}
/// A decoded image.
#[derive(Clone, Debug)]
pub struct Decoded {
pub format: Format,
/// Linear light, in the primaries described by [`Decoded::space`].
pub image: Rgb32f,
/// Straight (unassociated) alpha 0..1, when the source has transparency.
pub alpha: Option<Plane>,
pub space: SourceSpace,
/// Bits per sample in the file (8, 16, 32, …).
pub bit_depth: u8,
/// Sample format in the file was floating point.
pub float: bool,
pub has_alpha: bool,
/// The source is grayscale (expanded to RGB in `image`).
pub grayscale: bool,
/// The source was CMYK (converted).
pub cmyk: bool,
/// EXIF orientation value 1..=8 (1 when absent). **Not applied** to `image`.
pub orientation: u16,
/// TIFF-structured EXIF bytes (starting at the `II`/`MM` byte-order mark), if embedded.
/// For TIFF files the file itself is the TIFF structure and this is `None`.
pub exif: Option<Vec<u8>>,
pub xmp: Option<String>,
pub icc: Option<Vec<u8>>,
/// Dimensions of `image`.
pub width: u32,
pub height: u32,
/// Full-resolution dimensions of the source (differs from `width`/`height` when scaled).
pub source_width: u32,
pub source_height: u32,
}
impl Decoded {
/// Pixels converted to linear Rec.2020 D65.
pub fn to_working(&self) -> Rgb32f {
to_working(self)
}
/// Display-encoded 8-bit sRGB (gamut clipped), with alpha.
pub fn to_srgb8(&self) -> Rgba8 {
let m = self.space.to_space(&lightcraft_color::SRGB);
let ident = m == lightcraft_color::Mat3::IDENTITY;
let m = m.to_f32();
let enc = lightcraft_color::transfer::encode_srgb8;
let mut out = Rgba8::new(self.image.width, self.image.height);
let alpha = self.alpha.as_ref();
lightcraft_raster::par_rows(&mut out.data, self.image.width, |y, row| {
let src = self.image.row(y);
let a = alpha.map(|a| a.row(y));
for (x, (o, p)) in row.iter_mut().zip(src).enumerate() {
let q = if ident { *p } else { mat_apply(&m, *p) };
let al = a.map(|a| (a[x].clamp(0.0, 1.0) * 255.0 + 0.5) as u8).unwrap_or(255);
*o = [enc(q[0]), enc(q[1]), enc(q[2]), al];
}
});
out
}
}
#[inline]
pub(crate) fn mat_apply(m: &[[f32; 3]; 3], p: [f32; 3]) -> [f32; 3] {
[
m[0][0] * p[0] + m[0][1] * p[1] + m[0][2] * p[2],
m[1][0] * p[0] + m[1][1] * p[1] + m[1][2] * p[2],
m[2][0] * p[0] + m[2][1] * p[1] + m[2][2] * p[2],
]
}
/// Decode any supported format (see [`Format::can_decode`]).
///
/// Malformed input yields an error, never a panic: on unwinding targets a panic inside a third-party
/// decoder is caught and reported as [`Error::Malformed`].
pub fn decode(bytes: &[u8], opts: DecodeOptions) -> Result<Decoded> {
let format = sniff(bytes).ok_or(Error::UnknownFormat)?;
std::panic::catch_unwind(|| decode_unguarded(bytes, format, &opts)).unwrap_or_else(|_| Err(Error::Malformed(format, "decoder panicked".into())))
}
/// What a file's headers say about its image: enough to catalogue it without decoding pixels.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Header {
pub format: Format,
/// Full-resolution stored dimensions, orientation **not** applied ([`Decoded::source_width`]).
pub width: u32,
pub height: u32,
/// EXIF/TIFF orientation 1..=8, 1 when absent ([`Decoded::orientation`]).
pub orientation: u16,
}
/// Read the stored dimensions and orientation of any format [`decode`] supports, agreeing with
/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP and PSD are read from
/// their headers (markers, chunks, IFD, resources). GIF, BMP and JPEG XL fall back to a small
/// decode.
///
/// The header readers also refuse what a decode would refuse before reaching the pixels (an
/// unsupported layout, compression or frame type) and files whose image data is cut short
/// (truncated: the data runs past the end of the file, or a JPEG's markers don't reach its
/// end-of-image marker). Damage *inside* compressed data that is all present is only found by a
/// decode. Malformed input yields an error, never a panic (as for [`decode`], a panic inside a
/// third-party parser is caught on unwinding targets).
pub fn read_header(bytes: &[u8]) -> Result<Header> {
let format = sniff(bytes).ok_or(Error::UnknownFormat)?;
std::panic::catch_unwind(|| read_header_unguarded(bytes, format))
.unwrap_or_else(|_| Err(Error::Malformed(format, "header reader panicked".into())))
}
/// [`read_header`] without the panic guard (for fuzzing our own code paths).
#[doc(hidden)]
pub fn read_header_unguarded(bytes: &[u8], format: Format) -> Result<Header> {
let (width, height, orientation) = match format {
Format::Jpeg => jpeg::header(bytes)?,
Format::Png => png_codec::header(bytes)?,
Format::Tiff => tiff_codec::header(bytes)?,
Format::WebP => webp::header(bytes)?,
Format::Psd => psd::header(bytes)?,
// rare here (or, for JPEG XL, without a header reader yet): a small decode, as before
_ => {
let d = decode_unguarded(bytes, format, &DecodeOptions::fit(64, 64))?;
(d.source_width, d.source_height, d.orientation)
}
};
Ok(Header { format, width, height, orientation })
}
/// Decode a JPEG preview with its enclosing container's colour space as a fallback.
/// A JPEG carrying ICC or EXIF metadata keeps its own interpretation. The fallback is applied
/// before transfer decoding and linear-light resizing, never to already-resampled pixels.
pub fn decode_jpeg_with_fallback(bytes: &[u8], opts: DecodeOptions, fallback: NamedSpace) -> Result<Decoded> {
std::panic::catch_unwind(|| jpeg::decode_with_fallback(bytes, &opts, Some(fallback)))
.unwrap_or_else(|_| Err(Error::Malformed(Format::Jpeg, "decoder panicked".into())))
}
/// [`decode`] without the panic guard (for fuzzing our own code paths).
#[doc(hidden)]
pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result<Decoded> {
let opts = *opts;
match format {
Format::Jpeg => jpeg::decode(bytes, &opts),
Format::Png => png_codec::decode(bytes, &opts),
Format::Tiff => tiff_codec::decode(bytes, &opts),
Format::WebP => webp::decode(bytes, &opts),
Format::Gif | Format::Bmp => other::decode(bytes, format, &opts),
Format::Psd => psd::decode(bytes, &opts),
#[cfg(feature = "jxl")]
Format::Jxl => jxl::decode(bytes, &opts),
#[cfg(not(feature = "jxl"))]
Format::Jxl => Err(Error::Unsupported(format, "built without the `jxl` feature")),
Format::Avif => Err(Error::Unsupported(format, "no pure-Rust, permissively licensed AV1 decoder yet")),
Format::Heif => Err(Error::Unsupported(format, "no pure-Rust, permissively licensed HEVC decoder yet")),
Format::RawTiffLike | Format::RawOther => Err(Error::Unsupported(format, "camera raw: decode with lightcraft-raw")),
}
}
/// Convert a decode result to linear Rec.2020 D65 (Bradford-adapted), the develop working space.
pub fn to_working(d: &Decoded) -> Rgb32f {
if d.space.is_working() {
return d.image.clone();
}
let m = d.space.to_working().to_f32();
d.image.map(|p| mat_apply(&m, p))
}
/// Where a thumbnail came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ThumbnailSource {
/// The JPEG thumbnail embedded in EXIF (IFD1).
ExifThumbnail,
/// An MPF (CIPA DC-007) preview image embedded in a JPEG.
MpfPreview,
/// A (DCT-)scaled decode of the main image.
Scaled,
}
/// A small display-ready image.
#[derive(Clone, Debug)]
pub struct Thumbnail {
/// 8-bit sRGB with alpha, fitted within `max_edge` (orientation **not** applied).
pub image: Rgba8,
pub orientation: u16,
pub source: ThumbnailSource,
pub source_width: u32,
pub source_height: u32,
}
/// Thumbnail request.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThumbnailOptions {
/// Fit the result within `max_edge × max_edge`.
pub max_edge: u32,
/// Accept an embedded preview whose long edge is at least this (it is then returned at its own,
/// smaller size). Defaults to `max_edge`, i.e. only previews that need no upscaling. Lower it
/// (e.g. 160 for typical EXIF thumbnails) for an instant placeholder.
pub min_embedded_edge: u32,
/// Maximum source pixels for fallback decoding (default 64 MP). Non-JPEG formats
/// decode at source resolution before resizing, so output size does not bound memory.
#[serde(default = "thumbnail_max_pixels")]
pub max_pixels: u64,
}
fn thumbnail_max_pixels() -> u64 {
64_000_000
}
impl ThumbnailOptions {
pub fn new(max_edge: u32) -> Self {
ThumbnailOptions { max_edge, min_embedded_edge: max_edge, max_pixels: thumbnail_max_pixels() }
}
}
/// Fast thumbnail: an embedded JPEG preview (EXIF IFD1 thumbnail or MPF preview) at least `max_edge`
/// on its long side if one exists, else a scaled decode. The result fits within `max_edge × max_edge`.
pub fn decode_thumbnail(bytes: &[u8], max_edge: u32) -> Result<Thumbnail> {
decode_thumbnail_with(bytes, &ThumbnailOptions::new(max_edge))
}
/// [`decode_thumbnail`] with options.
pub fn decode_thumbnail_with(bytes: &[u8], opts: &ThumbnailOptions) -> Result<Thumbnail> {
let max_edge = opts.max_edge.max(1);
let min_edge = opts.min_embedded_edge.clamp(1, max_edge);
if sniff(bytes) == Some(Format::Jpeg)
&& let Ok(Some(t)) = std::panic::catch_unwind(|| jpeg::embedded_thumbnail(bytes, max_edge, min_edge))
{
return Ok(t);
}
let d = decode(bytes, DecodeOptions { max_size: Some((max_edge, max_edge)), max_pixels: opts.max_pixels })?;
Ok(Thumbnail {
image: d.to_srgb8(),
orientation: d.orientation,
source: ThumbnailSource::Scaled,
source_width: d.source_width,
source_height: d.source_height,
})
}