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.