diff --git a/ROADMAP.md b/ROADMAP.md index c1cc9d8a..6593c9b9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,7 +37,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). |---|---:|---| | JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks | | Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW preview estimates are only a starting point) | -| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only | +| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only (CR3: the full-size JPEG with full metadata) | | Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering | | Relies on AI (masks, denoise) | ~25% | No segmentation or AI denoise models | diff --git a/crates/meta/src/container.rs b/crates/meta/src/container.rs index 7e6c7270..8dda6b55 100644 --- a/crates/meta/src/container.rs +++ b/crates/meta/src/container.rs @@ -7,6 +7,8 @@ //! - WebP (RIFF container spec): `EXIF`, `XMP `, `ICCP` chunks. //! - TIFF-based files (TIFF, DNG and most raws): the whole file is the Exif block; XMP (700), ICC (34675) and //! IPTC (33723) from IFD0. +//! - Canon CR3 (ISO base media file, see [`crate::cr3`]): `CMT1`/`CMT2`/`CMT4` merged into one Exif block; the +//! XMP `uuid` box. use lightcraft_tiff::{Tiff, tags}; @@ -34,6 +36,9 @@ pub fn embedded(bytes: &[u8]) -> Embedded { _ => Embedded::default(), }; } + if let Some(c) = crate::cr3::parse_cr3(bytes) { + return Embedded { exif: crate::cr3::merged_exif(&c), xmp: c.xmp.map(|b| String::from_utf8_lossy(b).into_owned()), ..Default::default() }; + } if bytes.starts_with(&[0xff, 0xd8]) { jpeg_segments(bytes) } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { diff --git a/crates/meta/src/cr3.rs b/crates/meta/src/cr3.rs new file mode 100644 index 00000000..18d33ef7 --- /dev/null +++ b/crates/meta/src/cr3.rs @@ -0,0 +1,344 @@ +//! Canon CR3: the ISO base media file container (ISO/IEC 14496-12 box structure; CR3 layout from Laurent +//! Clévy's public CR3 format notes, confirmed black-box on EOS R6 Mark III files). +//! +//! - `ftyp` with major brand `crx `. +//! - `moov` holds a Canon `uuid` box (85c0b687-820f-11e0-8111-f4ce462b6a48) whose children are `CMT1` (a TIFF +//! stream: IFD0), `CMT2` (TIFF: the Exif IFD), `CMT3` (TIFF: the Canon maker note), `CMT4` (TIFF: GPS) and the +//! `THMB` thumbnail, then one `trak` per stream: a full-size JPEG, a reduced raw, the full raw (`CRAW` sample +//! entries; raws carry a `CMP1` coding header) and timed metadata (`CTMD`). +//! - A top-level `uuid` box with the XMP uuid (be7acfcb-97a9-42e8-9c71-999491e3afac, XMP spec part 3) holds the +//! XMP packet. +//! +//! Every size and offset is checked against the buffer; nesting and box counts are bounded. + +/// A track's sample-entry kind. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Cr3TrackKind { + /// `CRAW` with a `JPEG` child: the full-size JPEG. + Jpeg, + /// `CRAW` raw image: dimensions and the `CMP1` coding header (byte range in the file). + Raw { width: u16, height: u16, cmp1: Option<(usize, usize)> }, + /// Anything else (`CTMD` timed metadata, unknown entries). + Other([u8; 4]), +} + +/// One track's single sample: where it is in the file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Cr3Track { + pub kind: Cr3TrackKind, + /// Byte offset and length of the sample (the first one) in the file, when the tables give them. + pub data: Option<(usize, usize)>, +} + +/// The parts of a CR3 file LightCraft reads. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Cr3<'a> { + /// `CMT1`…`CMT4` (index 0…3). + pub cmt: [Option<&'a [u8]>; 4], + pub thumbnail: Option<&'a [u8]>, + pub xmp: Option<&'a [u8]>, + pub tracks: Vec, +} + +const CANON_UUID: [u8; 16] = [0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48]; +const XMP_UUID: [u8; 16] = [0xbe, 0x7a, 0xcf, 0xcb, 0x97, 0xa9, 0x42, 0xe8, 0x9c, 0x71, 0x99, 0x94, 0x91, 0xe3, 0xaf, 0xac]; +const MAX_DEPTH: usize = 12; +const MAX_BOXES: usize = 4096; +/// A `VisualSampleEntry` (78 bytes after the box header) plus 4 bytes Canon adds before the child boxes. +const CRAW_CHILDREN_AT: usize = 82; + +/// Does this look like a CR3 file (`ftyp` box with major brand `crx `)? +pub fn is_cr3(bytes: &[u8]) -> bool { + bytes.get(4..12) == Some(b"ftypcrx ".as_slice()) +} + +/// Parse a CR3 file's boxes; `None` when it is not a CR3 file. +pub fn parse_cr3(bytes: &[u8]) -> Option> { + if !is_cr3(bytes) { + return None; + } + let mut out = Cr3::default(); + let mut budget = MAX_BOXES; + for b in boxes(bytes, 0, bytes.len(), &mut budget) { + match &b.kind { + b"moov" => moov(bytes, &b, &mut out, &mut budget), + b"uuid" if b.uuid == Some(XMP_UUID) => out.xmp = bytes.get(b.body..b.end), + _ => {} + } + } + Some(out) +} + +struct BoxRef { + kind: [u8; 4], + /// Start of the payload (after the header and, for `uuid`, the 16-byte uuid). + body: usize, + end: usize, + uuid: Option<[u8; 16]>, +} + +/// The boxes directly inside `[from, to)`; stops at the first malformed header. +fn boxes(bytes: &[u8], from: usize, to: usize, budget: &mut usize) -> Vec { + let mut out = Vec::new(); + let mut at = from; + let to = to.min(bytes.len()); + while at.saturating_add(8) <= to && *budget > 0 { + *budget -= 1; + let (Some(size), Some(kind)) = (be32(bytes, at), bytes.get(at + 4..at + 8)) else { break }; + let mut kind4 = [0u8; 4]; + kind4.copy_from_slice(kind); + let (len, mut hdr) = match size { + 0 => (to - at, 8), + 1 => match be64(bytes, at + 8).and_then(|v| usize::try_from(v).ok()) { + Some(l) => (l, 16), + None => break, + }, + n => (n as usize, 8), + }; + let Some(end) = at.checked_add(len).filter(|&e| e <= to && len >= hdr) else { break }; + let mut uuid = None; + if &kind4 == b"uuid" { + let Some(u) = bytes.get(at + hdr..at + hdr + 16).filter(|_| at + hdr + 16 <= end) else { break }; + let mut a = [0u8; 16]; + a.copy_from_slice(u); + uuid = Some(a); + hdr += 16; + } + out.push(BoxRef { kind: kind4, body: at + hdr, end, uuid }); + at = end; + } + out +} + +fn moov<'a>(bytes: &'a [u8], m: &BoxRef, out: &mut Cr3<'a>, budget: &mut usize) { + for b in boxes(bytes, m.body, m.end, budget) { + match &b.kind { + b"uuid" if b.uuid == Some(CANON_UUID) => { + for c in boxes(bytes, b.body, b.end, budget) { + let slot = match &c.kind { + b"CMT1" => 0, + b"CMT2" => 1, + b"CMT3" => 2, + b"CMT4" => 3, + b"THMB" => { + out.thumbnail = bytes.get(c.body..c.end); + continue; + } + _ => continue, + }; + out.cmt[slot] = bytes.get(c.body..c.end); + } + } + b"trak" => { + if let Some(t) = trak(bytes, &b, budget) { + out.tracks.push(t); + } + } + _ => {} + } + } +} + +/// Descend `trak` → `mdia` → `minf` → `stbl` and read its sample entry and first sample's location. +fn trak(bytes: &[u8], t: &BoxRef, budget: &mut usize) -> Option { + let mut stbl = None; + let mut stack = vec![(t.body, t.end, 0usize)]; + while let Some((from, to, depth)) = stack.pop() { + if depth > MAX_DEPTH { + continue; + } + for b in boxes(bytes, from, to, budget) { + match &b.kind { + b"mdia" | b"minf" => stack.push((b.body, b.end, depth + 1)), + b"stbl" => stbl = Some(b), + _ => {} + } + } + } + let stbl = stbl?; + let (mut kind, mut size, mut offset) = (None, None, None); + for b in boxes(bytes, stbl.body, stbl.end, budget) { + match &b.kind { + // full box: version/flags, entry count, entries + b"stsd" => kind = boxes(bytes, b.body + 8, b.end, budget).first().map(|e| sample_entry(bytes, e, budget)), + // version/flags, sample_size (0 = per-sample table), count, sizes + b"stsz" => { + size = match be32(bytes, b.body + 4)? { + 0 if be32(bytes, b.body + 8)? > 0 => be32(bytes, b.body + 12), + 0 => None, + n => Some(n), + } + } + b"co64" if be32(bytes, b.body + 4)? > 0 => offset = be64(bytes, b.body + 8).and_then(|v| usize::try_from(v).ok()), + b"stco" if be32(bytes, b.body + 4)? > 0 => offset = be32(bytes, b.body + 8).map(|v| v as usize), + _ => {} + } + } + let data = match (offset, size) { + (Some(o), Some(s)) if o.checked_add(s as usize).is_some_and(|e| e <= bytes.len()) => Some((o, s as usize)), + _ => None, + }; + Some(Cr3Track { kind: kind?, data }) +} + +fn sample_entry(bytes: &[u8], e: &BoxRef, budget: &mut usize) -> Cr3TrackKind { + if &e.kind != b"CRAW" { + return Cr3TrackKind::Other(e.kind); + } + let (Some(width), Some(height)) = (be16(bytes, e.body + 24), be16(bytes, e.body + 26)) else { + return Cr3TrackKind::Other(e.kind); + }; + let mut cmp1 = None; + for c in boxes(bytes, e.body.saturating_add(CRAW_CHILDREN_AT), e.end, budget) { + match &c.kind { + b"JPEG" => return Cr3TrackKind::Jpeg, + b"CMP1" => cmp1 = Some((c.body, c.end - c.body)), + _ => {} + } + } + Cr3TrackKind::Raw { width, height, cmp1 } +} + +fn be16(b: &[u8], at: usize) -> Option { + b.get(at..at.checked_add(2)?).map(|s| u16::from_be_bytes([s[0], s[1]])) +} +fn be32(b: &[u8], at: usize) -> Option { + b.get(at..at.checked_add(4)?).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])) +} +fn be64(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(8)?)?; + let mut a = [0u8; 8]; + a.copy_from_slice(s); + Some(u64::from_be_bytes(a)) +} + +/// Merge `CMT1` (IFD0), `CMT2` (Exif) and `CMT4` (GPS) into one TIFF stream, so the Exif readers see a CR3 +/// like any TIFF-based raw. The maker note (`CMT3`) is left out: its offsets are relative to its own block. +pub fn merged_exif(c: &Cr3<'_>) -> Option> { + use lightcraft_tiff::{IfdBuilder, Tiff, TiffWriter, tags as t}; + let ifd0 = Tiff::parse(c.cmt[0]?).ok()?; + let order = ifd0.order; + let first = |i: usize| c.cmt[i].and_then(|b| Tiff::parse(b).ok()).and_then(|t| t.ifds.into_iter().next()); + // pointers and offsets that would dangle once the IFDs move + const SKIP: [u16; 9] = [t::EXIF_IFD, t::GPS_IFD, t::INTEROP_IFD, t::SUB_IFDS, t::MAKER_NOTE, 273, 279, 513, 514]; + let build = |ifd: &lightcraft_tiff::Ifd| { + let mut b = IfdBuilder::new(); + for e in ifd.entries.iter().filter(|e| !SKIP.contains(&e.tag)) { + b.set(e.tag, e.value.clone()); + } + b + }; + let mut root = build(ifd0.ifds.first()?); + if let Some(exif) = first(1) { + root.set_child(t::EXIF_IFD, build(&exif)); + } + if let Some(gps) = first(3) { + root.set_child(t::GPS_IFD, build(&gps)); + } + TiffWriter::new(order, false).write(&[root]).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bx(kind: &[u8; 4], body: &[u8]) -> Vec { + let mut v = ((body.len() + 8) as u32).to_be_bytes().to_vec(); + v.extend_from_slice(kind); + v.extend_from_slice(body); + v + } + fn uuid(u: &[u8; 16], body: &[u8]) -> Vec { + let mut b = u.to_vec(); + b.extend_from_slice(body); + bx(b"uuid", &b) + } + fn full(kind: &[u8; 4], body: &[u8]) -> Vec { + let mut b = vec![0u8; 4]; + b.extend_from_slice(body); + bx(kind, &b) + } + fn craw(w: u16, h: u16, children: &[u8]) -> Vec { + let mut b = vec![0u8; CRAW_CHILDREN_AT]; + b[24..26].copy_from_slice(&w.to_be_bytes()); + b[26..28].copy_from_slice(&h.to_be_bytes()); + b.extend_from_slice(children); + bx(b"CRAW", &b) + } + fn trak(entry: Vec, offset: u64, size: u32) -> Vec { + let mut stsd = 1u32.to_be_bytes().to_vec(); + stsd.extend(entry); + let mut stsz = 0u32.to_be_bytes().to_vec(); + stsz.extend(1u32.to_be_bytes()); + stsz.extend(size.to_be_bytes()); + let mut co64 = 1u32.to_be_bytes().to_vec(); + co64.extend(offset.to_be_bytes()); + let stbl = [full(b"stsd", &stsd), full(b"stsz", &stsz), full(b"co64", &co64)].concat(); + bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl)))) + } + fn tiff_with(tag: u16, v: lightcraft_tiff::Value) -> Vec { + let b = lightcraft_tiff::IfdBuilder::new().with(tag, v); + lightcraft_tiff::TiffWriter::new(lightcraft_tiff::ByteOrder::Little, false).write(&[b]).unwrap() + } + + /// A small synthetic CR3: Canon uuid with CMT1/CMT2/CMT4, a JPEG track, a raw track, XMP. + pub(crate) fn sample() -> Vec { + use lightcraft_tiff::{Value, tags as t}; + let cmt1 = tiff_with(t::MODEL, Value::Ascii("Canon EOS Test".into())); + let cmt2 = tiff_with(t::ISO_SPEED, Value::Short(vec![400])); + let cmt4 = tiff_with(1, Value::Ascii("N".into())); + let canon = uuid(&CANON_UUID, &[bx(b"CMT1", &cmt1), bx(b"CMT2", &cmt2), bx(b"CMT4", &cmt4), bx(b"THMB", b"thumb")].concat()); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + let payload_at = 4096u64; + let jpeg_t = trak(craw(64, 48, &bx(b"JPEG", &[0; 4])), payload_at, 16); + let raw_t = trak(craw(60, 40, &bx(b"CMP1", &[0xff, 0x10, 0, 0x30])), payload_at + 16, 32); + file.extend(bx(b"moov", &[canon, jpeg_t, raw_t].concat())); + file.extend(uuid(&XMP_UUID, b"")); + file.resize(payload_at as usize + 64, 0); + file + } + + #[test] + fn parses_canon_boxes_tracks_and_xmp() { + let f = sample(); + assert!(is_cr3(&f)); + let c = parse_cr3(&f).unwrap(); + assert!(c.cmt[0].is_some() && c.cmt[1].is_some() && c.cmt[2].is_none() && c.cmt[3].is_some()); + assert_eq!(c.thumbnail, Some(b"thumb".as_slice())); + assert_eq!(c.xmp, Some(b"".as_slice())); + assert_eq!(c.tracks.len(), 2); + assert_eq!(c.tracks[0], Cr3Track { kind: Cr3TrackKind::Jpeg, data: Some((4096, 16)) }); + let Cr3TrackKind::Raw { width, height, cmp1: Some((at, len)) } = c.tracks[1].kind else { panic!("{:?}", c.tracks[1]) }; + assert_eq!((width, height, len), (60, 40, 4)); + assert_eq!(&f[at..at + 2], &[0xff, 0x10]); + assert_eq!(c.tracks[1].data, Some((4112, 32))); + } + + #[test] + fn merged_exif_reads_like_a_tiff_raw() { + let f = sample(); + let m = crate::read_exif(&merged_exif(&parse_cr3(&f).unwrap()).unwrap()); + assert_eq!(m.model.as_deref(), Some("Canon EOS Test")); + assert_eq!(m.iso, Some(400)); + } + + #[test] + fn hostile_input_never_panics() { + let f = sample(); + // every truncation and single-byte corruption parses to something or nothing + for n in 0..f.len().min(800) { + let _ = parse_cr3(&f[..n]).map(|c| merged_exif(&c)); + } + for i in 0..f.len().min(800) { + for v in [0u8, 1, 0x7f, 0xff] { + let mut g = f.clone(); + g[i] = v; + let _ = parse_cr3(&g).map(|c| merged_exif(&c)); + } + } + // a sample offset past the end is not reported + let mut g = f.clone(); + g.truncate(4100); + assert!(parse_cr3(&g).unwrap().tracks.iter().all(|t| t.data.is_none())); + } +} diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 3408aed7..594129d0 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -7,10 +7,12 @@ //! - [`parse_xmp`] / [`write_xmp`] — XMP packets (interchange fields + the opaque `lc:settings` JSON). //! - [`parse_iptc`] — IPTC-IIM record 2 datasets. //! - [`parse_gpx`] — GPS track logs (GPX) for geotagging by capture time. -//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw). +//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw, Canon CR3). +//! - [`cr3`] — the Canon CR3 container (metadata blocks, XMP, the JPEG and raw tracks). #![forbid(unsafe_code)] mod container; +pub mod cr3; mod datetime; mod exif; mod gpx; diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 050870ce..ded35d29 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -96,10 +96,20 @@ pub fn embedded_preview(bytes: &[u8]) -> Option> { found.into_iter().filter(|s| is_dct_jpeg(s)).max_by_key(|s| s.len()).map(|s| trim_eoi(s).to_vec()) } +/// Canon CR3: the full-size JPEG track (see [`lightcraft_meta::cr3`]), else the `PRVW` / `THMB` boxes. +fn cr3_preview(bytes: &[u8]) -> Option<&[u8]> { + let full = lightcraft_meta::cr3::parse_cr3(bytes) + .and_then(|c| c.tracks.iter().find(|t| t.kind == lightcraft_meta::cr3::Cr3TrackKind::Jpeg).and_then(|t| t.data)); + if let Some(j) = full.and_then(|(at, len)| bytes.get(at..at.checked_add(len)?)).filter(|j| is_dct_jpeg(j)) { + return Some(j); + } + cr3_preview_boxes(bytes) +} + /// Canon CR3 (ISO base media file): the `PRVW` box (Laurent Clévy's CR3 notes; layout confirmed on a CC0 /// sample) is `u32 size, "PRVW", u32 0, u16 ?, u16 width, u16 height, u16 ?, u32 jpeg length, JPEG`; the smaller /// `THMB` box has the same shape. Returns the larger valid one. -fn cr3_preview(bytes: &[u8]) -> Option<&[u8]> { +fn cr3_preview_boxes(bytes: &[u8]) -> Option<&[u8]> { let mut best: Option<&[u8]> = None; for tag in [b"PRVW", b"THMB"] { let mut from = 0; @@ -180,4 +190,32 @@ mod tests { let _ = embedded_preview(&cr3[..n]); } } + + /// CR3 with a full-size JPEG track: that JPEG wins over the smaller `PRVW` box. + #[test] + fn cr3_prefers_the_full_size_jpeg_track() { + let bx = |kind: &[u8; 4], body: &[u8]| -> Vec { [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat() }; + let full = |kind: &[u8; 4], body: &[u8]| bx(kind, &[&[0u8; 4][..], body].concat()); + let (small, big) = (fake_jpeg(300), fake_jpeg(3000)); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + let mut prvw = b"\0\0\0\0\0\x01\x06\x54\x04\x38\0\x01".to_vec(); + prvw.extend_from_slice(&(small.len() as u32).to_be_bytes()); + prvw.extend_from_slice(&small); + file.extend(bx(b"PRVW", &prvw)); + // CRAW sample entry: 82 bytes, then a JPEG child box; one sample at `at` + let mut craw = vec![0u8; 82]; + craw.extend(bx(b"JPEG", &[0; 4])); + let at = 2048u64; + let stsd = full(b"stsd", &[&1u32.to_be_bytes()[..], &bx(b"CRAW", &craw)].concat()); + let stsz = full(b"stsz", &[0u32.to_be_bytes(), 1u32.to_be_bytes(), (big.len() as u32).to_be_bytes()].concat()); + let co64 = full(b"co64", &[&1u32.to_be_bytes()[..], &at.to_be_bytes()].concat()); + let trak = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &[stsd, stsz, co64].concat())))); + file.extend(bx(b"moov", &trak)); + file.resize(at as usize, 0); + file.extend_from_slice(&big); + assert_eq!(embedded_preview(&file).unwrap(), big); + // a track pointing past the end falls back to PRVW + file.truncate(at as usize + 100); + assert_eq!(embedded_preview(&file).unwrap(), small); + } } diff --git a/docs/parity.md b/docs/parity.md index fc94d08a..6a5a007e 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -122,7 +122,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, 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), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2, PEF, ORF. Missing: CR3, compressed RAF/ORF, RW2 v4, 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 | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, 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), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2, PEF, ORF. CR3 opens from its full-size embedded JPEG with full metadata (Exif, GPS, XMP from the `CMT1`/`CMT2`/`CMT4` boxes, `crates/meta/src/cr3.rs`) but its raw data is not decoded yet. Missing: CR3 raw decoding (CRX), compressed RAF/ORF, RW2 v4, 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 | | 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: ~40 CC0 corpus files (plus ~60 NEFs checked by hand) 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). Grow the CC0 corpus per model and test each sample decodes with plausible colour | | 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 3045dead..49f8634f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -83,6 +83,10 @@ changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel. ### Library +- Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the + 1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw + data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk picks + up the full-size preview; their camera metadata comes with a fresh import. - Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets `img_1-1.JPG`; on case-insensitive volumes the case change still goes through.