diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index ca219203..d6304944 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 = 17; +pub const RENDER_CACHE_VERSION: u64 = 23; /// 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 5a513d49..31c4f775 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -981,22 +981,36 @@ mod tests { assert_eq!(r.cfa.unwrap().name(), "RGGB"); } - /// A lossless-JPEG scan whose predictor selection value is outside 0 to 7 (the 3FR/FFF files use 8) is - /// not a coding the lossless decoder knows: the file stays an undecodable raw, with that as the reason, - /// and keeps the stand-in preview an unsupported container gets. + /// Hasselblad's own lossless-JPEG coding (selection value 8, as in FFF and most compressed 3FR files) of the + /// same layout decodes: the file is read like the uncompressed one. + #[test] + fn selection_value_8_cfa_tiff_decodes() { + let strip = ljpeg::tests::encode_hasselblad(&cfa_samples(), 8, 6); + let bytes = plain_cfa_tiff(7, strip, None); + assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); + let r = decode(&bytes).unwrap(); + assert_eq!((r.format, r.width, r.height), (RawFormat::CfaTiff, 8, 6)); + assert_eq!(r.data, RawData::U16(cfa_samples())); + assert_eq!(r.cfa.unwrap().name(), "RGGB"); + assert_eq!(probe_info(&bytes).unwrap().format, RawFormat::CfaTiff); + } + + /// A lossless-JPEG scan whose predictor selection value is neither 0 to 7 (T.81) nor 8 (Hasselblad) is not a + /// coding the lossless decoder knows: the file stays an undecodable raw, with that as the reason, and keeps the + /// stand-in preview an unsupported container gets. #[test] fn cfa_tiff_with_an_unknown_predictor_stays_undecodable() { let mut strip = ljpeg::encode(&cfa_samples(), 8, 6, 1, 16, 1, 0); let sos = strip.windows(2).position(|w| w == [0xff, 0xda]).unwrap(); let ss = sos + 2 + 2 + 1 + 2; assert_eq!(strip[ss], 1, "selection value byte"); - strip[ss] = 8; - assert!(matches!(ljpeg::decode(&strip, 1 << 20), Err(RawError::Unsupported(w)) if w.contains("selection value 8"))); + strip[ss] = 9; + assert!(matches!(ljpeg::decode(&strip, 1 << 20), Err(RawError::Unsupported(w)) if w.contains("selection value 9"))); let bytes = plain_cfa_tiff(7, strip, None); assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); assert!(!RawFormat::OtherTiff.is_supported()); let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; - assert!(why.contains("lossless JPEG") && why.contains("selection value 8"), "{why}"); + assert!(why.contains("lossless JPEG") && why.contains("selection value 9"), "{why}"); } /// Other codings of a CFA IFD (here Deflate) are not claimed by the plain reader. diff --git a/crates/raw/src/ljpeg.rs b/crates/raw/src/ljpeg.rs index eca3a59a..94330eba 100644 --- a/crates/raw/src/ljpeg.rs +++ b/crates/raw/src/ljpeg.rs @@ -5,6 +5,8 @@ //! restart intervals, `SSSS = 16` differences. //! - [`decode`] returns the frame as interleaved samples in raster order (`width × height × components`), //! which callers re-tile (DNG tiles, CR2 slices). +//! - Selection value 8, which T.81 leaves undefined, is Hasselblad's coding in 3FR and FFF files +//! ([`decode`] reads it; [`decode_hasselblad`] documents the rule and how it was established). //! - [`encode`] writes a conforming stream with per-component optimal Huffman tables built with the //! T.81 Annex K.2 procedure — used by tests (bit-exact round trips) and later by DNG export. @@ -81,7 +83,7 @@ impl Huffman { } #[inline] - pub fn decode(&self, br: &mut BitReader) -> Result { + pub fn decode(&self, br: &mut B) -> Result { let peek = br.peek(LOOKUP); let (len, sym) = self.fast[peek as usize]; if len > 0 { @@ -103,6 +105,33 @@ impl Huffman { } } +/// A source of entropy-coded bits, first bit most significant. +pub trait Bits { + /// The next `n` (1..=32) bits without consuming them; zeros past the end. + fn peek(&mut self, n: u32) -> u32; + fn consume(&mut self, n: u32); + #[inline] + fn get(&mut self, n: u32) -> u32 { + if n == 0 { + return 0; + } + let v = self.peek(n); + self.consume(n); + v + } +} + +impl Bits for BitReader<'_> { + #[inline] + fn peek(&mut self, n: u32) -> u32 { + BitReader::peek(self, n) + } + #[inline] + fn consume(&mut self, n: u32) { + BitReader::consume(self, n) + } +} + /// MSB-first bit reader over JPEG entropy-coded data (handles `FF 00` stuffing; stops at markers, feeding zeros). pub struct BitReader<'a> { data: &'a [u8], @@ -190,7 +219,7 @@ impl<'a> BitReader<'a> { /// Difference value for category `ssss` (T.81 Table H.2 / F.12 EXTEND). #[inline] -pub fn diff_value(br: &mut BitReader, ssss: u8) -> i32 { +pub fn diff_value(br: &mut B, ssss: u8) -> i32 { match ssss { 0 => 0, 16 => 32768, @@ -325,9 +354,9 @@ fn parse_header(d: &[u8]) -> Result { } h.predictor = seg[1 + 2 * ns]; h.pt = seg[3 + 2 * ns] & 15; - if h.predictor > 7 { + if h.predictor > 8 { return Err(RawError::Unsupported(format!( - "lossless JPEG scan with predictor selection value {} (T.81 defines 0 to 7)", + "lossless JPEG scan with predictor selection value {} (T.81 defines 0 to 7, Hasselblad uses 8)", h.predictor ))); } @@ -347,6 +376,9 @@ fn parse_header(d: &[u8]) -> Result { pub fn frame_info(d: &[u8]) -> Result<(usize, usize, usize, u8), RawError> { let h = parse_header(d)?; check_full_resolution(&h)?; + if h.predictor == 8 { + check_hasselblad(&h)?; + } Ok((h.width, h.height, h.comps.len(), h.precision)) } @@ -357,6 +389,134 @@ fn check_full_resolution(h: &Header) -> Result<(), RawError> { Ok(()) } +/// The layout of every Hasselblad selection-value-8 scan measured; other layouts are not known to follow the rule. +fn check_hasselblad(h: &Header) -> Result<(), RawError> { + if h.comps.len() != 1 || h.precision != 16 || h.pt != 0 || h.restart != 0 || h.width == 0 || !h.width.is_multiple_of(2) { + return Err(RawError::Unsupported(format!( + "lossless JPEG with selection value 8 and {} components, {} bits, point transform {}, restart interval {}, width {}", + h.comps.len(), + h.precision, + h.pt, + h.restart, + h.width + ))); + } + Ok(()) +} + +/// Bits of Hasselblad's entropy data: 32-bit little-endian words, each read from its most significant bit, with +/// no byte stuffing (every byte is data; `FF` bytes are not markers). +struct WordBits<'a> { + data: &'a [u8], + pos: usize, + buf: u64, + bits: u32, + /// Zero bits fed past the last whole word (consumption reached them once `overrun > bits`). + overrun: u32, +} + +impl<'a> WordBits<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0, buf: 0, bits: 0, overrun: 0 } + } + + #[inline] + fn fill(&mut self) { + while self.bits <= 32 { + let word = match self.data.get(self.pos..self.pos + 4) { + Some(w) => { + self.pos += 4; + u32::from_le_bytes([w[0], w[1], w[2], w[3]]) + } + None => { + self.overrun = self.overrun.saturating_add(32); + 0 + } + }; + self.buf |= (word as u64) << (32 - self.bits); + self.bits += 32; + } + } +} + +impl Bits for WordBits<'_> { + #[inline] + fn peek(&mut self, n: u32) -> u32 { + if self.bits < n { + self.fill(); + } + (self.buf >> (64 - n)) as u32 + } + #[inline] + fn consume(&mut self, n: u32) { + self.buf <<= n; + self.bits -= n; + } +} + +/// Hasselblad's lossless JPEG with selection value 8 (3FR and FFF raws). +/// +/// The rule was established by measurement on 14 sample files with the `ljpeg-explorer` tool, which tested it +/// against alternative bit orders, stuffing, sample groupings, category-16 treatments and predictors: +/// - the entropy data are 32-bit little-endian words, each read from its most significant bit, without byte +/// stuffing; +/// - each row is coded in pairs of samples: both Huffman categories (`SSSS`, T.81 table) come first, then both +/// differences; +/// - a difference has `SSSS` extra bits, extended as in T.81 Table H.2; after `SSSS = 16` come sixteen bits that +/// take no part, and the difference is -32768 (equal to T.81's +32768 modulo 2^16); +/// - a sample is the previous sample of the same column parity in its row plus the difference, modulo 2^16; before +/// the first pair of every row both are 32768 (half the 16-bit range). +pub fn decode_hasselblad(d: &[u8], max_samples: usize) -> Result { + let h = parse_header(d)?; + if h.predictor != 8 { + return Err(err("not a selection value 8 scan")); + } + hasselblad_frame(d, &h, max_samples) +} + +fn hasselblad_frame(d: &[u8], h: &Header, max_samples: usize) -> Result { + check_hasselblad(h)?; + let (w, ht) = (h.width, h.height); + let total = w.checked_mul(ht).ok_or_else(|| err("frame too large"))?; + if total > max_samples { + return Err(RawError::Limit("lossless JPEG frame larger than expected")); + } + let entropy = &d[h.scan_start..]; + if (entropy.len() as u64 + 64) * 8 < total as u64 { + return Err(err("entropy data too short for frame")); + } + let table = + h.table_for.first().and_then(|&t| h.tables.get(t)).and_then(Option::as_ref).ok_or_else(|| err("scan uses an undefined Huffman table"))?; + let mut out = vec![0u16; total]; + let mut br = WordBits::new(entropy); + let start = 1u32 << (h.precision - 1); + for row in out.chunks_exact_mut(w) { + let mut last = [start, start]; + // the width is even (checked), so every sample is in a pair + for pair in row.as_chunks_mut::<2>().0 { + let ssss = [table.decode(&mut br)?, table.decode(&mut br)?]; + for (k, s) in ssss.into_iter().enumerate() { + let diff = match s { + 0..=15 => diff_value(&mut br, s), + 16 => { + br.get(16); + -32768 + } + _ => return Err(err("invalid difference category")), + }; + let v = (last[k] as i32 + diff) as u32 & 0xffff; + last[k] = v; + pair[k] = v as u16; + } + } + // bits consumed beyond the last whole word + if br.overrun > br.bits { + return Err(err("entropy data exhausted")); + } + } + Ok(Frame { width: w, height: ht, components: 1, precision: h.precision, predictor: 8, point_transform: 0, data: out }) +} + fn check_subsampled(h: &Header) -> Result { let vertical = match h.sampling.as_slice() { [0x22, 0x11, 0x11] => 2, @@ -459,6 +619,9 @@ pub(crate) fn decode_subsampled(d: &[u8], max_samples: usize) -> Result Result { let h = parse_header(d)?; check_full_resolution(&h)?; + if h.predictor == 8 { + return hasselblad_frame(d, &h, max_samples); + } let (w, ht, nc) = (h.width, h.height, h.comps.len()); if w == 0 || ht == 0 { return Err(err("zero frame size")); @@ -945,6 +1108,150 @@ pub(crate) mod tests { let _ = decode(&bad, usize::MAX); } + // --- selection value 8 (Hasselblad 3FR / FFF) --- + + /// SOI, SOF3 (16 bits, `height` x `width`, one component), a DHT and an SOS with selection value 8, then + /// `entropy` and EOI. + fn hasselblad_stream(width: u16, height: u16, counts: &[u8; 16], values: &[u8], entropy: &[u8]) -> Vec { + let mut out = vec![0xff, 0xd8, 0xff, 0xc3, 0, 11, 16]; + out.extend_from_slice(&height.to_be_bytes()); + out.extend_from_slice(&width.to_be_bytes()); + out.extend_from_slice(&[1, 0, 0x11, 0]); + out.extend_from_slice(&[0xff, 0xc4]); + out.extend_from_slice(&((2 + 17 + values.len()) as u16).to_be_bytes()); + out.push(0); + out.extend_from_slice(counts); + out.extend_from_slice(values); + out.extend_from_slice(&[0xff, 0xda, 0, 8, 1, 0, 0, 8, 0, 0]); + out.extend_from_slice(entropy); + out.extend_from_slice(&[0xff, 0xd9]); + out + } + + /// Codes: SSSS 0 = `00`, 1 = `01`, 2 = `100`, 15 = `101`, 16 = `110`. + const HAND_COUNTS: [u8; 16] = [0, 2, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + const HAND_VALUES: [u8; 5] = [0, 1, 2, 15, 16]; + + /// A 4 x 2 frame written out bit by bit: + /// - row 0, pair 1: codes `110` `110` (SSSS 16 twice), then 16 bits each (`FFFF`, `7FFF`) that do not count: + /// both samples are 32768 - 32768 = 0; + /// - row 0, pair 2: codes `01` `100`, bits `1` (+1) and `01` (-2): 0 + 1 = 1 and 0 - 2 = 65534 (modulo 2^16); + /// - row 1, pair 1: codes `00` `01`, bit `0` (-1): both predictors start again at 32768, so 32768 and 32767; + /// - row 1, pair 2: codes `100` `00`, bits `11` (+3): 32771 and 32767. + /// + /// 58 bits, padded with zeros to two 32-bit words `DBFFFDFF` `FD9450C0`, stored little-endian. The `FF FD` + /// they start with would be a marker to a T.81 reader: this coding has no byte stuffing. + const HAND_WORDS: [u8; 8] = [0xff, 0xfd, 0xff, 0xdb, 0xc0, 0x50, 0x94, 0xfd]; + const HAND_SAMPLES: [u16; 8] = [0, 0, 1, 65534, 32768, 32767, 32771, 32767]; + + #[test] + fn selection_value_8_known_answer() { + let enc = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); + assert_eq!(frame_info(&enc).unwrap(), (4, 2, 1, 16)); + let f = decode(&enc, 8).unwrap(); + assert_eq!((f.width, f.height, f.components, f.predictor), (4, 2, 1, 8)); + assert_eq!(f.data, HAND_SAMPLES); + assert_eq!(decode_hasselblad(&enc, 8).unwrap().data, HAND_SAMPLES); + assert!(matches!(decode(&enc, 7), Err(RawError::Limit(_)))); + } + + /// The same bits in plain byte order (not 32-bit little-endian words) are a different stream. + #[test] + fn selection_value_8_reads_little_endian_words() { + let mut plain = HAND_WORDS; + plain[..4].reverse(); + plain[4..].reverse(); + let enc = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &plain); + assert_ne!(decode(&enc, 8).map(|f| f.data).ok(), Some(HAND_SAMPLES.to_vec())); + } + + #[test] + fn selection_value_8_rejects_bad_streams() { + // one word is not enough for the second row + let short = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS[..4]); + assert!(decode(&short, 8).is_err()); + // a category above 16 + let enc = hasselblad_stream(4, 2, &HAND_COUNTS, &[0, 1, 2, 15, 17], &HAND_WORDS); + assert!(decode(&enc, 8).is_err()); + // layouts never seen with selection value 8 are not guessed at + let mut odd = hasselblad_stream(3, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); + assert!(matches!(frame_info(&odd), Err(RawError::Unsupported(_)))); + assert!(matches!(decode(&odd, 8), Err(RawError::Unsupported(_)))); + odd = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); + odd[6] = 14; // precision + assert!(matches!(decode(&odd, 8), Err(RawError::Unsupported(_)))); + // selection values above 8 stay unknown + let mut nine = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); + let sos = nine.windows(2).position(|w| w == [0xff, 0xda]).unwrap(); + nine[sos + 7] = 9; + assert!(matches!(decode(&nine, 8), Err(RawError::Unsupported(w)) if w.contains("selection value 9"))); + // every prefix fails cleanly + let enc = hasselblad_stream(4, 2, &HAND_COUNTS, &HAND_VALUES, &HAND_WORDS); + for n in 0..enc.len() { + let _ = decode(&enc[..n], 8); + } + } + + /// Test-only writer of the selection-value-8 coding (the inverse of the rule in [`decode_hasselblad`]), with + /// a 5-bit code for every category. Category-16 differences carry `FFFF` as their ignored bits. + pub(crate) fn encode_hasselblad(data: &[u16], width: usize, height: usize) -> Vec { + assert!(width.is_multiple_of(2) && data.len() == width * height); + let mut bits: Vec = vec![]; + let put = |bits: &mut Vec, v: u32, n: u32| { + for i in (0..n).rev() { + bits.push((v >> i) & 1 == 1); + } + }; + for row in data.chunks_exact(width) { + let mut last = [32768i32, 32768]; + for pair in row.as_chunks::<2>().0 { + let mut d = [0i32; 2]; + for k in 0..2 { + let mut v = (pair[k] as i32 - last[k]) & 0xffff; + if v >= 32768 { + v -= 65536; + } + d[k] = v; + last[k] = pair[k] as i32; + } + let s = [ssss_of(d[0]), ssss_of(d[1])]; + put(&mut bits, s[0] as u32, 5); + put(&mut bits, s[1] as u32, 5); + for k in 0..2 { + match s[k] { + 0 => {} + 16 => put(&mut bits, 0xffff, 16), + n => { + let v = if d[k] < 0 { d[k] - 1 } else { d[k] }; + put(&mut bits, v as u32 & ((1 << n) - 1), n as u32); + } + } + } + } + } + let mut entropy = vec![]; + for word in bits.chunks(32) { + let w = word.iter().enumerate().fold(0u32, |a, (i, &b)| a | ((b as u32) << (31 - i))); + entropy.extend_from_slice(&w.to_le_bytes()); + } + let mut counts = [0u8; 16]; + counts[4] = 17; + let values: Vec = (0..=16).collect(); + hasselblad_stream(width as u16, height as u16, &counts, &values, &entropy) + } + + #[test] + fn selection_value_8_round_trips_through_the_test_writer() { + // extremes force SSSS 16 and wrap-around; noise exercises every other category + let mut data = noise(10 * 7, 16, 9); + data[0] = 0; + data[1] = 65535; + data[2] = 65535; + data[3] = 0; + let enc = encode_hasselblad(&data, 10, 7); + assert_eq!(decode(&enc, 70).unwrap().data, data); + } + #[test] fn code_lengths_are_limited_to_16() { // Fibonacci-like frequencies produce very long codes before adjustment diff --git a/docs/parity.md b/docs/parity.md index 87a90e35..96626978 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -142,7 +142,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 (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); 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`, `crates/raw/src/jxl.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 and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones decode but no real JPEG XL DNG has verified them yet; a single-chunk JPEG XL preview IFD is offered as the embedded preview), 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`), Hasselblad 3FR with uncompressed raw data (X1D, X1D II, X2D, CFV 100C: a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`; the lossless-JPEG 3FR and FFF files use scan selection value 8 and stay preview-only). 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`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature: `crates/heif`, heic-rs, same policy as PhotoCraft; 8/10-bit, alpha, irot/imir/clap, ICC/EXIF/XMP), 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`, `crates/raw/src/jxl.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 and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones decode but no real JPEG XL DNG has verified them yet; a single-chunk JPEG XL preview IFD is offered as the embedded preview), 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`), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool. 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`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature: `crates/heif`, heic-rs, same policy as PhotoCraft; 8/10-bit, alpha, irot/imir/clap, ICC/EXIF/XMP), 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 |