mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
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
57 lines
2.8 KiB
Rust
57 lines
2.8 KiB
Rust
//! WebP decode via `image-webp` (lossy + lossless, alpha, ICCP/EXIF/XMP chunks; first frame of
|
|
//! animations).
|
|
|
|
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
|
|
use crate::{DecodeOptions, Decoded, Error, Format, Result};
|
|
|
|
const F: Format = Format::WebP;
|
|
|
|
fn err(e: impl std::fmt::Display) -> Error {
|
|
Error::Malformed(F, e.to_string())
|
|
}
|
|
|
|
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
|
let mut d = image_webp::WebPDecoder::new(std::io::Cursor::new(bytes)).map_err(err)?;
|
|
// RIFF chunk lengths are untrusted. Metadata cannot exceed the input;
|
|
// otherwise a tiny truncated file can request gigabytes before read_exact fails.
|
|
d.set_memory_limit(bytes.len());
|
|
let (w, h) = d.dimensions();
|
|
check_size(F, w as u64, h as u64, opts)?;
|
|
let alpha = d.has_alpha();
|
|
let size = d.output_buffer_size().ok_or_else(|| err("buffer size overflow"))?;
|
|
let mut buf = vec![0u8; size];
|
|
d.read_image(&mut buf).map_err(err)?;
|
|
let icc = d.icc_profile().ok().flatten();
|
|
let exif = d.exif_metadata().ok().flatten().map(|e| if e.starts_with(b"Exif\0\0") { e[6..].to_vec() } else { e });
|
|
let xmp = d.xmp_metadata().ok().flatten().map(|x| String::from_utf8_lossy(&x).trim_end_matches('\0').to_string());
|
|
let raw = Raw { width: w as usize, height: h as usize, model: Model::Rgb, alpha, premultiplied: false, buf: Buf::U8(buf), bit_depth: 8 };
|
|
finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (w, h), opts)
|
|
}
|
|
|
|
/// Stored dimensions and EXIF orientation from the RIFF chunks and the bitstream header, without
|
|
/// decoding the image. Refused, as a decode would refuse it: no image chunk (`VP8 `, `VP8L` or an
|
|
/// animation frame), or one that runs past the end of the file (truncated). The compressed data
|
|
/// itself is not checked.
|
|
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
|
|
let mut p = 12usize;
|
|
loop {
|
|
let Some(h) = bytes.get(p..p.saturating_add(8)) else { return Err(err("no image data")) };
|
|
let size = u32::from_le_bytes([h[4], h[5], h[6], h[7]]) as usize;
|
|
let end = p.saturating_add(8).saturating_add(size);
|
|
if matches!(&h[0..4], b"VP8 " | b"VP8L" | b"ANMF") {
|
|
if end > bytes.len() {
|
|
return Err(err("truncated image data"));
|
|
}
|
|
break;
|
|
}
|
|
p = end.saturating_add(size & 1);
|
|
}
|
|
let mut d = image_webp::WebPDecoder::new(std::io::Cursor::new(bytes)).map_err(err)?;
|
|
d.set_memory_limit(bytes.len());
|
|
let (w, h) = d.dimensions();
|
|
check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
|
|
let exif = d.exif_metadata().ok().flatten();
|
|
let exif = exif.as_deref().map(|e| e.strip_prefix(b"Exif\0\0").unwrap_or(e));
|
|
Ok((w, h, exif.map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1)))
|
|
}
|