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.
This commit is contained in:
radialmonster 2026-10-08 14:26:09 -04:00
parent 48cd9c5e3a
commit 0b8c7a237a
8 changed files with 272 additions and 10 deletions

1
Cargo.lock generated
View File

@ -2892,6 +2892,7 @@ dependencies = [
"lightcraft-raw", "lightcraft-raw",
"lightcraft-scenes", "lightcraft-scenes",
"lightcraft-segment", "lightcraft-segment",
"lightcraft-tiff",
"log", "log",
"miniz_oxide 0.8.9", "miniz_oxide 0.8.9",
"rayon", "rayon",

View File

@ -40,3 +40,4 @@ sam = ["dep:lightcraft-segment"]
[dev-dependencies] [dev-dependencies]
libc = { workspace = true } libc = { workspace = true }
lightcraft-tiff = { workspace = true }

View File

@ -329,10 +329,24 @@ fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation {
lightcraft_meta::extract(raw_bytes).orientation.unwrap_or(Orientation::Normal) 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<std::borrow::Cow<'_, [u8]>> {
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. /// 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<lightcraft_codecs::Decoded> { pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOptions) -> Option<lightcraft_codecs::Decoded> {
let jpeg = lightcraft_raw::embedded_preview(bytes)?; let jpeg = stand_in_image(bytes)?;
match lightcraft_raw::embedded_preview_color_space(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) => { Some(space) => {
let space = match space { let space = match space {
lightcraft_raw::PreviewColorSpace::Srgb => lightcraft_codecs::NamedSpace::Srgb, 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. /// Oriented size of the embedded preview of a raw file.
fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> { 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 d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?;
let (mut w, mut h) = (d.source_width, d.source_height); let (mut w, mut h) = (d.source_width, d.source_height);
if preview_orientation(bytes, d.orientation).swaps_axes() { if preview_orientation(bytes, d.orientation).swaps_axes() {
@ -704,6 +718,49 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir); 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<u8> {
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<u8> = (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] #[test]
fn quick_jobs_show_embedded_previews_then_cached_renders() { fn quick_jobs_show_embedded_previews_then_cached_renders() {
use crate::media::QuickSource; use crate::media::QuickSource;

View File

@ -62,7 +62,7 @@ impl SettingsHashes {
} }
/// Bump when the pipeline's output changes, to invalidate cached thumbnails. /// 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). /// 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]; pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];

View File

@ -130,10 +130,7 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
return Some(RawFormat::Dng); 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 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| { let has_cfa = has_raw_ifd(&t);
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)
});
if make.starts_with("CANON") && t.ifds.len() >= 4 && t.ifds[3].u16(lightcraft_tiff::tags::COMPRESSION) == Some(6) { if make.starts_with("CANON") && t.ifds.len() >= 4 && t.ifds[3].u16(lightcraft_tiff::tags::COMPRESSION) == Some(6) {
return Some(RawFormat::Cr2); return Some(RawFormat::Cr2);
} }
@ -149,12 +146,94 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
if make.starts_with("SAMSUNG") { if make.starts_with("SAMSUNG") {
return Some(RawFormat::Srw); return Some(RawFormat::Srw);
} }
if has_cfa { if has_cfa || thumbnail_shell(&t, bytes.len()).is_some() {
return Some(RawFormat::OtherTiff); return Some(RawFormat::OtherTiff);
} }
None 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<ThumbnailShell> {
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. /// Decode a raw file.
pub fn decode(bytes: &[u8]) -> Result<RawImage> { pub fn decode(bytes: &[u8]) -> Result<RawImage> {
decode_with(bytes, Mode::Full) decode_with(bytes, Mode::Full)
@ -195,6 +274,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> {
RawFormat::Rw2 => vendor::rw2::decode(bytes, mode), RawFormat::Rw2 => vendor::rw2::decode(bytes, mode),
RawFormat::Pef => vendor::pef::decode(bytes, mode), RawFormat::Pef => vendor::pef::decode(bytes, mode),
RawFormat::Orf => vendor::orf::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"))), 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); 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<u8> {
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] #[test]
fn probe_rejects_junk() { fn probe_rejects_junk() {
assert_eq!(probe(b""), None); assert_eq!(probe(b""), None);

View File

@ -534,3 +534,38 @@ fn corpus_fujifilm_compressed_samples() {
} }
eprintln!("verified {seen} Fujifilm compressed sensor arrays"); 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");
}

View File

@ -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-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-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-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-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-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 | | 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 |

View File

@ -17,6 +17,9 @@
are unchanged; photos imported before this change get it when imported again. 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 - 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. 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, - 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. 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 - Sony A7R II (and other) raws whose camera JPEG is lens-corrected no longer open grey and too dark (issue #232): the