Add RAW decode and a working image viewer
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).
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "dr-decode"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dr-types.workspace = true
|
||||
rawler.workspace = true
|
||||
zune-jpeg.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
env_logger.workspace = true
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Smoke test against real RAW files.
|
||||
//!
|
||||
//! cargo run -p dr-decode --example smoke -- <file-or-dir>...
|
||||
//!
|
||||
//! Reports, per file, what each entry point costs — which is the whole reason
|
||||
//! they are separate (ARCH §3.2).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.is_empty() {
|
||||
eprintln!("usage: smoke <file-or-dir>...");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
for a in &args {
|
||||
let p = PathBuf::from(a);
|
||||
if p.is_dir() {
|
||||
collect(&p, &mut files);
|
||||
} else {
|
||||
files.push(p);
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
files.truncate(8);
|
||||
|
||||
println!(
|
||||
"{:<20} {:>7} {:>8} {:>9} {:>13} {:>9} {:>13}",
|
||||
"file", "size", "meta", "thumb", "thumb dims", "full", "full dims"
|
||||
);
|
||||
println!("{}", "-".repeat(88));
|
||||
|
||||
let (mut ok, mut failed) = (0, 0);
|
||||
for f in &files {
|
||||
match run_one(f) {
|
||||
Ok(line) => {
|
||||
println!("{line}");
|
||||
ok += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{:<22} {e}", truncate(&name(f), 22));
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n{ok} ok, {failed} failed");
|
||||
if failed > 0 {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_one(path: &Path) -> Result<String, String> {
|
||||
let size = std::fs::metadata(path).map_err(|e| e.to_string())?.len();
|
||||
|
||||
// The culling path: read only the header region, not the whole file.
|
||||
let probe_bytes =
|
||||
read_prefix(path, dr_decode::PREVIEW_PROBE_BYTES).map_err(|e| e.to_string())?;
|
||||
let t0 = Instant::now();
|
||||
let fmt = dr_decode::probe(&probe_bytes);
|
||||
let meta = dr_decode::metadata(&probe_bytes).ok();
|
||||
let meta_ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
let all = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
|
||||
// The culling rung.
|
||||
let t1 = Instant::now();
|
||||
let thumb = dr_decode::extract_preview(&all, dr_decode::PreviewSize::Thumbnail)
|
||||
.map_err(|e| format!("thumb: {e}"))?;
|
||||
let thumb_ms = t1.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
// The full-resolution rung, for comparison.
|
||||
let t2 = Instant::now();
|
||||
let full = dr_decode::extract_preview(&all, dr_decode::PreviewSize::Full)
|
||||
.map_err(|e| format!("full: {e}"))?;
|
||||
let full_ms = t2.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
let model = meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.model.clone())
|
||||
.unwrap_or_else(|| "?".into());
|
||||
let budget = if thumb_ms <= 50.0 {
|
||||
""
|
||||
} else {
|
||||
" OVER BUDGET"
|
||||
};
|
||||
|
||||
Ok(format!(
|
||||
"{:<20} {:>6.1}M {:>6.1}ms {:>7.1}ms {:>7}x{:<5} {:>7.1}ms {:>7}x{:<5} {:?} {}{}",
|
||||
truncate(&name(path), 20),
|
||||
size as f64 / 1e6,
|
||||
meta_ms,
|
||||
thumb_ms,
|
||||
thumb.width,
|
||||
thumb.height,
|
||||
full_ms,
|
||||
full.width,
|
||||
full.height,
|
||||
fmt,
|
||||
model.trim(),
|
||||
budget,
|
||||
))
|
||||
}
|
||||
|
||||
fn read_prefix(path: &Path, n: u64) -> std::io::Result<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
let mut f = std::fs::File::open(path)?;
|
||||
let mut buf = vec![0u8; n as usize];
|
||||
let read = f.read(&mut buf)?;
|
||||
buf.truncate(read);
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_file() {
|
||||
let ext = p
|
||||
.extension()
|
||||
.map(|s| s.to_string_lossy().to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
if dr_types::Format::from_extension(&ext).is_some() {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn name(p: &Path) -> String {
|
||||
p.file_name().unwrap_or_default().to_string_lossy().into()
|
||||
}
|
||||
|
||||
fn truncate(s: &str, n: usize) -> String {
|
||||
if s.len() <= n {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}…", &s[..n - 1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/// TRACES: FR-RAW-4 | NFR-SEC-1
|
||||
/// Failures from decoding.
|
||||
///
|
||||
/// Per FR-RAW-4 a malformed file must not abort a batch, so these are always
|
||||
/// returned rather than panicking — and the decode path is the one place
|
||||
/// untrusted input arrives (NFR-SEC-1).
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DecodeError {
|
||||
#[error("read failed: {0}")]
|
||||
Read(String),
|
||||
|
||||
#[error("unsupported or unrecognised format: {0}")]
|
||||
Unsupported(String),
|
||||
|
||||
#[error("decode failed: {0}")]
|
||||
Decode(String),
|
||||
|
||||
#[error("metadata unavailable: {0}")]
|
||||
Metadata(String),
|
||||
|
||||
#[error("no embedded preview in this file")]
|
||||
NoPreview,
|
||||
|
||||
#[error("embedded preview is corrupt: {0}")]
|
||||
CorruptPreview(String),
|
||||
}
|
||||
|
||||
impl DecodeError {
|
||||
/// Whether a fallback path might still produce an image.
|
||||
///
|
||||
/// A missing preview is not a failure to display the file — it means fall
|
||||
/// through to full decode (FR-CULL-2, M-11).
|
||||
pub fn has_fallback(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
DecodeError::NoPreview | DecodeError::CorruptPreview(_)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn preview_failures_fall_through_rather_than_failing() {
|
||||
assert!(DecodeError::NoPreview.has_fallback());
|
||||
assert!(DecodeError::CorruptPreview("truncated".into()).has_fallback());
|
||||
// A genuinely unsupported file has nowhere to fall through to.
|
||||
assert!(!DecodeError::Unsupported("unknown".into()).has_fallback());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! RAW decoding for DarkRoom.
|
||||
//!
|
||||
//! Four separate entry points rather than one `decode`, because callers differ
|
||||
//! sharply in what they need (ARCH §3.2):
|
||||
//!
|
||||
//! - **Culling** wants [`embedded_preview`] and nothing else — a ~200 KB read
|
||||
//! against a 34 MB file.
|
||||
//! - **The grid** wants [`metadata`].
|
||||
//! - **Develop and export** need [`decode`], the only path that touches sensor
|
||||
//! data.
|
||||
//!
|
||||
//! Fusing them would force a full decode where a header read suffices, which
|
||||
//! is exactly why Lightroom stalls ~2 s per image during culling.
|
||||
|
||||
mod error;
|
||||
mod preview;
|
||||
|
||||
pub use error::DecodeError;
|
||||
pub use preview::{
|
||||
decode_jpeg, extract_embedded_preview, extract_preview, Preview, PreviewSize,
|
||||
PREVIEW_PROBE_BYTES,
|
||||
};
|
||||
|
||||
use dr_types::Format;
|
||||
|
||||
/// Capture metadata read from a file header.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct Metadata {
|
||||
pub make: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub lens: Option<String>,
|
||||
/// Exposure time in seconds.
|
||||
pub shutter: Option<f32>,
|
||||
pub aperture: Option<f32>,
|
||||
pub iso: Option<u32>,
|
||||
pub focal_length: Option<f32>,
|
||||
/// Full sensor dimensions, before crop.
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
}
|
||||
|
||||
/// Decoded sensor data, before demosaic.
|
||||
///
|
||||
/// Deliberately *not* RGB: demosaic is a GPU pipeline stage (ARCH §5.2), so
|
||||
/// this carries CFA-pattern samples plus what the shader needs to interpret
|
||||
/// them.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RawImage {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// One sample per photosite, in sensor order.
|
||||
pub data: Vec<u16>,
|
||||
pub cfa_pattern: CfaPattern,
|
||||
pub black_level: [u16; 4],
|
||||
pub white_level: u16,
|
||||
/// As-shot white balance, as per-channel multipliers.
|
||||
pub wb_coeffs: [f32; 4],
|
||||
/// Camera-to-XYZ colour matrix (FR-DEV-3e).
|
||||
pub color_matrix: Option<[f32; 9]>,
|
||||
}
|
||||
|
||||
/// TRACES: FR-RAW-5
|
||||
/// The colour filter array layout.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CfaPattern {
|
||||
Rggb,
|
||||
Bggr,
|
||||
Grbg,
|
||||
Gbrg,
|
||||
/// Fujifilm's 6×6 pattern. Needs a different demosaic entirely
|
||||
/// (FR-RAW-5), at roughly 2× the cost of Bayer.
|
||||
XTrans,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl CfaPattern {
|
||||
/// Whether this needs the X-Trans demosaic path rather than Bayer.
|
||||
pub fn is_xtrans(self) -> bool {
|
||||
matches!(self, CfaPattern::XTrans)
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-RAW-1 | M-9
|
||||
/// Identify a format from a file header.
|
||||
///
|
||||
/// Content-based, not extension-based: an extension is a hint, and a
|
||||
/// mismatched one should not produce a confusing decode failure downstream.
|
||||
pub fn probe(header: &[u8]) -> Option<Format> {
|
||||
if header.len() < 16 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// JPEG: SOI marker.
|
||||
if header.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||
return Some(Format::Jpeg);
|
||||
}
|
||||
|
||||
// Fujifilm RAF carries an ASCII signature.
|
||||
if header.starts_with(b"FUJIFILMCCD-RAW") {
|
||||
return Some(Format::Raf);
|
||||
}
|
||||
|
||||
// CR3 is ISO-BMFF: a `ftyp` box with a Canon brand.
|
||||
if header.len() >= 12 && &header[4..8] == b"ftyp" && &header[8..11] == b"crx" {
|
||||
return Some(Format::Cr3);
|
||||
}
|
||||
|
||||
// The TIFF-derived formats share a byte-order mark plus magic. CR2 adds
|
||||
// its own marker at offset 8; the rest are indistinguishable from the
|
||||
// header alone and need the extension to disambiguate.
|
||||
let le = header.starts_with(&[0x49, 0x49, 0x2A, 0x00]);
|
||||
let be = header.starts_with(&[0x4D, 0x4D, 0x00, 0x2A]);
|
||||
if le || be {
|
||||
if header.len() >= 11 && &header[8..10] == b"CR" {
|
||||
return Some(Format::Cr2);
|
||||
}
|
||||
// Ambiguous between NEF, ARW, DNG, ORF, RW2 — caller falls back to
|
||||
// the extension.
|
||||
return None;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5 | M-12
|
||||
/// Read capture metadata without decoding sensor data.
|
||||
pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
|
||||
use rawler::rawsource::RawSource;
|
||||
|
||||
let source = RawSource::new_from_slice(bytes);
|
||||
let decoder =
|
||||
rawler::get_decoder(&source).map_err(|e| DecodeError::Unsupported(e.to_string()))?;
|
||||
let md = decoder
|
||||
.raw_metadata(&source, &Default::default())
|
||||
.map_err(|e| DecodeError::Metadata(e.to_string()))?;
|
||||
|
||||
let exif = &md.exif;
|
||||
Ok(Metadata {
|
||||
make: Some(md.make.clone()).filter(|s| !s.is_empty()),
|
||||
model: Some(md.model.clone()).filter(|s| !s.is_empty()),
|
||||
lens: exif.lens_model.clone(),
|
||||
shutter: exif.exposure_time.map(|r| r.n as f32 / r.d.max(1) as f32),
|
||||
aperture: exif.fnumber.map(|r| r.n as f32 / r.d.max(1) as f32),
|
||||
iso: exif.iso_speed_ratings.map(|v| v as u32),
|
||||
focal_length: exif.focal_length.map(|r| r.n as f32 / r.d.max(1) as f32),
|
||||
width: None,
|
||||
height: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// TRACES: FR-RAW-3 | FR-EXP-9
|
||||
/// Fully decode sensor data.
|
||||
///
|
||||
/// The expensive path — reads the whole file and unpacks every photosite.
|
||||
/// Only develop and export should call it; culling and the grid must not
|
||||
/// (FR-CULL-1).
|
||||
pub fn decode(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
||||
use rawler::rawsource::RawSource;
|
||||
|
||||
let source = RawSource::new_from_slice(bytes);
|
||||
let decoder =
|
||||
rawler::get_decoder(&source).map_err(|e| DecodeError::Unsupported(e.to_string()))?;
|
||||
let image = decoder
|
||||
.raw_image(&source, &Default::default(), false)
|
||||
.map_err(|e| DecodeError::Decode(e.to_string()))?;
|
||||
|
||||
let data = match image.data {
|
||||
rawler::RawImageData::Integer(v) => v,
|
||||
rawler::RawImageData::Float(v) => {
|
||||
// Float sensor data is rare; normalise to the u16 the pipeline
|
||||
// expects rather than carrying two representations.
|
||||
v.iter()
|
||||
.map(|&f| (f * 65535.0).clamp(0.0, 65535.0) as u16)
|
||||
.collect()
|
||||
}
|
||||
};
|
||||
|
||||
let cfa = cfa_from_rawler(&image.camera.cfa, image.camera.model.as_str());
|
||||
// Black levels are rationals; the pipeline wants plain u16 samples.
|
||||
let bl = &image.blacklevel.levels;
|
||||
let level_at = |i: usize| -> u16 {
|
||||
bl.get(i)
|
||||
.map(|r| (r.n as f32 / r.d.max(1) as f32).round() as u16)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let black_level = [level_at(0), level_at(1), level_at(2), level_at(3)];
|
||||
|
||||
Ok(RawImage {
|
||||
width: image.width as u32,
|
||||
height: image.height as u32,
|
||||
data,
|
||||
cfa_pattern: cfa,
|
||||
black_level,
|
||||
white_level: image
|
||||
.whitelevel
|
||||
.0
|
||||
.first()
|
||||
.map(|v| *v as u16)
|
||||
.unwrap_or(u16::MAX),
|
||||
wb_coeffs: image.wb_coeffs,
|
||||
color_matrix: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn cfa_from_rawler(cfa: &rawler::CFA, model: &str) -> CfaPattern {
|
||||
// rawler exposes the pattern as a string; X-Trans is 6x6 rather than 2x2.
|
||||
let name = cfa.name.to_ascii_uppercase();
|
||||
if name.len() > 4 || model.contains("X-") {
|
||||
return CfaPattern::XTrans;
|
||||
}
|
||||
match name.as_str() {
|
||||
"RGGB" => CfaPattern::Rggb,
|
||||
"BGGR" => CfaPattern::Bggr,
|
||||
"GRBG" => CfaPattern::Grbg,
|
||||
"GBRG" => CfaPattern::Gbrg,
|
||||
_ => CfaPattern::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn probe_identifies_jpeg() {
|
||||
let mut h = vec![0xFF, 0xD8, 0xFF, 0xE0];
|
||||
h.extend_from_slice(&[0u8; 16]);
|
||||
assert_eq!(probe(&h), Some(Format::Jpeg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_identifies_cr2_by_its_marker() {
|
||||
// Little-endian TIFF, then CR2's own magic at offset 8.
|
||||
let mut h = vec![0x49, 0x49, 0x2A, 0x00, 0x10, 0, 0, 0];
|
||||
h.extend_from_slice(b"CR\x02\x00");
|
||||
h.extend_from_slice(&[0u8; 8]);
|
||||
assert_eq!(probe(&h), Some(Format::Cr2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_identifies_raf_by_signature() {
|
||||
let mut h = b"FUJIFILMCCD-RAW ".to_vec();
|
||||
h.extend_from_slice(&[0u8; 16]);
|
||||
assert_eq!(probe(&h), Some(Format::Raf));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_returns_none_for_ambiguous_tiff() {
|
||||
// NEF, ARW, DNG and ORF share this header; the extension has to
|
||||
// disambiguate, and claiming a format here would be a lie.
|
||||
let mut h = vec![0x49, 0x49, 0x2A, 0x00];
|
||||
h.extend_from_slice(&[0u8; 20]);
|
||||
assert_eq!(probe(&h), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_rejects_short_input() {
|
||||
assert_eq!(probe(&[0xFF, 0xD8]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xtrans_is_distinguishable() {
|
||||
assert!(CfaPattern::XTrans.is_xtrans());
|
||||
assert!(!CfaPattern::Rggb.is_xtrans());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user