From 7ff39bfda85fae5eca6eb6b6e8a0990611a26008 Mon Sep 17 00:00:00 2001 From: Christos Date: Mon, 9 Nov 2026 14:03:25 +0200 Subject: [PATCH] image-codecs: read a JPEG's frame from its markers (jpeg_info) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For storytold/photocraft#12473: PhotoCraft lifts its 2^30 px JPEG cap only after admitting the decode against free memory, and for that it needs the frame before decoding. jpeg_info reads it from the markers alone: the stored size, the layout decode returns, and whole_coefficients, set when decode hands the file to craft-jpeg (arithmetic-coded, lossless or 12-bit frames), whose working set is several times the decoded image. None without a frame of 1, 3 or 4 components. This was part of storytold/photocraft#12604 until storytold/photocraft#14236 moved the codecs here. Co-Authored-By: Νύξ (Nyx, AI) 🌑 --- crates/image-codecs/src/codecs/jpeg.rs | 27 ++++++++++++++++++++++++++ crates/image-codecs/src/lib.rs | 17 ++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/crates/image-codecs/src/codecs/jpeg.rs b/crates/image-codecs/src/codecs/jpeg.rs index 8e68451ef..2612a7f22 100644 --- a/crates/image-codecs/src/codecs/jpeg.rs +++ b/crates/image-codecs/src/codecs/jpeg.rs @@ -42,6 +42,18 @@ pub(crate) struct JpegMeta { pub frame: Option<(u32, u32, u8)>, } +/// The first frame of `b` as [`crate::JpegInfo`], from its markers alone (see [`crate::jpeg_info`]). +pub(crate) fn info(b: &[u8]) -> Option { + let (width, height, components) = scan_metadata(b).frame?; + let layout = match components { + 1 => ChannelLayout::Gray, + 3 => ChannelLayout::Rgb, + 4 => ChannelLayout::Cmyk, + _ => return None, + }; + Some(crate::JpegInfo { width, height, layout, whole_coefficients: uses_craft_jpeg(b) }) +} + /// Walk the marker segments up to SOS and collect metadata. pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta { let mut m = JpegMeta::default(); @@ -573,6 +585,21 @@ mod tests { bytes.get(2..11) == Some(b"\xff\xe0\0\x10JFIF\0") } + /// `info` reads the frame from the markers, and which decoder the file goes to. + #[test] + fn info_reads_the_frame_and_the_decoder() { + let img = Image::from_raw(37, 21, ChannelLayout::Rgb, SampleType::U8, vec![128; 37 * 21 * 3]).unwrap(); + let rgb = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); + assert_eq!(info(&rgb), Some(crate::JpegInfo { width: 37, height: 21, layout: ChannelLayout::Rgb, whole_coefficients: false })); + // A 12-bit extended frame (SOF1) declaring 40,000 × 30,000 gray, markers only. + let mut twelve = vec![0xFF, 0xD8, 0xFF, 0xC1, 0, 11, 12]; + twelve.extend_from_slice(&30_000u16.to_be_bytes()); + twelve.extend_from_slice(&40_000u16.to_be_bytes()); + twelve.extend_from_slice(&[1, 1, 0x11, 0, 0xFF, 0xD9]); + assert_eq!(info(&twelve), Some(crate::JpegInfo { width: 40_000, height: 30_000, layout: ChannelLayout::Gray, whole_coefficients: true })); + assert_eq!(info(b"not a jpeg"), None); + } + /// An APP1 Extended XMP fragment (GUID, total size, offset, data). fn extended_xmp(offset: u32) -> Vec { let mut seg = EXTENDED_XMP_HEADER.to_vec(); diff --git a/crates/image-codecs/src/lib.rs b/crates/image-codecs/src/lib.rs index d3ab272fa..3045cd090 100644 --- a/crates/image-codecs/src/lib.rs +++ b/crates/image-codecs/src/lib.rs @@ -212,6 +212,23 @@ pub fn probe_size(bytes: &[u8]) -> Result<(u32, u32), CodecError> { Ok(if (5..=8).contains(&o) { (h, w) } else { (w, h) }) } +/// A JPEG's frame as its header declares it, read without decoding pixels: the stored size, the +/// layout [`decode`] returns, and whether [`decode`] hands the file to its whole-coefficient +/// decoder (arithmetic-coded, lossless or 12-bit frames), whose working set is several times the +/// decoded image. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct JpegInfo { + pub width: u32, + pub height: u32, + pub layout: ChannelLayout, + pub whole_coefficients: bool, +} + +/// [`JpegInfo`] of `bytes`; `None` without a readable frame of 1, 3 or 4 components. +pub fn jpeg_info(bytes: &[u8]) -> Option { + jpeg::info(bytes) +} + /// The colour table of a GIF's first frame (its Local Color Table, else the Global one) and the /// entry its Graphic Control Extension makes transparent, read without decoding pixels. pub fn gif_palette(bytes: &[u8]) -> Option<(Vec<[u8; 3]>, Option)> {