From 0b8c7a237a6736f7688527ab8111b112e00956e7 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Thu, 8 Oct 2026 14:26:09 -0400 Subject: [PATCH] LR-IMP-FORMATS: TIFF raws with a thumbnail-only IFD0 import as preview only, not as a tiny image Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs keep their raw data in a private block. IFD0 is only a 160-640 px RGB copy and no IFD is marked as raw, so they opened as an ordinary TIFF of that size (a 150 MP file rendered 296x220) with no preview-only label. probe() now recognises such a file from its structure (OtherTiff): a picture at least 4x larger than IFD0 in both directions is declared by a SubIFDs child, or by Exif PixelXDimension/PixelYDimension together with enough bytes outside every IFD's strips and tiles to hold it at one bit per pixel. decode() reports the sizes as the reason. A raw we cannot decode now falls back to its embedded JPEG, else to the TIFF reader's view of its first image, so it imports preview-only with that reason. RENDER_CACHE_VERSION 12: these files render differently. --- Cargo.lock | 1 + crates/engine/Cargo.toml | 1 + crates/engine/src/files.rs | 63 ++++++++++++- crates/engine/src/media.rs | 2 +- crates/raw/src/lib.rs | 175 +++++++++++++++++++++++++++++++++++-- crates/raw/tests/corpus.rs | 35 ++++++++ docs/parity.md | 2 +- docs/whats-new.md | 3 + 8 files changed, 272 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3606369f..b1f42080 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2892,6 +2892,7 @@ dependencies = [ "lightcraft-raw", "lightcraft-scenes", "lightcraft-segment", + "lightcraft-tiff", "log", "miniz_oxide 0.8.9", "rayon", diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index b92742b1..d419eaea 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -40,3 +40,4 @@ sam = ["dep:lightcraft-segment"] [dev-dependencies] libc = { workspace = true } +lightcraft-tiff = { workspace = true } diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 91be84b8..1686ebb8 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -329,10 +329,24 @@ fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation { lightcraft_meta::extract(raw_bytes).orientation.unwrap_or(Orientation::Normal) } +/// What stands in for a raw file we can't decode: its largest embedded JPEG, else, for a TIFF-based +/// raw, the file's own first image when the TIFF reader can show it (a reduced RGB copy: the only +/// preview some containers carry). Returns image bytes for [`lightcraft_codecs::decode`]. +fn stand_in_image(bytes: &[u8]) -> Option> { + if let Some(jpeg) = lightcraft_raw::embedded_preview(bytes) { + return Some(std::borrow::Cow::Owned(jpeg)); + } + let undecodable = lightcraft_raw::probe(bytes).is_some_and(|f| !f.is_supported()); + (undecodable && lightcraft_codecs::sniff(bytes).is_some_and(|f| f == lightcraft_codecs::Format::Tiff)) + .then_some(std::borrow::Cow::Borrowed(bytes)) +} + /// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting. pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOptions) -> Option { - let jpeg = lightcraft_raw::embedded_preview(bytes)?; - match lightcraft_raw::embedded_preview_color_space(bytes) { + let jpeg = stand_in_image(bytes)?; + // the colour space is a property of the JPEG; a TIFF stand-in is read by the TIFF reader + let is_jpeg = matches!(jpeg, std::borrow::Cow::Owned(_)); + match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) { Some(space) => { let space = match space { lightcraft_raw::PreviewColorSpace::Srgb => lightcraft_codecs::NamedSpace::Srgb, @@ -346,7 +360,7 @@ pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOp /// Oriented size of the embedded preview of a raw file. fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> { - let jpeg = lightcraft_raw::embedded_preview(bytes)?; + let jpeg = stand_in_image(bytes)?; let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?; let (mut w, mut h) = (d.source_width, d.source_height); if preview_orientation(bytes, d.orientation).swaps_axes() { @@ -704,6 +718,49 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A raw whose TIFF shell holds only a small RGB image (IFD0) next to a private block, as Exif + /// declares a picture 25x its size. + fn tiff_shell(w: u32, h: u32, shell: bool) -> Vec { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd0.set(t::COMPRESSION, Value::Short(vec![1])); + let px: Vec = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + ifd0.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![px] }); + ifd0.set_child( + t::EXIF_IFD, + IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w * 25])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h * 25])), + ); + let mut b = TiffWriter::default().write(&[ifd0]).unwrap(); + if shell { + b.extend(std::iter::repeat_n(0x5au8, (w * h * 625 / 8) as usize + 100)); + } + b + } + + /// A TIFF raw whose only image is a small thumbnail used to open as a plain image of that size + /// ("a 150 MP file renders 296x220"), with nothing saying so. Now it is recognised as a raw we can't + /// decode: preview only, with the reason, still showing that image. + #[test] + fn thumbnail_only_tiff_raw_is_preview_only_not_a_plain_image() { + let b = tiff_shell(24, 16, true); + let p = probe_bytes("IMG_0001.IIQ", &b).unwrap(); + assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "IIQ", 24, 16)); + let why = p.preview_only.expect("marked preview only"); + assert!(why.contains("600x400") && why.contains("24x16"), "{why}"); + let (img, src) = load_bytes(&b, 12).unwrap(); + assert_eq!((img.width, img.height, src.raw), (12, 8, false)); + assert!(embedded_preview_srgb(&b, 12).is_some()); + // the same pixels without the private block are a small ordinary image + let plain = probe_bytes("small.tif", &tiff_shell(24, 16, false)).unwrap(); + assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 24, 16)); + assert!(lightcraft_raw::probe(&tiff_shell(24, 16, false)).is_none()); + } + #[test] fn quick_jobs_show_embedded_previews_then_cached_renders() { use crate::media::QuickSource; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index beb55883..5d0c5d82 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 13; +pub const RENDER_CACHE_VERSION: u64 = 14; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 105690ae..3bd59a3e 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -130,10 +130,7 @@ pub fn probe(bytes: &[u8]) -> Option { return Some(RawFormat::Dng); } let make = t.find(lightcraft_tiff::tags::MAKE).and_then(|e| e.value.as_str()).unwrap_or_default().to_ascii_uppercase(); - let has_cfa = t.all_ifds().iter().any(|i| { - i.u16(lightcraft_tiff::tags::PHOTOMETRIC) == Some(lightcraft_tiff::tags::photometric::CFA) - || i.u16(lightcraft_tiff::tags::COMPRESSION).is_some_and(|c| c == 34713 || c == 32767 || c == 32769 || c == 32770) - }); + let has_cfa = has_raw_ifd(&t); if make.starts_with("CANON") && t.ifds.len() >= 4 && t.ifds[3].u16(lightcraft_tiff::tags::COMPRESSION) == Some(6) { return Some(RawFormat::Cr2); } @@ -149,12 +146,94 @@ pub fn probe(bytes: &[u8]) -> Option { if make.starts_with("SAMSUNG") { return Some(RawFormat::Srw); } - if has_cfa { + if has_cfa || thumbnail_shell(&t, bytes.len()).is_some() { return Some(RawFormat::OtherTiff); } None } +/// Whether some IFD is marked as raw: CFA photometric, or a raw-only compression value. +fn has_raw_ifd(t: &Tiff) -> bool { + t.all_ifds().iter().any(|i| { + i.u16(lightcraft_tiff::tags::PHOTOMETRIC) == Some(lightcraft_tiff::tags::photometric::CFA) + || i.u16(lightcraft_tiff::tags::COMPRESSION).is_some_and(|c| c == 34713 || c == 32767 || c == 32769 || c == 32770) + }) +} + +/// A TIFF whose first image is only a reduced copy of a picture stored somewhere else. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ThumbnailShell { + /// Size of the first image (IFD0). + pub thumb: (u32, u32), + /// Size of the picture it stands in for. + pub full: (u32, u32), +} + +/// Some raw files are a TIFF "shell" around a private block: IFD0 holds a small RGB preview and nothing +/// in the TIFF structure marks the real image as raw (no CFA photometric, no raw compression). The +/// structure still gives them away, with a generic rule that names no maker or model: the file +/// declares a picture at least 4x larger than IFD0 in both directions, and either +/// - a `SubIFDs` child of IFD0 is that bigger image (an 8-bit grey mosaic in one such file), or +/// - the Exif `PixelXDimension` / `PixelYDimension` say so *and* the file holds enough bytes outside +/// every IFD's strips and tiles to carry that many pixels at one bit each (a downsized copy that kept +/// its old Exif size has no such block). +/// +/// A multi-page TIFF is not caught (its pages are the IFD chain, not `SubIFDs`), nor a pyramid whose +/// IFD0 is the full image. +fn thumbnail_shell(t: &Tiff, len: usize) -> Option { + use lightcraft_tiff::tags::{IMAGE_LENGTH, IMAGE_WIDTH, PIXEL_X_DIMENSION, PIXEL_Y_DIMENSION}; + let ifd0 = t.ifds.first()?; + let (w0, h0) = (ifd0.u32(IMAGE_WIDTH)?, ifd0.u32(IMAGE_LENGTH)?); + if w0 == 0 || h0 == 0 { + return None; + } + let much_bigger = |(w, h): (u32, u32)| u64::from(w) >= 4 * u64::from(w0) && u64::from(h) >= 4 * u64::from(h0); + let shell = |full| Some(ThumbnailShell { thumb: (w0, h0), full }); + let mut best: Option<(u32, u32)> = None; + for s in &ifd0.sub_ifds { + if s.image().is_ok() + && let (Some(w), Some(h)) = (s.u32(IMAGE_WIDTH), s.u32(IMAGE_LENGTH)) + && much_bigger((w, h)) + && best.is_none_or(|(bw, bh)| u64::from(w) * u64::from(h) > u64::from(bw) * u64::from(bh)) + { + best = Some((w, h)); + } + } + if let Some(full) = best { + return shell(full); + } + let exif = t.exif()?; + let full = (exif.u32(PIXEL_X_DIMENSION)?, exif.u32(PIXEL_Y_DIMENSION)?); + if !much_bigger(full) { + return None; + } + // bytes covered by image data (strips and tiles shared between IFDs, e.g. IFD0 and its thumbnail, count once) + let mut seen = std::collections::BTreeMap::new(); + for ifd in t.all_ifds() { + if let Ok(info) = ifd.image() { + for c in info.chunks(len as u64) { + seen.insert(c.offset, c.len); + } + } + } + let covered = seen.values().fold(0u64, |a, &l| a.saturating_add(l)); + let spare = (len as u64).saturating_sub(covered); + (spare.saturating_mul(8) >= u64::from(full.0) * u64::from(full.1)).then_some(())?; + shell(full) +} + +/// Why [`decode`] gives up on a file [`probe`] called [`RawFormat::OtherTiff`]. +fn other_tiff_reason(bytes: &[u8]) -> String { + let t = Tiff::parse_with(bytes, &lightcraft_tiff::ParseOptions { max_ifds: 256, ..Default::default() }).ok(); + match t.as_ref().filter(|t| !has_raw_ifd(t)).and_then(|t| thumbnail_shell(t, bytes.len())) { + Some(s) => format!( + "{}x{} raw image in a private block, not decoded yet (the file's first image is a {}x{} reduced copy)", + s.full.0, s.full.1, s.thumb.0, s.thumb.1 + ), + None => "OtherTiff files are not decoded yet".to_string(), + } +} + /// Decode a raw file. pub fn decode(bytes: &[u8]) -> Result { decode_with(bytes, Mode::Full) @@ -195,6 +274,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result { RawFormat::Rw2 => vendor::rw2::decode(bytes, mode), RawFormat::Pef => vendor::pef::decode(bytes, mode), RawFormat::Orf => vendor::orf::decode(bytes, mode), + RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))), other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))), } } @@ -623,6 +703,91 @@ mod tests { assert_eq!(BlackLevel::uniform(7.0).at(9, 9, 0, 3), 7.0); } + // --- TIFF shells around a private raw block (thumbnail-only IFD0) --- + + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + + /// An uncompressed 8-bit RGB image IFD. + fn rgb_ifd(w: u32, h: u32) -> IfdBuilder { + let mut ifd = IfdBuilder::new(); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![90u8; (w * h * 3) as usize]] }); + ifd + } + + fn exif_size(w: u32, h: u32) -> IfdBuilder { + IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h])) + } + + fn write(chain: &[IfdBuilder]) -> Vec { + TiffWriter::default().write(chain).unwrap() + } + + const SHELL_REASON: &str = "raw image in a private block"; + + /// A bigger image in a `SubIFDs` child of a small IFD0 (the layout of an 8-bit-mosaic TIFF raw). + #[test] + fn thumbnail_with_a_bigger_sub_ifd_is_an_undecodable_raw() { + let mut ifd0 = rgb_ifd(16, 12); + ifd0.add_sub_ifd(rgb_ifd(64, 48)); + let bytes = write(&[ifd0]); + assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); + let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; + assert!(why.contains("64x48") && why.contains("16x12") && why.contains(SHELL_REASON), "{why}"); + // a sub-IFD that is not much bigger (a pyramid level) does not count + let mut ifd0 = rgb_ifd(16, 12); + ifd0.add_sub_ifd(rgb_ifd(32, 24)); + assert_eq!(probe(&write(&[ifd0])), None); + } + + /// Exif says the picture is far bigger than IFD0 and the file holds a block that could carry it. + #[test] + fn thumbnail_with_a_private_block_behind_the_exif_size_is_an_undecodable_raw() { + let mut ifd0 = rgb_ifd(16, 12); + ifd0.set_child(t::EXIF_IFD, exif_size(400, 300)); + let mut bytes = write(&[ifd0.clone()]); + // no private block: a downsized copy that kept its old Exif size is an ordinary image + assert_eq!(probe(&bytes), None); + // 400 x 300 pixels at one bit each is 15000 bytes: 14000 is not enough, 16000 is + bytes.extend(std::iter::repeat_n(7u8, 14_000)); + assert_eq!(probe(&bytes), None); + bytes.extend(std::iter::repeat_n(7u8, 2_000)); + assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); + let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; + assert!(why.contains("400x300") && why.contains("16x12") && why.contains(SHELL_REASON), "{why}"); + // truncating it never panics + for n in (0..bytes.len()).step_by(37) { + let _ = probe(&bytes[..n]); + let _ = decode(&bytes[..n]); + } + } + + /// Ordinary TIFFs stay images: the full picture first, equal Exif size, or later pages that are bigger. + #[test] + fn ordinary_tiffs_are_not_shells() { + let padded = |chain: &[IfdBuilder]| { + let mut b = write(chain); + b.extend(std::iter::repeat_n(0u8, 100_000)); + b + }; + let mut full = rgb_ifd(400, 300); + full.set_child(t::EXIF_IFD, exif_size(400, 300)); + assert_eq!(probe(&padded(&[full])), None); + // the Exif size is much bigger than IFD0, with spare bytes, but IFD0 is not tiny next to it + let mut near = rgb_ifd(120, 90); + near.set_child(t::EXIF_IFD, exif_size(400, 300)); + assert_eq!(probe(&padded(&[near])), None); + // a multi-page TIFF whose first page is small + assert_eq!(probe(&padded(&[rgb_ifd(16, 12), rgb_ifd(400, 300)])), None); + // no dimensions at all + assert_eq!(probe(&padded(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("X".into()))])), None); + } + #[test] fn probe_rejects_junk() { assert_eq!(probe(b""), None); diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 42cca03a..b3c0ade9 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -534,3 +534,38 @@ fn corpus_fujifilm_compressed_samples() { } eprintln!("verified {seen} Fujifilm compressed sensor arrays"); } + +/// The container rule for TIFF "shells" (a small IFD0 next to a private raw block) must not touch real +/// raws: every corpus file keeps the format its maker implies, and none is described as a private block. +#[test] +fn corpus_raws_keep_their_container_and_are_not_thumbnail_shells() { + let dir = corpus_root().join("raw"); + let Ok(rd) = std::fs::read_dir(&dir) else { + eprintln!("skip: {} absent", dir.display()); + return; + }; + let mut checked = 0; + for p in rd.flatten().map(|e| e.path()).filter(|p| p.is_file()) { + let name = p.file_name().unwrap().to_string_lossy().to_lowercase(); + let expected = match name.split(['-', '.']).next().unwrap_or_default() { + "arw" => RawFormat::Arw, + "cr2" => RawFormat::Cr2, + "cr3" => RawFormat::Cr3, + "dng" => RawFormat::Dng, + "nef" => RawFormat::Nef, + "nrw" => RawFormat::Nef, // probed as Nef when the file has several IFDs + "orf" => RawFormat::Orf, + "pef" => RawFormat::Pef, + "raf" => RawFormat::Raf, + "rw2" | "rwl" | "raw" => RawFormat::Rw2, + _ => continue, + }; + let bytes = std::fs::read(&p).unwrap(); + assert_eq!(probe(&bytes), Some(expected), "{name}"); + if let Err(RawError::Unsupported(why)) = probe_info(&bytes) { + assert!(!why.contains("private block"), "{name}: {why}"); + } + checked += 1; + } + eprintln!("{checked} corpus raws keep their container"); +} diff --git a/docs/parity.md b/docs/parity.md index 72401646..42568f97 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -138,7 +138,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop; full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode, palette (indexed-colour) TIFF (refused by the `tiff` crate) | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop; full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), HEIC/AVIF decode, palette (indexed-colour) TIFF (refused by the `tiff` crate) | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | diff --git a/docs/whats-new.md b/docs/whats-new.md index d02e7f22..da89a1b7 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -17,6 +17,9 @@ are unchanged; photos imported before this change get it when imported again. - Canon CR3 raws now develop from their sensor data: lossless RAW and C-RAW, checked sample for sample on the EOS M50, R100 and R8. CR3 files the decoder can't read yet still open from their embedded JPEG, as before. +- Raw files whose raw data sits in a private block of a TIFF (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS + TIFFs) are no longer opened as a thumbnail-sized ordinary image. They are recognised as raws LightCraft can't decode + yet and import as "preview only", with the reason. - Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB, preserving RAW editing & full-resolution export instead of using embedded JPEG previews. - Sony A7R II (and other) raws whose camera JPEG is lens-corrected no longer open grey and too dark (issue #232): the