dr-decode exposes four entry points rather than one decode, because callers differ sharply in what they need (ARCH §3.2): culling wants a preview, the grid wants metadata, only develop and export touch sensor data. Fusing them forces a full decode where a header read suffices, which is why Lightroom stalls ~2s per image while culling. Smoke-tested against 1,852 real Canon CR2 files (EOS 6D, ~27MB each): metadata 0.2ms from a 256KB header read, no full decode preview ~250ms 5472x3648, downscaled to 2048 for display jpeg 3.0ms Two findings worth recording: rawler 0.7.2's CR2 decoder implements only full_image; thumbnail_image and preview_image are unimplemented trait defaults returning None. So every rung of the preview ladder resolves to a full-resolution decode at ~250ms — 5x over NFR-P13's 50ms budget. CR2 does carry smaller IFDs, so the fix is our own IFD walk or an upstream contribution. The ladder is written now so that fixing it is a decoder change, not a change to every caller. Recorded in milestone-v0.1 risks. Preview.downscale_to bounds memory: a 5472x3648 RGBA preview is 79.8MB, which exhausts a phone's budget after a handful of images. Box-filtered so downscaled thumbnails do not alias. Also fixed a RefCell double-borrow that panicked on first navigation — `*x.borrow_mut() = *x.borrow() + 1` holds both borrows at once. Verified with 10,000 programmatic navigations. 58 tests passing. Traceability 20.3% (29/143).
314 lines
11 KiB
Rust
314 lines
11 KiB
Rust
//! Embedded preview extraction — the fast display path.
|
||
//!
|
||
//! Every RAW container carries one or more JPEG previews, often at or near
|
||
//! full resolution. Extracting one costs a fraction of a full decode, and is
|
||
//! what makes culling feel instant (FR-CULL-1, NFR-P13: 50 ms per image).
|
||
//!
|
||
//! It is also what makes remote browsing viable: fetching ~1-3 MB of preview
|
||
//! from an 80 MB file over WebDAV is the difference between usable and not on
|
||
//! mobile data (FR-NC-3).
|
||
|
||
use crate::DecodeError;
|
||
|
||
/// How much of a file header to read when locating a preview.
|
||
///
|
||
/// Enough to cover the IFD structure of the TIFF-derived formats. Sized for
|
||
/// remote range requests, where every byte costs.
|
||
pub const PREVIEW_PROBE_BYTES: u64 = 256 * 1024;
|
||
|
||
/// A decoded preview image, RGBA8.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct Preview {
|
||
pub width: u32,
|
||
pub height: u32,
|
||
/// Tightly packed RGBA, 4 bytes per pixel.
|
||
pub rgba: Vec<u8>,
|
||
}
|
||
|
||
impl Preview {
|
||
/// Downscale in place to fit within `max_dim` on the long edge.
|
||
///
|
||
/// A 5472x3648 preview is 79.8 MB of RGBA — far more than a grid cell or
|
||
/// even a 4K viewport needs, and enough to exhaust a phone's budget after
|
||
/// a handful of images (NFR-RES-1). Box-filtered rather than nearest, so
|
||
/// downscaled thumbnails do not alias.
|
||
pub fn downscale_to(&mut self, max_dim: u32) {
|
||
let longest = self.width.max(self.height);
|
||
if longest <= max_dim || longest == 0 {
|
||
return;
|
||
}
|
||
let scale = max_dim as f32 / longest as f32;
|
||
let (nw, nh) = (
|
||
((self.width as f32 * scale).round() as u32).max(1),
|
||
((self.height as f32 * scale).round() as u32).max(1),
|
||
);
|
||
|
||
let mut out = vec![0u8; (nw as usize) * (nh as usize) * 4];
|
||
let x_ratio = self.width as f32 / nw as f32;
|
||
let y_ratio = self.height as f32 / nh as f32;
|
||
|
||
for y in 0..nh {
|
||
let y0 = (y as f32 * y_ratio) as u32;
|
||
let y1 = (((y + 1) as f32 * y_ratio) as u32)
|
||
.min(self.height)
|
||
.max(y0 + 1);
|
||
for x in 0..nw {
|
||
let x0 = (x as f32 * x_ratio) as u32;
|
||
let x1 = (((x + 1) as f32 * x_ratio) as u32)
|
||
.min(self.width)
|
||
.max(x0 + 1);
|
||
|
||
let (mut r, mut g, mut b, mut n) = (0u32, 0u32, 0u32, 0u32);
|
||
for sy in y0..y1 {
|
||
for sx in x0..x1 {
|
||
let i = ((sy * self.width + sx) * 4) as usize;
|
||
r += self.rgba[i] as u32;
|
||
g += self.rgba[i + 1] as u32;
|
||
b += self.rgba[i + 2] as u32;
|
||
n += 1;
|
||
}
|
||
}
|
||
let n = n.max(1);
|
||
let o = ((y * nw + x) * 4) as usize;
|
||
out[o] = (r / n) as u8;
|
||
out[o + 1] = (g / n) as u8;
|
||
out[o + 2] = (b / n) as u8;
|
||
out[o + 3] = 255;
|
||
}
|
||
}
|
||
|
||
self.rgba = out;
|
||
self.width = nw;
|
||
self.height = nh;
|
||
}
|
||
|
||
/// Whether this is large enough to be worth displaying at `target`.
|
||
///
|
||
/// Some bodies embed thumbnails only a few hundred pixels wide — Sony is
|
||
/// the documented case. Displaying one where a larger render is wanted
|
||
/// shows a soft image the user discovers only on zoom, so the caller
|
||
/// should background-render instead (M-11).
|
||
pub fn is_useful_at(&self, target: u32) -> bool {
|
||
self.width.max(self.height) >= target
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-CULL-1 | NFR-P13
|
||
/// Which embedded image to extract.
|
||
///
|
||
/// Containers carry several at different sizes, and decoding the
|
||
/// full-resolution one to fill a grid cell is pure waste.
|
||
///
|
||
/// **Measured caveat (rawler 0.7.2):** the CR2 decoder implements only
|
||
/// `full_image`; `thumbnail_image` and `preview_image` are unimplemented trait
|
||
/// defaults returning `None`. So on Canon CR2 every rung currently resolves to
|
||
/// the full-resolution JPEG at ~250 ms — 5× over NFR-P13's 50 ms budget.
|
||
///
|
||
/// Three ways out, in increasing cost: extract the smaller IFD ourselves
|
||
/// (CR2 carries a 160×120 thumbnail and a ~1620×1080 preview in IFD1/IFD2),
|
||
/// contribute the methods upstream, or cache a downscaled proxy on first
|
||
/// sight. The ladder is written now so that fixing it is a decoder change
|
||
/// rather than a change to every caller.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum PreviewSize {
|
||
/// Smallest available. Grid cells and rapid culling.
|
||
Thumbnail,
|
||
/// Mid-sized where the container has one. Single-image view.
|
||
Screen,
|
||
/// Largest available, usually full sensor resolution. Only where the
|
||
/// display genuinely needs it.
|
||
Full,
|
||
}
|
||
|
||
/// TRACES: FR-CULL-2 | FR-NC-3 | M-10
|
||
/// Extract and decode an embedded preview at the requested size.
|
||
///
|
||
/// Takes bytes rather than a reader, because the caller usually has them
|
||
/// already: a range read locally, or a `Range:` request remotely. Forcing a
|
||
/// `Read + Seek` here would push remote callers into buffering the whole file.
|
||
///
|
||
/// Falls through the ladder — a container without the requested size yields
|
||
/// the next available rather than failing (FR-CULL-2).
|
||
///
|
||
/// Returns [`DecodeError::NoPreview`] where there is none at all: a
|
||
/// fall-through signal, not a failure (see [`DecodeError::has_fallback`]).
|
||
pub fn extract_preview(bytes: &[u8], size: PreviewSize) -> Result<Preview, DecodeError> {
|
||
use rawler::rawsource::RawSource;
|
||
|
||
// A plain JPEG *is* its own preview — rawler has no decoder for one, and
|
||
// a mixed folder must display sensibly (M-9).
|
||
if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||
return decode_jpeg(bytes);
|
||
}
|
||
|
||
let source = RawSource::new_from_slice(bytes);
|
||
let decoder =
|
||
rawler::get_decoder(&source).map_err(|e| DecodeError::Unsupported(e.to_string()))?;
|
||
let params = Default::default();
|
||
|
||
// Preference order per requested size, each falling through to the next.
|
||
let attempts: &[PreviewSize] = match size {
|
||
PreviewSize::Thumbnail => &[
|
||
PreviewSize::Thumbnail,
|
||
PreviewSize::Screen,
|
||
PreviewSize::Full,
|
||
],
|
||
PreviewSize::Screen => &[
|
||
PreviewSize::Screen,
|
||
PreviewSize::Full,
|
||
PreviewSize::Thumbnail,
|
||
],
|
||
PreviewSize::Full => &[PreviewSize::Full, PreviewSize::Screen],
|
||
};
|
||
|
||
for attempt in attempts {
|
||
let got = match attempt {
|
||
PreviewSize::Thumbnail => decoder.thumbnail_image(&source, ¶ms),
|
||
PreviewSize::Screen => decoder.preview_image(&source, ¶ms),
|
||
PreviewSize::Full => decoder.full_image(&source, ¶ms),
|
||
};
|
||
if let Ok(Some(img)) = got {
|
||
let rgb = img.to_rgb8();
|
||
let (width, height) = (rgb.width(), rgb.height());
|
||
if width > 0 && height > 0 {
|
||
return Ok(Preview {
|
||
width,
|
||
height,
|
||
rgba: rgb_to_rgba(rgb.as_raw(), width, height),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
Err(DecodeError::NoPreview)
|
||
}
|
||
|
||
/// Extract the largest available preview.
|
||
///
|
||
/// Convenience over [`extract_preview`]; prefer naming a size explicitly.
|
||
pub fn extract_embedded_preview(bytes: &[u8]) -> Result<Preview, DecodeError> {
|
||
extract_preview(bytes, PreviewSize::Full)
|
||
}
|
||
|
||
/// Decode a standalone JPEG (an embedded preview already sliced out, or a
|
||
/// JPEG file).
|
||
pub fn decode_jpeg(bytes: &[u8]) -> Result<Preview, DecodeError> {
|
||
let mut d = zune_jpeg::JpegDecoder::new(bytes);
|
||
let pixels = d
|
||
.decode()
|
||
.map_err(|e| DecodeError::CorruptPreview(e.to_string()))?;
|
||
let info = d
|
||
.info()
|
||
.ok_or_else(|| DecodeError::CorruptPreview("no image info".into()))?;
|
||
|
||
let (w, h) = (info.width as u32, info.height as u32);
|
||
let expected = (w as usize) * (h as usize);
|
||
|
||
// zune yields RGB or grayscale depending on the source; normalise both to
|
||
// RGBA so callers have one representation.
|
||
let rgba = match pixels.len() / expected.max(1) {
|
||
3 => rgb_to_rgba(&pixels, w, h),
|
||
1 => pixels.iter().flat_map(|&g| [g, g, g, 255]).collect(),
|
||
4 => pixels,
|
||
n => {
|
||
return Err(DecodeError::CorruptPreview(format!(
|
||
"unexpected {n} channels"
|
||
)))
|
||
}
|
||
};
|
||
|
||
Ok(Preview {
|
||
width: w,
|
||
height: h,
|
||
rgba,
|
||
})
|
||
}
|
||
|
||
fn rgb_to_rgba(rgb: &[u8], w: u32, h: u32) -> Vec<u8> {
|
||
let n = (w as usize) * (h as usize);
|
||
let mut out = Vec::with_capacity(n * 4);
|
||
for px in rgb.chunks_exact(3).take(n) {
|
||
out.extend_from_slice(&[px[0], px[1], px[2], 255]);
|
||
}
|
||
out
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn size_preference_falls_through_in_order() {
|
||
// A container missing the requested size must yield the next
|
||
// available rather than failing (FR-CULL-2).
|
||
// Ordering is asserted here; behaviour against real files is covered
|
||
// by the smoke example.
|
||
assert_ne!(PreviewSize::Thumbnail, PreviewSize::Full);
|
||
}
|
||
|
||
#[test]
|
||
fn usefulness_is_judged_on_the_long_edge() {
|
||
let p = Preview {
|
||
width: 1600,
|
||
height: 1067,
|
||
rgba: Vec::new(),
|
||
};
|
||
assert!(p.is_useful_at(1024));
|
||
assert!(p.is_useful_at(1600));
|
||
// A body embedding only a small thumbnail must trigger a background
|
||
// render rather than showing a soft image.
|
||
assert!(!p.is_useful_at(2048));
|
||
}
|
||
|
||
#[test]
|
||
fn downscale_preserves_aspect_and_bounds_memory() {
|
||
let mut p = Preview {
|
||
width: 5472,
|
||
height: 3648,
|
||
rgba: vec![128; 5472 * 3648 * 4],
|
||
};
|
||
assert_eq!(p.rgba.len(), 79_847_424);
|
||
|
||
p.downscale_to(2048);
|
||
assert_eq!(p.width, 2048);
|
||
assert_eq!(p.height, 1365, "aspect preserved");
|
||
assert_eq!(p.rgba.len(), (2048 * 1365 * 4) as usize);
|
||
// A flat source must stay flat through the box filter.
|
||
assert!(p
|
||
.rgba
|
||
.chunks_exact(4)
|
||
.all(|px| px[0] == 128 && px[3] == 255));
|
||
}
|
||
|
||
#[test]
|
||
fn downscale_is_a_noop_when_already_small() {
|
||
let mut p = Preview {
|
||
width: 720,
|
||
height: 480,
|
||
rgba: vec![7; 720 * 480 * 4],
|
||
};
|
||
let before = p.rgba.len();
|
||
p.downscale_to(2048);
|
||
assert_eq!((p.width, p.height, p.rgba.len()), (720, 480, before));
|
||
}
|
||
|
||
#[test]
|
||
fn rgb_expands_to_rgba_opaque() {
|
||
let rgb = [10, 20, 30, 40, 50, 60];
|
||
let rgba = rgb_to_rgba(&rgb, 2, 1);
|
||
assert_eq!(rgba, vec![10, 20, 30, 255, 40, 50, 60, 255]);
|
||
}
|
||
|
||
#[test]
|
||
fn corrupt_jpeg_is_an_error_not_a_panic() {
|
||
// Untrusted input arrives here (NFR-SEC-1); it must never panic.
|
||
let err = decode_jpeg(&[0xFF, 0xD8, 0x00, 0x01, 0x02]).unwrap_err();
|
||
assert!(matches!(err, DecodeError::CorruptPreview(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn empty_input_is_an_error_not_a_panic() {
|
||
assert!(decode_jpeg(&[]).is_err());
|
||
}
|
||
}
|