image-codecs: read a JPEG's frame from its markers (jpeg_info)

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) 🌑 <nyx@despotak.is>
This commit is contained in:
ChristosandΝύξ 🌑 committed 2026-11-14 12:05:40 +02:00
1 parent f0f4758c01
commit 7ff39bfda8
2 files changed
+44

No files matched your search

+27
View File
@@ -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<crate::JpegInfo> {
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<u8> {
let mut seg = EXTENDED_XMP_HEADER.to_vec();
+17
View File
@@ -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<JpegInfo> {
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<u8>)> {