From 499ceca4b75276f46c54ada3ae2dbd0f926a7cb6 Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:52:23 -0400 Subject: [PATCH] feat(decode): read strip-based embedded previews and colour-manage thumbnails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a folder of modern DNGs showed 18 grey boxes. Every one of them carried a multi-megapixel JPEG preview that was simply never looked for. rawshift 0.1.1 reads only `JPEGInterchangeFormat` / `-Length` (0x0201/0x0202), the TIFF/EP thumbnail convention. Modern DNG writers — Adobe, and Apple for ProRAW — use the other form the DNG spec allows: a full IFD with `Compression = 7` whose bitstream is located by `StripOffsets` / `StripByteCounts` (0x0111/0x0117). `rawshift_image::tiff` is a public module, so both conventions can be read here without forking upstream; rawshift's own extractor stays as a fallback for formats this reader does not cover. On the 18-file ProRAW corpus: 0 previews found before, 18 after. This matters beyond thumbnails. Preview extraction never decodes raw pixels, so it works on files `decode_file` rejects outright — which is most of them while decode support is still growing. A directory stays browsable and cullable no matter how far rawshift has got. Colour. A preview is someone else's rendering and arrives tagged with its own space. Apple writes Display P3, and treating those bytes as sRGB showed every thumbnail oversaturated — the filmstrip disagreeing with the viewport about the same file. Thumbnails now convert through the preview's embedded ICC to the same `viewport::DISPLAY_GAMUT` the shader uses. Untagged input is assumed sRGB; an unparseable profile falls back to sRGB rather than dropping the image, because slightly wrong colour beats a blank tile. Adds `moxcms` (BSD-3-Clause OR Apache-2.0, with num-traits and pxfm, all AGPL-compatible per HARD-LICENSE) as a `focale-app` dependency only. It is display-side and must never reach the export path, which generates its own output profiles in `focale-export::icc`. Orientation. Previews are stored in sensor orientation: 16 of the 18 sample files are orientation 6, so without rotation nearly every thumbnail displayed on its side. IFD0's orientation is returned from the same TIFF parse and applied before display. Size. These previews are not small — six of the corpus are 8064x6048, about 145 MB of RGB once decoded. Letting every worker take one at once is over a gigabyte of transient allocation for a filmstrip. Decodes are now bounded in flight, ordered nearest-first so the tiles under the user's eyes resolve first, and textures are evicted by distance from the open image. A file with no embedded preview is tracked separately from one that failed to load. It is not broken and must not be badged as though it were; it shows "no preview" instead. focale-app tests 20 -> 33. Export bytes unchanged. --- Cargo.lock | 13 +- Cargo.toml | 8 + crates/focale-app/Cargo.toml | 1 + crates/focale-app/src/app.rs | 188 ++++++++++++-- crates/focale-app/src/thumbs.rs | 237 ++++++++++++++++- crates/focale-core/src/decode/mod.rs | 33 ++- crates/focale-core/src/decode/preview.rs | 307 +++++++++++++++++++++++ crates/focale-core/tests/decode.rs | 34 ++- docs/subsystems/decode.md | 42 ++++ 9 files changed, 826 insertions(+), 37 deletions(-) create mode 100644 crates/focale-core/src/decode/preview.rs diff --git a/Cargo.lock b/Cargo.lock index 1615db8..50603d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1332,6 +1332,7 @@ dependencies = [ "focale-segment", "focale-sidecar", "half", + "moxcms 0.9.1", "rfd", "tracing", "tracing-subscriber", @@ -1904,7 +1905,7 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", - "moxcms", + "moxcms 0.8.1", "num-traits", "png", "tiff", @@ -2453,6 +2454,16 @@ dependencies = [ "pxfm", ] +[[package]] +name = "moxcms" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396077b717181ce3f9a6b66739b269b82d756be5ec0f76be0bc40a7c51775830" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "naga" version = "30.0.0" diff --git a/Cargo.toml b/Cargo.toml index 3dc126d..b4b2b2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,14 @@ rfd = "0.15" serde = { version = "1", features = ["derive"] } sha2 = "0.10" zune-jpeg = "0.5" + +# Input ICC handling for embedded previews (colour-managed thumbnails and the +# view-only path). BSD-3-Clause OR Apache-2.0, plus num-traits (MIT/Apache-2.0) +# and pxfm (BSD-3-Clause/Apache-2.0) — all AGPL-compatible ([HARD-LICENSE]). +# Deliberately a `focale-app` dependency only: it is display-side and must +# never reach the deterministic export path, which generates its own output +# profiles in `focale-export::icc`. +moxcms = "0.9" thiserror = "2" tiff = "0.11" tracing = "0.1" diff --git a/crates/focale-app/Cargo.toml b/crates/focale-app/Cargo.toml index 55218c1..229542b 100644 --- a/crates/focale-app/Cargo.toml +++ b/crates/focale-app/Cargo.toml @@ -16,6 +16,7 @@ focale-sidecar.workspace = true eframe.workspace = true half.workspace = true +moxcms.workspace = true rfd.workspace = true tracing.workspace = true zune-jpeg.workspace = true diff --git a/crates/focale-app/src/app.rs b/crates/focale-app/src/app.rs index ef7cb1c..a71c74d 100644 --- a/crates/focale-app/src/app.rs +++ b/crates/focale-app/src/app.rs @@ -23,6 +23,17 @@ use crate::suggest::{self, SuggestionSet}; use crate::thumbs; use crate::viewport::{self, ViewportCallback, ViewportRenderer}; +/// Long edge of a decoded thumbnail, in pixels. +const THUMBNAIL_LONG_EDGE: usize = 256; + +/// Thumbnail decodes allowed in flight at once. See `request_thumbnails` for +/// why this is bounded rather than left to the worker count. +const MAX_THUMBNAIL_DECODES: usize = 3; + +/// Thumbnail textures kept resident. Roughly a screenful of grid tiles plus +/// margin; beyond this, the ones furthest from the primary are dropped. +const MAX_THUMBNAIL_TEXTURES: usize = 300; + /// How long a non-error notice holds the status bar before it reverts to /// showing pipeline warnings. Errors are not aged out (`Notices::current`). const NOTICE_TTL: Duration = Duration::from_secs(8); @@ -32,7 +43,7 @@ enum Msg { Base(PathBuf, Result), AiMask(PathBuf, Result), Frame(PathBuf, Result, String>, RenderTiming), - Thumb(PathBuf, ColorImage), + Thumb(PathBuf, Option), Export(usize, ExportStatus), Suggest(PathBuf, SuggestionSet), } @@ -93,6 +104,13 @@ pub struct FocaleApp { /// Filmstrip thumbnails. thumbs: HashMap, thumbs_requested: HashSet, + /// Thumbnail jobs that have reported back, successfully or not. With + /// `thumbs_requested` this gives the in-flight count that bounds decode + /// concurrency. + thumbs_completed: usize, + /// Files that carry no embedded preview. Distinct from `load_errors`: + /// these files are not broken, they just have no thumbnail to show. + thumbs_missing: HashSet, /// Active rendering gamut (status-bar key, docs/subsystems/color.md). gamut: Gamut, @@ -163,6 +181,8 @@ impl FocaleApp { show_notices: false, thumbs: HashMap::new(), thumbs_requested: HashSet::new(), + thumbs_completed: 0, + thumbs_missing: HashSet::new(), gamut: Gamut::Srgb, tool: Tool::Pan, zoom: None, @@ -198,6 +218,8 @@ impl FocaleApp { self.load_errors.clear(); self.thumbs.clear(); self.thumbs_requested.clear(); + self.thumbs_completed = 0; + self.thumbs_missing.clear(); self.request_primary_preview(); } Err(e) => { @@ -510,12 +532,25 @@ impl FocaleApp { } } Msg::Thumb(path, image) => { - let handle = ctx.load_texture( - format!("thumb:{}", path.display()), - image, - Default::default(), - ); - self.thumbs.insert(path, handle); + self.thumbs_completed += 1; + match image { + Some(image) => { + let handle = ctx.load_texture( + format!("thumb:{}", path.display()), + image, + Default::default(), + ); + self.thumbs.insert(path, handle); + } + None => { + // No usable preview. This is *not* a load error: + // the file may open and develop perfectly well and + // simply carry no embedded JPEG. Tracked + // separately so the tile reads "no preview" rather + // than accusing the file of being broken. + self.thumbs_missing.insert(path); + } + } } Msg::Export(index, status) => { match (&status, self.exports.get(index)) { @@ -607,27 +642,97 @@ impl FocaleApp { false } + /// Queues thumbnail decodes, nearest the primary first and only a few at + /// a time. + /// + /// Both limits are about memory, not politeness. An embedded preview is + /// whatever the camera chose to store, and phones store big ones — the + /// sample corpus this was built against carries a single 8064×6048 JPEG + /// per file, which is ~145 MB of RGB once decoded. Letting every worker + /// take one at once is well over a gigabyte of transient allocation for a + /// filmstrip. Bounded in flight, the peak is a few hundred megabytes. + /// + /// Ordering by distance from the primary means the tiles the user is + /// looking at resolve first, rather than whichever happened to sort first + /// by file name. fn request_thumbnails(&mut self) { - let paths: Vec = self + let in_flight = self.thumbs_requested.len() - self.thumbs_completed; + if in_flight >= MAX_THUMBNAIL_DECODES { + return; + } + let budget = MAX_THUMBNAIL_DECODES - in_flight; + let anchor = self.session.primary.unwrap_or(0); + + let mut pending: Vec<(usize, PathBuf)> = self .session .entries .iter() - .map(|e| e.path.clone()) - .filter(|p| !self.thumbs.contains_key(p) && !self.thumbs_requested.contains(p)) - .take(8) + .enumerate() + .filter(|(_, e)| { + !self.thumbs.contains_key(&e.path) && !self.thumbs_requested.contains(&e.path) + }) + .map(|(i, e)| (i, e.path.clone())) .collect(); - for path in paths { + pending.sort_by_key(|(i, _)| distance_from(*i, anchor)); + + for (_, path) in pending.into_iter().take(budget) { self.thumbs_requested.insert(path.clone()); let tx = self.tx.clone(); self.scheduler.submit(Priority::Thumbnail, move || { - if let Ok(Some(jpeg)) = focale_core::decode::extract_thumbnail(&path) - && let Some(img) = thumbs::decode_thumbnail(&jpeg, 256) - { - let _ = tx.send(Msg::Thumb(path, img)); - } + let image = focale_core::decode::embedded_preview(&path) + .ok() + .flatten() + .and_then(|pv| { + thumbs::decode_thumbnail( + &pv.jpeg, + THUMBNAIL_LONG_EDGE, + pv.orientation.unwrap_or(1), + ) + }); + // Always reply, even with nothing: the reply is what releases + // the in-flight slot, so a file with no usable preview must + // not stall the queue behind it. + let _ = tx.send(Msg::Thumb(path, image)); }); } } + + /// Drops thumbnail textures far from the primary once the cache is over + /// budget, so browsing a large directory does not grow GPU memory without + /// bound. Textures are derived data and re-decode on demand. + fn evict_thumbnails(&mut self) { + if self.thumbs.len() <= MAX_THUMBNAIL_TEXTURES { + return; + } + let anchor = self.session.primary.unwrap_or(0); + let mut ranked: Vec<(usize, PathBuf)> = self + .session + .entries + .iter() + .enumerate() + .filter(|(_, e)| self.thumbs.contains_key(&e.path)) + .map(|(i, e)| (distance_from(i, anchor), e.path.clone())) + .collect(); + // Entries no longer in the session keep no texture at all. + let live: HashSet<&PathBuf> = ranked.iter().map(|(_, p)| p).collect(); + let orphans: Vec = self + .thumbs + .keys() + .filter(|p| !live.contains(p)) + .cloned() + .collect(); + for p in orphans { + self.thumbs.remove(&p); + self.thumbs_requested.remove(&p); + } + ranked.sort_by_key(|(d, _)| *d); + for (_, path) in ranked.into_iter().skip(MAX_THUMBNAIL_TEXTURES) { + self.thumbs.remove(&path); + // Allow a re-request when it comes back into view. + self.thumbs_requested.remove(&path); + self.thumbs_completed = self.thumbs_completed.saturating_sub(1); + } + } } impl eframe::App for FocaleApp { @@ -644,6 +749,7 @@ impl eframe::App for FocaleApp { self.handle_messages(&ctx, frame); self.sync_viewport(frame); self.request_thumbnails(); + self.evict_thumbnails(); // ---- Top bar ---- egui::Panel::top("top").show(root, |ui| { @@ -860,9 +966,8 @@ impl eframe::App for FocaleApp { ui.visuals().extreme_bg_color, ); } - // A file Focale cannot read must not look - // identical to one whose thumbnail has simply - // not arrived yet. + // Three states must look different: broken, + // no preview available, and not arrived yet. if failed.is_some() { ui.painter().text( rect.center(), @@ -871,6 +976,14 @@ impl eframe::App for FocaleApp { egui::FontId::proportional(20.0), ui.visuals().error_fg_color, ); + } else if self.thumbs_missing.contains(&entry.path) { + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + "no preview", + egui::FontId::proportional(11.0), + ui.visuals().weak_text_color(), + ); } ui.painter().rect_stroke( rect, @@ -1597,6 +1710,16 @@ impl FocaleApp { } } +/// Absolute distance between two entry indices. +/// +/// Thumbnail work is ordered by this so the tiles under the user's eyes +/// resolve first, and eviction drops the ones furthest away. Computed on +/// `usize` without casting through `isize`, which would be a silent wrap on a +/// directory larger than `isize::MAX` and is needless besides. +fn distance_from(index: usize, anchor: usize) -> usize { + index.abs_diff(anchor) +} + /// Short, user-facing name for a path: the file name where there is one, /// else the full path. Notices address the user, who thinks in file names, /// not in absolute paths. @@ -1636,6 +1759,31 @@ mod tests { assert_eq!(file_label(std::path::Path::new("..")), ".."); } + #[test] + fn distance_is_symmetric_around_the_anchor() { + assert_eq!(distance_from(5, 5), 0); + assert_eq!(distance_from(7, 5), 2); + assert_eq!(distance_from(3, 5), 2); + } + + #[test] + fn distance_does_not_wrap_on_large_indices() { + // The obvious `(a as isize - b as isize).abs()` wraps here. + assert_eq!(distance_from(0, usize::MAX), usize::MAX); + } + + /// Thumbnail work is ordered by this, so the tiles the user is looking at + /// must come first regardless of file-name order. + #[test] + fn nearest_entries_sort_first() { + let anchor = 10usize; + let mut candidates: Vec = vec![0, 3, 9, 11, 14, 20]; + candidates.sort_by_key(|i| distance_from(*i, anchor)); + // Distances: 9→1, 11→1, 14→4, 3→7, then 0 and 20 tie at 10, where a + // stable sort keeps the original order. + assert_eq!(candidates, vec![9, 11, 14, 3, 0, 20]); + } + /// The status bar must not claim to be showing a gamut the surface cannot /// present (`docs/subsystems/color.md`, HARD). v1 surfaces are sRGB, so a /// wider selection has to read as a mismatch. diff --git a/crates/focale-app/src/thumbs.rs b/crates/focale-app/src/thumbs.rs index 4cb5696..6c899f2 100644 --- a/crates/focale-app/src/thumbs.rs +++ b/crates/focale-app/src/thumbs.rs @@ -1,29 +1,250 @@ -//! Filmstrip thumbnails from embedded raw previews. +//! Thumbnails from embedded previews, colour-managed. +//! +//! An embedded preview is *someone else's* rendering, so it arrives tagged +//! with its own colour space and has to be converted before it can share a +//! screen with the viewport. Apple writes Display P3 previews; treating those +//! bytes as sRGB — which is what this module used to do — shows every +//! thumbnail oversaturated, and makes the filmstrip disagree with the image +//! the viewport is painting from the same file. +//! +//! Conversion targets [`viewport::DISPLAY_GAMUT`], the same constant the +//! viewport shader uses, so both surfaces answer to one definition of "what +//! this display is". +//! +//! **Off the deterministic export path** (`[HARD-DET]`). Nothing here feeds +//! an export: `focale-export` generates its own output profiles. This is +//! display-side only, which is why a general CMS is acceptable here and would +//! not be there. use eframe::egui::ColorImage; +use focale_core::color::Gamut; +use moxcms::{ColorProfile, Layout, TransformOptions}; -/// Decodes an embedded JPEG preview into an egui image, downscaled to at -/// most `max_edge` pixels on the long edge (nearest-neighbour decimation — -/// thumbnails are not on any colour-critical path). -pub fn decode_thumbnail(jpeg: &[u8], max_edge: usize) -> Option { +use crate::viewport; + +/// Decodes an embedded JPEG preview into an egui image, downscaled to at most +/// `max_edge` pixels on the long edge. +/// +/// Colour is converted from the preview's embedded ICC profile to the display +/// gamut. An untagged preview is assumed sRGB, which is the web/JPEG +/// convention and the only defensible guess. A profile that cannot be parsed +/// or transformed is also treated as sRGB rather than dropping the thumbnail: +/// a slightly wrong colour is more useful than a blank tile, and the file is +/// still browsable. +/// +/// `orientation` is an EXIF orientation value (1–8); the preview is rotated +/// to match so portrait frames do not display on their side. +pub fn decode_thumbnail(jpeg: &[u8], max_edge: usize, orientation: u16) -> Option { use zune_jpeg::zune_core::bytestream::ZCursor; let mut decoder = zune_jpeg::JpegDecoder::new(ZCursor::new(jpeg)); decoder.decode_headers().ok()?; let info = decoder.info()?; + let icc = decoder.icc_profile(); let pixels = decoder.decode().ok()?; let (w, h) = (info.width as usize, info.height as usize); let comps = decoder.output_colorspace()?.num_components(); - if comps < 3 || pixels.len() < w * h * comps { + if w == 0 || h == 0 || comps < 3 || pixels.len() < w * h * comps { return None; } + + // Decimate first, convert second: the transform then runs over thumbnail + // pixels rather than over a 48-megapixel preview. let step = (w.max(h)).div_ceil(max_edge).max(1); let (tw, th) = (w.div_ceil(step), h.div_ceil(step)); - let mut rgba = Vec::with_capacity(tw * th * 4); + let mut rgb = Vec::with_capacity(tw * th * 3); for y in (0..h).step_by(step) { for x in (0..w).step_by(step) { let i = (y * w + x) * comps; - rgba.extend_from_slice(&[pixels[i], pixels[i + 1], pixels[i + 2], 255]); + rgb.extend_from_slice(&[pixels[i], pixels[i + 1], pixels[i + 2]]); } } + + let rgb = to_display_gamut(rgb, icc.as_deref()); + let rgba: Vec = rgb + .as_chunks::<3>() + .0 + .iter() + .flat_map(|c| [c[0], c[1], c[2], 255]) + .collect(); + let (rgba, tw, th) = apply_orientation(rgba, tw, th, orientation); Some(ColorImage::from_rgba_unmultiplied([tw, th], &rgba)) } + +/// Converts interleaved 8-bit RGB from `icc` (or sRGB when absent) into +/// [`viewport::DISPLAY_GAMUT`]. Returns the input unchanged when no +/// conversion is needed or possible. +fn to_display_gamut(rgb: Vec, icc: Option<&[u8]>) -> Vec { + let Some(icc) = icc else { + // Untagged: assume sRGB. If the display is sRGB there is nothing to + // do, and if it is not, the assumption is still the right starting + // point. + return rgb; + }; + let Ok(source) = ColorProfile::new_from_slice(icc) else { + return rgb; + }; + let destination = match viewport::DISPLAY_GAMUT { + Gamut::Srgb => ColorProfile::new_srgb(), + // Only sRGB surfaces exist in v1; when a wider one can be configured + // (issues #6/#10) this gains the matching profile rather than + // silently converting to the wrong space. + _ => ColorProfile::new_srgb(), + }; + let Ok(transform) = source.create_transform_8bit( + Layout::Rgb, + &destination, + Layout::Rgb, + TransformOptions::default(), + ) else { + return rgb; + }; + let mut out = vec![0u8; rgb.len()]; + match moxcms::TransformExecutor::transform(&*transform, &rgb, &mut out) { + Ok(()) => out, + Err(_) => rgb, + } +} + +/// Rotates/flips 8-bit RGBA pixels per an EXIF orientation value (1–8). +/// +/// Returns the possibly-transposed dimensions alongside the pixels. Values +/// outside 1–8, and the identity value 1, are returned untouched. +fn apply_orientation( + rgba: Vec, + w: usize, + h: usize, + orientation: u16, +) -> (Vec, usize, usize) { + if !(2..=8).contains(&orientation) { + return (rgba, w, h); + } + // Transposing orientations swap the output dimensions. + let transposed = matches!(orientation, 5..=8); + let (ow, oh) = if transposed { (h, w) } else { (w, h) }; + let mut out = vec![0u8; rgba.len()]; + for y in 0..h { + for x in 0..w { + // Destination coordinate for source (x, y), per EXIF 2.32 §4.6.4. + let (dx, dy) = match orientation { + 2 => (w - 1 - x, y), + 3 => (w - 1 - x, h - 1 - y), + 4 => (x, h - 1 - y), + 5 => (y, x), + 6 => (h - 1 - y, x), + 7 => (h - 1 - y, w - 1 - x), + 8 => (y, w - 1 - x), + _ => (x, y), + }; + let src = (y * w + x) * 4; + let dst = (dy * ow + dx) * 4; + out[dst..dst + 4].copy_from_slice(&rgba[src..src + 4]); + } + } + (out, ow, oh) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 2x1 image: left pixel red, right pixel green. + fn two_by_one() -> Vec { + vec![255, 0, 0, 255, 0, 255, 0, 255] + } + + #[test] + fn orientation_1_is_identity() { + let (out, w, h) = apply_orientation(two_by_one(), 2, 1, 1); + assert_eq!((w, h), (2, 1)); + assert_eq!(out, two_by_one()); + } + + #[test] + fn orientation_2_mirrors_horizontally() { + let (out, w, h) = apply_orientation(two_by_one(), 2, 1, 2); + assert_eq!((w, h), (2, 1)); + assert_eq!(&out[0..4], &[0, 255, 0, 255], "green moved to the left"); + assert_eq!(&out[4..8], &[255, 0, 0, 255]); + } + + #[test] + fn orientation_6_transposes_dimensions() { + let (out, w, h) = apply_orientation(two_by_one(), 2, 1, 6); + assert_eq!((w, h), (1, 2), "a 2x1 frame rotates to 1x2"); + assert_eq!(out.len(), 8); + } + + #[test] + fn every_orientation_covers_all_pixels() { + // A transform that dropped or doubled a pixel would leave a + // transparent hole; alpha is 255 everywhere in the source. + let (w, h) = (3usize, 2usize); + let src: Vec = (0..w * h).flat_map(|i| [i as u8, 0, 0, 255]).collect(); + for orientation in 1..=8u16 { + let (out, ow, oh) = apply_orientation(src.clone(), w, h, orientation); + assert_eq!(ow * oh, w * h, "orientation {orientation} changed area"); + assert!( + out.as_chunks::<4>().0.iter().all(|p| p[3] == 255), + "orientation {orientation} left an unwritten pixel" + ); + } + } + + #[test] + fn out_of_range_orientation_is_identity() { + for orientation in [0u16, 9, 65535] { + let (out, w, h) = apply_orientation(two_by_one(), 2, 1, orientation); + assert_eq!((w, h), (2, 1)); + assert_eq!(out, two_by_one()); + } + } + + #[test] + fn untagged_pixels_pass_through_unchanged() { + let rgb = vec![10, 20, 30, 40, 50, 60]; + assert_eq!(to_display_gamut(rgb.clone(), None), rgb); + } + + #[test] + fn unparseable_profile_falls_back_rather_than_dropping_the_image() { + let rgb = vec![10, 20, 30]; + assert_eq!( + to_display_gamut(rgb.clone(), Some(b"not an icc profile")), + rgb + ); + } + + /// A Display P3 preview must not be shown as if it were sRGB. The same + /// encoded values mean a more saturated colour in P3, so converting to an + /// sRGB display has to move them. + #[test] + fn display_p3_is_converted_not_passed_through() { + let icc = ColorProfile::new_display_p3() + .encode() + .expect("serialize P3 profile"); + // A mixed, in-gamut colour. Pure primaries are a bad probe: P3's + // primaries sit outside sRGB, so a relative-colorimetric transform + // clips them straight back onto sRGB's primaries and looks like a + // no-op even though it worked. + let rgb = vec![200u8, 100, 50]; + let out = to_display_gamut(rgb.clone(), Some(&icc)); + assert_ne!(out, rgb, "P3 values were passed through untouched"); + assert!( + out[0] > rgb[0] && out[2] < rgb[2], + "converting P3 to the narrower sRGB must increase saturation, got {out:?}" + ); + } + + /// Both profiles are D65, so the neutral axis must survive the round + /// trip. A shifted grey is the classic sign of a mismatched white point. + #[test] + fn neutral_grey_is_preserved() { + let icc = ColorProfile::new_display_p3() + .encode() + .expect("serialize P3 profile"); + let out = to_display_gamut(vec![128u8, 128, 128], Some(&icc)); + for c in out { + assert!((i32::from(c) - 128).abs() <= 1, "grey drifted to {c}"); + } + } +} diff --git a/crates/focale-core/src/decode/mod.rs b/crates/focale-core/src/decode/mod.rs index 1d83c53..b7a7130 100644 --- a/crates/focale-core/src/decode/mod.rs +++ b/crates/focale-core/src/decode/mod.rs @@ -80,6 +80,8 @@ //! as-is (`ColorMatrix2`, conventionally the D65 calibration, is preferred //! when interpolation inputs are missing). +pub mod preview; + use std::fs::File; use std::io::BufReader; use std::path::Path; @@ -245,14 +247,35 @@ pub fn decode_file(path: &Path) -> Result { }) } -/// Extracts the embedded JPEG thumbnail, if the file carries one. +/// Extracts the largest embedded JPEG preview, if the file carries one. +/// +/// Returns `Ok(None)` when the file has no embedded preview. /// -/// Returns `Ok(None)` when the format has no embedded thumbnail (or rawshift -/// does not extract it yet). The bytes are the JPEG stream exactly as stored. -pub fn extract_thumbnail(path: &Path) -> Result>, DecodeError> { +/// Tries [`preview::extract_preview`] first, which understands both the +/// TIFF/EP thumbnail tags and DNG's strip-based preview IFDs, then falls back +/// to rawshift's own extractor for formats whose previews live somewhere this +/// module does not look (Sony's larger preview in MakerNote, for instance). +/// +/// Neither path decodes raw pixel data, so this works on files +/// [`decode_file`] cannot develop — which is the point: a directory stays +/// browsable and cullable regardless of how far decode support has got. +pub fn embedded_preview(path: &Path) -> Result, DecodeError> { + if let Ok(Some(found)) = preview::extract_preview(path) { + return Ok(Some(found)); + } let file = File::open(path)?; let mut raw = RawFile::open(BufReader::new(file)).map_err(open_error)?; - raw.thumbnail().map_err(decode_error) + Ok(raw + .thumbnail() + .map_err(decode_error)? + .map(|jpeg| preview::EmbeddedPreview { + jpeg, + width: None, + height: None, + // rawshift does not report orientation alongside the thumbnail; + // the display side treats `None` as "leave it alone". + orientation: None, + })) } /// Returns true when `path` has a raw extension this module can try to diff --git a/crates/focale-core/src/decode/preview.rs b/crates/focale-core/src/decode/preview.rs new file mode 100644 index 0000000..0014d62 --- /dev/null +++ b/crates/focale-core/src/decode/preview.rs @@ -0,0 +1,307 @@ +//! Embedded JPEG preview extraction. +//! +//! **Off the deterministic export path.** Previews are what the file's +//! producer baked in, not what Focale's pipeline computes, so nothing here +//! feeds an export ([pipeline](../../../docs/subsystems/pipeline.md)). They +//! exist so a directory can be browsed and culled even when the raw itself +//! cannot be developed — which is most of the time while decode support is +//! still growing (`docs/subsystems/decode.md`). +//! +//! # Why this is not just `RawFile::thumbnail()` +//! +//! rawshift 0.1.1 looks only at `JPEGInterchangeFormat` / `-Length` +//! (0x0201 / 0x0202), the TIFF/EP thumbnail convention. Modern DNG writers +//! — Adobe, and Apple for ProRAW — store previews the other way the DNG +//! specification allows: a full IFD with `NewSubfileType = 1` +//! (reduced-resolution), `Compression = 7` (JPEG), and the bitstream located +//! by `StripOffsets` / `StripByteCounts` (0x0111 / 0x0117). +//! +//! The consequence was that every such file reported "no thumbnail" while +//! carrying a multi-megapixel JPEG that was simply never looked for. This +//! module reads both conventions and returns the largest preview found, so +//! the answer does not depend on which tool wrote the file. + +use std::io::{BufReader, Read, Seek}; +use std::path::Path; + +use rawshift_image::tiff::{CompressionType, Ifd, TiffParser, TiffTag, metadata_helper}; + +use super::DecodeError; + +/// An embedded preview image. +#[derive(Debug, Clone)] +pub struct EmbeddedPreview { + /// The JPEG bitstream, exactly as stored in the file. + pub jpeg: Vec, + /// Pixel width as declared by the containing IFD, when it declares one. + /// + /// Advisory only: it is the container's claim, not the JPEG's own + /// `SOF` header. Callers that need the true size should read the decoded + /// image. + pub width: Option, + /// Pixel height as declared by the containing IFD, when it declares one. + pub height: Option, + /// The file's EXIF orientation (1–8) from IFD0, when present. + /// + /// Previews are stored in sensor orientation, so a portrait frame arrives + /// as a landscape JPEG and has to be rotated before display. Returned + /// here because it comes from the same TIFF parse — asking for it + /// separately would mean reading the file twice. + pub orientation: Option, +} + +impl EmbeddedPreview { + /// Declared pixel count, used to pick the largest candidate. Falls back + /// to the byte length when the IFD declares no dimensions, which orders + /// sensibly because a bigger JPEG is almost always a bigger image. + fn rank(&self) -> u64 { + match (self.width, self.height) { + (Some(w), Some(h)) => u64::from(w) * u64::from(h), + _ => self.jpeg.len() as u64, + } + } +} + +/// Largest JPEG preview embedded in `path`, or `None` when it carries none. +/// +/// Reads both the TIFF/EP thumbnail tags and strip-based preview IFDs (see +/// the module docs). Never decodes raw pixel data, so it stays cheap and +/// works on files whose raw payload Focale cannot decode at all. +pub fn extract_preview(path: &Path) -> Result, DecodeError> { + let file = std::fs::File::open(path)?; + let mut parser = TiffParser::new(BufReader::new(file)) + .map_err(|e| DecodeError::Decode(format!("TIFF header: {e}")))?; + + let file_size = parser + .file_size() + .map_err(|e| DecodeError::Decode(format!("file size: {e}")))?; + + // IFD0 plus the rest of the chain. Errors walking the chain are not + // fatal: a preview found in IFD0 is still a usable answer. + let mut ifds = Vec::new(); + match parser.walk_ifd_chain() { + Ok(chain) => ifds.extend(chain), + Err(_) => { + if let Ok(ifd0) = parser.parse_ifd0() { + ifds.push(ifd0); + } + } + } + + // SubIFDs are where DNG most often keeps its previews. + let mut all = Vec::new(); + for ifd in &ifds { + collect(ifd, &mut all); + } + + // IFD0 carries the orientation that applies to the whole file. + let orientation = ifds + .first() + .and_then(|ifd0| metadata_helper::extract_orientation(&mut parser, ifd0)); + + let mut best: Option = None; + for ifd in all { + for candidate in [ + read_interchange(&mut parser, ifd, file_size), + read_strips(&mut parser, ifd, file_size), + ] + .into_iter() + .flatten() + { + if best.as_ref().is_none_or(|b| candidate.rank() > b.rank()) { + best = Some(candidate); + } + } + } + // Stamped once at the end: the orientation belongs to the file, not to + // whichever IFD the winning preview came from. + Ok(best.map(|mut p| { + p.orientation = orientation; + p + })) +} + +/// Flattens an IFD and its SubIFDs into one list, depth-first. +fn collect<'a>(ifd: &'a Ifd, out: &mut Vec<&'a Ifd>) { + out.push(ifd); + for sub in &ifd.sub_ifds { + collect(sub, out); + } +} + +/// Reads the value of `tag` as a single `u64`. +fn tag_u64(parser: &mut TiffParser, ifd: &Ifd, tag: TiffTag) -> Option { + let entry = ifd.get(tag)?.clone(); + parser.read_value(&entry).ok()?.as_u64() +} + +/// Reads the value of `tag` as a list of `u64`. +fn tag_u64_vec( + parser: &mut TiffParser, + ifd: &Ifd, + tag: TiffTag, +) -> Option> { + let entry = ifd.get(tag)?.clone(); + parser.read_value(&entry).ok()?.as_u64_vec() +} + +/// Declared dimensions of the image an IFD describes. +fn dimensions(parser: &mut TiffParser, ifd: &Ifd) -> (Option, Option) { + let w = tag_u64(parser, ifd, TiffTag::ImageWidth).and_then(|v| u32::try_from(v).ok()); + let h = tag_u64(parser, ifd, TiffTag::ImageLength).and_then(|v| u32::try_from(v).ok()); + (w, h) +} + +/// Reads bytes at `offset`, refusing anything that runs past the end of the +/// file. A truncated or hostile file must fail the read, never allocate a +/// buffer sized from an unchecked header field. +fn read_at( + parser: &mut TiffParser, + offset: u64, + len: u64, + file_size: u64, +) -> Option> { + if len == 0 || offset.checked_add(len)? > file_size { + return None; + } + let len = usize::try_from(len).ok()?; + parser.seek_to(offset).ok()?; + parser.read_bytes(len).ok() +} + +/// The TIFF/EP convention: one JPEG located by 0x0201 / 0x0202. +fn read_interchange( + parser: &mut TiffParser, + ifd: &Ifd, + file_size: u64, +) -> Option { + let offset = tag_u64(parser, ifd, TiffTag::JPEGInterchangeFormat)?; + let len = tag_u64(parser, ifd, TiffTag::JPEGInterchangeFormatLength)?; + let jpeg = read_at(parser, offset, len, file_size)?; + if !is_jpeg(&jpeg) { + return None; + } + // These tags describe the *thumbnail*, while the IFD's ImageWidth / + // ImageLength describe the main image, so dimensions are not read here. + Some(EmbeddedPreview { + jpeg, + width: None, + height: None, + orientation: None, + }) +} + +/// The DNG convention: a JPEG-compressed image IFD whose bitstream is +/// located by `StripOffsets` / `StripByteCounts`. +fn read_strips( + parser: &mut TiffParser, + ifd: &Ifd, + file_size: u64, +) -> Option { + let compression = tag_u64(parser, ifd, TiffTag::Compression) + .and_then(|v| u16::try_from(v).ok()) + .and_then(CompressionType::from_u16)?; + // Only baseline JPEG is a preview we can hand to a JPEG decoder. Lossy + // and JPEG XL DNG payloads are raw image data, not previews. + if !matches!( + compression, + CompressionType::Jpeg | CompressionType::OldJpeg + ) { + return None; + } + + let offsets = tag_u64_vec(parser, ifd, TiffTag::StripOffsets)?; + let counts = tag_u64_vec(parser, ifd, TiffTag::StripByteCounts)?; + if offsets.is_empty() || offsets.len() != counts.len() { + return None; + } + + // A JPEG-compressed preview is conventionally a single strip. Multi-strip + // JPEG data is per-strip bitstreams that cannot simply be concatenated + // into one decodable image, so those are declined rather than corrupted. + if offsets.len() != 1 { + return None; + } + + let jpeg = read_at(parser, offsets[0], counts[0], file_size)?; + if !is_jpeg(&jpeg) { + return None; + } + let (width, height) = dimensions(parser, ifd); + Some(EmbeddedPreview { + jpeg, + width, + height, + orientation: None, + }) +} + +/// JPEG SOI marker. Guards against handing a decoder something that merely +/// sat where a JPEG was expected. +fn is_jpeg(bytes: &[u8]) -> bool { + bytes.starts_with(&[0xFF, 0xD8, 0xFF]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_jpeg_requires_the_soi_marker() { + assert!(is_jpeg(&[0xFF, 0xD8, 0xFF, 0xE0, 0x00])); + assert!(!is_jpeg(&[0xFF, 0xD8])); + assert!(!is_jpeg(&[0x89, b'P', b'N', b'G'])); + assert!(!is_jpeg(&[])); + } + + #[test] + fn rank_prefers_declared_pixels_over_byte_length() { + let big_bytes = EmbeddedPreview { + jpeg: vec![0; 10_000], + width: None, + height: None, + orientation: None, + }; + let small_bytes_many_pixels = EmbeddedPreview { + jpeg: vec![0; 10], + width: Some(4032), + height: Some(3024), + orientation: None, + }; + assert!(small_bytes_many_pixels.rank() > big_bytes.rank()); + } + + #[test] + fn rank_falls_back_to_byte_length_without_dimensions() { + let a = EmbeddedPreview { + jpeg: vec![0; 10], + width: None, + height: None, + orientation: None, + }; + let b = EmbeddedPreview { + jpeg: vec![0; 20], + width: None, + height: None, + orientation: None, + }; + assert!(b.rank() > a.rank()); + } + + #[test] + fn partial_dimensions_fall_back_to_byte_length() { + let p = EmbeddedPreview { + jpeg: vec![0; 7], + width: Some(100), + height: None, + orientation: None, + }; + assert_eq!(p.rank(), 7); + } + + #[test] + fn missing_file_is_an_io_error_not_a_panic() { + let err = extract_preview(Path::new("/nonexistent/focale/none.dng")); + assert!(matches!(err, Err(DecodeError::Io(_)))); + } +} diff --git a/crates/focale-core/tests/decode.rs b/crates/focale-core/tests/decode.rs index 6de73f0..bb5811e 100644 --- a/crates/focale-core/tests/decode.rs +++ b/crates/focale-core/tests/decode.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; -use focale_core::decode::{DecodeError, decode_file, extract_thumbnail, is_raw_candidate}; +use focale_core::decode::{DecodeError, decode_file, embedded_preview, is_raw_candidate}; use sha2::{Digest, Sha256}; /// SHA-256 of the decoded f32 pixel buffer (little-endian bytes) for the @@ -126,8 +126,8 @@ fn extracts_fixture_metadata() { fn fixture_has_no_embedded_thumbnail() { // The synthetic fixture carries no JPEG preview; the call must succeed // and report that, not error. - let thumb = extract_thumbnail(&fixture()).expect("thumbnail extraction must not fail"); - assert_eq!(thumb, None); + let thumb = embedded_preview(&fixture()).expect("preview extraction must not fail"); + assert!(thumb.is_none()); } #[test] @@ -158,3 +158,31 @@ fn raw_candidate_matches_fixture() { assert!(is_raw_candidate(&fixture())); assert!(!is_raw_candidate(&fixture().with_extension("fcl"))); } + +/// A file with no embedded preview must report absence, not fail. The +/// synthetic fixture is a bare 64×48 Bayer DNG with no preview IFD, so it is +/// exactly the "nothing to find" case. +#[test] +fn preview_extraction_reports_absence_without_erroring() { + let path = fixture(); + let found = focale_core::decode::preview::extract_preview(&path) + .expect("a readable DNG must not error while looking for a preview"); + assert!(found.is_none(), "synthetic.dng carries no embedded preview"); +} + +/// Preview extraction must never decode raw pixels, so it has to work on +/// files `decode_file` rejects outright. Guarded here with a non-raw file: +/// it is not a TIFF at all, so the answer is an error or `None` — never a +/// panic, and never a hang. +#[test] +fn preview_extraction_declines_non_tiff_input() { + let mut path = std::env::temp_dir(); + path.push(format!("focale-not-a-tiff-{}.dng", std::process::id())); + std::fs::write(&path, b"this is not a TIFF file at all").unwrap(); + let result = focale_core::decode::preview::extract_preview(&path); + let _ = std::fs::remove_file(&path); + match result { + Ok(None) | Err(_) => {} + Ok(Some(_)) => panic!("found a preview in a file that has none"), + } +} diff --git a/docs/subsystems/decode.md b/docs/subsystems/decode.md index c505023..36af3ff 100644 --- a/docs/subsystems/decode.md +++ b/docs/subsystems/decode.md @@ -66,3 +66,45 @@ Owning code: `focale-core/src/decode`. Governing invariants: `[HARD-DET]`, - **Optics metadata** parsed (or not) at decode time is the input to the optical corrections stage; the presence struct and its limits are specified in [optics](optics.md). + +## Embedded previews (`v1 (shipped)`) + +Owning code: `focale-core/src/decode/preview.rs`. **Off the deterministic +export path** — a preview is the camera's rendering, not Focale's, so it never +feeds an export. + +Previews exist so a directory is browsable and cullable regardless of how far +raw decode support has got. A file whose raw payload Focale cannot develop +still shows, still rates, still flags. + +Two conventions are read, because which one a file uses depends on who wrote +it: + +- **TIFF/EP thumbnail tags** — `JPEGInterchangeFormat` / `-Length` + (0x0201 / 0x0202). +- **Strip-based preview IFDs** — a full IFD with `Compression = 7` (JPEG) + whose bitstream is located by `StripOffsets` / `StripByteCounts` + (0x0111 / 0x0117). This is what modern DNG writers use, Apple ProRAW + included. + +The largest preview found wins. rawshift 0.1.1 reads only the first +convention, which is why every strip-based file reported "no thumbnail" while +carrying a multi-megapixel JPEG; its extractor is kept as a fallback for +formats whose previews live somewhere this reader does not look. + +Previews are stored in sensor orientation, so IFD0's `Orientation` is returned +alongside and applied before display — without it, a portrait frame shows on +its side. + +**Colour.** A preview carries its own ICC profile and must be converted, not +assumed. Apple writes Display P3; treating those bytes as sRGB shows every +thumbnail oversaturated and makes the filmstrip disagree with the viewport +rendering the same file. Conversion happens in `focale-app/src/thumbs.rs` +against the same display gamut the viewport shader uses. An untagged preview +is assumed sRGB; a profile that cannot be parsed falls back to sRGB rather +than dropping the image. + +**Size.** An embedded preview can be full-resolution — 8064×6048 in the +corpus this was built against, ~145 MB of RGB once decoded — so thumbnail +decodes are bounded in flight and ordered nearest-first, and thumbnail +textures are evicted by distance from the open image.