From 632a455e344e12febe88156e750e6358e3c101df Mon Sep 17 00:00:00 2001 From: peiyan0 Date: Fri, 9 Oct 2026 17:05:25 +0800 Subject: [PATCH 01/33] raw: recognize camera-authored TIFFs without CFA as images (#281) --- crates/engine/src/files.rs | 315 +++++++++++++++++++++---------------- crates/raw/src/lib.rs | 25 ++- 2 files changed, 197 insertions(+), 143 deletions(-) diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 95481a4ff..c81c7c97b 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -102,53 +102,65 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { let (meta, captured) = meta_of(&m); if lightcraft_raw::probe(bytes).is_some() { let raw = match lightcraft_raw::probe_info(bytes).map_err(|e| preview_reason(bytes, e)) { - Ok(r) => r, + Ok(r) => Some(r), Err(Ok(why)) => { // a raw variant we can't decode yet: describe it from its embedded preview - let (w, h) = embedded_preview_size(bytes).ok_or(format!("unsupported raw ({why}) without an embedded preview"))?; - return Ok(ProbeInfo { - width: w, - height: h, - format: ext_upper(name), - kind: MediaKind::Raw, - file_size: bytes.len() as u64, - captured, - meta, - as_shot_wb: None, - content_hash, - xmp: lightcraft_meta::embedded(bytes).xmp, - preview_only: Some(why), - ..Default::default() - }); + if let Some((w, h)) = embedded_preview_size(bytes) { + return Ok(ProbeInfo { + width: w, + height: h, + format: ext_upper(name), + kind: MediaKind::Raw, + file_size: bytes.len() as u64, + captured, + meta, + as_shot_wb: None, + content_hash, + xmp: lightcraft_meta::embedded(bytes).xmp, + preview_only: Some(why), + ..Default::default() + }); + } + if !(lightcraft_codecs::sniff(bytes).is_some_and(|f| f.can_decode()) && lightcraft_codecs::read_header(bytes).is_ok()) { + return Err(format!("unsupported raw ({why}) without an embedded preview")); + } + None + } + Err(Err(e)) => { + if !(lightcraft_codecs::sniff(bytes).is_some_and(|f| f.can_decode()) && lightcraft_codecs::read_header(bytes).is_ok()) { + return Err(e); + } + None } - Err(Err(e)) => return Err(e), }; - let (mut w, mut h) = (raw.crop.width.max(1) as u32, raw.crop.height.max(1) as u32); - if w <= 1 || h <= 1 { - (w, h) = (raw.active_area.width as u32, raw.active_area.height as u32); + if let Some(raw) = raw { + let (mut w, mut h) = (raw.crop.width.max(1) as u32, raw.crop.height.max(1) as u32); + if w <= 1 || h <= 1 { + (w, h) = (raw.active_area.width as u32, raw.active_area.height as u32); + } + if raw.orientation.swaps_axes() { + std::mem::swap(&mut w, &mut h); + } + let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw)); + // Vendor RGB multipliers do not identify an absolute illuminant without camera calibration. + let relative = crate::camera_preview::file_local_look(raw.format) && !lightcraft_raw::color::has_matrix(&raw.color); + let as_shot_wb = Some(if relative { (6500.0, 0.0) } else { (t.round(), tint.round()) }); + let embedded_lens = embedded_lens(&raw); + return Ok(ProbeInfo { + embedded_lens, + width: w, + height: h, + format: ext_upper(name), + kind: MediaKind::Raw, + file_size: bytes.len() as u64, + captured, + meta, + as_shot_wb, + content_hash, + xmp: lightcraft_meta::embedded(bytes).xmp, + preview_only: None, + }); } - if raw.orientation.swaps_axes() { - std::mem::swap(&mut w, &mut h); - } - let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw)); - // Vendor RGB multipliers do not identify an absolute illuminant without camera calibration. - let relative = crate::camera_preview::file_local_look(raw.format) && !lightcraft_raw::color::has_matrix(&raw.color); - let as_shot_wb = Some(if relative { (6500.0, 0.0) } else { (t.round(), tint.round()) }); - let embedded_lens = embedded_lens(&raw); - return Ok(ProbeInfo { - embedded_lens, - width: w, - height: h, - format: ext_upper(name), - kind: MediaKind::Raw, - file_size: bytes.len() as u64, - captured, - meta, - as_shot_wb, - content_hash, - xmp: lightcraft_meta::embedded(bytes).xmp, - preview_only: None, - }); } let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?; if !fmt.can_decode() { @@ -263,107 +275,118 @@ fn load_bytes_now( denoise: Option<&DenoiseSpec>, ) -> Result<(Rgb32f, Option, SourceInfo), String> { if lightcraft_raw::probe(&bytes).is_some() { - let mut raw = match lightcraft_raw::decode(&bytes).map_err(|e| preview_reason(&bytes, e)) { - Ok(r) => r, + let raw = match lightcraft_raw::decode(&bytes).map_err(|e| preview_reason(&bytes, e)) { + Ok(r) => Some(r), Err(Ok(why)) => { // show the camera's embedded JPEG (rendered, not raw) until the variant is supported - return load_embedded_preview(&bytes, max_edge) - .map(|(img, info)| (img, None, info)) - .ok_or(format!("unsupported raw ({why}) without an embedded preview")); + if let Some((img, info)) = load_embedded_preview(&bytes, max_edge) { + return Ok((img, None, info)); + } + if !lightcraft_codecs::sniff(&bytes).is_some_and(|f| f.can_decode()) { + return Err(format!("unsupported raw ({why}) without an embedded preview")); + } + None } - Err(Err(e)) => return Err(e), - }; - // Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in - // (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`). - let lens = embedded_lens(&raw.info()); - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - let xy = lightcraft_raw::color::as_shot_white_xy(&raw); - let t = lightcraft_raw::color::camera_transform(&raw, xy); - // the source's segmentation mattes (DNG semantic masks), read while the preview is fitted - let (camera_look, mattes) = rayon::join(|| crate::camera_preview::fit_preview(&raw, &bytes, &t), || dng_mattes(&bytes, &raw)); - drop(bytes); - // Previews and thumbnails bin the mosaic straight to (about) the size they need; only - // larger levels (exports, 1:1) demosaic the whole sensor. - let t0 = web_time::Instant::now(); - let bin = bin_factor(&raw, max_edge); - let binned = match bin { - Some(k) => raw.develop_binned(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?, - None => None, - }; - let factor = if binned.is_some() { bin.unwrap_or(1) } else { 1 }; - let img = match binned { - Some(img) => img, - None => { - let method = if max_edge <= 600 { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; - raw.develop(method).map_err(|e| e.to_string())? + Err(Err(e)) => { + if !lightcraft_codecs::sniff(&bytes).is_some_and(|f| f.can_decode()) { + return Err(e); + } + None } }; - // the same development from the denoised mosaic, when the photo has been denoised - let twin = denoise.and_then(|spec| denoised_camera_rgb(&raw, spec, factor)).filter(|d| d.width == img.width && d.height == img.height); - // the samples aren't needed any more (the colour model below reads only the tags) - raw.data = lightcraft_raw::RawData::U16(Vec::new()); - let wb = t.wb; - let m = camera_look.as_ref().map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix).to_f32(); - let hue_sat = camera_look.as_ref().and_then(|p| p.hue_sat.as_ref()).and_then(crate::camera_preview::HueSat::new); - let gain = 2f32.powf(t.baseline_exposure as f32); - // A DNG's own profile look (hue/saturation map, look table), DNG spec chapter 6. - let tables = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)); - // camera RGB → what the pipeline takes: clipped highlights rebuilt, white balance and the colour model applied, - // fitted to `max_edge` and upright (the same for the plain and the denoised picture) - let finish = |mut img: Rgb32f| { - let mut stages = vec![("develop", t0.elapsed())]; - stages.push(("transform", t0.elapsed())); - lightcraft_raw::highlight::reconstruct(&mut img, wb, HIGHLIGHT_CLIP); - stages.push(("highlights", t0.elapsed())); - img.map_in_place(|p| { - let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; - let rgb = [ - m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2], - m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2], - m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2], - ]; - let rgb = match &tables { - Some(tables) => tables.apply(rgb, gain), - None => rgb.map(|v| v * gain), - }; - hue_sat.as_ref().map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0)) + if let Some(mut raw) = raw { + // Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in + // (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`). + let lens = embedded_lens(&raw.info()); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + let xy = lightcraft_raw::color::as_shot_white_xy(&raw); + let t = lightcraft_raw::color::camera_transform(&raw, xy); + // the source's segmentation mattes (DNG semantic masks), read while the preview is fitted + let (camera_look, mattes) = rayon::join(|| crate::camera_preview::fit_preview(&raw, &bytes, &t), || dng_mattes(&bytes, &raw)); + drop(bytes); + // Previews and thumbnails bin the mosaic straight to (about) the size they need; only + // larger levels (exports, 1:1) demosaic the whole sensor. + let t0 = web_time::Instant::now(); + let bin = bin_factor(&raw, max_edge); + let binned = match bin { + Some(k) => raw.develop_binned(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?, + None => None, + }; + let factor = if binned.is_some() { bin.unwrap_or(1) } else { 1 }; + let img = match binned { + Some(img) => img, + None => { + let method = if max_edge <= 600 { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; + raw.develop(method).map_err(|e| e.to_string())? + } + }; + // the same development from the denoised mosaic, when the photo has been denoised + let twin = denoise.and_then(|spec| denoised_camera_rgb(&raw, spec, factor)).filter(|d| d.width == img.width && d.height == img.height); + // the samples aren't needed any more (the colour model below reads only the tags) + raw.data = lightcraft_raw::RawData::U16(Vec::new()); + let wb = t.wb; + let m = camera_look.as_ref().map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix).to_f32(); + let hue_sat = camera_look.as_ref().and_then(|p| p.hue_sat.as_ref()).and_then(crate::camera_preview::HueSat::new); + let gain = 2f32.powf(t.baseline_exposure as f32); + // A DNG's own profile look (hue/saturation map, look table), DNG spec chapter 6. + let tables = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)); + // camera RGB → what the pipeline takes: clipped highlights rebuilt, white balance and the colour model applied, + // fitted to `max_edge` and upright (the same for the plain and the denoised picture) + let finish = |mut img: Rgb32f| { + let mut stages = vec![("develop", t0.elapsed())]; + stages.push(("transform", t0.elapsed())); + lightcraft_raw::highlight::reconstruct(&mut img, wb, HIGHLIGHT_CLIP); + stages.push(("highlights", t0.elapsed())); + img.map_in_place(|p| { + let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; + let rgb = [ + m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2], + m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2], + m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2], + ]; + let rgb = match &tables { + Some(tables) => tables.apply(rgb, gain), + None => rgb.map(|v| v * gain), + }; + hue_sat.as_ref().map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0)) + }); + stages.push(("colour", t0.elapsed())); + let img = fit(&img, max_edge, max_edge, Filter::Box); + stages.push(("fit", t0.elapsed())); + let img = img.into_oriented(raw.orientation); + stages.push(("orient", t0.elapsed())); + (img, stages) + }; + let (img, stages) = finish(img); + if lightcraft_pipeline::profiling() { + let mut prev = std::time::Duration::ZERO; + let parts: Vec = stages + .iter() + .map(|(n, t)| { + let d = *t - prev; + prev = *t; + format!("{n} {:.1}", d.as_secs_f64() * 1e3) + }) + .collect(); + eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", ")); + } + let twin = twin.map(|picture| finish(picture).0); + let (temp, tint) = xy_to_temp_tint(xy); + let relative = crate::camera_preview::file_local_look(raw.format) && t.matrix_is_fallback; + // White balance re-evaluates the file's own colour model (when it has one and no + // file-local look matrix sits on top of it) + let camera_color = (!t.matrix_is_fallback && camera_look.is_none()).then(|| { + let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; + Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) }); - stages.push(("colour", t0.elapsed())); - let img = fit(&img, max_edge, max_edge, Filter::Box); - stages.push(("fit", t0.elapsed())); - let img = img.into_oriented(raw.orientation); - stages.push(("orient", t0.elapsed())); - (img, stages) - }; - let (img, stages) = finish(img); - if lightcraft_pipeline::profiling() { - let mut prev = std::time::Duration::ZERO; - let parts: Vec = stages - .iter() - .map(|(n, t)| { - let d = *t - prev; - prev = *t; - format!("{n} {:.1}", d.as_secs_f64() * 1e3) - }) - .collect(); - eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", ")); + let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve)); + let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) }; + return Ok(( + img, + twin, + SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_color, camera_tone, mattes }, + )); } - let twin = twin.map(|picture| finish(picture).0); - let (temp, tint) = xy_to_temp_tint(xy); - let relative = crate::camera_preview::file_local_look(raw.format) && t.matrix_is_fallback; - // White balance re-evaluates the file's own colour model (when it has one and no - // file-local look matrix sits on top of it) - let camera_color = (!t.matrix_is_fallback && camera_look.is_none()).then(|| { - let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; - Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) - }); - let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve)); - let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) }; - return Ok(( - img, - twin, - SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_color, camera_tone, mattes }, - )); } let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?; drop(bytes); @@ -1067,6 +1090,26 @@ mod tests { 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()); + + // TIFFs preserving camera Make tags (SONY, NIKON, etc.) are ordinary images, not raws + for make in ["SONY", "NIKON CORPORATION", "PENTAX", "RICOH", "SAMSUNG"] { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![24])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![16])); + 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])); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + let px: Vec = (0..24 * 16).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + ifd0.set_image(ImageData::Strips { rows_per_strip: 16, strips: vec![px] }); + let bytes = TiffWriter::default().write(&[ifd0]).unwrap(); + let p = probe_bytes("photo.tif", &bytes).unwrap(); + assert_eq!((p.kind, p.preview_only, p.width, p.height), (MediaKind::Image, None, 24, 16), "make: {make}"); + let (img, src) = load_bytes(&bytes, 24).unwrap(); + assert_eq!((img.width, img.height, src.raw), (24, 16, false), "make: {make}"); + } } /// Recognised raw containers we don't decode (Minolta MRW here: the preview's first byte is diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index d38b2fc68..11ec839fd 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -156,16 +156,16 @@ pub fn probe(bytes: &[u8]) -> Option { if make.starts_with("CANON") && t.ifds.len() >= 4 && t.ifds[3].u16(lightcraft_tiff::tags::COMPRESSION) == Some(6) { return Some(RawFormat::Cr2); } - if make.starts_with("NIKON") { - return Some(if has_cfa || t.all_ifds().len() > 1 { RawFormat::Nef } else { RawFormat::Nrw }); + if make.starts_with("NIKON") && has_cfa { + return Some(if t.all_ifds().len() > 1 { RawFormat::Nef } else { RawFormat::Nrw }); } - if make.starts_with("SONY") { + if make.starts_with("SONY") && has_cfa { return Some(RawFormat::Arw); } - if make.starts_with("PENTAX") || make.starts_with("RICOH") { + if (make.starts_with("PENTAX") || make.starts_with("RICOH")) && has_cfa { return Some(RawFormat::Pef); } - if make.starts_with("SAMSUNG") { + if make.starts_with("SAMSUNG") && has_cfa { return Some(RawFormat::Srw); } if has_cfa || thumbnail_shell(&t, bytes.len()).is_some() || is_preview_container(ifd0) { @@ -175,11 +175,15 @@ pub fn probe(bytes: &[u8]) -> Option { } /// Whether some IFD is marked as raw: CFA photometric, or a raw-only compression value (99 is not a -/// registered TIFF compression; Leaf MOS files use it for their tiled 16-bit lossless-JPEG raw). +/// registered TIFF compression; Leaf MOS files use it for their tiled 16-bit lossless-JPEG raw), +/// or vendor-specific raw tags (Sony tone curve, CFA pattern, or Pentax compression 65535). 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 || c == 99) + || i.u16(lightcraft_tiff::tags::COMPRESSION).is_some_and(|c| matches!(c, 34713 | 32767 | 32769 | 32770 | 99 | 65535)) + || i.contains(0x7010) + || i.contains(lightcraft_tiff::tags::CFA_PATTERN_EP) + || i.contains(lightcraft_tiff::tags::CFA_REPEAT_PATTERN_DIM) }) } @@ -872,6 +876,13 @@ mod tests { 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); + // camera-authored or exported TIFFs preserving camera Make tags remain ordinary images + for make in ["SONY", "NIKON CORPORATION", "PENTAX", "RICOH", "SAMSUNG"] { + let mut cam = rgb_ifd(400, 300); + cam.set(t::MAKE, Value::Ascii(make.into())); + cam.set_child(t::EXIF_IFD, exif_size(400, 300)); + assert_eq!(probe(&padded(&[cam])), None, "{make} TIFF should not be probed as raw"); + } } // --- containers that are recognised but not decoded, with a preview --- From 41e51a78efa9253862e12feb33a0339d96093cb9 Mon Sep 17 00:00:00 2001 From: Elliot Graham Date: Fri, 9 Oct 2026 12:49:48 +0100 Subject: [PATCH 02/33] LR-OPT-PROFILES: decode Sony embedded distortion tables --- ROADMAP.md | 5 +- crates/engine/src/media.rs | 2 +- crates/raw/src/vendor/arw.rs | 19 ++- crates/raw/src/vendor/arw_lens.rs | 209 ++++++++++++++++++++++++++++++ crates/raw/src/vendor/mod.rs | 1 + docs/parity.md | 6 +- docs/sony-lens-corrections.md | 72 ++++++++++ 7 files changed, 304 insertions(+), 10 deletions(-) create mode 100644 crates/raw/src/vendor/arw_lens.rs create mode 100644 docs/sony-lens-corrections.md diff --git a/ROADMAP.md b/ROADMAP.md index 4a6b101a5..d0ba2a291 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,7 +36,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | User | Readiness | What blocks them | |---|---:|---| | JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks | -| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); Panasonic compacts and kit zooms also miss their embedded distortion correction | +| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); embedded distortion is supported for Panasonic / Leica RW2 and Sony Bayer ARW 16-sample tables (24–105 / 200–600 tested); other lens metadata needs coverage and validation | | Fujifilm raw shooter | ~65% | Uncompressed and lossless/lossy compressed RAF decoded; camera colour calibration remains missing. Verified on 13 bodies, including 14/16-bit GFX; see `docs/raf-compression.md` | | Canon CR3 / Olympus compressed-raw shooter | ~45% | CR3 lossless Bayer and version 0x100/0x200 C-RAW develop from sensor data (M50, R100, R8 verified pixel for pixel); other CRX variants and compressed ORF open as embedded JPEG previews. Camera colour and broader model verification remain | | Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering | @@ -57,7 +57,8 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par checked for plausible colour; fix per-model bugs (#85). 4. **Render fidelity suite** (LR-BEHAV-RENDER-FIDELITY, P1): measure our output against Lightroom on the same CC0 raws (references stay in the local `plan/`), then tune against the numbers. -5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1). +5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1): DNG, Panasonic / Leica RW2 and Sony Bayer ARW + embedded distortion are supported; broaden validation and decode remaining vignetting / lateral CA metadata. 6. **AI model strategy** (maintainer decision): which permissively licensed models (or our own training) for segmentation masks and denoise. Bayer denoise inference is implemented in pure Rust with an opt-in GPL model; release policy and independently licensed weights remain open. Unblocks M12 and Enhance. Face detection and recognition run on our own checked Rust engine. Object / Describe masks now run Meta's SAM 3 in pure Rust (optional, user-consented download under the non-OSI SAM License; CDN mirrors still diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index b0439390d..4519e8f97 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 = 16; +pub const RENDER_CACHE_VERSION: u64 = 17; /// 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/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index acaacf0b5..bd2ef1d08 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -425,6 +425,15 @@ fn default_crop(raw: &Ifd, mn: Option<&makernote::MakerNote>, w: usize, h: usize { return Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h); } + // The standard default crop is in raw-array coordinates. SonyCropTopLeft can instead start at + // zero after sensor margins (e.g. ILCE-7RM4A: x=0 versus DefaultCropOrigin x=32). Prefer the + // explicit standard origin so the image and its embedded distortion share the correct centre. + if let (Some([x, y]), Some([cw, ch])) = (raw.u64s(CROP_TOP_LEFT).as_deref(), raw.u64s(CROP_SIZE).as_deref()) + && *cw > 0 + && *ch > 0 + { + return Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h); + } match mn.and_then(|m| m.ifd.u64s(MN_FULL_IMAGE_SIZE)).as_deref() { // only a plausible trim: never more than 64 pixels per side, never an enlargement Some([fh, fw]) if *fw as usize <= w && *fh as usize <= h && *fw as usize + 64 >= w && *fh as usize + 64 >= h => { @@ -527,10 +536,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { [(v[0] / g) as f32, 1.0, (v[3] / g) as f32] }) .or_else(|| mn.as_ref().and_then(|m| tag2010_wb(&model, m.ifd.bytes(MN_TAG2010)?, m.order))); - let crop = match (raw.u64s(CROP_TOP_LEFT).as_deref(), raw.u64s(CROP_SIZE).as_deref()) { - (Some([x, y]), Some([cw, ch])) if *cw > 0 && *ch > 0 => Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h), - _ => default_crop(raw, mn.as_ref(), w, h), - }; + let crop = default_crop(raw, mn.as_ref(), w, h); let mut metadata = lightcraft_meta::from_tiff(&tiff); metadata.width = Some(crop.width as u32); metadata.height = Some(crop.height as u32); @@ -552,7 +558,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { // makes a neutral surface magenta. WB edits remain relative to this as-shot RGB. wb_multipliers: if linear_rgb { Some([1.0; 3]) } else { wb }, linearized: false, - opcodes: OpcodeLists::default(), + opcodes: OpcodeLists { + list3: if linear_rgb { Vec::new() } else { super::arw_lens::distortion(raw, Rect::new(0, 0, w, h), crop).into_iter().collect() }, + ..Default::default() + }, metadata, }; img.validate_for(mode)?; diff --git a/crates/raw/src/vendor/arw_lens.rs b/crates/raw/src/vendor/arw_lens.rs new file mode 100644 index 000000000..4841ea1b4 --- /dev/null +++ b/crates/raw/src/vendor/arw_lens.rs @@ -0,0 +1,209 @@ +//! Sony's plain raw-IFD distortion table, expressed as a DNG warp for the shared optics pipeline. +//! +//! Tag identity/layout: https://exiftool.org/TagNames/EXIF.html (`0x7037`, signed shorts). +//! Geometry inferred independently from files and Sony Imaging Edge exports; see docs/sony-lens-corrections.md. +//! No lens database or external decoder code. The unrelated MakerNote table has a different scale. + +use crate::{Opcode, Rect}; +use lightcraft_tiff::{Ifd, Value}; + +const DISTORTION: u16 = 0x7037; +const SAMPLES: usize = 1024; +const MAX_ERROR: f64 = 0.0005; + +/// The 16-entry layout only. Unknown layouts and implausible/non-smooth maps are left uncorrected. +/// A camera setting of Off does not remove the lens data: the user can enable it in the developer. +pub(super) fn distortion(ifd: &Ifd, active: Rect, crop: Rect) -> Option { + let Value::SShort(values) = &ifd.get(DISTORTION)?.value else { return None }; + let [16, table @ ..] = values.as_slice() else { return None }; + let table: &[i16; 16] = table.try_into().ok()?; + if table.iter().all(|v| *v == 0) || table.iter().any(|v| !(-4096..=4096).contains(v)) { + return None; + } + let crop = crop.clipped(active.width, active.height); + if active.width < 2 || active.height < 2 || crop.width < 2 || crop.height < 2 { + return None; + } + // Only native 3:2 framing has been validated. An in-camera aspect crop may retain a table + // normalized to the full sensor frame; do not silently reinterpret it as the smaller crop. + if (crop.width as f64 / crop.height as f64 - 1.5).abs() > 0.005 { + return None; + } + // Interpolate fractional radial scale at evenly spaced radii from centre to cropped corner. + let scale = |r: f64| { + let at = r.clamp(0.0, 1.0) * 15.0; + let i = (at.floor() as usize).min(14); + let a = f64::from(table[i]); + let b = f64::from(table[i + 1]); + 1.0 + (a + (b - a) * (at - i as f64)) / 16384.0 + }; + let (cw, ch) = (crop.width as f64 - 1.0, crop.height as f64 - 1.0); + let diagonal = cw.hypot(ch); + // Zoom until the entire rectangular output boundary is inside the source. For a piecewise-linear + // scale its maximum on that boundary occurs at its nearest radius or at one of the remaining knots. + let nearest = cw.min(ch) / diagonal; + let max_scale = (0..16).map(|i| i as f64 / 15.0).filter(|&r| r >= nearest).map(scale).fold(scale(nearest), f64::max); + let zoom = 1.0 / max_scale; + let basis = |r: f64| { + let r2 = r * r; + [r, r * r2, r * r2 * r2, r * r2 * r2 * r2] + }; + let mut matrix = [[0.0; 4]; 4]; + let mut rhs = [0.0; 4]; + let mut previous = -1.0; + for j in 0..=SAMPLES { + let r = j as f64 / SAMPLES as f64; + let source = r * scale(r) * zoom; + if source <= previous { + return None; + } + previous = source; + let terms = basis(r); + for (i, &a) in terms.iter().enumerate() { + rhs[i] += a * source; + for (k, &b) in terms.iter().enumerate() { + matrix[i][k] += a * b; + } + } + } + let mut k = solve4(matrix, rhs)?; + // Keep the polynomial within a fraction of a preview pixel of the file's table, and forbid folds. + for j in 0..=SAMPLES { + let r = j as f64 / SAMPLES as f64; + let fit = basis(r).iter().zip(k).map(|(a, b)| a * b).sum::(); + let r2 = r * r; + let derivative = k[0] + r2 * (3.0 * k[1] + r2 * (5.0 * k[2] + r2 * 7.0 * k[3])); + if (fit - r * scale(r) * zoom).abs() > MAX_ERROR || derivative <= 0.0 { + return None; + } + } + // DNG uses the farthest *active-area* corner, not the cropped diagonal. Preserve the same mapping + // after the engine translates the opcode into default-cropped and EXIF-oriented coordinates. + let (ax, ay) = (active.width as f64 - 1.0, active.height as f64 - 1.0); + let (cx, cy) = (crop.x as f64 + cw / 2.0, crop.y as f64 + ch / 2.0); + let farthest = cx.max(ax - cx).hypot(cy.max(ay - cy)); + let ratio2 = (2.0 * farthest / diagonal).powi(2); + for (i, coefficient) in k.iter_mut().enumerate() { + *coefficient *= ratio2.powi(i as i32); + } + Some(Opcode::WarpRectilinear { planes: vec![[k[0], k[1], k[2], k[3], 0.0, 0.0]], center: [cx / ax, cy / ay] }) +} + +/// Small fixed-size least-squares system; partial pivoting, no input-sized allocations. +fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> { + for col in 0..4 { + let pivot = (col..4).max_by(|&i, &j| a[i][col].abs().total_cmp(&a[j][col].abs()))?; + if !a[pivot][col].is_finite() || a[pivot][col].abs() < 1e-12 { + return None; + } + a.swap(col, pivot); + b.swap(col, pivot); + for row in col + 1..4 { + let factor = a[row][col] / a[col][col]; + for j in col..4 { + a[row][j] -= factor * a[col][j]; + } + b[row] -= factor * b[col]; + } + } + let mut x = [0.0; 4]; + for i in (0..4).rev() { + x[i] = (b[i] - (i + 1..4).map(|j| a[i][j] * x[j]).sum::()) / a[i][i]; + } + x.iter().all(|v| v.is_finite()).then_some(x) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_tiff::Entry; + + fn ifd(value: Value) -> Ifd { + Ifd { entries: vec![Entry { tag: DISTORTION, value, offset: 0 }], ..Default::default() } + } + fn table(values: &[i16]) -> Ifd { + ifd(Value::SShort(std::iter::once(16).chain(values.iter().copied()).collect())) + } + const TELE: [i16; 16] = [-2, 1, 7, 17, 31, 48, 68, 93, 120, 152, 187, 225, 265, 309, 356, 403]; + const WIDE: [i16; 16] = [14, 0, -27, -59, -106, -158, -223, -291, -367, -440, -519, -593, -667, -731, -792, -839]; + const AREA: Rect = Rect { x: 0, y: 0, width: 9504, height: 6336 }; + + #[test] + fn matches_independent_sony_reference_geometry() { + // On/off feature correspondences from Imaging Edge 4.1 at 600 mm, independently fitted. + let Opcode::WarpRectilinear { planes, center } = distortion(&table(&TELE), AREA, AREA).unwrap() else { panic!("warp") }; + assert_eq!(center, [0.5, 0.5]); + let reference = [0.9760159044, 0.0254891644, -0.0005984764, -0.0010120638]; + for i in 0..=100 { + let r = i as f64 / 100.0; + let evaluate = |k: &[f64]| r * (k[0] + r * r * (k[1] + r * r * (k[2] + r * r * k[3]))); + assert!((evaluate(&planes[0]) - evaluate(&reference)).abs() < 0.00015); + } + } + + #[test] + fn barrel_framing_and_offset_crop_keep_the_same_geometry() { + let crop = Rect::new(32, 20, 9504, 6336); + let active = Rect::new(0, 0, 9600, 6376); + let Opcode::WarpRectilinear { planes: a, .. } = distortion(&table(&WIDE), AREA, AREA).unwrap() else { panic!("warp") }; + let Opcode::WarpRectilinear { planes: b, center } = distortion(&table(&WIDE), active, crop).unwrap() else { panic!("warp") }; + assert!(a[0][0] > 1.02 && a[0][0] < 1.03, "barrel correction expands the middle"); + assert!((center[0] * 9599.0 - 4783.5).abs() < 1e-10); + assert!((center[1] * 6375.0 - 3187.5).abs() < 1e-10); + let ratio = (4815.5f64.hypot(3187.5)) / (4751.5f64.hypot(3167.5)); + for i in 0..4 { + assert!((a[0][i] - b[0][i] / ratio.powi(2 * i as i32)).abs() < 1e-10); + } + } + + #[test] + fn refuses_missing_unknown_malformed_or_unusable_tables() { + for bad in [ + Ifd::default(), + table(&[]), + table(&TELE[..15]), + table(&[0; 16]), + table(&[i16::MIN; 16]), + table(&[0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + ifd(Value::SShort(vec![15; 17])), + ifd(Value::Short(vec![16; 17])), + ifd(Value::Double(vec![f64::NAN; 17])), + ] { + assert!(distortion(&bad, AREA, AREA).is_none()); + } + assert!(distortion(&table(&TELE), Rect::new(0, 0, 1, 1), AREA).is_none()); + assert!(distortion(&table(&TELE), AREA, Rect::new(0, 0, 9504, 5346)).is_none()); + assert!(distortion(&table(&TELE), AREA, Rect::new(99999, 0, 10, 10)).is_none()); + } + #[test] + fn header_and_full_decode_preserve_correction_through_dng() { + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, tags as t}; + for order in [ByteOrder::Little, ByteOrder::Big] { + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("synthetic correction test".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![24])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::WHITE_LEVEL, Value::Long(vec![16383])); + raw.set(0x74c7, Value::Long(vec![0, 2])); + raw.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![2, 2])); + raw.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![30, 20])); + raw.set(0x74c8, Value::Long(vec![30, 20])); + raw.set(DISTORTION, Value::SShort(std::iter::once(16).chain(TELE).collect())); + // Zero pixels have the same byte representation in both orders. + raw.set_image(ImageData::Strips { rows_per_strip: 24, strips: vec![vec![0; 32 * 24 * 2]] }); + let file = TiffWriter { order, ..Default::default() }.write(&[raw]).unwrap(); + let header = super::super::arw::decode(&file, crate::Mode::Header).unwrap(); + let full = super::super::arw::decode(&file, crate::Mode::Full).unwrap(); + assert_eq!(header.info(), full.info()); + assert_eq!(full.opcodes.list3.len(), 1); + assert_eq!(full.crop, Rect::new(2, 2, 30, 20)); + let dng = crate::write_dng(&full, &Default::default()).unwrap(); + assert_eq!(crate::decode(&dng).unwrap().opcodes.list3, full.opcodes.list3); + } + } +} diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index fbbc18eb4..f77f63789 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -1,6 +1,7 @@ //! Vendor raw formats (TIFF-based) and helpers shared by them. pub mod arw; +mod arw_lens; pub mod cr2; pub mod cr3; pub mod crx; diff --git a/docs/parity.md b/docs/parity.md index c06dad1b0..07fc85bb7 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -75,7 +75,9 @@ Take the first one nobody is working on. 4b. **LR-VIEW-ZOOM** (P0): finish the sharp zoomed views of issue #323: windows for compare, overlays and soft proofing (Before/After has them); check sharpness and the wide-stage margin on real raw photos (only synthetic scenes so far). 5. **LR-EDIT-OPTICS-PROFILE** (P1): a lens-profile database of our own (corrections embedded in DNG files and the - distortion correction Panasonic / Leica cameras record in RW2 / RWL files work today; issue #256). + distortion correction Panasonic / Leica cameras record in RW2 / RWL files work today; issue #256). Sony Bayer + ARW 16-sample tables in native 3:2 framing now feed the same distortion path; broader coverage and Sony + vignetting / lateral CA remain (see `docs/sony-lens-corrections.md`). 6. **AI masks and Enhance** (LR-MASK-SUBJECT / SKY / PEOPLE / OBJECTS, LR-EDIT-DETAIL-DENOISE, SUPERRES, LENSBLUR): Object and Describe masks run SAM 3 in pure Rust (`crates/segment`); the weights (SAM License) are never bundled: the app offers a consented, verified download, but **LightCraft's CDN mirrors are not configured yet** (the @@ -272,7 +274,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | | | LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | | | LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | `crates/ui-egui/src/panels/edit.rs` (checkbox), `ctl:optics.caRed`, `ctl:optics.caBlue` | | -| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); vignetting and lateral CA data of other raw formats (Olympus, Fujifilm, Sony) not read yet; no lens-profile database | +| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, validated on ILCE-7RM4A with FE 24–105 and FE 200–600 lenses (see `docs/sony-lens-corrections.md`); vignetting and lateral CA data of other raw formats (Olympus, Fujifilm, Sony) not read yet; no lens-profile database | ) | LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | diff --git a/docs/sony-lens-corrections.md b/docs/sony-lens-corrections.md new file mode 100644 index 000000000..a2fa705d6 --- /dev/null +++ b/docs/sony-lens-corrections.md @@ -0,0 +1,72 @@ +# Sony embedded distortion corrections + +LightCraft reads the signed 16-sample distortion table in the raw image IFD (`0x7037`) of Sony ARWs. +It converts that table into `OpcodeList3` / `WarpRectilinear`, so the existing profile correction control, +CPU/GPU optics path, EXIF orientation handling, and DNG export use the same correction. + +This adds **distortion only**. Sony vignetting and lateral chromatic-aberration tables are not decoded. +The tested files are Bayer ARWs from the ILCE-7RM4A with the FE 24–105mm F4 G OSS and +FE 200–600mm F5.6–6.3 G OSS. Linear YCbCr ARWs and non-3:2 camera crops are excluded pending validation. An aspect crop may +retain the full-frame radial normalization; the decoder does not guess that relationship. Older files that carry +only encrypted correction metadata, different table lengths, and rejected tables remain uncorrected. +The camera's distortion Off setting does not erase its table. Newly imported photos use LightCraft's existing +embedded-profile default (enabled); the profile correction control can disable or adjust it. + +## Clean-room evidence + +The [ExifTool EXIF tag-name documentation](https://exiftool.org/TagNames/EXIF.html) identifies +`0x7037` as 17 signed 16-bit integers. The first value is the count, 16, followed by radial samples. +No external photo decoder implementation or Adobe lens profile was consulted. +Sony Imaging Edge Edit 4.1 was used only as a black-box reference on local scratch copies. + +The observed inverse mapping, with radius `r` normalized to the default crop's half diagonal, is: + +``` +f(r) = 1 + interpolate(table, r) / 16384 +source = center + (output - center) * zoom * f(r) +zoom = 1 / max(f(r)) over radii on the rectangular output boundary +``` + +The border radii range from the shorter half edge / half diagonal to 1. Table knots span 0 through 1. +Linear interpolation approximates the observed map closely; we do not claim to reproduce Sony's +internal interpolator. A fixed-size weighted least-squares fit expresses source radius as +`r * (k0 + k1*r² + k2*r⁴ + k3*r⁶)`. The fit is rejected if radial error exceeds 0.0005 of the +half diagonal (about 0.43 px at a 1440 px 3:2 preview), or the mapping folds. Table values are bounded +and the input must have precisely the supported signed-short layout. Crop offset and normalization +are converted into DNG's active-area coordinate system before creating the opcode. The standard +`DefaultCropOrigin` / `DefaultCropSize` take precedence over Sony crop tags: on the tested A7R IVA +files the former start at raw x=32 and the latter at x=0. The standard origin aligns with Sony +exports; the old precedence shifted content by eight pixels in a 2376-pixel-wide export. + +The main raw-IFD table is important: the similarly named MakerNote table has different values and +must not be substituted into this formula. Main raw-IFD and SR2SubIFD values agreed in the test files. + +## Validation + +At 2376×1584, independent SIFT correspondences between Sony distortion On and Off TIFF exports gave: + +| Lens / focal length | Median residual | 95th percentile | Maximum matched radius | +|---|---:|---:|---:| +| FE 200–600 / 200 mm | 0.043 px | 0.191 px | 0.97 half diagonals | +| FE 200–600 / 600 mm | 0.055 px | 0.231 px | 0.95 half diagonals | + +These use the fixed 16384 scale and automatic border framing, not fitted per-image scale or zoom. +A median translation below 0.004 px was removed from the residuals. Correspondences do not cover +all pixels; these are geometric feature residuals, not photometric equality or a worst-case guarantee. +Across 20 sampled RAWs (16 at 24–105 mm, four at 200–600 mm), the polynomial's maximum deviation +from the interpolated file table was below 0.12 px at 1440 px. That measures approximation error, +not agreement with an independent renderer. Camera JPEG and Apple Core Image comparisons support +the wide-lens interpretation but are weaker evidence than same-renderer On/Off pairs. + +Actual LightCraft exports after crop normalization were also compared directly with Sony's corrected +TIFFs. At 2376 px wide, median / 95th-percentile feature residuals were 0.35 / 1.04 px at 24 mm, +0.15 / 0.40 px at 200 mm, and 0.13 / 0.56 px at 600 mm (median translation below 0.024 px). +The uncorrected medians at 200 and 600 mm were 8.63 and 10.82 px respectively. Different colour, +sharpening, and demosaicing affect feature localization; these are geometry checks, not colour parity. +An isolated desktop run on Apple M1 Metal detected the profile, rendered without GPU fallback or +warnings, and exposed the existing Optics controls. Existing library edits are not reset: once the +source has loaded, enable lens corrections if they were previously disabled. + +Tests cover signedness/length rejection, zero and malformed tables, independent 600 mm reference +geometry, barrel framing, offset crop normalization, both TIFF byte orders, header/full consistency, +and preservation through DNG export. No private photographs or reference TIFFs are committed. From 621dd51cbf9dbbb97e6c8a60e84e97217a84cf2e Mon Sep 17 00:00:00 2001 From: radialmonster Date: Fri, 9 Oct 2026 10:45:59 -0400 Subject: [PATCH 03/33] LR-IMP-FORMATS: decode Hasselblad lossless JPEG with selection value 8 (FFF, H3D/H4D/CFV-50c 3FR) Lossless JPEG scans whose SOS gives predictor selection value 8 (which T.81 leaves undefined) are read as Hasselblad codes them (ljpeg::decode_hasselblad): 32-bit little-endian words read from their most significant bit, no byte stuffing; each row in pairs of samples, both Huffman categories before both differences; category 16 is followed by 16 ignored bits and means -32768; each sample is the previous sample of its column parity in the row plus the difference, modulo 2^16, both starting at 32768 on every row. Only the layout measured is accepted (one component, 16 bits, no point transform or restarts, even width); other layouts and selection values above 8 stay Unsupported. The rule was measured on 14 CC0 files with the ljpeg-explorer tool. RENDER_CACHE_VERSION 23. --- crates/engine/src/media.rs | 2 +- crates/raw/src/lib.rs | 26 ++- crates/raw/src/ljpeg.rs | 315 ++++++++++++++++++++++++++++++++++++- docs/parity.md | 2 +- 4 files changed, 333 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index ca2192035..d6304944f 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 5a513d49b..31c4f7758 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 eca3a59ad..94330eba1 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 87a90e35c..966269785 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 | From 7343c8f85fb355f443d59cc918e771fdf9ff21e3 Mon Sep 17 00:00:00 2001 From: Elliot Graham Date: Fri, 9 Oct 2026 17:25:14 +0100 Subject: [PATCH 04/33] LR-EDIT-OPTICS-PROFILE: restrict Sony correction to validated camera --- ROADMAP.md | 2 +- crates/engine/src/media.rs | 2 +- crates/raw/src/vendor/arw.rs | 11 ++-- crates/raw/src/vendor/arw_lens.rs | 87 ++++++++++++++++++------------- crates/raw/tests/corpus.rs | 35 +++++++++++++ docs/parity.md | 4 +- docs/sony-lens-corrections.md | 45 +++++++++++++++- xtask/src/main.rs | 10 ++++ 8 files changed, 149 insertions(+), 47 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d0ba2a291..6609e6603 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -57,7 +57,7 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par checked for plausible colour; fix per-model bugs (#85). 4. **Render fidelity suite** (LR-BEHAV-RENDER-FIDELITY, P1): measure our output against Lightroom on the same CC0 raws (references stay in the local `plan/`), then tune against the numbers. -5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1): DNG, Panasonic / Leica RW2 and Sony Bayer ARW +5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1): DNG, Panasonic / Leica RW2 and Sony ILCE-7RM4A Bayer ARW embedded distortion are supported; broaden validation and decode remaining vignetting / lateral CA metadata. 6. **AI model strategy** (maintainer decision): which permissively licensed models (or our own training) for segmentation masks and denoise. Bayer denoise inference is implemented in pure Rust with an opt-in GPL model; release policy and independently licensed weights remain open. Unblocks M12 and Enhance. Face detection and recognition run on our own checked Rust engine. Object / Describe masks now diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index ce41bfaa2..e45191375 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 = 22; +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/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index 28943b625..05442ff6d 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -412,11 +412,10 @@ fn sr2_black_levels(cipher: &[u8], order: lightcraft_tiff::ByteOrder) -> Option< (levels.len() == 4 && (64..=4096).contains(&lo) && hi - lo <= 64).then(|| levels.iter().map(|&v| f32::from(v)).sum::() / 4.0) } -/// The image area for files without Sony's crop tags (`0x74c7/0x74c8`, written since about 2017): the DNG-style -/// default crop when the raw IFD has one, else the maker note's `FullImageSize` (the camera JPEG's size) anchored -/// at the top-left. Older bodies store a few columns of padding at the right edge of the raw frame (constant -/// values, 8–32 columns on the samples we checked) inside a frame 16–48 pixels wider than `FullImageSize`, so -/// the anchored crop removes them while keeping the CFA phase. +/// The image area: prefer the standard `DefaultCropOrigin` / `DefaultCropSize`, then Sony's +/// crop tags (`0x74c7/0x74c8`), then a plausible maker-note `FullImageSize` anchored at the top-left, +/// otherwise the full raw frame. Older bodies store a few padding columns at the right edge; +/// the anchored fallback removes those while keeping the CFA phase. All crops are clipped to the frame. fn default_crop(raw: &Ifd, mn: Option<&makernote::MakerNote>, w: usize, h: usize) -> Rect { let full = Rect::new(0, 0, w, h); if let (Some([x, y]), Some([cw, ch])) = (raw.u64s(t::DEFAULT_CROP_ORIGIN).as_deref(), raw.u64s(t::DEFAULT_CROP_SIZE).as_deref()) @@ -563,7 +562,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { wb_multipliers: if linear_rgb { Some([1.0; 3]) } else { wb }, linearized: false, opcodes: OpcodeLists { - list3: if linear_rgb { Vec::new() } else { super::arw_lens::distortion(raw, Rect::new(0, 0, w, h), crop).into_iter().collect() }, + list3: if linear_rgb { Vec::new() } else { super::arw_lens::distortion(&model, raw, Rect::new(0, 0, w, h), crop).into_iter().collect() }, ..Default::default() }, metadata, diff --git a/crates/raw/src/vendor/arw_lens.rs b/crates/raw/src/vendor/arw_lens.rs index 4841ea1b4..9f0cc80d4 100644 --- a/crates/raw/src/vendor/arw_lens.rs +++ b/crates/raw/src/vendor/arw_lens.rs @@ -11,9 +11,15 @@ const DISTORTION: u16 = 0x7037; const SAMPLES: usize = 1024; const MAX_ERROR: f64 = 0.0005; -/// The 16-entry layout only. Unknown layouts and implausible/non-smooth maps are left uncorrected. +/// Only independently validated camera models and the 16-entry layout. Unknown models/layouts +/// and implausible/non-smooth maps are left uncorrected. See docs/sony-lens-corrections.md. /// A camera setting of Off does not remove the lens data: the user can enable it in the developer. -pub(super) fn distortion(ifd: &Ifd, active: Rect, crop: Rect) -> Option { +pub(super) fn distortion(model: &str, ifd: &Ifd, active: Rect, crop: Rect) -> Option { + // A matching table shape does not establish its scale or radial normalization on another body. + // Keep this exact: ILCE-7RM4 and ILCE-7RM4A are distinct models, not interchangeable aliases. + if model != "ILCE-7RM4A" { + return None; + } let Value::SShort(values) = &ifd.get(DISTORTION)?.value else { return None }; let [16, table @ ..] = values.as_slice() else { return None }; let table: &[i16; 16] = table.try_into().ok()?; @@ -131,7 +137,7 @@ mod tests { #[test] fn matches_independent_sony_reference_geometry() { // On/off feature correspondences from Imaging Edge 4.1 at 600 mm, independently fitted. - let Opcode::WarpRectilinear { planes, center } = distortion(&table(&TELE), AREA, AREA).unwrap() else { panic!("warp") }; + let Opcode::WarpRectilinear { planes, center } = distortion("ILCE-7RM4A", &table(&TELE), AREA, AREA).unwrap() else { panic!("warp") }; assert_eq!(center, [0.5, 0.5]); let reference = [0.9760159044, 0.0254891644, -0.0005984764, -0.0010120638]; for i in 0..=100 { @@ -145,8 +151,8 @@ mod tests { fn barrel_framing_and_offset_crop_keep_the_same_geometry() { let crop = Rect::new(32, 20, 9504, 6336); let active = Rect::new(0, 0, 9600, 6376); - let Opcode::WarpRectilinear { planes: a, .. } = distortion(&table(&WIDE), AREA, AREA).unwrap() else { panic!("warp") }; - let Opcode::WarpRectilinear { planes: b, center } = distortion(&table(&WIDE), active, crop).unwrap() else { panic!("warp") }; + let Opcode::WarpRectilinear { planes: a, .. } = distortion("ILCE-7RM4A", &table(&WIDE), AREA, AREA).unwrap() else { panic!("warp") }; + let Opcode::WarpRectilinear { planes: b, center } = distortion("ILCE-7RM4A", &table(&WIDE), active, crop).unwrap() else { panic!("warp") }; assert!(a[0][0] > 1.02 && a[0][0] < 1.03, "barrel correction expands the middle"); assert!((center[0] * 9599.0 - 4783.5).abs() < 1e-10); assert!((center[1] * 6375.0 - 3187.5).abs() < 1e-10); @@ -169,41 +175,52 @@ mod tests { ifd(Value::Short(vec![16; 17])), ifd(Value::Double(vec![f64::NAN; 17])), ] { - assert!(distortion(&bad, AREA, AREA).is_none()); + assert!(distortion("ILCE-7RM4A", &bad, AREA, AREA).is_none()); } - assert!(distortion(&table(&TELE), Rect::new(0, 0, 1, 1), AREA).is_none()); - assert!(distortion(&table(&TELE), AREA, Rect::new(0, 0, 9504, 5346)).is_none()); - assert!(distortion(&table(&TELE), AREA, Rect::new(99999, 0, 10, 10)).is_none()); + assert!(distortion("ILCE-7RM4A", &table(&TELE), Rect::new(0, 0, 1, 1), AREA).is_none()); + assert!(distortion("ILCE-7RM4A", &table(&TELE), AREA, Rect::new(0, 0, 9504, 5346)).is_none()); + assert!(distortion("ILCE-7RM4A", &table(&TELE), AREA, Rect::new(99999, 0, 10, 10)).is_none()); } + #[test] + fn refuses_unvalidated_models_even_with_a_valid_table() { + let valid = table(&TELE); + assert!(distortion("ILCE-7RM4A", &valid, AREA, AREA).is_some()); + for model in ["", "ILCE-7M3", "ILCE-7M4", "ILCE-7RM4", "ILCE-7RM5", "ILCE-9M2", "DSC-RX100M3", "ILCE-7RM4A unknown"] { + assert!(distortion(model, &valid, AREA, AREA).is_none(), "{model}"); + } + } + #[test] fn header_and_full_decode_preserve_correction_through_dng() { use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, tags as t}; - for order in [ByteOrder::Little, ByteOrder::Big] { - let mut raw = IfdBuilder::new(); - raw.set(t::MAKE, Value::Ascii("SONY".into())); - raw.set(t::MODEL, Value::Ascii("synthetic correction test".into())); - raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); - raw.set(t::IMAGE_LENGTH, Value::Long(vec![24])); - raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); - raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); - raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); - raw.set(t::COMPRESSION, Value::Short(vec![1])); - raw.set(t::WHITE_LEVEL, Value::Long(vec![16383])); - raw.set(0x74c7, Value::Long(vec![0, 2])); - raw.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![2, 2])); - raw.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![30, 20])); - raw.set(0x74c8, Value::Long(vec![30, 20])); - raw.set(DISTORTION, Value::SShort(std::iter::once(16).chain(TELE).collect())); - // Zero pixels have the same byte representation in both orders. - raw.set_image(ImageData::Strips { rows_per_strip: 24, strips: vec![vec![0; 32 * 24 * 2]] }); - let file = TiffWriter { order, ..Default::default() }.write(&[raw]).unwrap(); - let header = super::super::arw::decode(&file, crate::Mode::Header).unwrap(); - let full = super::super::arw::decode(&file, crate::Mode::Full).unwrap(); - assert_eq!(header.info(), full.info()); - assert_eq!(full.opcodes.list3.len(), 1); - assert_eq!(full.crop, Rect::new(2, 2, 30, 20)); - let dng = crate::write_dng(&full, &Default::default()).unwrap(); - assert_eq!(crate::decode(&dng).unwrap().opcodes.list3, full.opcodes.list3); + for (model, expected) in [("ILCE-7RM4A", 1), ("ILCE-7RM4", 0), ("ILCE-7M3", 0), ("", 0)] { + for order in [ByteOrder::Little, ByteOrder::Big] { + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii(model.into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![24])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::WHITE_LEVEL, Value::Long(vec![16383])); + raw.set(0x74c7, Value::Long(vec![0, 2])); + raw.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![2, 2])); + raw.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![30, 20])); + raw.set(0x74c8, Value::Long(vec![30, 20])); + raw.set(DISTORTION, Value::SShort(std::iter::once(16).chain(TELE).collect())); + // Zero pixels have the same byte representation in both orders. + raw.set_image(ImageData::Strips { rows_per_strip: 24, strips: vec![vec![0; 32 * 24 * 2]] }); + let file = TiffWriter { order, ..Default::default() }.write(&[raw]).unwrap(); + let header = super::super::arw::decode(&file, crate::Mode::Header).unwrap(); + let full = super::super::arw::decode(&file, crate::Mode::Full).unwrap(); + assert_eq!(header.info(), full.info()); + assert_eq!(full.opcodes.list3.len(), expected, "{model} {order:?}"); + assert_eq!(full.crop, Rect::new(2, 2, 30, 20)); + let dng = crate::write_dng(&full, &Default::default()).unwrap(); + assert_eq!(crate::decode(&dng).unwrap().opcodes.list3, full.opcodes.list3); + } } } } diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 53193f1f1..24a03f04d 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -610,3 +610,38 @@ fn corpus_raws_keep_their_container_and_are_not_thumbnail_shells() { } eprintln!("{checked} corpus raws keep their container"); } + +/// Matching Sony table dimensions are not evidence of a shared correction model. Exercise the +/// actual corpus headers and decoded files as well as the synthetic model-boundary unit tests. +#[test] +fn sony_embedded_distortion_is_limited_to_validated_models() { + let root = corpus_root().join("raw"); + let mut checked = 0; + for (name, model, expected) in [ + ("arw-sony-a7rm4a-compressed.arw", "ILCE-7RM4A", 1), + ("arw-sony-a9m2-compressed.arw", "ILCE-9M2", 0), + ("arw-sony-a7m3-compressed.arw", "ILCE-7M3", 0), + ("arw-sony-a7m3-uncompressed.arw", "ILCE-7M3", 0), + ("arw-sony-a7m4-14bit.arw", "ILCE-7M4", 0), + ("arw-sony-a7m4-lossless-l.arw", "ILCE-7M4", 0), + ("arw-sony-a7m4-lossless-m.arw", "ILCE-7M4", 0), + ("arw-sony-a7m4-lossless-s.arw", "ILCE-7M4", 0), + ("arw-sony-a7rm2-12bit-uncompressed.arw", "ILCE-7RM2", 0), + ("arw-sony-rx100.arw", "DSC-RX100", 0), + ("arw-sony-rx100m3.arw", "DSC-RX100M3", 0), + ] { + let path = root.join(name); + if !path.exists() { + eprintln!("skip: {} absent", path.display()); + continue; + } + let bytes = std::fs::read(&path).unwrap(); + let full = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(probe_info(&bytes).unwrap(), full.info(), "{name}"); + assert_eq!(full.metadata.model.as_deref(), Some(model), "{name}"); + assert_eq!(full.opcodes.list3.len(), expected, "{name}: only the validated Sony model gets a distortion warp"); + eprintln!("{name}: {expected} warp(s); header and full decode agree"); + checked += 1; + } + eprintln!("Sony distortion model boundary: {checked} corpus files checked"); +} diff --git a/docs/parity.md b/docs/parity.md index b92a7f209..76986a4e6 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -75,7 +75,7 @@ Take the first one nobody is working on. 4b. **LR-VIEW-ZOOM** (P0): finish the sharp zoomed views of issue #323: windows for compare, overlays and soft proofing (Before/After has them); check sharpness and the wide-stage margin on real raw photos (only synthetic scenes so far). 5. **LR-EDIT-OPTICS-PROFILE** (P1): a lens-profile database of our own (corrections embedded in DNG files and the - distortion correction Panasonic / Leica cameras record in RW2 / RWL files work today; issue #256). Sony Bayer + distortion correction Panasonic / Leica cameras record in RW2 / RWL files work today; issue #256). Sony ILCE-7RM4A Bayer ARW 16-sample tables in native 3:2 framing now feed the same distortion path; broader coverage and Sony vignetting / lateral CA remain (see `docs/sony-lens-corrections.md`). 6. **AI masks and Enhance** (LR-MASK-SUBJECT / SKY / PEOPLE / OBJECTS, LR-EDIT-DETAIL-DENOISE, SUPERRES, LENSBLUR): @@ -274,7 +274,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | | | LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | | | LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | `crates/ui-egui/src/panels/edit.rs` (checkbox), `ctl:optics.caRed`, `ctl:optics.caBlue` | | -| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, validated on ILCE-7RM4A with FE 24–105 and FE 200–600 lenses (see `docs/sony-lens-corrections.md`); vignetting and lateral CA data of other raw formats (Olympus, Fujifilm, Sony) not read yet; no lens-profile database | +| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses; other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); vignetting and lateral CA data of other raw formats (Olympus, Fujifilm, Sony) not read yet; no lens-profile database | ) | LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | diff --git a/docs/sony-lens-corrections.md b/docs/sony-lens-corrections.md index a2fa705d6..05f2fdc58 100644 --- a/docs/sony-lens-corrections.md +++ b/docs/sony-lens-corrections.md @@ -1,11 +1,13 @@ # Sony embedded distortion corrections -LightCraft reads the signed 16-sample distortion table in the raw image IFD (`0x7037`) of Sony ARWs. +LightCraft reads the signed 16-sample distortion table in the raw image IFD (`0x7037`) of +Sony **ILCE-7RM4A (A7R IVA)** Bayer ARWs. Other camera models are deliberately left uncorrected, +even when they contain a table with the same layout. It converts that table into `OpcodeList3` / `WarpRectilinear`, so the existing profile correction control, CPU/GPU optics path, EXIF orientation handling, and DNG export use the same correction. This adds **distortion only**. Sony vignetting and lateral chromatic-aberration tables are not decoded. -The tested files are Bayer ARWs from the ILCE-7RM4A with the FE 24–105mm F4 G OSS and +The enabled and independently validated model is ILCE-7RM4A with the FE 24–105mm F4 G OSS and FE 200–600mm F5.6–6.3 G OSS. Linear YCbCr ARWs and non-3:2 camera crops are excluded pending validation. An aspect crop may retain the full-frame radial normalization; the decoder does not guess that relationship. Older files that carry only encrypted correction metadata, different table lengths, and rejected tables remain uncorrected. @@ -70,3 +72,42 @@ source has loaded, enable lens corrections if they were previously disabled. Tests cover signedness/length rejection, zero and malformed tables, independent 600 mm reference geometry, barrel framing, offset crop normalization, both TIFF byte orders, header/full consistency, and preservation through DNG export. No private photographs or reference TIFFs are committed. + +## Camera coverage and regression boundary + +The model check is exact: `ILCE-7RM4` (A7R IV without the A suffix), `ILCE-7M3`, unknown/missing +model names, and every other model remain unsupported by this distortion decoder. Matching tag +numbers and table lengths do not establish the scale, interpolation or crop normalization on another +body. Support can expand after independent geometry validation; do not add model-name prefix matching. + +The original nine Sony samples listed by `cargo xtask corpus --download` were downloaded from its +CC0 URLs. The manifest now also includes CC0 samples 4822 (A7R IVA / 24–105 mm) and 3989 +(A9 II / 200–600 mm), with their SHA-256 hashes recorded alongside the URLs. All eleven files +are checked with `sony_embedded_distortion_is_limited_to_validated_models`: + +| Camera | Corpus samples | Raw-IFD table observed | Expected correction | +|---|---|---|---| +| ILCE-7RM4A | Compressed (4822) | 16 samples | One distortion warp | +| ILCE-9M2 | Compressed (3989) | 16 samples | None | +| ILCE-7M3 | Compressed, uncompressed | 16 samples | None | +| ILCE-7M4 | 14-bit, lossless L/M/S | 11 samples in the 14-bit file; 16 in L/M/S | None | +| ILCE-7RM2 | 12-bit uncompressed | 16 samples | None | +| DSC-RX100 | One | No raw-IFD table | None | +| DSC-RX100M3 | One | 11 samples | None | + +These are **model-boundary regression checks, not new geometric validation of those models**. In particular, +refusing the A7 III tables resolves the unverified corner warp identified in review. The lossless M/S +files are linear YCbCr and remain excluded independently of the model check. Header and full decode +must agree for every sample. Synthetic tests also verify both TIFF byte orders, exact model matching, +standard crop precedence, and that DNG export preserves an accepted warp without adding one to rejected +models. The existing independent Sony geometry tests continue to cover the enabled A7R IVA path. +Re-exporting the 24, 200 and 600 mm reference files after the model restriction produced byte-identical +corrected PNGs to the earlier validated exports. Isolated headless app checks showed the A7R IVA profile +controls present and the A7 III profile controls absent, with no notices and completed RAW renders. + +Before enabling another body, compare corrected and uncorrected renders against independent references +across multiple lenses/focal lengths and photographs with features near the edges and corners. Record +residuals and spatial coverage, and check crop/aspect and compression variants. Embedded JPEGs are +useful when the camera actually applied distortion correction; an Off JPEG cannot certify an On warp. +Prefer same-renderer Sony On/Off exports when the camera setting or geometry is ambiguous. Table-fit +error alone only checks our polynomial approximation, not whether the table's interpretation is correct. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 0db8f6b2a..4c4a9484c 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -308,6 +308,16 @@ fn cmd_ci() -> Result<(), String> { /// CC0 raw samples from raw.pixls.us (each verified CC0 on the site; files matched by their published SHA-256). /// One per format / compression variant we decode or deliberately report as unsupported (preview only). const RAW_SAMPLES: &[(&str, &str)] = &[ + // ILCE-7RM4A / CC0-1.0; SHA-256 690c774f1d7bc1db3fa8c2489762743d8e7586e50b6f65d0b6a61cb467972c67 + ( + "arw-sony-a7rm4a-compressed.arw", + "https://raw.pixls.us/getfile.php/4822/nice/Sony%20-%20ILCE-7RM4A%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW", + ), + // ILCE-9M2 / CC0-1.0; SHA-256 161c2a9da2b5f1e50be6117a0b4da0ce249660b5d7de13568d301c4034716b97 + ( + "arw-sony-a9m2-compressed.arw", + "https://raw.pixls.us/getfile.php/3989/nice/Sony%20-%20ILCE-9M2%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW", + ), ( "arw-sony-a7m3-compressed.arw", "https://raw.pixls.us/getfile.php/2414/nice/Sony%20-%20ILCE-7M3%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW", From 82ff68d4f3b9f261d36999a0f98daa619090aa1e Mon Sep 17 00:00:00 2001 From: ROSSA Date: Thu, 8 Oct 2026 21:20:45 -0500 Subject: [PATCH 05/33] M11.4: raws from 52 camera models start from matrices fitted to measured spectral sensitivities A raw without colour matrices of its own now gets DNG ColorMatrix1/2 and ForwardMatrix1/2 for its model when the model is one of the 52 cameras in rawtoaces-data (Academy Software Foundation, Apache-2.0): 22 Canon, 11 Nikon, 11 Sony, 3 Fujifilm and 5 others, plus the other names its alias list gives (Rebel T3i and Kiss X5 for the EOS 600D; bodies it lists as sharing a sensor). tools/spectral_camera_table.py fits, under Standard Illuminant A and D65, a white-preserving 3x3 to the measured spectral sensitivities over rawtoaces-data's 190 training reflectances and writes crates/raw/src/spectral_table.rs. No number comes from Adobe or from a raw decoder. lightcraft_raw::spectral looks a camera up by Exif make and model; the DNG colour model does the rest. camera_preview::starting_colour takes the first available of: the file's own matrices, a camera profile for the model (fitted to the JPEG for tone), the spectral matrices, the file-local JPEG fit, the neutral fallback. A profiled model whose profile fit fails takes its spectral matrices. White balance stays relative to the as-shot look for every raw without matrices of its own, so Photo::relative_wb and the controls are unchanged; calibrate still pools covered models. Tests: one per precedence step (synthetic), the table's lookup, aliases and DNG shape, and a corpus test of the order on ILCE-7M4 (profile), EOS 5D Mark III and D5100 (spectral), EOS 7D (own fit) and a DNG. The ILCE-7RM2 and GFX100S corpus tests now expect the spectral matrices. Attribution in assets/ATTRIBUTION.md and NOTICE. RENDER_CACHE_VERSION 23. Co-Authored-By: Claude Opus 5.5 --- NOTICE | 7 + ROADMAP.md | 7 +- assets/ATTRIBUTION.md | 1 + crates/engine/src/camera_preview.rs | 189 +++++++++- crates/engine/src/files.rs | 15 +- crates/engine/src/media.rs | 2 +- crates/raw/src/lib.rs | 4 +- crates/raw/src/spectral.rs | 171 +++++++++ crates/raw/src/spectral_table.rs | 349 +++++++++++++++++++ docs/camera-preview-colour.md | 28 ++ docs/parity.md | 2 +- docs/whats-new.md | 4 + tools/requirements-spectral-camera-table.txt | 5 + tools/spectral_camera_table.py | 122 +++++++ 14 files changed, 879 insertions(+), 27 deletions(-) create mode 100644 crates/raw/src/spectral.rs create mode 100644 crates/raw/src/spectral_table.rs create mode 100644 tools/requirements-spectral-camera-table.txt create mode 100644 tools/spectral_camera_table.py diff --git a/NOTICE b/NOTICE index 254e26280..5fd60227f 100644 --- a/NOTICE +++ b/NOTICE @@ -15,6 +15,13 @@ list (author, source, license) is in assets/ATTRIBUTION.md. Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt. Showcase photographs by Ansel Adams, Dorothea Lange and NASA (docs/images/) — public domain. See assets/ATTRIBUTION.md. +crates/raw/src/spectral_table.rs (lightcraft-raw) holds camera colour matrices derived from the +camera spectral sensitivities, training reflectances and model alias list of rawtoaces-data +(https://github.com/AcademySoftwareFoundation/rawtoaces-data, v1.1.0), an Academy Software +Foundation project licensed under the Apache License 2.0, and from the CIE 1931 colour-matching +functions it ships (CIE, CC-BY-4.0). The matrices were fitted by the LightCraft contributors +(2026) with tools/spectral_camera_table.py. See assets/ATTRIBUTION.md. + crates/segment (lightcraft-segment) ports the SAM 3 model code of Hugging Face Transformers (https://github.com/huggingface/transformers, src/transformers/models/sam3 and sam3_tracker), Copyright The HuggingFace Team and Meta Platforms, Inc., licensed under the Apache License 2.0. diff --git a/ROADMAP.md b/ROADMAP.md index 32f6a89bc..c4bd73cef 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,7 +24,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). |---|---:|---|---| | **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim | | **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, uncompressed Samsung SRW (NX5 to NX1100, EX1, WB2000), RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini), Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) | -| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) | +| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); 52 models start from matrices fitted to measured spectral sensitivities; other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) | | **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) | | **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering | | **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ | @@ -49,7 +49,8 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par 1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2, Pentax PEF and Samsung SRW have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet - checked on the corpus); then ORF, then validate fidelity. + checked on the corpus); 52 models start from matrices fitted to measured spectral sensitivities (rawtoaces-data); + then ORF, then validate fidelity. 2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, NEF lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source, no LGPL dependency); compressed NEF (#86) is the template. @@ -188,7 +189,7 @@ Not decoded yet — preview only (no permissively licensed description; black-bo alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis). - **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**. -**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2, PEF and Samsung SRW files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged +**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2, PEF and Samsung SRW files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. 52 models without a camera profile start from matrices fitted to their measured spectral sensitivities instead (rawtoaces-data, Apache-2.0; `crates/raw/src/spectral.rs`). Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged `matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our own chart-based calibration (M11.4). Adobe matrices are never used. diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 52074466d..30709cb12 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -13,6 +13,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none | | `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via ``, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` | | `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none | +| `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix | | `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a | | `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a | | `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots | diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index dd3d7361f..582580217 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -4,7 +4,8 @@ //! A global matrix can't follow the camera's hue-dependent rendering (the best matrix rendered a //! lime shirt olive that the camera kept lime): a hue/saturation table fitted to the residuals //! (applied like a DNG `ProfileHueSatMap`) corrects that when it also improves the held-out pixels. -use lightcraft_color::{D50, D65, Mat3, PROPHOTO, REC2020, bradford, luminance_2020}; +use crate::camera_profiles::CameraProfile; +use lightcraft_color::{D50, D65, Mat3, PROPHOTO, REC2020, Xy, bradford, luminance_2020}; use lightcraft_pipeline::tone::{CameraTone, ToneMap}; use lightcraft_raster::{ Rgb32f, @@ -45,16 +46,52 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool { ) } -pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option { - if !transform.matrix_is_fallback || !file_local_look(raw.format) { +/// The colour a raw starts from, first available first: the file's own colour matrices (DNG); the camera profile for +/// its model (bundled or local, `camera_profiles`), with tone and chroma fitted to the file's JPEG; the model's colour +/// matrices from measured spectral sensitivities (`lightcraft_raw::spectral`); a fit to the file's own JPEG; the +/// neutral fallback. The spectral matrices are written into `raw.color`. Returns the as-shot white, the camera +/// transform at it and the fitted look. +pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8]) -> (Xy, CameraTransform, Option) { + let start = |raw: &RawImage| { + let xy = lightcraft_raw::color::as_shot_white_xy(raw); + (xy, lightcraft_raw::color::camera_transform(raw, xy)) + }; + let (xy, t) = start(raw); + let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); + let spectral = match raw.metadata.model.as_deref() { + Some(model) if t.matrix_is_fallback => lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), model), + _ => None, + }; + let Some(camera) = spectral else { return (xy, t, fit_preview(raw, bytes, &t, profile.as_deref(), true)) }; + if let Some(look) = fit_preview(raw, bytes, &t, profile.as_deref(), false) { + return (xy, t, Some(look)); + } + camera.fill(&mut raw.color); + if lightcraft_pipeline::profiling() { + eprintln!("[profile] {:?} camera colour from spectral sensitivities: {} {}", raw.format, camera.make, camera.model); + } + let (xy, t) = start(raw); + (xy, t, None) +} + +/// The look fitted to the file's JPEG: with `profile`'s colour when there is one, else (or when that fails the +/// gates, if `own_colour`) with colour fitted to this photo alone. +fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, profile: Option<&CameraProfile>, own_colour: bool) -> Option { + if !transform.matrix_is_fallback || !file_local_look(raw.format) || (profile.is_none() && !own_colour) { + return None; + } + // A camera profile pooled from many photos knows colours this photo shows too little of; + // try its colour first and fit tone/chroma per photo (DRO and picture styles vary). + let colour = profile.and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); + if colour.is_none() && !own_colour { return None; } let (sensor, reference) = proxies(raw, bytes, transform, PROXY)?; - // A camera profile pooled from many photos knows colours this photo shows too little of; - // try its colour first and fit tone/chroma per photo (DRO and picture styles vary). - let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); - let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); - let look = fit_pairs_with(&sensor, &reference, colour)?; + let look = if own_colour { + fit_pairs_with(&sensor, &reference, colour)? + } else { + TONE_FITS.iter().find_map(|&tone| fit_candidate(&sensor, &reference, colour.clone(), tone))? + }; if lightcraft_pipeline::profiling() { eprintln!( "[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile available {}", @@ -410,9 +447,12 @@ const EDGE_CONTRAST: f32 = 3.0; /// it is tried once more on the pixels away from edges (same gates): there colour pairs stay /// valid when the camera JPEG's geometry differs slightly from the raw's. fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { - fit_pairs_ordered(sensor, reference, colour, &[ToneFit::Quantile, ToneFit::ConditionalMedian]) + fit_pairs_ordered(sensor, reference, colour, &TONE_FITS) } +/// The tone fits a look tries, in order (see [`fit_pairs_ordered`]). +const TONE_FITS: [ToneFit; 2] = [ToneFit::Quantile, ToneFit::ConditionalMedian]; + /// [`fit_pairs_with`] with the tone fits to try, in order: the first whose look passes the /// acceptance gates is used. Quantile matching comes first because it cannot produce flat steps; /// the conditional-median fit follows because on a few photos (compacts, early Micro Four Thirds) @@ -1102,6 +1142,84 @@ mod tests { assert!(fit_hue_sat(&[], &Mat3::IDENTITY).is_none(), "too few samples"); } + fn raw_of(format: RawFormat, make: &str, model: &str, color: lightcraft_raw::ColorData) -> RawImage { + let area = lightcraft_raw::Rect::new(0, 0, 2, 2); + RawImage { + format, + width: 2, + height: 2, + cpp: 1, + data: lightcraft_raw::RawData::U16(vec![0; 4]), + cfa: None, + bits: 14, + black: Default::default(), + white: vec![16383.0], + active_area: area, + crop: area, + orientation: Default::default(), + color, + wb_multipliers: Some([2.0, 1.0, 1.5]), + linearized: false, + opcodes: Default::default(), + metadata: lightcraft_raw::Metadata { make: Some(make.to_owned()), model: Some(model.to_owned()), ..Default::default() }, + } + } + + /// Step 1: a file's own colour matrices win over everything, including a spectral row for its model. + #[test] + fn a_files_own_matrices_come_first() { + let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]); + let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() }; + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", color.clone()); + let (_, t, look) = starting_colour(&mut raw, &[]); + assert_eq!(raw.color, color); + assert!(!t.matrix_is_fallback && look.is_none()); + } + + /// Step 2: a camera profile comes before the spectral matrices; when its fit can't run (here no camera JPEG) the + /// spectral matrices follow. The ILCE-7M4 has a bundled profile and a spectral row. + #[test] + fn a_camera_profile_is_tried_before_the_spectral_matrices() { + assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); + let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M4").unwrap(); + let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default()); + let (_, t, look) = starting_colour(&mut raw, &[]); + assert!(look.is_none()); + assert_eq!(raw.color.color_matrix, camera.color_matrix.map(Some)); + assert!(!t.matrix_is_fallback); + } + + /// Step 3: a camera without a profile takes its spectral matrices, and no JPEG fit is tried: the look is `None` + /// and the transform is the DNG model's, at the as-shot white the matrices give the vendor multipliers. + #[test] + fn spectral_matrices_come_before_the_photos_own_fit() { + let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap(); + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default()); + let (xy, t, look) = starting_colour(&mut raw, &[]); + assert!(look.is_none()); + assert_eq!(raw.color.illuminant, [17, 21]); + assert_eq!(raw.color.forward_matrix, camera.forward_matrix.map(Some)); + assert!(!t.matrix_is_fallback); + assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw)); + // white-balanced white stays white + let w = t.matrix.apply([1.0; 3]); + assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-4), "{w:?}"); + } + + /// Steps 4 and 5: a camera outside the table keeps today's path, the photo's own JPEG fit and else the neutral + /// fallback (here: no camera JPEG, so the fallback). + #[test] + fn a_camera_outside_the_table_keeps_the_fit_or_the_fallback() { + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default()); + let (_, t, look) = starting_colour(&mut raw, &[]); + assert!(look.is_none() && t.matrix_is_fallback); + assert_eq!(raw.color, lightcraft_raw::ColorData::default()); + // without a model there is no table row + let mut raw = raw_of(RawFormat::Cr2, "Canon", "", Default::default()); + raw.metadata.model = None; + assert!(starting_colour(&mut raw, &[]).1.matrix_is_fallback); + } + #[test] fn supported_raws_get_a_file_local_look() { // every decoded raw without a colour matrix of its own (issue #310: CR2 rendered flat with the fallback) @@ -1172,8 +1290,7 @@ mod tests { }; assert_eq!(raw.format, RawFormat::Cr3); assert_eq!(raw.info().developed_size(), (6000, 4000)); - let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform); + let (_, _, look) = starting_colour(&mut raw.clone(), &bytes); eprintln!("R100 guarded colour fit accepted: {}", look.is_some()); assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment"); let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); @@ -1219,7 +1336,8 @@ mod tests { /// Issue #232 (skipped without the corpus): the public ILCE-7RM2 sample's camera JPEG is /// distortion-corrected, and on its glass façade the fit on all pixels misses the gate - /// (held-out RMS 0.113): it used to open grey with the neutral fallback. + /// (held-out RMS 0.113): it used to open grey with the neutral fallback. The ILCE-7RM2 now starts from its + /// spectral matrices; the fit away from edges is still what a camera outside that table gets. #[test] fn corpus_arw_with_a_lens_corrected_preview_gets_a_camera_look() { let path = std::env::var_os("LIGHTCRAFT_CORPUS") @@ -1230,8 +1348,49 @@ mod tests { eprintln!("skip: {} absent", path.display()); return; }; + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + assert!(fit_preview(&raw, &bytes, &transform, None, true).is_some(), "no camera look fitted"); let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); - assert!(info.camera_tone.is_some(), "no camera look fitted"); + assert!(info.camera_tone.is_none(), "the spectral matrices come before the photo's own fit"); + assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0); + } + + /// The colour precedence on public raws (each skipped without its corpus file): the ILCE-7M4 has a bundled + /// profile, fitted for tone to its JPEG; the EOS 5D Mark III and D5100 have no profile and take their spectral + /// matrices; the EOS 7D is in neither and keeps its own fit; a DNG keeps its own matrices. White balance stays + /// relative to the as-shot look for every raw without matrices of its own. + #[test] + fn corpus_colour_precedence() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + // (file, spectral matrices used, camera look fitted) + for (name, spectral, look) in [ + ("arw-sony-a7m4-14bit.arw", false, true), + ("cr2-canon-5d3.cr2", true, false), + ("nef-nikon-d5100-lossless.nef", true, false), + ("cr2-canon-7d.cr2", false, true), + ("dng-canon-5d3-16bit.dng", false, false), + ] { + let Ok(bytes) = std::fs::read(dir.join(name)) else { + eprintln!("skip: {name} absent"); + continue; + }; + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + let own = lightcraft_raw::color::has_matrix(&raw.color); + let before = raw.color.clone(); + let (_, t, fitted) = starting_colour(&mut raw, &bytes); + assert_eq!(raw.color != before, spectral, "{name}"); + assert_eq!(fitted.is_some(), look, "{name}"); + assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}"); + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert_eq!(info.camera_tone.is_some(), look, "{name}"); + assert_eq!(info.relative_wb, !own, "{name}"); + } } /// Issue #232: an ILCE-7RM2 camera JPEG is lens-corrected ("Distortion Comp.: Auto"), so on a @@ -1289,7 +1448,9 @@ mod tests { .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw"); - for (name, accepted) in [("raf-fuji-xt2-865.raf", true), ("raf-fuji-gfx100s-4503.raf", true), ("raf-fuji-xt20-compressed.raf", true)] { + // The GFX100S shares the GFX 100's sensor (rawtoaces-data's alias list), so it starts from those spectral + // matrices instead of a look fitted to its JPEG. + for (name, accepted) in [("raf-fuji-xt2-865.raf", true), ("raf-fuji-gfx100s-4503.raf", false), ("raf-fuji-xt20-compressed.raf", true)] { let Ok(bytes) = std::fs::read(dir.join(name)) else { continue }; let header = crate::files::probe_bytes(name, &bytes).unwrap(); assert_eq!(header.as_shot_wb, Some((6500.0, 0.0)), "{name}"); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index db762f009..572069224 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -275,12 +275,13 @@ fn load_bytes_now( }; // Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in // (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`). - let lens = embedded_lens(&raw.info()); + let info = raw.info(); + let lens = embedded_lens(&info); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - let xy = lightcraft_raw::color::as_shot_white_xy(&raw); - let t = lightcraft_raw::color::camera_transform(&raw, xy); - // the source's segmentation mattes (DNG semantic masks), read while the preview is fitted - let (camera_look, mattes) = rayon::join(|| crate::camera_preview::fit_preview(&raw, &bytes, &t), || dng_mattes(&bytes, &raw)); + // Without colour matrices of its own, white balance is relative to the as-shot look (`Photo::relative_wb`). + let own_matrix = lightcraft_raw::color::has_matrix(&raw.color); + // the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted + let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes), || dng_mattes(&bytes, &info)); drop(bytes); // Previews and thumbnails bin the mosaic straight to (about) the size they need; only // larger levels (exports, 1:1) demosaic the whole sensor. @@ -350,7 +351,7 @@ fn load_bytes_now( } let twin = twin.map(|picture| finish(picture).0); let (temp, tint) = xy_to_temp_tint(xy); - let relative = crate::camera_preview::file_local_look(raw.format) && t.matrix_is_fallback; + let relative = crate::camera_preview::file_local_look(raw.format) && !own_matrix; // White balance re-evaluates the file's own colour model (when it has one and no // file-local look matrix sits on top of it) let camera_color = (!t.matrix_is_fallback && camera_look.is_none()).then(|| { @@ -398,7 +399,7 @@ fn local_tone(raw: &lightcraft_raw::RawImage) -> Option Option> { +fn dng_mattes(bytes: &[u8], raw: &lightcraft_raw::RawInfo) -> Option> { use lightcraft_pipeline::masks::{MatteKind, Mattes}; if raw.format != lightcraft_raw::RawFormat::Dng { return None; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 1231221b9..fcfbbe208 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 = 22; +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 963a4be89..7f725090a 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -20,7 +20,8 @@ //! [`embedded_preview`] covers these containers' JPEG previews. Variants we can't decode yet (Nikon "lossy after split" NEF, //! compressed ORF, CR3 unverified marker families / C-RAW configurations) return [`RawError::Unsupported`]; each vendor module documents its sources //! (public specifications, tag-name documentation, black-box analysis of CC0 samples) and gaps. Non-DNG files carry no -//! colour matrix: [`color`] falls back to a documented neutral model. The decoders never panic on malformed input. +//! colour matrix: [`spectral`] has matrices fitted to measured spectral sensitivities for 52 models, and +//! otherwise [`color`] falls back to a documented neutral model. The decoders never panic on malformed input. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -38,6 +39,7 @@ pub mod opcodes; mod preview; pub mod profile; pub mod semantic; +pub mod spectral; mod tiffraw; mod unpack; mod vendor; diff --git a/crates/raw/src/spectral.rs b/crates/raw/src/spectral.rs new file mode 100644 index 000000000..00f41b554 --- /dev/null +++ b/crates/raw/src/spectral.rs @@ -0,0 +1,171 @@ +//! Camera colour matrices from measured spectral sensitivities, for raws that carry no colour matrix. +//! +//! [`find`] looks a camera up by its Exif make and model in a table generated by +//! `tools/spectral_camera_table.py` from rawtoaces-data (Academy Software Foundation, Apache-2.0): per model, DNG +//! `ColorMatrix1/2` and `ForwardMatrix1/2` for Standard Illuminant A and D65, fitted to the camera's measured spectral +//! sensitivities. [`Camera::fill`] writes them into a file's [`ColorData`], and [`crate::color`]'s DNG model does the +//! rest. Other names of a covered camera (Rebel T3i for EOS 600D) come from rawtoaces-data's alias list. No number +//! comes from Adobe or from a GPL raw decoder. Which colour a raw starts from is the engine's choice +//! (`docs/camera-preview-colour.md`). +use crate::ColorData; +use lightcraft_color::Mat3; + +#[path = "spectral_table.rs"] +mod table; + +/// DNG `CalibrationIlluminant` codes of the two matrices: Standard Illuminant A, D65. +const ILLUMINANTS: [u16; 2] = [17, 21]; + +/// One camera's colour matrices. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Camera { + pub make: &'static str, + pub model: &'static str, + /// XYZ → camera under Standard Illuminant A and D65 (`ColorMatrix1/2`). + pub color_matrix: [Mat3; 2], + /// White-balanced camera → XYZ D50 (`ForwardMatrix1/2`). + pub forward_matrix: [Mat3; 2], +} + +impl Camera { + /// Give `color` this camera's matrices, when it has no colour matrix of its own. Returns whether it did. + pub fn fill(&self, color: &mut ColorData) -> bool { + if crate::color::has_matrix(color) { + return false; + } + color.illuminant = ILLUMINANTS; + color.color_matrix = self.color_matrix.map(Some); + color.forward_matrix = self.forward_matrix.map(Some); + true + } +} + +/// Model names compared without case, the brand, a leading `EOS` or a trailing `DIGITAL` +/// (Exif "Canon EOS 400D DIGITAL" is the table's "EOS 400D"). +fn key(brand: &str, model: &str) -> String { + let mut words: Vec = model.split_whitespace().map(str::to_uppercase).collect(); + if words.first().is_some_and(|w| *w == brand) { + words.remove(0); + } + if words.first().is_some_and(|w| w == "EOS") { + words.remove(0); + } + if words.len() > 1 && words.last().is_some_and(|w| w == "DIGITAL") { + words.pop(); + } + words.join(" ") +} + +/// First word of a make, upper case ("NIKON CORPORATION" → "NIKON"). +fn brand(make: &str) -> String { + make.split_whitespace().next().unwrap_or_default().to_uppercase() +} + +/// The table's camera for a file's Exif make and model, directly or through an alias. Without a make, the model +/// alone must match. +pub fn find(make: Option<&str>, model: &str) -> Option<&'static Camera> { + let make = make.map(brand).filter(|b| !b.is_empty()); + let same = |row_make: &str, row_model: &str| { + let row_brand = brand(row_make); + let wanted = key(&row_brand, model); + make.as_ref().is_none_or(|m| *m == row_brand) && !wanted.is_empty() && key(&row_brand, row_model) == wanted + }; + match table::ALIASES.iter().find(|(m, alias, _)| same(m, alias)) { + Some((m, _, target)) => table::CAMERAS.iter().find(|c| brand(c.make) == brand(m) && c.model == *target), + None => table::CAMERAS.iter().find(|c| same(c.make, c.model)), + } +} + +/// Every camera in the table. +pub fn cameras() -> &'static [Camera] { + table::CAMERAS +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exif_names_find_their_rows() { + for (make, model, row) in [ + ("Canon", "Canon EOS 5D Mark III", "EOS 5D Mark III"), + ("Canon", "Canon EOS 600D", "EOS 600D"), + ("Canon", "Canon EOS-1Ds Mark III", "EOS-1Ds Mark III"), + ("Canon", "Canon EOS DIGITAL REBEL XTi", "Digital Rebel XTi"), + ("Canon", "Canon EOS R5m2", "EOS R5m2"), + ("Canon", "Canon PowerShot S90", "PowerShot S90"), + ("NIKON CORPORATION", "NIKON D850", "D850"), + ("NIKON CORPORATION", "NIKON Z f", "Z f"), + ("SONY", "ILCE-7M3", "ILCE-7M3"), + ("FUJIFILM", "X-T4", "X-T4"), + ("Panasonic", "DC-GX9", "DC-GX9"), + ] { + assert_eq!(find(Some(make), model).map(|c| c.model), Some(row), "{make} {model}"); + } + assert_eq!(find(None, "NIKON D850").map(|c| c.model), Some("D850")); + assert_eq!(find(None, "ILCE-7M3").map(|c| c.model), Some("ILCE-7M3")); + } + + #[test] + fn aliases_reach_the_camera_they_name() { + for (make, model, row) in [ + ("Canon", "Canon EOS 400D DIGITAL", "Digital Rebel XTi"), + ("Canon", "Canon EOS Kiss Digital X", "Digital Rebel XTi"), + ("Canon", "Canon EOS REBEL T3i", "EOS 600D"), + ("Canon", "Canon EOS Kiss X5", "EOS 600D"), + ("Canon", "Canon EOS R5 Mark II", "EOS R5m2"), + ("NIKON CORPORATION", "NIKON D800", "D800E"), + ("NIKON CORPORATION", "NIKON Z 6_2", "Z f"), + ("SONY", "ILCE-7RM4A", "ILCE-7RM4"), + ("FUJIFILM", "GFX100S", "GFX 100"), + ] { + assert_eq!(find(Some(make), model).map(|c| c.model), Some(row), "{make} {model}"); + } + } + + #[test] + fn other_cameras_and_other_makes_find_nothing() { + assert_eq!(find(Some("Canon"), "Canon EOS 7D"), None); + assert_eq!(find(Some("SONY"), "ILCE-7M2"), None); + assert_eq!(find(Some("OLYMPUS IMAGING CORP."), "E-M5"), None); + // the model of another make + assert_eq!(find(Some("Canon"), "D850"), None); + assert_eq!(find(Some("SONY"), "X-T4"), None); + assert_eq!(find(None, ""), None); + assert_eq!(find(Some(""), ""), None); + } + + #[test] + fn every_row_is_a_usable_dng_profile() { + assert_eq!(cameras().len(), 52); + for c in cameras() { + assert_eq!(find(Some(c.make), c.model), Some(c), "{} {}", c.make, c.model); + for m in c.color_matrix.iter().chain(&c.forward_matrix) { + assert!(m.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 4.0), "{}", c.model); + assert!(m.inverse().is_some(), "{}", c.model); + } + // a forward matrix maps white-balanced white to D50 (DNG spec, chapter 6) + for fm in &c.forward_matrix { + let w = fm.apply([1.0; 3]); + let d50 = lightcraft_color::D50.to_xyz(); + assert!((0..3).all(|i| (w[i] - d50[i]).abs() < 1e-4), "{} {w:?}", c.model); + } + } + } + + #[test] + fn fill_keeps_a_files_own_matrices() { + let c = find(Some("Canon"), "Canon EOS 600D").unwrap(); + let mut color = ColorData::default(); + assert!(c.fill(&mut color)); + assert_eq!(color.illuminant, [17, 21]); + assert_eq!(color.color_matrix, [Some(c.color_matrix[0]), Some(c.color_matrix[1])]); + assert_eq!(color.forward_matrix, [Some(c.forward_matrix[0]), Some(c.forward_matrix[1])]); + + let own = Mat3([[0.5, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.7]]); + let mut color = ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() }; + let before = color.clone(); + assert!(!c.fill(&mut color)); + assert_eq!(color, before); + } +} diff --git a/crates/raw/src/spectral_table.rs b/crates/raw/src/spectral_table.rs new file mode 100644 index 000000000..f116e2335 --- /dev/null +++ b/crates/raw/src/spectral_table.rs @@ -0,0 +1,349 @@ +// Generated by tools/spectral_camera_table.py from rawtoaces-data (https://github.com/AcademySoftwareFoundation/rawtoaces-data, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c). +// Derived from the camera spectral sensitivities of rawtoaces-data (Academy Software Foundation), licensed under +// the Apache License 2.0 (LICENSE-APACHE); see NOTICE and assets/ATTRIBUTION.md. Do not edit by hand. +use super::Camera; +use lightcraft_color::Mat3; + +#[rustfmt::skip] +pub(super) const CAMERAS: &[Camera] = &[ + Camera { + make: "ARRI", + model: "D21", + color_matrix: [Mat3([[0.933065, -0.03997, 0.042177], [-0.321253, 0.916394, 0.250116], [-0.047929, 0.258088, 0.770888]]), Mat3([[0.841118, 0.11685, -0.05716], [-0.416513, 1.06087, 0.169491], [-0.121062, 0.361236, 0.692345]])], + forward_matrix: [Mat3([[0.913988, -0.102353, 0.152661], [0.315506, 0.849769, -0.165276], [-0.134707, -0.698389, 1.6582]]), Mat3([[1.01361, -0.108757, 0.059444], [0.405694, 0.811177, -0.216871], [-0.027612, -0.326433, 1.17915]])], + }, + Camera { + make: "Arashi Vision", + model: "Insta360 X5", + color_matrix: [Mat3([[1.0899, -0.436896, 0.023525], [-0.482767, 1.39879, 0.369626], [-0.056659, 0.169421, 0.72407]]), Mat3([[0.700283, -0.158934, -0.077847], [-0.543955, 1.30291, 0.196622], [-0.148964, 0.25165, 0.516509]])], + forward_matrix: [Mat3([[0.710808, 0.160362, 0.093126], [0.231838, 0.891164, -0.123002], [0.020454, -0.498248, 1.3029]]), Mat3([[0.706099, 0.209581, 0.048616], [0.287186, 0.8971, -0.184286], [0.044158, -0.279999, 1.06095]])], + }, + Camera { + make: "Canon", + model: "Digital Rebel XTi", + color_matrix: [Mat3([[0.984721, -0.317618, -0.03823], [-0.536205, 1.41139, 0.499196], [-0.019493, 0.0898, 0.993219]]), Mat3([[0.654378, -0.152128, -0.077165], [-0.504141, 1.17532, 0.279049], [-0.071499, 0.132862, 0.672873]])], + forward_matrix: [Mat3([[0.735947, 0.130604, 0.097745], [0.271991, 0.870186, -0.142177], [-0.013289, -0.229681, 1.06808]]), Mat3([[0.693623, 0.247958, 0.022715], [0.295094, 0.978904, -0.273998], [0.00951, -0.117049, 0.932644]])], + }, + Camera { + make: "Canon", + model: "EOS-1D X Mark II", + color_matrix: [Mat3([[0.841821, -0.109388, -0.029826], [-0.469467, 1.34341, 0.484215], [-0.074495, 0.204633, 0.843881]]), Mat3([[0.559829, 0.012974, -0.076841], [-0.474068, 1.17573, 0.252424], [-0.137973, 0.263217, 0.557436]])], + forward_matrix: [Mat3([[0.867342, -0.069209, 0.166163], [0.282942, 0.879178, -0.162119], [0.042308, -0.560464, 1.34326]]), Mat3([[0.866644, -0.0379, 0.135551], [0.340507, 0.913959, -0.254466], [0.035971, -0.324047, 1.11318]])], + }, + Camera { + make: "Canon", + model: "EOS-1Ds Mark III", + color_matrix: [Mat3([[0.933464, -0.245919, -0.026807], [-0.571496, 1.49286, 0.379177], [-0.048229, 0.100118, 0.927528]]), Mat3([[0.623037, -0.073582, -0.085177], [-0.54689, 1.28786, 0.213006], [-0.105037, 0.156191, 0.659429]])], + forward_matrix: [Mat3([[0.785005, 0.07525, 0.10404], [0.285685, 0.799015, -0.084701], [0.044611, -0.231741, 1.01223]]), Mat3([[0.764142, 0.106772, 0.093381], [0.318478, 0.835999, -0.154477], [0.03106, -0.128764, 0.922809]])], + }, + Camera { + make: "Canon", + model: "EOS-1Ds Mark II", + color_matrix: [Mat3([[0.945891, -0.245875, -0.023592], [-0.58518, 1.49586, 0.412994], [-0.049647, 0.124624, 1.04996]]), Mat3([[0.633076, -0.080316, -0.073882], [-0.552992, 1.27833, 0.227086], [-0.10768, 0.180208, 0.730732]])], + forward_matrix: [Mat3([[0.78531, 0.073316, 0.10567], [0.29637, 0.804671, -0.101041], [0.021036, -0.254656, 1.05872]]), Mat3([[0.780605, 0.119757, 0.063934], [0.332784, 0.851961, -0.184745], [0.021321, -0.137163, 0.940946]])], + }, + Camera { + make: "Canon", + model: "EOS 100D", + color_matrix: [Mat3([[0.823379, -0.153284, -0.011827], [-0.451822, 1.34611, 0.422153], [-0.063154, 0.17914, 0.866791]]), Mat3([[0.537713, -0.001778, -0.071702], [-0.479729, 1.20511, 0.230384], [-0.135862, 0.245077, 0.608091]])], + forward_matrix: [Mat3([[0.839709, -0.013024, 0.137611], [0.260264, 0.869968, -0.130232], [0.040567, -0.473274, 1.25781]]), Mat3([[0.852444, -0.005311, 0.117164], [0.33135, 0.880249, -0.211599], [0.038232, -0.260159, 1.04703]])], + }, + Camera { + make: "Canon", + model: "EOS 200D", + color_matrix: [Mat3([[0.882798, -0.11491, -0.016841], [-0.443662, 1.31235, 0.491834], [-0.062151, 0.206192, 0.798346]]), Mat3([[0.570466, 0.029473, -0.079371], [-0.461712, 1.15876, 0.257222], [-0.118587, 0.24972, 0.538517]])], + forward_matrix: [Mat3([[0.867592, -0.074026, 0.17073], [0.27684, 0.904905, -0.181745], [0.007997, -0.615366, 1.43247]]), Mat3([[0.884048, -0.067045, 0.147293], [0.347584, 0.917256, -0.26484], [0.021454, -0.322825, 1.12647]])], + }, + Camera { + make: "Canon", + model: "EOS 200D II", + color_matrix: [Mat3([[0.820814, -0.08112, -0.025232], [-0.449049, 1.31524, 0.500348], [-0.063522, 0.20839, 0.807016]]), Mat3([[0.534782, 0.039491, -0.075304], [-0.461016, 1.15287, 0.26203], [-0.117011, 0.248633, 0.539767]])], + forward_matrix: [Mat3([[0.879874, -0.096427, 0.180849], [0.283378, 0.900453, -0.183831], [0.006798, -0.61219, 1.4305]]), Mat3([[0.897644, -0.089573, 0.156225], [0.354938, 0.914467, -0.269405], [0.020307, -0.323555, 1.12835]])], + }, + Camera { + make: "Canon", + model: "EOS 5DS", + color_matrix: [Mat3([[0.784071, -0.14278, -0.018109], [-0.493528, 1.36672, 0.49299], [-0.067845, 0.178082, 0.792129]]), Mat3([[0.501948, -0.005161, -0.069927], [-0.500698, 1.19504, 0.25793], [-0.125608, 0.228657, 0.525639]])], + forward_matrix: [Mat3([[0.838414, -0.019978, 0.145859], [0.285256, 0.874752, -0.160008], [0.038705, -0.507361, 1.29376]]), Mat3([[0.840997, -0.003166, 0.126464], [0.342942, 0.904091, -0.247033], [0.034419, -0.289165, 1.07985]])], + }, + Camera { + make: "Canon", + model: "EOS 5D", + color_matrix: [Mat3([[0.961758, -0.26807, -0.006664], [-0.565407, 1.47679, 0.405541], [-0.067833, 0.12912, 0.951998]]), Mat3([[0.616895, -0.064661, -0.085183], [-0.554321, 1.28625, 0.22097], [-0.137616, 0.197613, 0.656267]])], + forward_matrix: [Mat3([[0.79062, 0.078103, 0.095573], [0.287453, 0.812941, -0.100394], [0.068474, -0.279714, 1.03635]]), Mat3([[0.77434, 0.091394, 0.098562], [0.324069, 0.841224, -0.165293], [0.044245, -0.169979, 0.950838]])], + }, + Camera { + make: "Canon", + model: "EOS 5D Mark III", + color_matrix: [Mat3([[0.798227, -0.156522, -0.024001], [-0.479014, 1.37953, 0.412187], [-0.067815, 0.178094, 0.795854]]), Mat3([[0.536462, -0.020759, -0.074508], [-0.493492, 1.22724, 0.222071], [-0.136505, 0.242956, 0.547031]])], + forward_matrix: [Mat3([[0.829015, -0.007278, 0.142558], [0.266702, 0.857593, -0.124295], [0.048011, -0.488746, 1.26584]]), Mat3([[0.818929, 0.023372, 0.121994], [0.319742, 0.879191, -0.198933], [0.040465, -0.281513, 1.06615]])], + }, + Camera { + make: "Canon", + model: "EOS 5D Mark II", + color_matrix: [Mat3([[0.765281, -0.126067, -0.028016], [-0.539891, 1.44802, 0.407637], [-0.058008, 0.128461, 0.861097]]), Mat3([[0.508976, -0.002163, -0.072663], [-0.524812, 1.25489, 0.224018], [-0.11344, 0.183978, 0.599963]])], + forward_matrix: [Mat3([[0.84396, -0.009524, 0.12986], [0.296711, 0.799464, -0.096175], [0.052873, -0.321603, 1.09383]]), Mat3([[0.8407, 0.001511, 0.122085], [0.343565, 0.82753, -0.171095], [0.035331, -0.182192, 0.971966]])], + }, + Camera { + make: "Canon", + model: "EOS 5D Mark IV", + color_matrix: [Mat3([[0.763394, -0.085035, -0.016567], [-0.442907, 1.31347, 0.486351], [-0.080725, 0.247335, 0.884784]]), Mat3([[0.49672, 0.034424, -0.068025], [-0.461551, 1.16055, 0.255439], [-0.154521, 0.307914, 0.579555]])], + forward_matrix: [Mat3([[0.880649, -0.094084, 0.177732], [0.27666, 0.906137, -0.182797], [0.024429, -0.659213, 1.45989]]), Mat3([[0.898336, -0.087734, 0.153694], [0.347611, 0.924078, -0.271689], [0.035769, -0.378779, 1.16812]])], + }, + Camera { + make: "Canon", + model: "EOS 600D", + color_matrix: [Mat3([[0.800214, -0.150575, -0.010842], [-0.462694, 1.36091, 0.41413], [-0.054076, 0.17327, 0.853835]]), Mat3([[0.528239, -0.007001, -0.067784], [-0.485979, 1.21691, 0.225002], [-0.13144, 0.244539, 0.592949]])], + forward_matrix: [Mat3([[0.836952, -0.011114, 0.138458], [0.267123, 0.859868, -0.12699], [0.017395, -0.463764, 1.27147]]), Mat3([[0.849461, 0.005947, 0.108888], [0.332065, 0.875561, -0.207627], [0.033552, -0.262503, 1.05405]])], + }, + Camera { + make: "Canon", + model: "EOS M", + color_matrix: [Mat3([[0.858133, -0.188655, -0.015316], [-0.477476, 1.37657, 0.415737], [-0.066593, 0.173487, 0.870331]]), Mat3([[0.559337, -0.024286, -0.076489], [-0.498439, 1.22646, 0.227107], [-0.13663, 0.237788, 0.612213]])], + forward_matrix: [Mat3([[0.818185, 0.015661, 0.130449], [0.263684, 0.85776, -0.121444], [0.046738, -0.442459, 1.22083]]), Mat3([[0.820106, 0.031315, 0.112875], [0.32549, 0.874009, -0.199499], [0.03847, -0.243276, 1.02991]])], + }, + Camera { + make: "Canon", + model: "EOS R10", + color_matrix: [Mat3([[0.900395, -0.120204, -0.023257], [-0.450784, 1.32079, 0.490112], [-0.060508, 0.207987, 0.757971]]), Mat3([[0.586274, 0.022629, -0.081629], [-0.465757, 1.16444, 0.255537], [-0.114546, 0.247787, 0.505639]])], + forward_matrix: [Mat3([[0.860416, -0.07424, 0.178119], [0.279245, 0.90614, -0.185385], [-0.005252, -0.64772, 1.47808]]), Mat3([[0.874599, -0.057155, 0.146852], [0.346428, 0.920831, -0.267259], [0.018292, -0.341711, 1.14852]])], + }, + Camera { + make: "Canon", + model: "EOS R5", + color_matrix: [Mat3([[0.906792, -0.181028, -0.039982], [-0.497007, 1.37995, 0.46653], [-0.056558, 0.135904, 0.790883]]), Mat3([[0.599617, -0.03146, -0.086837], [-0.500836, 1.20715, 0.246929], [-0.122485, 0.204473, 0.529195]])], + forward_matrix: [Mat3([[0.825544, 0.000874, 0.137878], [0.27921, 0.851443, -0.130653], [0.053855, -0.393303, 1.16455]]), Mat3([[0.80269, 0.040879, 0.120727], [0.321664, 0.899552, -0.221215], [0.039913, -0.245426, 1.03062]])], + }, + Camera { + make: "Canon", + model: "EOS R5m2", + color_matrix: [Mat3([[0.925997, -0.201878, -0.03247], [-0.514263, 1.39694, 0.472057], [-0.067213, 0.159605, 0.932813]]), Mat3([[0.603973, -0.037874, -0.08705], [-0.520173, 1.22523, 0.247204], [-0.147012, 0.240988, 0.619754]])], + forward_matrix: [Mat3([[0.816853, 0.016859, 0.130583], [0.284699, 0.847793, -0.132492], [0.049678, -0.384186, 1.15961]]), Mat3([[0.796684, 0.049994, 0.117618], [0.326624, 0.8894, -0.216024], [0.040655, -0.243309, 1.02776]])], + }, + Camera { + make: "Canon", + model: "EOS R6", + color_matrix: [Mat3([[0.887235, -0.176902, -0.018642], [-0.462429, 1.33965, 0.473061], [-0.070097, 0.19962, 0.862666]]), Mat3([[0.571385, -0.014759, -0.079163], [-0.485367, 1.18708, 0.251862], [-0.145333, 0.265796, 0.580543]])], + forward_matrix: [Mat3([[0.830203, -0.011614, 0.145706], [0.268315, 0.891706, -0.16002], [0.03391, -0.533608, 1.3248]]), Mat3([[0.829826, 0.011021, 0.123449], [0.329107, 0.920067, -0.249174], [0.037653, -0.307101, 1.09455]])], + }, + Camera { + make: "Canon", + model: "EOS R6m2", + color_matrix: [Mat3([[0.904651, -0.185945, -0.032292], [-0.490303, 1.37329, 0.46456], [-0.076482, 0.19622, 0.939437]]), Mat3([[0.595351, -0.032062, -0.086354], [-0.499687, 1.20659, 0.246445], [-0.151602, 0.266204, 0.633694]])], + forward_matrix: [Mat3([[0.822515, -0.0028, 0.14458], [0.275534, 0.86495, -0.140483], [0.04532, -0.468236, 1.24802]]), Mat3([[0.80207, 0.038555, 0.123671], [0.322259, 0.905003, -0.227262], [0.03728, -0.271382, 1.05921]])], + }, + Camera { + make: "Canon", + model: "EOS RP", + color_matrix: [Mat3([[0.843269, -0.158656, -0.026198], [-0.497933, 1.37758, 0.476056], [-0.070882, 0.179269, 0.839769]]), Mat3([[0.548578, -0.014251, -0.077893], [-0.504763, 1.20691, 0.250579], [-0.139008, 0.243991, 0.560854]])], + forward_matrix: [Mat3([[0.836529, -0.017241, 0.145008], [0.285362, 0.860694, -0.146057], [0.049163, -0.481077, 1.25702]]), Mat3([[0.825922, 0.011858, 0.126516], [0.334357, 0.898585, -0.232942], [0.038621, -0.283664, 1.07015]])], + }, + Camera { + make: "Canon", + model: "EOS R", + color_matrix: [Mat3([[0.732886, -0.080769, -0.010303], [-0.454757, 1.32829, 0.481287], [-0.083062, 0.251827, 0.895746]]), Mat3([[0.473635, 0.038459, -0.063761], [-0.474141, 1.17757, 0.250797], [-0.157456, 0.313293, 0.589901]])], + forward_matrix: [Mat3([[0.884185, -0.093978, 0.174089], [0.284166, 0.893986, -0.178152], [0.023242, -0.654769, 1.45663]]), Mat3([[0.908841, -0.095987, 0.151442], [0.356864, 0.902145, -0.259009], [0.034581, -0.371735, 1.16226]])], + }, + Camera { + make: "Canon", + model: "PowerShot S90", + color_matrix: [Mat3([[1.37542, -0.592063, 0.037432], [-0.219062, 1.12456, 0.326212], [-0.021707, 0.09788, 0.58587]]), Mat3([[0.90732, -0.244506, -0.087152], [-0.314903, 1.06475, 0.215416], [-0.11223, 0.179775, 0.439924]])], + forward_matrix: [Mat3([[0.67794, 0.203129, 0.083227], [0.105177, 1.01192, -0.1171], [0.055684, -0.464622, 1.23404]]), Mat3([[0.655349, 0.321862, -0.012915], [0.180096, 1.10102, -0.281111], [0.064028, -0.26917, 1.03025]])], + }, + Camera { + make: "Fujifilm", + model: "GFX 100", + color_matrix: [Mat3([[1.37208, -0.633889, 0.081365], [-0.430548, 1.32101, 0.427017], [-0.039908, 0.114944, 0.782345]]), Mat3([[0.834968, -0.233255, -0.07589], [-0.475844, 1.18948, 0.241344], [-0.126239, 0.194812, 0.541708]])], + forward_matrix: [Mat3([[0.673446, 0.25104, 0.039809], [0.200798, 0.950696, -0.151494], [0.031013, -0.340478, 1.13457]]), Mat3([[0.679917, 0.304542, -0.020163], [0.261808, 1.00662, -0.268426], [0.044444, -0.214012, 0.994673]])], + }, + Camera { + make: "Fujifilm", + model: "X-T3", + color_matrix: [Mat3([[1.29933, -0.598524, 0.075033], [-0.403354, 1.28265, 0.450876], [-0.037817, 0.112326, 0.810909]]), Mat3([[0.785701, -0.221043, -0.071522], [-0.456611, 1.15627, 0.255052], [-0.124341, 0.191243, 0.562458]])], + forward_matrix: [Mat3([[0.671025, 0.255703, 0.037568], [0.190434, 0.97306, -0.163493], [0.03182, -0.334086, 1.12737]]), Mat3([[0.676828, 0.316449, -0.028982], [0.256753, 1.03712, -0.293871], [0.04319, -0.207191, 0.989106]])], + }, + Camera { + make: "Fujifilm", + model: "X-T4", + color_matrix: [Mat3([[1.27406, -0.587718, 0.074639], [-0.371617, 1.24808, 0.450058], [-0.038746, 0.113215, 0.780014]]), Mat3([[0.771728, -0.215706, -0.070731], [-0.439625, 1.14183, 0.253491], [-0.125229, 0.192512, 0.542394]])], + forward_matrix: [Mat3([[0.668116, 0.258486, 0.037694], [0.175163, 0.992049, -0.167213], [0.040145, -0.355105, 1.14006]]), Mat3([[0.675162, 0.317733, -0.028599], [0.248581, 1.05006, -0.298641], [0.04702, -0.219918, 0.998002]])], + }, + Camera { + make: "Hasselblad", + model: "L1D-20c", + color_matrix: [Mat3([[0.697908, -0.225515, 0.018865], [-0.403519, 1.29608, 0.413637], [-0.048508, 0.167167, 0.74727]]), Mat3([[0.466971, -0.076456, -0.040872], [-0.471572, 1.21777, 0.21163], [-0.133006, 0.246905, 0.516655]])], + forward_matrix: [Mat3([[0.754419, 0.110524, 0.099352], [0.21061, 0.943846, -0.154456], [0.016488, -0.523808, 1.33242]]), Mat3([[0.780313, 0.168783, 0.0152], [0.295267, 0.942833, -0.2381], [0.041, -0.301041, 1.08515]])], + }, + Camera { + make: "Hasselblad", + model: "L2D-20c", + color_matrix: [Mat3([[0.787283, -0.249155, 0.044752], [-0.415734, 1.29586, 0.451949], [-0.060535, 0.144901, 0.709626]]), Mat3([[0.506449, -0.071884, -0.038243], [-0.435817, 1.14056, 0.251334], [-0.129637, 0.209508, 0.474062]])], + forward_matrix: [Mat3([[0.776836, 0.120396, 0.067064], [0.218196, 0.951802, -0.169998], [0.071832, -0.4598, 1.21307]]), Mat3([[0.807201, 0.167509, -0.010414], [0.291699, 1.01528, -0.306978], [0.062694, -0.296911, 1.05932]])], + }, + Camera { + make: "Nikon", + model: "D200", + color_matrix: [Mat3([[1.37569, -0.560621, 0.01677], [-0.606594, 1.52996, 0.38327], [-0.024329, 0.069398, 0.99135]]), Mat3([[0.898945, -0.249527, -0.086342], [-0.568281, 1.29839, 0.222009], [-0.098369, 0.139456, 0.687094]])], + forward_matrix: [Mat3([[0.708545, 0.18748, 0.068271], [0.274935, 0.818921, -0.093855], [0.012224, -0.169279, 0.98216]]), Mat3([[0.685079, 0.274473, 0.004744], [0.294469, 0.898619, -0.193087], [0.026107, -0.10149, 0.900488]])], + }, + Camera { + make: "Nikon", + model: "D3300", + color_matrix: [Mat3([[1.01047, -0.365965, 0.032301], [-0.473579, 1.3513, 0.474736], [-0.075188, 0.164522, 1.02012]]), Mat3([[0.644589, -0.136221, -0.056505], [-0.47509, 1.16499, 0.26318], [-0.15352, 0.237111, 0.681108]])], + forward_matrix: [Mat3([[0.738138, 0.162349, 0.063809], [0.234836, 0.925124, -0.15996], [0.059989, -0.351996, 1.11711]]), Mat3([[0.742449, 0.23561, -0.013763], [0.290358, 1.00587, -0.29623], [0.04507, -0.217376, 0.99741]])], + }, + Camera { + make: "Nikon", + model: "D5100", + color_matrix: [Mat3([[1.34772, -0.48474, 0.012024], [-0.466856, 1.29899, 0.44042], [-0.076248, 0.161652, 0.981853]]), Mat3([[0.929942, -0.213704, -0.081538], [-0.481178, 1.17186, 0.262186], [-0.14942, 0.233631, 0.699016]])], + forward_matrix: [Mat3([[0.732398, 0.159354, 0.072543], [0.240376, 0.912366, -0.152741], [0.06111, -0.349037, 1.11303]]), Mat3([[0.727572, 0.254229, -0.017505], [0.287762, 1.00467, -0.292432], [0.040205, -0.206009, 0.990908]])], + }, + Camera { + make: "Nikon", + model: "D5300", + color_matrix: [Mat3([[0.943297, -0.302977, 0.012055], [-0.489398, 1.36679, 0.480036], [-0.079912, 0.172098, 1.02217]]), Mat3([[0.613661, -0.114638, -0.052652], [-0.475392, 1.16153, 0.266621], [-0.147109, 0.232899, 0.680179]])], + forward_matrix: [Mat3([[0.75961, 0.127618, 0.077067], [0.24786, 0.911605, -0.159465], [0.062139, -0.362458, 1.12542]]), Mat3([[0.765013, 0.208344, -0.009061], [0.301196, 0.998195, -0.299391], [0.042143, -0.217084, 1.00005]])], + }, + Camera { + make: "Nikon", + model: "D7000", + color_matrix: [Mat3([[1.0579, -0.370378, 0.024968], [-0.50908, 1.38998, 0.475636], [-0.075079, 0.158561, 1.00866]]), Mat3([[0.675661, -0.143226, -0.053077], [-0.488456, 1.16985, 0.270384], [-0.137868, 0.21334, 0.681191]])], + forward_matrix: [Mat3([[0.744976, 0.150244, 0.069076], [0.251735, 0.900835, -0.15257], [0.058406, -0.336086, 1.10278]]), Mat3([[0.755556, 0.237153, -0.028413], [0.304411, 0.998446, -0.302857], [0.039148, -0.192875, 0.978832]])], + }, + Camera { + make: "Nikon", + model: "D700", + color_matrix: [Mat3([[1.25876, -0.491229, 0.017162], [-0.619342, 1.54791, 0.372191], [-0.050936, 0.098145, 1.01122]]), Mat3([[0.835285, -0.217528, -0.074624], [-0.583051, 1.32613, 0.209425], [-0.121368, 0.168895, 0.708639]])], + forward_matrix: [Mat3([[0.722062, 0.17127, 0.070964], [0.279306, 0.809472, -0.088779], [0.042476, -0.200528, 0.983157]]), Mat3([[0.707886, 0.252255, 0.004155], [0.304416, 0.875438, -0.179854], [0.032977, -0.118624, 0.910752]])], + }, + Camera { + make: "Nikon", + model: "D70", + color_matrix: [Mat3([[0.95752, -0.326533, -0.000865], [-0.654123, 1.5628, 0.437699], [-0.01723, 0.05324, 0.899754]]), Mat3([[0.621434, -0.137207, -0.067353], [-0.59081, 1.28888, 0.250406], [-0.066923, 0.098053, 0.633702]])], + forward_matrix: [Mat3([[0.74958, 0.134109, 0.080606], [0.313007, 0.787492, -0.100498], [0.005453, -0.15183, 0.971482]]), Mat3([[0.722318, 0.215673, 0.026305], [0.326263, 0.879351, -0.205614], [0.017282, -0.077471, 0.885294]])], + }, + Camera { + make: "Nikon", + model: "D800E", + color_matrix: [Mat3([[0.981723, -0.320813, 0.025417], [-0.500428, 1.37669, 0.486261], [-0.07946, 0.169419, 1.04548]]), Mat3([[0.629114, -0.117451, -0.046422], [-0.483155, 1.16337, 0.27171], [-0.147278, 0.230496, 0.696276]])], + forward_matrix: [Mat3([[0.760723, 0.133423, 0.07015], [0.25382, 0.906695, -0.160515], [0.059962, -0.34837, 1.11351]]), Mat3([[0.780492, 0.212338, -0.028534], [0.311949, 0.998057, -0.310006], [0.041771, -0.208304, 0.991637]])], + }, + Camera { + make: "Nikon", + model: "D810", + color_matrix: [Mat3([[0.983274, -0.318832, 0.030267], [-0.51358, 1.39278, 0.481653], [-0.076666, 0.176366, 1.07197]]), Mat3([[0.625688, -0.110113, -0.045912], [-0.497828, 1.18171, 0.267671], [-0.149244, 0.239807, 0.71615]])], + forward_matrix: [Mat3([[0.763991, 0.129679, 0.070625], [0.262526, 0.897486, -0.160012], [0.045849, -0.35404, 1.13329]]), Mat3([[0.791151, 0.196615, -0.023471], [0.322368, 0.977225, -0.299593], [0.038389, -0.209045, 0.995761]])], + }, + Camera { + make: "Nikon", + model: "D850", + color_matrix: [Mat3([[1.17359, -0.461087, -0.011934], [-0.607581, 1.52285, 0.4063], [-0.096127, 0.196063, 1.00005]]), Mat3([[0.773836, -0.196466, -0.097677], [-0.609077, 1.35526, 0.205388], [-0.190117, 0.290252, 0.676129]])], + forward_matrix: [Mat3([[0.713588, 0.158895, 0.091813], [0.273041, 0.838318, -0.111359], [0.055293, -0.367196, 1.13701]]), Mat3([[0.669707, 0.227388, 0.0672], [0.291703, 0.871502, -0.163205], [0.042827, -0.229884, 1.01216]])], + }, + Camera { + make: "Nikon", + model: "Z f", + color_matrix: [Mat3([[1.16951, -0.415422, 0.033906], [-0.516721, 1.41065, 0.441119], [-0.080466, 0.180954, 1.07672]]), Mat3([[0.749962, -0.151755, -0.063228], [-0.504317, 1.20913, 0.248149], [-0.162888, 0.256933, 0.729432]])], + forward_matrix: [Mat3([[0.744024, 0.151119, 0.069153], [0.253649, 0.888649, -0.142297], [0.049891, -0.351314, 1.12653]]), Mat3([[0.756117, 0.216534, -0.008355], [0.304737, 0.961046, -0.265784], [0.041615, -0.212408, 0.995897]])], + }, + Camera { + make: "Panasonic", + model: "DC-GX9", + color_matrix: [Mat3([[0.820911, -0.254983, 0.035979], [-0.372553, 1.25333, 0.438192], [-0.054294, 0.146795, 0.747319]]), Mat3([[0.534195, -0.07728, -0.038219], [-0.400358, 1.10647, 0.251688], [-0.12982, 0.216241, 0.50214]])], + forward_matrix: [Mat3([[0.772645, 0.116685, 0.074966], [0.196365, 0.972505, -0.16887], [0.06062, -0.463162, 1.22765]]), Mat3([[0.805529, 0.178696, -0.01993], [0.275218, 1.04725, -0.322465], [0.060973, -0.29745, 1.06158]])], + }, + Camera { + make: "Sony", + model: "DSC-RX100M4", + color_matrix: [Mat3([[0.783848, -0.284096, 0.031033], [-0.411895, 1.30629, 0.410807], [-0.047532, 0.165091, 0.725564]]), Mat3([[0.507376, -0.094837, -0.042497], [-0.488786, 1.23246, 0.213166], [-0.137082, 0.247475, 0.503834]])], + forward_matrix: [Mat3([[0.733625, 0.143965, 0.086705], [0.210364, 0.945779, -0.156144], [0.012886, -0.528963, 1.34118]]), Mat3([[0.767183, 0.193058, 0.004054], [0.296288, 0.94455, -0.240838], [0.043397, -0.304922, 1.08663]])], + }, + Camera { + make: "Sony", + model: "ILCE-6400", + color_matrix: [Mat3([[0.686447, -0.204525, 0.026311], [-0.43013, 1.31387, 0.445779], [-0.061279, 0.132669, 0.791917]]), Mat3([[0.44557, -0.064452, -0.027734], [-0.437513, 1.13079, 0.261784], [-0.122323, 0.190304, 0.552427]])], + forward_matrix: [Mat3([[0.782555, 0.106676, 0.075064], [0.224677, 0.924591, -0.149268], [0.078407, -0.377222, 1.12392]]), Mat3([[0.821757, 0.17479, -0.032251], [0.30346, 1.00862, -0.31208], [0.052261, -0.224433, 0.997277]])], + }, + Camera { + make: "Sony", + model: "ILCE-7CM2", + color_matrix: [Mat3([[0.758033, -0.221503, 0.036455], [-0.492549, 1.36195, 0.503357], [-0.065665, 0.148645, 0.906293]]), Mat3([[0.482991, -0.070139, -0.027429], [-0.478804, 1.14702, 0.282926], [-0.119226, 0.194381, 0.616628]])], + forward_matrix: [Mat3([[0.788283, 0.10759, 0.068422], [0.261923, 0.909809, -0.171732], [0.053026, -0.362705, 1.13478]]), Mat3([[0.83437, 0.173346, -0.043421], [0.336938, 0.996333, -0.333271], [0.037073, -0.203664, 0.991696]])], + }, + Camera { + make: "Sony", + model: "ILCE-7M3", + color_matrix: [Mat3([[0.759047, -0.22787, 0.042531], [-0.502563, 1.37732, 0.491076], [-0.066877, 0.150947, 0.900094]]), Mat3([[0.482867, -0.070899, -0.024574], [-0.489531, 1.16667, 0.274244], [-0.123988, 0.201218, 0.610148]])], + forward_matrix: [Mat3([[0.787201, 0.113043, 0.064052], [0.265374, 0.902211, -0.167585], [0.052744, -0.364541, 1.1369]]), Mat3([[0.840001, 0.174345, -0.05005], [0.340881, 0.981246, -0.322127], [0.039232, -0.209817, 0.995689]])], + }, + Camera { + make: "Sony", + model: "ILCE-7M4", + color_matrix: [Mat3([[0.763892, -0.227658, 0.033049], [-0.512687, 1.38412, 0.503225], [-0.063774, 0.141899, 0.91715]]), Mat3([[0.485445, -0.074907, -0.026973], [-0.491937, 1.15934, 0.283071], [-0.115422, 0.185993, 0.622862]])], + forward_matrix: [Mat3([[0.784064, 0.112194, 0.068037], [0.2698, 0.896428, -0.166229], [0.050008, -0.338826, 1.11392]]), Mat3([[0.830062, 0.181159, -0.046925], [0.341401, 0.985843, -0.327244], [0.034749, -0.188826, 0.979182]])], + }, + Camera { + make: "Sony", + model: "ILCE-7RM2", + color_matrix: [Mat3([[0.775308, -0.25358, 0.026979], [-0.524545, 1.40154, 0.490876], [-0.060608, 0.132591, 0.920838]]), Mat3([[0.492753, -0.089554, -0.036484], [-0.505928, 1.18284, 0.273705], [-0.116121, 0.182519, 0.626106]])], + forward_matrix: [Mat3([[0.76369, 0.133034, 0.067571], [0.267238, 0.888036, -0.155274], [0.048003, -0.313698, 1.0908]]), Mat3([[0.787687, 0.201628, -0.025019], [0.326839, 0.971594, -0.298433], [0.034173, -0.177854, 0.968786]])], + }, + Camera { + make: "Sony", + model: "ILCE-7RM3", + color_matrix: [Mat3([[0.742519, -0.22996, 0.032883], [-0.500197, 1.37662, 0.485744], [-0.063378, 0.142467, 0.898759]]), Mat3([[0.473388, -0.075142, -0.031025], [-0.487453, 1.16732, 0.271829], [-0.119917, 0.192619, 0.610537]])], + forward_matrix: [Mat3([[0.776802, 0.119664, 0.06783], [0.260471, 0.900308, -0.160779], [0.051869, -0.347156, 1.12039]]), Mat3([[0.813753, 0.181916, -0.031373], [0.32902, 0.979656, -0.308676], [0.037764, -0.198487, 0.985828]])], + }, + Camera { + make: "Sony", + model: "ILCE-7RM4", + color_matrix: [Mat3([[0.732028, -0.220808, 0.020118], [-0.501296, 1.37502, 0.49365], [-0.061615, 0.13838, 0.885461]]), Mat3([[0.472151, -0.07908, -0.031322], [-0.483014, 1.15528, 0.279019], [-0.111192, 0.18072, 0.604588]])], + forward_matrix: [Mat3([[0.775623, 0.114594, 0.074079], [0.260863, 0.901608, -0.162471], [0.050735, -0.342927, 1.1173]]), Mat3([[0.806038, 0.193021, -0.034763], [0.32686, 0.992356, -0.319216], [0.033831, -0.189102, 0.980375]])], + }, + Camera { + make: "Sony", + model: "ILCE-7SM2", + color_matrix: [Mat3([[0.615775, -0.137144, 0.06099], [-0.408209, 1.28402, 0.462018], [-0.084205, 0.196387, 0.85663]]), Mat3([[0.395982, -0.016098, -0.005517], [-0.420931, 1.11058, 0.265879], [-0.162165, 0.267589, 0.576945]])], + forward_matrix: [Mat3([[0.854583, 0.041834, 0.067878], [0.234125, 0.948414, -0.182539], [0.097857, -0.530195, 1.25744]]), Mat3([[0.948357, 0.092307, -0.076369], [0.33668, 1.02973, -0.366408], [0.073834, -0.330955, 1.08223]])], + }, + Camera { + make: "Sony", + model: "ILCE-7SM3", + color_matrix: [Mat3([[0.749715, -0.228299, 0.030188], [-0.512062, 1.38442, 0.500457], [-0.072776, 0.169469, 0.933545]]), Mat3([[0.476058, -0.07383, -0.031403], [-0.495874, 1.16904, 0.277606], [-0.134473, 0.222664, 0.625349]])], + forward_matrix: [Mat3([[0.779691, 0.113899, 0.070706], [0.268632, 0.905927, -0.174559], [0.04639, -0.390929, 1.16964]]), Mat3([[0.816745, 0.179015, -0.031464], [0.334824, 0.988848, -0.323672], [0.037938, -0.229363, 1.01653]])], + }, + Camera { + make: "Sony", + model: "ILCE-9", + color_matrix: [Mat3([[0.747492, -0.216385, 0.049086], [-0.499768, 1.37142, 0.499037], [-0.071324, 0.164705, 0.951496]]), Mat3([[0.473639, -0.063703, -0.016557], [-0.488372, 1.1591, 0.280181], [-0.129154, 0.213406, 0.651]])], + forward_matrix: [Mat3([[0.796409, 0.106147, 0.061739], [0.26822, 0.906732, -0.174952], [0.050574, -0.378333, 1.15286]]), Mat3([[0.868551, 0.167059, -0.071314], [0.354491, 0.985971, -0.340462], [0.037779, -0.211149, 0.998474]])], + }, +]; + +/// `(make, model as sold, table model)`: other names of a camera in [`CAMERAS`], or of a camera with the same sensor. +#[rustfmt::skip] +pub(super) const ALIASES: &[(&str, &str, &str)] = &[ + ("Canon", "EOS 400D", "Digital Rebel XTi"), // same model, alternative name + ("Canon", "Kiss Digital X", "Digital Rebel XTi"), // same model, alternative name + ("Canon", "EOS Rebel SL1", "EOS 100D"), // same model, alternative name + ("Canon", "Kiss X7", "EOS 100D"), // same model, alternative name + ("Canon", "EOS Rebel SL2", "EOS 200D"), // same model, alternative name + ("Canon", "Kiss X9", "EOS 200D"), // same model, alternative name + ("Canon", "EOS 250D", "EOS 200D II"), // same model, alternative name + ("Canon", "EOS Rebel SL3", "EOS 200D II"), // same model, alternative name + ("Canon", "Kiss X10", "EOS 200D II"), // same model, alternative name + ("Canon", "EOS Rebel T3i", "EOS 600D"), // same model, alternative name + ("Canon", "Kiss X5", "EOS 600D"), // same model, alternative name + ("Canon", "EOS R5 Mark II", "EOS R5m2"), // same model, alternative name + ("Canon", "EOS 5DS R", "EOS 5DS"), // different model, same sensor + ("Canon", "EOS 6D Mark II", "EOS RP"), // different model, same sensor + ("Canon", "EOS R5 C", "EOS R5"), // different model, same sensor + ("Canon", "EOS R50", "EOS R10"), // different model, same sensor + ("Canon", "EOS R8", "EOS R6m2"), // different model, same sensor + ("Nikon", "D800", "D800E"), // different model, same sensor + ("Nikon", "Z 6_2", "Z f"), // different model, same sensor + ("Sony", "ILCE-7RM5", "ILCE-7RM4"), // different model, same sensor + ("Sony", "ILCE-7RM4A", "ILCE-7RM4"), // updated model, same sensor + ("Sony", "ILCE-7RM3A", "ILCE-7RM3"), // updated model, same sensor + ("Fujifilm", "GFX100S", "GFX 100"), // different model, same sensor +]; diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index dfad77c22..ca529a001 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -77,6 +77,34 @@ One photo can show too little of a colour for its own fit to learn it: in a seco Profiles are JSON files `.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; X-H2S, 201 photos; X-T4, 545 photos. They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (DRO and picture styles vary per shot); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt. +## Spectral camera matrices and the order of precedence + +For 52 camera models LightCraft has colour matrices fitted to the camera's measured spectral sensitivities +(`crates/raw/src/spectral.rs`, generated into `spectral_table.rs` by `tools/spectral_camera_table.py` from +[rawtoaces-data](https://github.com/AcademySoftwareFoundation/rawtoaces-data) v1.1.0, Apache-2.0): 22 Canon, 11 Nikon, +11 Sony, 3 Fujifilm and 5 others, plus the other names rawtoaces-data lists for them (EOS Rebel T3i and Kiss X5 for +the EOS 600D; bodies it lists as sharing a sensor, such as the GFX100S with the GFX 100). Per model the generator fits, +under CIE Standard Illuminant A and under D65, a white-preserving 3×3 from white-balanced camera RGB to XYZ by the +least mean CIE76 error over rawtoaces-data's 190 training reflectances, and writes DNG `ColorMatrix1/2` and +`ForwardMatrix1/2`. The existing DNG colour model then interpolates them for the file's as-shot white, which the +vendor white-balance multipliers give through the same matrices. No number comes from Adobe or from a raw decoder. + +A raw starts from the first of these that is available (`camera_preview::starting_colour`): + +1. the file's own colour matrices (DNG); +2. a camera profile for its model (bundled or local), with tone and chroma fitted to the file's JPEG; +3. the model's spectral matrices, with LightCraft's default tone curve and no JPEG fit; +4. the file-local fit to its own JPEG described above; +5. the neutral fallback. + +A profiled model whose profile fit fails the gates (or that has no usable JPEG) takes its spectral matrices when it +has them. White balance stays relative to the as-shot look for every raw without matrices of its own, as before, so +the catalog's `Photo::relative_wb` and the controls are unchanged. `lightcraft-cli calibrate` still pools the +uncorrected camera colour of covered models, so a profile can be fitted for them and then takes precedence. + +No colour-difference measurement of the spectral matrices against the camera JPEG, or against the file-local fit, +is recorded here yet. Moving step 3 after step 4 is a one-line change in `starting_colour`. + ## Nikon crop and preview colour metadata Nikon maker-note `CropArea` (0x0045) supplies the default `[left, top, width, height]` crop. The decoder validates the rectangle against the active sensor area and falls back to that area for missing or invalid values. The CFA origin is unchanged; cropping follows demosaicing. diff --git a/docs/parity.md b/docs/parity.md index 4c27755b5..67ab95d73 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -296,7 +296,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF and Samsung SRW get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF and Samsung SRW get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); 52 models without a profile start from DNG-style matrices fitted to their measured spectral sensitivities instead of the file-local fit (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | diff --git a/docs/whats-new.md b/docs/whats-new.md index 33557867d..61c554767 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -40,6 +40,10 @@ - Samsung SRW files without compression now open as raws: NX5, NX10, NX11, NX20, NX200, NX210, NX1000, NX1100, EX1 and WB2000. The compressed ones (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini) still open from their camera JPEG. Photos already imported pick the change up on Reload. +- Raws from 52 camera models (22 Canon, 11 Nikon, 11 Sony, 3 Fujifilm and 5 others, plus their other names) start + from colour matrices fitted to the camera's measured spectral sensitivities, from the Academy Software + Foundation's rawtoaces-data. A camera profile for the model still comes first. See + [camera preview colour](camera-preview-colour.md#spectral-camera-matrices-and-the-order-of-precedence). - Canon CR2 and Pentax PEF raws get the same starting look fitted to the camera's own JPEG as ARW, NEF, RW2, RAF and CR3, instead of opening flat and desaturated (issue #310). Photos already imported pick it up when re-rendered. - JPEG XL compressed DNGs (DNG 1.7) now open: lossless tiles decode sample for sample (checked on synthetic files); diff --git a/tools/requirements-spectral-camera-table.txt b/tools/requirements-spectral-camera-table.txt new file mode 100644 index 000000000..c758ce1d9 --- /dev/null +++ b/tools/requirements-spectral-camera-table.txt @@ -0,0 +1,5 @@ +# Python packages for tools/spectral_camera_table.py (crates/raw/src/spectral_table.rs). +# python3 -m venv .venv-spectral && .venv-spectral/bin/pip install -r tools/requirements-spectral-camera-table.txt +numpy==2.5.3 +scipy==1.18.1 +colour-science==0.4.7 diff --git a/tools/spectral_camera_table.py b/tools/spectral_camera_table.py new file mode 100644 index 000000000..b05ac2802 --- /dev/null +++ b/tools/spectral_camera_table.py @@ -0,0 +1,122 @@ +# Writes crates/raw/src/spectral_table.rs: DNG-shaped colour matrices per camera model, fitted to the +# measured spectral sensitivities in rawtoaces-data (Academy Software Foundation, Apache-2.0). +# +# git clone https://github.com/AcademySoftwareFoundation/rawtoaces-data ../rawtoaces-data +# git -C ../rawtoaces-data checkout v1.1.0 +# python3 -m venv .venv-spectral && .venv-spectral/bin/pip install -r tools/requirements-spectral-camera-table.txt +# .venv-spectral/bin/python tools/spectral_camera_table.py ../rawtoaces-data crates/raw/src/spectral_table.rs +# +# Method (Academy P-2013-001, written from its description; no rawtoaces code is used): the camera and the CIE 1931 +# observer both look at the 190 training reflectances of rawtoaces-data under CIE Standard Illuminant A and under D65. +# The camera values are white balanced to that illuminant, and a white-preserving 3x3 to XYZ is fitted by least +# mean CIE76 error in Lab. Per illuminant this gives ColorMatrix (XYZ -> camera, DNG CalibrationIlluminant 17 / 21) +# and ForwardMatrix (white-balanced camera -> XYZ D50). The aliases are rawtoaces-data's own `data/aliases.json`. +import json +import os +import subprocess +import sys + +import colour +import numpy as np +from scipy.optimize import minimize + +WL = np.arange(380, 781, 5) +D50 = colour.xy_to_XYZ(colour.CCS_ILLUMINANTS["CIE 1931 2 Degree Standard Observer"]["D50"]) + + +def spectral(path, columns): + data = json.load(open(path))["spectral_data"]["data"]["main"] + return np.array([[data[str(w)][i] for i in range(columns)] for w in WL], dtype=float) + + +def illuminant(name): + sd = colour.SDS_ILLUMINANTS[name] + return np.array([sd[w] for w in WL], dtype=float) + + +def fit(cmf, refl, ssf, light, target_white): + """White-balanced camera RGB -> XYZ adapted to `target_white`; maps (1, 1, 1) to `target_white`.""" + w = ssf.T @ light + cam = (ssf.T @ (light[:, None] * refl)) / w[:, None] + k = 1.0 / (cmf[:, 1] @ light) + xyz = k * (cmf.T @ (light[:, None] * refl)) + white = k * (cmf.T @ light) + cat = colour.adaptation.matrix_chromatic_adaptation_VonKries(white, target_white, transform="Bradford") + xyz = cat @ xyz + target_xy = colour.XYZ_to_xy(target_white) + lab = colour.XYZ_to_Lab(xyz.T, target_xy) + + def build(p): + return np.stack([p[:3], p[3:], target_white - p[:3] - p[3:]], axis=1) + + def loss(p): + return float(np.mean(np.linalg.norm(colour.XYZ_to_Lab((build(p) @ cam).T, target_xy) - lab, axis=1))) + + m0 = xyz @ np.linalg.pinv(cam) + r = minimize(loss, np.concatenate([m0[:, 0], m0[:, 1]]), method="Nelder-Mead", options={"maxiter": 20000, "xatol": 1e-9, "fatol": 1e-9}) + return build(r.x), loss(r.x) + + +def dng_tags(cmf, refl, ssf): + """[(ColorMatrix, ForwardMatrix, mean training dE76)] for Standard Illuminant A, then D65.""" + out = [] + for name in ("A", "D65"): + light = illuminant(name) + forward, error = fit(cmf, refl, ssf, light, D50) + k = 1.0 / (cmf[:, 1] @ light) + white = k * (cmf.T @ light) + to_xyz, _ = fit(cmf, refl, ssf, light, white) + cam_from_xyz = np.diag(ssf.T @ light) @ np.linalg.inv(to_xyz) + cam_from_xyz = cam_from_xyz / (cam_from_xyz @ white).max() + out.append((cam_from_xyz.round(6), forward.round(6), error)) + return out + + +def mat(m): + return "Mat3([" + ", ".join("[" + ", ".join(f"{v:.6}" for v in row) + "]" for row in m) + "])" + + +def main(root, out): + commit = subprocess.run(["git", "-C", root, "rev-parse", "HEAD"], capture_output=True, text=True, check=True).stdout.strip() + cmf = spectral(os.path.join(root, "data/cmf/cmf_1931.json"), 3) + training = json.load(open(os.path.join(root, "data/training/training_spectral.json")))["spectral_data"]["data"]["main"] + refl = np.array([training[str(w)] for w in WL], dtype=float) + rows = [] + for f in sorted(os.listdir(os.path.join(root, "data/camera"))): + if not f.endswith("_380_780_5.json"): + continue + path = os.path.join(root, "data/camera", f) + header = json.load(open(path))["header"] + if header.get("license") != "Apache-2.0": + sys.exit(f"{f}: licence {header.get('license')!r}, expected Apache-2.0") + (cm1, fm1, e1), (cm2, fm2, e2) = dng_tags(cmf, refl, spectral(path, 3)) + print(f"{f}: training dE76 A {e1:.3f}, D65 {e2:.3f}", file=sys.stderr) + rows.append( + f' Camera {{\n make: "{header["manufacturer"]}",\n model: "{header["model"]}",\n' + f" color_matrix: [{mat(cm1)}, {mat(cm2)}],\n forward_matrix: [{mat(fm1)}, {mat(fm2)}],\n }}," + ) + aliases = json.load(open(os.path.join(root, "data/aliases.json")))["data"]["camera"]["model"] + alias_rows = [ + f' ("{make}", "{alias}", "{target["model"]}"), // {target.get("comments", "")}' + for make, models in aliases.items() + for alias, target in models.items() + ] + rust = ( + "// Generated by tools/spectral_camera_table.py from rawtoaces-data " + f"(https://github.com/AcademySoftwareFoundation/rawtoaces-data, commit {commit}).\n" + "// Derived from the camera spectral sensitivities of rawtoaces-data (Academy Software Foundation), licensed under\n" + "// the Apache License 2.0 (LICENSE-APACHE); see NOTICE and assets/ATTRIBUTION.md. Do not edit by hand.\n" + "use super::Camera;\n" + "use lightcraft_color::Mat3;\n\n" + "#[rustfmt::skip]\npub(super) const CAMERAS: &[Camera] = &[\n" + "\n".join(rows) + "\n];\n\n" + "/// `(make, model as sold, table model)`: other names of a camera in [`CAMERAS`], or of a camera with the same sensor.\n" + "#[rustfmt::skip]\npub(super) const ALIASES: &[(&str, &str, &str)] = &[\n" + "\n".join(alias_rows) + "\n];\n" + ) + open(out, "w").write(rust) + print(f"{len(rows)} cameras, {len(alias_rows)} aliases -> {out}", file=sys.stderr) + + +if __name__ == "__main__": + if len(sys.argv) != 3: + sys.exit("usage: spectral_camera_table.py ") + main(sys.argv[1], sys.argv[2]) From 5ed59252e87a653741deada5155544735d337d5f Mon Sep 17 00:00:00 2001 From: radialmonster Date: Fri, 9 Oct 2026 12:13:52 -0400 Subject: [PATCH 06/33] LR-IMP-FORMATS: decode packed 12-bit Sony ARW (DSLR-A900) from its exact 1.5 bytes per pixel strip Compression 32767 with BitsPerSample 12 and one strip of exactly width x height x 1.5 bytes is plain packed 12-bit data: two pixels per three bytes, least-significant-bit first (measured on the DSLR-A900 sample by same-colour neighbour smoothness, 12x smoother than the other orders). RGGB per the file's pattern tag (checked against the camera JPEG), black 128, crop = FullImageSize centred in the frame, white balance from the plain maker-note block. Any other strip size stays Unsupported. RENDER_CACHE_VERSION 22 -> 24 (adjust at merge). --- crates/engine/src/media.rs | 2 +- crates/raw/src/vendor/arw.rs | 149 ++++++++++++++++++++++++++++++++++- 2 files changed, 146 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index ce41bfaa2..d719a32de 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 = 22; +pub const RENDER_CACHE_VERSION: u64 = 24; /// 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/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index 7c2990466..da055b7e7 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -20,6 +20,10 @@ //! `0x7310`, else the level stored in the encrypted `SR2SubIFD` (see [`SR2_BLACK_AT`]; 800 rather than the //! default 512 on 1″-sensor bodies such as the RX100 series), else 512 (14-bit) / 128 (12-bit). //! +//! Packed 12-bit ARW (DSLR-A900; Compression 32767 but BitsPerSample 12 and a strip of exactly width × height × 1.5 +//! bytes): plain 12-bit samples, two per three bytes, least-significant-bit first ([`unpack_row12`]); linear, black 128 +//! (512 on the 14-bit scale), CFA from the file's own pattern tag, crop centred on the frame (see [`default_crop`]). +//! //! Also: uncompressed 16-bit ARW, and lossless-compressed ARW (Compression 7, ILCE-7M4 and later): LJ92 tiles whose //! frames hold one 2×2 CFA cell per four-component sample ([`read_quad_tiles`]). Other lossless-JPEG layouts go //! through the generic TIFF path. @@ -42,6 +46,11 @@ const REFERENCE_BLACK_WHITE: u16 = 532; /// Maker-note tags (ExifTool Sony tag names): the enciphered `Tag2010` block and `FullImageSize` (height, width). const MN_TAG2010: u16 = 0x2010; const MN_FULL_IMAGE_SIZE: u16 = 0xb02b; +/// Maker-note tag that holds, on the DSLR-A900 sample, the as-shot white balance in plain form: four big-endian +/// `u16` (R, G, G, B; 256 = unity) at [`MN_A900_WB_AT`] in a block of exactly [`MN_A900_BLOCK_LEN`] bytes. +const MN_A900_BLOCK: u16 = 0x0020; +const MN_A900_BLOCK_LEN: usize = 19148; +const MN_A900_WB_AT: usize = 188; /// Inverse of Sony's maker-note byte substitution. ExifTool's Sony tag documentation states that the data of /// tags `0x2010`, `0x9050` and `0x94xx` "is encrypted by a simple substitution cipher" (no decoder source was @@ -171,6 +180,41 @@ fn tag2010_wb(model: &str, block: &[u8], order: lightcraft_tiff::ByteOrder) -> O ((0.2..=8.0).contains(&r) && (0.2..=8.0).contains(&b)).then_some([r, 1.0, b]) } +/// As-shot white balance of the packed 12-bit ARW (DSLR-A900): see [`MN_A900_BLOCK`]. Only the exact block size +/// measured on the sample is read, and the two green levels must both be unity, so another layout is rejected +/// rather than misread. +fn a900_wb(block: &[u8]) -> Option<[f32; 3]> { + if block.len() != MN_A900_BLOCK_LEN { + return None; + } + let at = |i: usize| block.get(MN_A900_WB_AT + 2 * i..MN_A900_WB_AT + 2 * i + 2).map(|b| f32::from(u16::from_be_bytes([b[0], b[1]]))); + let (r, g1, g2, b) = (at(0)?, at(1)?, at(2)?, at(3)?); + if g1 != 256.0 || g2 != 256.0 { + return None; + } + let (r, b) = (r / 256.0, b / 256.0); + ((0.2..=8.0).contains(&r) && (0.2..=8.0).contains(&b)).then_some([r, 1.0, b]) +} + +/// Whether a 12-bit, single-strip, Sony-compressed (32767) image is stored as plain packed 12-bit samples: the strip +/// is exactly 1.5 bytes per pixel (width even), so every row is `1.5 · width` bytes with no padding. On the one +/// DSLR-A900 sample this is the whole strip (36 917 760 = 6080 × 4048 × 1.5) and it ends at the end of the file. +fn is_packed12(info: &ImageInfo, strip_count: usize, strip_len: u64) -> bool { + let (w, h) = (info.width as u64, info.height as u64); + info.bits() == 12 && strip_count == 1 && w % 2 == 0 && w > 0 && h > 0 && strip_len == w * h * 3 / 2 +} + +/// Unpack plain 12-bit samples stored least-significant-bit first: two pixels per three bytes, the first being +/// `b0 | (b1 & 0xf) << 8` and the second `b1 >> 4 | b2 << 4` (measured: same-colour neighbours differ by ~16 codes +/// in this order against ~200 in the most-significant-first order). `row` is `1.5 · out.len()` bytes. +pub(crate) fn unpack_row12(row: &[u8], out: &mut [u16]) { + for (i, b) in row.windows(3).step_by(3).enumerate() { + let Some(px) = out.get_mut(2 * i..2 * i + 2) else { break }; + px[0] = u16::from(b[0]) | (u16::from(b[1] & 0xf) << 8); + px[1] = u16::from(b[1] >> 4) | (u16::from(b[2]) << 4); + } +} + /// The inverse tone curve: 11-bit code → 14-bit sensor value. pub(crate) fn code_curve(thresholds: &[u64]) -> Vec { let mut bp = [0usize, 4095, 4095, 4095, 4095, 4095]; @@ -417,7 +461,11 @@ fn sr2_black_levels(cipher: &[u8], order: lightcraft_tiff::ByteOrder) -> Option< /// at the top-left. Older bodies store a few columns of padding at the right edge of the raw frame (constant /// values, 8–32 columns on the samples we checked) inside a frame 16–48 pixels wider than `FullImageSize`, so /// the anchored crop removes them while keeping the CFA phase. -fn default_crop(raw: &Ifd, mn: Option<&makernote::MakerNote>, w: usize, h: usize) -> Rect { +/// +/// `centred` places the `FullImageSize` window in the middle of the frame (on even offsets, keeping the CFA phase) +/// instead of at the top-left: the packed 12-bit frame has no padding at either edge, and the camera JPEG is +/// centred on the frame. +fn default_crop(raw: &Ifd, mn: Option<&makernote::MakerNote>, w: usize, h: usize, centred: bool) -> Rect { let full = Rect::new(0, 0, w, h); if let (Some([x, y]), Some([cw, ch])) = (raw.u64s(t::DEFAULT_CROP_ORIGIN).as_deref(), raw.u64s(t::DEFAULT_CROP_SIZE).as_deref()) && *cw > 0 @@ -428,7 +476,8 @@ fn default_crop(raw: &Ifd, mn: Option<&makernote::MakerNote>, w: usize, h: usize match mn.and_then(|m| m.ifd.u64s(MN_FULL_IMAGE_SIZE)).as_deref() { // only a plausible trim: never more than 64 pixels per side, never an enlargement Some([fh, fw]) if *fw as usize <= w && *fh as usize <= h && *fw as usize + 64 >= w && *fh as usize + 64 >= h => { - Rect::new(0, 0, *fw as usize, *fh as usize) + let (fw, fh) = (*fw as usize, *fh as usize); + if centred { Rect::new(((w - fw) / 2) & !1, ((h - fh) / 2) & !1, fw, fh) } else { Rect::new(0, 0, fw, fh) } } _ => full, } @@ -458,6 +507,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { // archive measure exactly width x height. A200/A230/A350 files carry a strip 4-23 % larger than that (and A290/A390 one // smaller), which is a different packing, so they are refused rather than read with the wrong layout. let one_byte_per_sample = chunks.len() == 1 && strip_len == (w * h) as u64; + let packed12 = info.compression == 32767 && is_packed12(&info, chunks.len(), strip_len); let (data, out_bits) = match info.compression { 7 if linear_rgb => (RawData::U16(read_ycbcr_tiles(bytes, &info, raw, mode)?), 14), 32767 if one_byte_per_sample && mode == Mode::Header => { @@ -477,6 +527,21 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { }); (RawData::U16(data), 14) } + 32767 if packed12 && mode == Mode::Header => { + chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; + (RawData::U16(Vec::new()), 12) + } + 32767 if packed12 => { + let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; + let row_bytes = w / 2 * 3; + let mut data = vec![0u16; w * h]; + data.par_chunks_mut(w).enumerate().for_each(|(y, out)| { + if let Some(row) = src.get(y * row_bytes..(y + 1) * row_bytes) { + unpack_row12(row, out); + } + }); + (RawData::U16(data), 12) + } 32767 => return Err(RawError::Unsupported("Sony ARW version 1 / packed compressed variant (raw strip is not one byte per pixel)".into())), 7 if is_quad_tiled(bytes, &info) => match mode { Mode::Full => (RawData::U16(read_quad_tiles(bytes, &info)?), bits), @@ -530,10 +595,11 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let g = (v[1] + v[2]) / 2.0; [(v[0] / g) as f32, 1.0, (v[3] / g) as f32] }) - .or_else(|| mn.as_ref().and_then(|m| tag2010_wb(&model, m.ifd.bytes(MN_TAG2010)?, m.order))); + .or_else(|| mn.as_ref().and_then(|m| tag2010_wb(&model, m.ifd.bytes(MN_TAG2010)?, m.order))) + .or_else(|| mn.as_ref().filter(|_| packed12).and_then(|m| a900_wb(m.ifd.bytes(MN_A900_BLOCK)?))); let crop = match (raw.u64s(CROP_TOP_LEFT).as_deref(), raw.u64s(CROP_SIZE).as_deref()) { (Some([x, y]), Some([cw, ch])) if *cw > 0 && *ch > 0 => Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h), - _ => default_crop(raw, mn.as_ref(), w, h), + _ => default_crop(raw, mn.as_ref(), w, h, packed12), }; let mut metadata = lightcraft_meta::from_tiff(&tiff); metadata.width = Some(crop.width as u32); @@ -630,6 +696,81 @@ mod tests { } } + /// A 32767-compressed, 12-bit ARW of `w` x `h` whose single strip is `strip`. + fn packed_arw(w: u32, h: u32, strip: Vec) -> Vec { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("DSLR-A900".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![32767])); + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + raw.set(TONE_CURVE, Value::Short(vec![8000, 10400, 12900, 14100])); + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![strip] }); + TiffWriter::default().write(&[raw]).unwrap() + } + + /// Pack 12-bit samples as the file does: `b0 = a & 0xff`, `b1 = a >> 8 | b << 4 & 0xf0`, `b2 = b >> 4`. + fn pack12(samples: &[u16]) -> Vec { + samples.chunks(2).flat_map(|p| [p[0] as u8, (p[0] >> 8) as u8 | ((p[1] & 0xf) << 4) as u8, (p[1] >> 4) as u8]).collect() + } + + #[test] + fn packed_12_bit_strip_of_one_and_a_half_bytes_per_pixel_decodes() { + let (w, h) = (8usize, 4usize); + let samples: Vec = (0..w * h).map(|i| (i as u16 * 129 + 130) & 0xfff).collect(); + let file = packed_arw(w as u32, h as u32, pack12(&samples)); + let full = decode(&file, Mode::Full).unwrap(); + let RawData::U16(ref got) = full.data else { panic!("integer ARW") }; + assert_eq!(got, &samples); + assert_eq!((full.width, full.height, full.bits), (w, h, 12)); + assert_eq!(full.cfa.as_ref().map(|c| c.pattern.clone()), Some(vec![0, 1, 1, 2])); + assert_eq!(full.black.values.first().copied().unwrap_or(0.0), 128.0); + let header = decode(&file, Mode::Header).unwrap(); + assert_eq!(header.info(), full.info()); + // the first three bytes 0x12 0xA3 0x45 are the samples 0x312 and 0x45A (least-significant-bit first) + let mut row = vec![0u16; 2]; + unpack_row12(&[0x12, 0xa3, 0x45], &mut row); + assert_eq!(row, [0x312, 0x45a]); + } + + #[test] + fn packed_12_bit_needs_the_exact_strip_size() { + let (w, h) = (8usize, 4usize); + let exact = w * h * 3 / 2; + for strip in [exact - 1, exact + 1, exact + 3 * h] { + for mode in [Mode::Header, Mode::Full] { + let err = decode(&packed_arw(w as u32, h as u32, vec![0u8; strip]), mode).unwrap_err(); + assert!(matches!(err, RawError::Unsupported(ref m) if m.contains("packed compressed variant")), "{strip} {mode:?}: {err:?}"); + } + } + // an odd width cannot be packed in pairs + let err = decode(&packed_arw(7, 4, vec![0u8; 7 * 4 * 3 / 2]), Mode::Full).unwrap_err(); + assert!(matches!(err, RawError::Unsupported(_)), "{err:?}"); + } + + #[test] + fn a900_white_balance_block_is_read_only_when_it_matches() { + let mut block = vec![0u8; MN_A900_BLOCK_LEN]; + for (i, v) in [672u16, 256, 256, 379].iter().enumerate() { + block[MN_A900_WB_AT + 2 * i..MN_A900_WB_AT + 2 * i + 2].copy_from_slice(&v.to_be_bytes()); + } + let wb = a900_wb(&block).unwrap(); + assert_eq!(wb, [2.625, 1.0, 379.0 / 256.0]); + assert!(a900_wb(&block[..MN_A900_BLOCK_LEN - 1]).is_none()); + let mut other = block.clone(); + other[MN_A900_WB_AT + 2] = 2; // green no longer unity + assert!(a900_wb(&other).is_none()); + other = block.clone(); + other[MN_A900_WB_AT] = 0xff; // red gain far out of range + assert!(a900_wb(&other).is_none()); + } + /// Encode one 16-value set as an ARW2 block (the paper's scheme, used here to test the decoder). pub(crate) fn encode_block(v: &[u16; 16]) -> [u8; 16] { let (imax, &max) = v.iter().enumerate().max_by_key(|(i, x)| (**x, usize::MAX - *i)).unwrap(); From 04e3b2f1aa7acff0dc7442b70bf6a63a3e4ca6bd Mon Sep 17 00:00:00 2001 From: radialmonster Date: Fri, 9 Oct 2026 16:26:32 -0400 Subject: [PATCH 07/33] LR-CLOUD-SMARTPREVIEW: smart previews record the camera-look fit version and are rebuilt when it is older A smart preview keeps the colour and tone curves the camera-look fit gave it, so a fit change (#499, #528, #583) left existing previews with the old look. They now record LOOK_VERSION in the header; an older one is rebuilt from its original when the library is opened or Build Smart Previews runs, and kept as it is while the original is offline. --- crates/engine/src/camera_preview.rs | 10 ++ crates/engine/src/cmd/previews.rs | 76 ++++++++++++++- crates/engine/src/lib.rs | 2 +- crates/engine/src/library.rs | 5 + crates/engine/src/smart.rs | 33 ++++++- crates/engine/src/tests_import.rs | 146 ++++++++++++++++++++++++++++ docs/camera-preview-colour.md | 12 +++ docs/parity.md | 2 +- 8 files changed, 278 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index dd3d7361f..602fa5275 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -12,6 +12,16 @@ use lightcraft_raster::{ }; use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTable}; +/// The version of the camera-look fit: how a raw's colour matrix, hue/saturation table and tone +/// and chroma curves are chosen. Smart previews record it in their header (`look_version`), because +/// they keep the curve and the colour the fit produced; one written by an older version is rebuilt +/// from its original when that is online, and kept as it is while it is offline. +/// +/// **Bump this whenever a change makes the fit give a different result for the same file** (the +/// fit, its gates, the fallbacks, the camera profiles' effect on it). Do not bump it for changes +/// that leave the fitted look alone. `RENDER_CACHE_VERSION` covers renders; this covers proxies. +pub const LOOK_VERSION: u32 = 1; + #[derive(Clone, Debug)] pub(crate) struct CameraLook { pub matrix: Mat3, diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index 3dacc31f2..09075fd89 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -120,11 +120,29 @@ struct SmartCounts { built: usize, /// Of those, damaged ones (cut short by a crash or a full drive) that were rebuilt. repaired: usize, + /// Of those, proxies written by an older camera-look fit that were rebuilt from their online originals. + refreshed: usize, + /// Of those kept as they were: out of date, but the original can't be read now (offline), so + /// the stored look stays and the next run with the original online rebuilds them. + stale_kept: usize, removed: usize, /// `[[id, why]]` failed: Vec, } +/// `source` if it reads the photo's original, else `None`: a cached source decoded from a smart +/// preview (it has no decoder facts), a proxy standing in for an offline original, and the proxy +/// fallback of a file source are not the original. +#[cfg(not(target_arch = "wasm32"))] +fn original_only(source: crate::media::SourceRef) -> Option { + use crate::media::SourceRef; + match source { + SourceRef::File { path, max_edge, loader, denoise, .. } => Some(SourceRef::File { path, max_edge, loader, fallback: None, denoise }), + SourceRef::Loaded(d) if d.info.is_some() => Some(SourceRef::Loaded(d)), + _ => None, + } +} + /// The file-system half of Build / Discard Smart Previews (runs on a worker thread in the app). /// `custom`: the folder was chosen (on another drive, perhaps): it must be there, it is never /// recreated. With `state`, progress and cancel. @@ -154,12 +172,17 @@ fn smart_run( n.removed += 1; } } else { - // a complete proxy is kept; a missing or damaged one (cut short by a crash or a full - // drive) is (re)built + // a complete, current proxy is kept; a missing or damaged one (cut short by a crash or a + // full drive) is (re)built, and so is one written by an older camera-look fit (see + // `camera_preview::LOOK_VERSION`) when its original can be read let damaged = path.exists(); - if damaged && crate::smart::is_valid(&path) { + let stale = damaged && crate::smart::is_stale(&path); + if damaged && !stale && crate::smart::is_valid(&path) { n.built += 1; } else { + // a stale proxy is rebuilt from the original only: never from itself (the file + // fallback or a source decoded from a proxy would stamp the old look as current) + let source = if stale { source.and_then(original_only) } else { source }; // atomic: a failed write leaves no partial proxy that would pass for a built one; // and synced (issue #134): built so the photo can be edited while its original // is offline, when it is the only copy — the sync is small next to the decode @@ -171,13 +194,21 @@ fn smart_run( match r { Ok(()) => { n.built += 1; - if damaged { + if stale { + n.refreshed += 1; + } else if damaged { n.repaired += 1; if let Some(st) = state { st.repaired.fetch_add(1, Ordering::Relaxed); } } } + Err(e) if stale => { + // offline (or unreadable) original: the proxy is all there is, keep its look + log::info!("smart preview {} keeps its older look: {e}", path.display()); + n.built += 1; + n.stale_kept += 1; + } Err(e) => { n.failed.push(json!([id.0, e])); if let Some(st) = state { @@ -236,7 +267,42 @@ fn smart(s: &mut Session, p: &Value) -> Result { return Ok(state.json()); } let r = smart_run(&dir, custom, discard, jobs, None).map_err(|e| bad(C, e))?; - Ok(json!({"built": r.built, "repaired": r.repaired, "removed": r.removed, "failed": r.failed})) + Ok( + json!({"built": r.built, "repaired": r.repaired, "refreshed": r.refreshed, "staleKept": r.stale_kept, "removed": r.removed, "failed": r.failed}), + ) +} + +/// Bring smart previews written by an older camera-look fit up to date, once per library open: +/// on a worker thread (the UI never waits on the drives), each stale proxy is rebuilt from its +/// original when that can be read and kept as it is when it can't (its header still says it is +/// stale, so the next open with the original online rebuilds it). Only existing proxies are +/// touched: none is built, and a missing folder is never created. Returns the thread, if started. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option> { + let dir = s.media.smart_dir.clone()?; + let max_edge = crate::media::SourceLevel::Preview.max_edge(); + let photos: Vec<_> = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. })).collect(); + let jobs: Vec = + photos.iter().map(|p| (p.id, dir.join(crate::smart::file_name(p)), Some(s.media.origin_ref(&p.source, max_edge)))).collect(); + std::thread::Builder::new() + .name("lc-smart-look".into()) + .spawn(move || refresh_stale(&dir, jobs)) + .map_err(|e| log::warn!("smart previews: could not start the look refresh: {e}")) + .ok() +} + +/// The body of [`refresh_stale_smart_previews`]: rebuild the stale proxies among `jobs`. +#[cfg(not(target_arch = "wasm32"))] +fn refresh_stale(dir: &std::path::Path, jobs: Vec) { + let stale: Vec = jobs.into_iter().filter(|(_, path, _)| crate::smart::is_stale(path)).collect(); + if stale.is_empty() { + return; + } + // `custom`: the folder is there already, and must not be recreated if it went away since + match smart_run(dir, true, false, stale, None) { + Ok(n) => log::info!("smart previews: {} refreshed to the current camera look, {} kept (original offline)", n.refreshed, n.stale_kept), + Err(e) => log::warn!("smart previews: look refresh: {e}"), + } } /// The smart previews folder: where it is, whether it is custom, what it holds, whether it can be diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a6c5f6420..20028949a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -15,7 +15,7 @@ extern crate lightcraft_denoise_core as lightcraft_denoise; pub mod availability; -mod camera_preview; +pub(crate) mod camera_preview; pub mod camera_profiles; pub mod cmd; pub mod config; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index a83dc4a82..4c8bf28aa 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -419,6 +419,11 @@ impl Session { lib.forgot_local = Some(plan); } } + // smart previews written by an older camera-look fit are brought up to date in the background + #[cfg(not(target_arch = "wasm32"))] + if on_disk { + crate::cmd::previews::refresh_stale_smart_previews(self); + } self.library_identity = std::sync::Arc::new(()); Ok(()) } diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs index a48fcd040..1421558c2 100644 --- a/crates/engine/src/smart.rs +++ b/crates/engine/src/smart.rs @@ -24,6 +24,10 @@ pub fn file_name(p: &Photo) -> String { /// The most header [`is_valid`] reads (the JSON line before the JPEG). const HEADER_MAX: u64 = 4096; +/// The header field that records which version of the camera-look fit (`camera_preview::LOOK_VERSION`) +/// wrote the proxy's pixels (colour matrix and profile are baked into them) and stored tone curve. +const LOOK_FIELD: &str = "look_version"; + /// Encode a source image (and the decoder's camera tone curve, if any) as a smart preview. pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result, String> { // scale so all but the brightest 0.05 % fit into 0..1 @@ -53,7 +57,7 @@ pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result, String ) .map_err(|e| e.to_string())?; let mut out = MAGIC.to_vec(); - let mut head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale}); + let mut head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION}); if let Some(t) = tone { head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?; } @@ -187,6 +191,33 @@ pub fn is_valid(path: &Path) -> bool { f.seek(SeekFrom::End(-2)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9] } +/// The fit version a header records: a missing, negative, fractional or otherwise malformed value +/// counts as 0 (older than any real version). +fn header_look_version(head: &serde_json::Value) -> u64 { + head.get(LOOK_FIELD).and_then(serde_json::Value::as_u64).unwrap_or(0) +} + +/// Whether the complete proxy at `path` was written by an older camera-look fit than this build's +/// (so its stored curve, and the colour baked into its pixels, are out of date). A proxy from a +/// newer build is left alone (rebuilding it here would only undo that build's work), as is one +/// that can't be read: those are [`is_valid`]'s business. +pub fn is_stale(path: &Path) -> bool { + use std::io::Read; + if !is_valid(path) { + return false; + } + let Ok(f) = std::fs::File::open(path) else { return false }; + let mut head = Vec::with_capacity(1024); + if f.take(HEADER_MAX).read_to_end(&mut head).is_err() { + return false; + } + let Some(rest) = head.strip_prefix(MAGIC) else { return false }; + let Some(nl) = rest.iter().position(|b| *b == b'\n') else { return false }; + rest.get(..nl) + .and_then(|h| serde_json::from_slice::(h).ok()) + .is_some_and(|h| header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION)) +} + /// Load the proxy at `path`. pub fn load(path: &Path) -> Result { let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 4a4cad539..6c0cc6b50 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -1105,3 +1105,149 @@ fn folder_import_picks_up_undecodable_raw_containers_as_preview_only() { assert!(s.catalog.photos().all(|p| p.preview_only.is_some() && p.kind == lightcraft_catalog::MediaKind::Raw && (p.width, p.height) == (40, 30))); let _ = std::fs::remove_dir_all(&src); } + +/// Rewrite the JSON header line of the smart preview at `file`. +fn edit_smart_header(file: &Path, edit: impl FnOnce(&mut Value)) { + let bytes = std::fs::read(file).unwrap(); + let start = b"LCSP1\n".len(); + let nl = start + bytes[start..].iter().position(|b| *b == b'\n').unwrap(); + let mut head: Value = serde_json::from_slice(&bytes[start..nl]).unwrap(); + edit(&mut head); + let mut out = bytes[..start].to_vec(); + out.extend_from_slice(head.to_string().as_bytes()); + out.extend_from_slice(&bytes[nl..]); + std::fs::write(file, out).unwrap(); +} + +fn smart_header(file: &Path) -> Value { + let bytes = std::fs::read(file).unwrap(); + let start = b"LCSP1\n".len(); + let nl = start + bytes[start..].iter().position(|b| *b == b'\n').unwrap(); + serde_json::from_slice(&bytes[start..nl]).unwrap() +} + +/// A camera tone curve with a recognisable shape (`k` changes it). +fn test_tone(k: f32) -> lightcraft_pipeline::tone::CameraTone { + lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| { + let x = 0.004 * 1.17f32.powi(i as i32); + [x, 0.95 * (1.0 - (-k * x).exp())] + })) + .unwrap() +} + +/// `plain`, but reporting `tone` as the camera look the (current) fit gives the file; counts its calls. +fn look_loader( + plain: crate::media::FileLoader, + tone: lightcraft_pipeline::tone::CameraTone, + calls: std::sync::Arc, +) -> crate::media::FileLoader { + std::sync::Arc::new(move |path, max_edge| { + calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let (img, mut info) = plain(path, max_edge)?; + info.camera_tone = Some(tone); + Ok((img, info)) + }) +} + +/// The follow-up to the camera tone curve change (#499): a smart preview keeps the look the fit +/// gave it, so one written by an older fit is rebuilt when its original is online, kept while the +/// original is offline, and not rebuilt again once current. +#[test] +fn smart_previews_from_an_older_look_fit_are_refreshed() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let src = temp_dir("smartlook-src"); + let lib = temp_dir("smartlook-lib"); + write_png(&src.join("a.png"), 3); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + let file = s.media.smart_dir.clone().unwrap().join(crate::smart::file_name(s.catalog.photo(id).unwrap())); + let (old, new) = (test_tone(1.5), test_tone(3.5)); + let calls = std::sync::Arc::new(AtomicUsize::new(0)); + let plain = s.media.file_loader.clone().unwrap(); + s.media.file_loader = Some(look_loader(plain.clone(), old, calls.clone())); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(1), Some(0)), "{r}"); + assert_eq!(smart_header(&file)["look_version"], json!(crate::camera_preview::LOOK_VERSION), "the writer stamps the fit version"); + assert!(!crate::smart::is_stale(&file)); + + // current header: kept as it is, the original is not even read + let before = std::fs::read(&file).unwrap(); + calls.store(0, Ordering::SeqCst); + s.media.file_loader = Some(look_loader(plain.clone(), new, calls.clone())); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(1), Some(0)), "{r}"); + assert!(std::fs::read(&file).unwrap() == before, "a current proxy is not rebuilt"); + assert_eq!(calls.load(Ordering::SeqCst), 0); + + // an old header (no field), original offline: kept unchanged, and still marked stale + edit_smart_header(&file, |h| { + h.as_object_mut().unwrap().remove("look_version"); + }); + assert!(crate::smart::is_stale(&file), "no field = version 0"); + let old_bytes = std::fs::read(&file).unwrap(); + std::fs::rename(&src, src.with_extension("offline")).unwrap(); + s.media.forget(id); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64(), r["staleKept"].as_u64()), (Some(1), Some(0), Some(1)), "{r}"); + assert!(r["failed"].as_array().unwrap().is_empty(), "{r}"); + assert!(std::fs::read(&file).unwrap() == old_bytes, "offline: the stored preview and curve are untouched"); + assert!(crate::smart::is_stale(&file), "and it stays marked for the next time"); + assert_eq!(crate::smart::decode(&old_bytes).unwrap().1, Some(old), "the old look is still there"); + // ... and the opening refresh keeps it too + crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap(); + assert!(std::fs::read(&file).unwrap() == old_bytes); + + // the original comes back: the library's next opening rebuilds it with the current look + std::fs::rename(src.with_extension("offline"), &src).unwrap(); + calls.store(0, Ordering::SeqCst); + crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(smart_header(&file)["look_version"], json!(crate::camera_preview::LOOK_VERSION)); + assert_eq!(crate::smart::decode(&std::fs::read(&file).unwrap()).unwrap().1, Some(new), "rebuilt with the current look"); + assert!(!crate::smart::is_stale(&file)); + // once only + let rebuilt = std::fs::read(&file).unwrap(); + crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap(); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["refreshed"].as_u64(), calls.load(Ordering::SeqCst)), (Some(0), 1), "{r}"); + assert!(std::fs::read(&file).unwrap() == rebuilt); + + // Build Smart Previews refreshes a stale one the same way + edit_smart_header(&file, |h| h["look_version"] = json!(0)); + s.media.file_loader = Some(plain); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!(r["refreshed"], 1, "{r}"); + assert_eq!(crate::smart::decode(&std::fs::read(&file).unwrap()).unwrap().1, None, "this fit gives no curve: none is kept"); + let _ = std::fs::remove_dir_all(&src); + let _ = std::fs::remove_dir_all(&lib); +} + +/// A hostile or damaged `look_version` never panics: anything that is not a non-negative integer +/// counts as 0 (rebuilt when the original is online); a version from a newer build is left alone. +#[test] +fn malformed_smart_preview_look_versions_are_handled() { + let dir = temp_dir("smartlook-bad"); + let img = lightcraft_scenes::demo_library()[0].render(32, 20); + let file = dir.join("a.lcsp"); + std::fs::write(&file, crate::smart::encode(&img, None).unwrap()).unwrap(); + let cur = u64::from(crate::camera_preview::LOOK_VERSION); + for bad in [json!("1"), json!(-1), json!(1.5), json!(null), json!([]), json!({}), json!(true), json!(1e30), json!(-0.0)] { + let shown = bad.to_string(); + edit_smart_header(&file, |h| h["look_version"] = bad); + assert!(crate::smart::is_valid(&file), "{shown}"); + assert!(crate::smart::is_stale(&file), "{shown} counts as version 0"); + assert!(crate::smart::decode(&std::fs::read(&file).unwrap()).is_ok(), "{shown}"); + } + for fine in [json!(cur), json!(cur + 1), json!(u64::MAX)] { + let shown = fine.to_string(); + edit_smart_header(&file, |h| h["look_version"] = fine); + assert!(!crate::smart::is_stale(&file), "{shown}"); + } + // not a header at all, or missing + assert!(!crate::smart::is_stale(&dir.join("missing.lcsp"))); + std::fs::write(&file, b"LCSP1\n{not json\n\xFF\xD8").unwrap(); + assert!(!crate::smart::is_stale(&file)); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index dfad77c22..d17cb792c 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -77,6 +77,18 @@ One photo can show too little of a colour for its own fit to learn it: in a seco Profiles are JSON files `.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; X-H2S, 201 photos; X-T4, 545 photos. They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (DRO and picture styles vary per shot); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt. +## Smart previews and the look version + +A smart preview keeps the result of the fit twice: the colour matrix is baked into its pixels and the tone and chroma curves are stored in its header. When the fit changes, a preview built earlier keeps the old look until it is rebuilt, and the render cache version does not reach it (renders are keyed on the library's files, proxies are files of their own). + +The fit therefore has a version, `LOOK_VERSION` in `crates/engine/src/camera_preview.rs`, and every smart preview records the version that wrote it in its header line (`look_version`). **Bump the constant in any change that makes the fit give a different result for the same file**: the matrix, hue/saturation, tone or chroma fit, their gates and fallbacks, or how a camera profile feeds them. Changes that leave the fitted look alone don't need it. A header without the field (everything built before it existed) counts as version 0, and so does a value that is not a non-negative integer; a value from a newer build is left alone. + +A proxy older than the constant is brought up to date by the existing Build Smart Previews path (`smart_run`), which also repairs damaged proxies: + +- when the library is opened, a background thread looks at the existing proxies and rebuilds the stale ones from their originals (no proxy is created, nothing is shown, the UI never waits on a drive); +- Build Smart Previews does the same for the photos it is run on (`refreshed` in its result); +- when the original can't be read (offline drive), the proxy and its curve are kept exactly as they are. The old version in its header is the mark: the next opening, or the next Build Smart Previews, with the original online rebuilds it. A rebuilt proxy carries the current version, so nothing is rebuilt twice. A proxy is only ever rebuilt from the original, never from another proxy. + ## Nikon crop and preview colour metadata Nikon maker-note `CropArea` (0x0045) supplies the default `[left, top, width, height]` crop. The decoder validates the rectangle against the active sensor area and falls back to that area for missing or invalid values. The CFA origin is unchanged; cropping follows demosaicing. diff --git a/docs/parity.md b/docs/parity.md index 4c27755b5..f656a85b8 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -500,7 +500,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-CLOUD-SYNC | Cloud sync | OOS | 🚫 | | | -| LR-CLOUD-SMARTPREVIEW | Editable proxies | P2 | ✅ | `cmd:library.smartPreviews`, `cmd:library.smartPreviewsLocation`, `cmd:photo.smartPreview`, `crates/engine/src/smart.rs`, `crates/ui-egui/src/panels/settings.rs` | File ▸ Previews ▸ Build / Discard Smart Previews (on a worker thread, progress and Stop like Build Previews): ~1 MB proxies in the library; with the original offline the photo renders, edits and exports (at proxy size) from its proxy; Info shows the status; the proxy folder is chosen per library (Settings → Performance → Smart previews, saved in the library): the effective path, count and size are shown, changing it needs a choice for the proxies already built (move / leave / delete), and an unavailable or unwritable folder is an error, never a fallback to the library drive; proxies are written atomically and a damaged one (cut short by a crash or a full drive) doesn't count as built, is rebuilt (`repaired`) and is never moved over a good one (issue #106). The thumbnail cache stays in the library | +| LR-CLOUD-SMARTPREVIEW | Editable proxies | P2 | ✅ | `cmd:library.smartPreviews`, `cmd:library.smartPreviewsLocation`, `cmd:photo.smartPreview`, `crates/engine/src/smart.rs`, `crates/ui-egui/src/panels/settings.rs` | File ▸ Previews ▸ Build / Discard Smart Previews (on a worker thread, progress and Stop like Build Previews): ~1 MB proxies in the library; with the original offline the photo renders, edits and exports (at proxy size) from its proxy; Info shows the status; the proxy folder is chosen per library (Settings → Performance → Smart previews, saved in the library): the effective path, count and size are shown, changing it needs a choice for the proxies already built (move / leave / delete), and an unavailable or unwritable folder is an error, never a fallback to the library drive; proxies are written atomically and a damaged one (cut short by a crash or a full drive) doesn't count as built, is rebuilt (`repaired`) and is never moved over a good one (issue #106). Each proxy records the version of the camera-look fit that wrote it (`look_version`, `LOOK_VERSION` in `crates/engine/src/camera_preview.rs`, bumped whenever the fit changes); one from an older fit is rebuilt from its original when the library is opened or Build Smart Previews runs, and kept as it is while the original is offline (`docs/camera-preview-colour.md`). The thumbnail cache stays in the library | | LR-AI-UPDATE-INDICATOR | AI-settings update indicator | P2 | ⬜ | | | | LR-AI-CREDITS | Generative credits | OOS | 🚫 | | | From ac9311891195923c0579ae00abaffe50aef5da02 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Fri, 9 Oct 2026 18:13:20 -0400 Subject: [PATCH 08/33] LR-EDIT-LIGHT-HIGHLIGHTS: partly clipped highlights take the colour of bright neighbours, binned previews clip before averaging (#548) Highlight reconstruction rebuilds clipped channels from the chromaticity of unclipped pixels nearby. Three changes to that colour: - an unclipped pixel counts by its brightness ((m / 0.8 clip)^4, at most 1), filled coarse to fine by push-pull, so dark wires across a bright cloud no longer tint it (the iPhone frame of #548 turned cyan at Highlights -100); - pixels within 6 px of clipping (partly demosaiced from clipped samples, 25 % greener than the sky on that frame) give no colour unless nothing else does; - the rebuilt colour is drawn toward neutral at its brightest channel as more channels clip ((n - 1 + s) / 2, s rising as the next channel nears its clip). Binned previews keep each channel the mean of its block and mark blocks with a clipped sample in a mask (develop_binned_masked, reconstruct_masked), instead of taking the block maximum: one clipped specular sample no longer becomes a green speckle at 500 px (HUAWEI PRA-LX1, WAS-LX1A). The denoised twin uses the same mask. Camera-colour fitting keeps the old binning. RENDER_CACHE_VERSION 26 (adjust at merge). --- crates/engine/src/files.rs | 52 +++- crates/engine/src/media.rs | 2 +- crates/raw/src/binned.rs | 72 ++++- crates/raw/src/highlight.rs | 568 ++++++++++++++++++++++++------------ crates/raw/src/lib.rs | 3 +- docs/parity.md | 2 +- 6 files changed, 495 insertions(+), 204 deletions(-) diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index db762f009..512c31d32 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -237,8 +237,9 @@ pub fn load_vec_pair(bytes: Vec, max_edge: usize, spec: &DenoiseSpec) -> Res } /// The denoised camera RGB of `raw` from its cached product, as the plain development would have it: the default -/// crop, and `factor` × `factor` blocks averaged into one pixel when the plain one was binned. `None` when the product -/// is missing, damaged, made from something else or not the size of this raw's active area. +/// crop, and `factor` × `factor` blocks averaged into one pixel when the plain one was binned (plain means: the +/// plain development's clip mask tells highlight reconstruction which of them held clipped samples). `None` when +/// the product is missing, damaged, made from something else or not the size of this raw's active area. fn denoised_camera_rgb(raw: &lightcraft_raw::RawImage, spec: &DenoiseSpec, factor: usize) -> Option { use lightcraft_denoise::product::{self, Window}; let a = raw.active_area; @@ -249,7 +250,7 @@ fn denoised_camera_rgb(raw: &lightcraft_raw::RawImage, spec: &DenoiseSpec, facto if raw.opcodes.list3.is_empty() { let c = raw.develop_crop(a.width, a.height); let window = Window { x: c.x, y: c.y, width: c.width, height: c.height }; - product::read_window(&spec.product, &spec.key, Some(window), factor, Some(HIGHLIGHT_CLIP)).ok() + product::read_window(&spec.product, &spec.key, Some(window), factor, None).ok() } else { // (the plain development is not binned either when the file has opcodes of this kind: `factor` is 1) let whole = product::read(&spec.product, &spec.key, 1).ok()?; @@ -286,16 +287,17 @@ fn load_bytes_now( // larger levels (exports, 1:1) demosaic the whole sensor. let t0 = web_time::Instant::now(); let bin = bin_factor(&raw, max_edge); + // (binned: block means, and which colours had a clipped sample in each block) let binned = match bin { - Some(k) => raw.develop_binned(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?, + Some(k) => raw.develop_binned_masked(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?, None => None, }; let factor = if binned.is_some() { bin.unwrap_or(1) } else { 1 }; - let img = match binned { - Some(img) => img, + let (img, clip_mask) = match binned { + Some((img, mask)) => (img, Some(mask)), None => { let method = if max_edge <= 600 { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; - raw.develop(method).map_err(|e| e.to_string())? + (raw.develop(method).map_err(|e| e.to_string())?, None) } }; // the same development from the denoised mosaic, when the photo has been denoised @@ -313,7 +315,10 @@ fn load_bytes_now( let finish = |mut img: Rgb32f| { let mut stages = vec![("develop", t0.elapsed())]; stages.push(("transform", t0.elapsed())); - lightcraft_raw::highlight::reconstruct(&mut img, wb, HIGHLIGHT_CLIP); + match &clip_mask { + Some(mask) => lightcraft_raw::highlight::reconstruct_masked(&mut img, wb, HIGHLIGHT_CLIP, mask), + None => lightcraft_raw::highlight::reconstruct(&mut img, wb, HIGHLIGHT_CLIP), + }; stages.push(("highlights", t0.elapsed())); img.map_in_place(|p| { let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; @@ -621,6 +626,37 @@ mod tests { assert!(probe_bytes("x.jpg", b"not an image").is_err()); } + /// Issue #548: a small preview bins the mosaic by 8; specular points that clip a single green sample + /// of a block on a grey surface must not become green speckles (the block's green used to take the + /// clip level, which highlight reconstruction then kept). + #[test] + fn coarse_binning_keeps_specular_points_neutral() { + let (w, h) = (256usize, 192usize); + // grey after the file's white balance (as-shot neutral 0.5 : 1 : 0.7), brighter to the right + let neutral = [0.5f32, 1.0, 0.7]; + let cfa = lightcraft_raw::Cfa::bayer("RGGB").unwrap(); + let mut data: Vec = (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let v = (0.3 + 0.2 * x as f32 / w as f32) * neutral[cfa.color_at(x, y).min(2) as usize]; + (256.0 + v * (16000.0 - 256.0)).round() as u16 + }) + .collect(); + // one clipped green sample (RGGB: even row, odd column) in every other 8 × 8 block + for by in 0..h / 8 { + for bx in (by % 2..w / 8).step_by(2) { + data[(by * 8 + 4) * w + bx * 8 + 5] = 16000; + } + } + let bytes = crate::tests_xmp::synthetic_dng_of(w, h, "RGGB", data, None, Default::default()); + let raw = lightcraft_raw::decode(&bytes).unwrap(); + assert_eq!(bin_factor(&raw, 32), Some(8)); + let (img, _) = load_bytes(&bytes, 32).unwrap(); + assert_eq!((img.width, img.height), (32, 24)); + let worst = img.data.iter().map(|p| p[1] / ((p[0] + p[2]) / 2.0).max(1e-6)).fold(0.0f32, f32::max); + assert!(worst < 1.12, "green speckles: green {worst:.2}× the other channels"); + } + #[test] fn cached_denoise_uses_the_same_white_balance_profile_and_orientation_as_plain_raw() { use lightcraft_denoise::product; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index b3f25df52..81e900ae7 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 = 25; +pub const RENDER_CACHE_VERSION: u64 = 26; /// 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/binned.rs b/crates/raw/src/binned.rs index fde6c2c0b..1be08089a 100644 --- a/crates/raw/src/binned.rs +++ b/crates/raw/src/binned.rs @@ -8,9 +8,13 @@ //! interpolation, so there are no demosaicing artefacts; the colour planes are offset by less than //! one output pixel, which is invisible at these scales. //! -//! Clipping is preserved for [`crate::highlight::reconstruct`]: a channel whose block contains a -//! sample at or above `clip` takes that block's maximum sample of the colour (instead of a mean that -//! would fall just below the clip level and escape reconstruction). +//! Clipping is decided per sample, before averaging. [`RawImage::develop_binned_masked`] keeps every +//! channel the mean of its samples and says, per output pixel, which colours had a sample at or above +//! `clip` (for [`crate::highlight::reconstruct_masked`], which rebuilds them with the mean as a lower +//! bound). A specular point that clips one green sample of the 32 in an 8 × 8 block thus counts as +//! partly clipped without the block's green jumping to the clip level, which would read as a green +//! speckle once reconstructed. [`RawImage::develop_binned`] instead gives such a channel the block's +//! maximum sample of the colour, so that plain value thresholds see it as clipped. use crate::{Normalized, RawData, RawImage, Result, Rgb32f}; use rayon::prelude::*; @@ -31,6 +35,19 @@ impl RawImage { /// not oriented). `None` when the data cannot be binned ([`RawImage::can_bin`]) or carries an /// `OpcodeList3` (whose operations are defined at full resolution); callers then demosaic. pub fn develop_binned(&self, k: usize, clip: f32) -> Result> { + Ok(self.bin(k, clip, true)?.map(|(img, _)| img)) + } + + /// Like [`RawImage::develop_binned`], with every channel the mean of its block's samples, and the + /// clip mask: per output pixel, bits [`crate::highlight::CLIPPED_R`] / `_G` / `_B` for the colours + /// with a sample at or above `clip` in the block. Hand both to [`crate::highlight::reconstruct_masked`]. + pub fn develop_binned_masked(&self, k: usize, clip: f32) -> Result)>> { + self.bin(k, clip, false) + } + + /// The binned image and its clip mask; `keep_max`: a channel with a clipped sample takes the + /// block's maximum sample of the colour instead of the mean. + fn bin(&self, k: usize, clip: f32, keep_max: bool) -> Result)>> { self.validate()?; if !self.can_bin(k) || !self.opcodes.list3.is_empty() { return Ok(None); @@ -63,7 +80,8 @@ impl RawImage { .collect(); let wlen = bw * k; let mut out = Rgb32f::new(bw, bh); - out.data.par_chunks_mut(bw).enumerate().for_each(|(by, row)| { + let mut mask = vec![0u8; bw * bh]; + out.data.par_chunks_mut(bw).zip(mask.par_chunks_mut(bw)).enumerate().for_each(|(by, (row, mrow))| { // the block row's k sample rows, normalised let mut rows = vec![0f32; k * wlen]; for dy in 0..k { @@ -86,7 +104,7 @@ impl RawImage { } } let py = (c.y + by * k) % ph; - for (bx, px) in row.iter_mut().enumerate() { + for (bx, (px, m)) in row.iter_mut().zip(mrow.iter_mut()).enumerate() { let (layout, inv) = &layouts[py * pw + (c.x + bx * k) % pw]; let (mut sum, mut max) = ([0f32; 3], [f32::MIN; 3]); for dy in 0..k { @@ -98,11 +116,13 @@ impl RawImage { } } for ch in 0..3 { - px[ch] = if max[ch] >= clip { max[ch] } else { sum[ch] * inv[ch] }; + let clipped = max[ch] >= clip; + px[ch] = if clipped && keep_max { max[ch] } else { sum[ch] * inv[ch] }; + *m |= u8::from(clipped) << ch; } } }); - Ok(Some(out)) + Ok(Some((out, mask))) } } @@ -177,6 +197,44 @@ mod tests { assert!((xb.get(4, 3)[2] - 0.3).abs() < 2e-3); } + /// Issue #548: specular points that clip one green sample in some 8 × 8 blocks of a grey surface. + /// Taking the block's maximum makes those blocks' green the clip level, which reconstruction then + /// keeps (a green speckle); the masked binning keeps the mean and marks the green as clipped. + #[test] + fn clipping_is_decided_before_averaging() { + let img = Rgb32f::filled(64, 64, [0.3, 0.3, 0.3]); + let mut raw = raw_from(&mosaic_from_rgb(&img, &Cfa::bayer("RGGB").unwrap()), 0.0, 4095.0); + if let RawData::U16(d) = &mut raw.data { + // one green sample (RGGB: odd column of an even row) in every other block + for by in 0..8 { + for bx in (by % 2..8).step_by(2) { + d[(by * 8 + 2) * 64 + bx * 8 + 3] = 4095; + } + } + } + let wb = [1.0f32; 3]; + let (mut masked, mask) = raw.develop_binned_masked(8, 0.99).unwrap().unwrap(); + assert_eq!(mask.len(), 64); + assert_eq!(mask.iter().filter(|&&m| m == highlight::CLIPPED_G).count(), 32); + assert!(mask.iter().all(|&m| m & (highlight::CLIPPED_R | highlight::CLIPPED_B) == 0)); + // the clipped block's green is its mean: (31 · 0.3 + 1) / 32 + assert!((masked.get(0, 0)[1] - (31.0 * 0.3 + 1.0) / 32.0).abs() < 1e-3, "{:?}", masked.get(0, 0)); + assert_eq!(highlight::reconstruct_masked(&mut masked, wb, 0.99, &mask), 32); + let green = |img: &Rgb32f| img.data.iter().map(|p| p[1] / p[0]).fold(0.0f32, f32::max); + // (no more than the mean itself: the clipped sample counts at the clip level) + assert!(green(&masked) < 1.08, "green excess {}", green(&masked)); + // the maximum rule: a green speckle at the clip level + let mut max = raw.develop_binned(8, 0.99).unwrap().unwrap(); + assert!(max.get(0, 0)[1] >= 0.99); + highlight::reconstruct(&mut max, wb, 0.99); + assert!(green(&max) > 3.0, "{}", green(&max)); + // without clipped samples both are the same plain means, and the mask is empty + let clean = raw_from(&mosaic_from_rgb(&img, &Cfa::bayer("RGGB").unwrap()), 0.0, 4095.0); + let (a, m) = clean.develop_binned_masked(8, 0.99).unwrap().unwrap(); + assert!(m.iter().all(|&v| v == 0)); + assert!(a.data == clean.develop_binned(8, 0.99).unwrap().unwrap().data); + } + #[test] fn binning_matches_demosaic_downscaled_on_smooth_images() { let img = Rgb32f::from_fn(64, 48, |x, y| [0.2 + 0.3 * (x as f32 / 64.0), 0.4 - 0.2 * (y as f32 / 48.0), 0.25]); diff --git a/crates/raw/src/highlight.rs b/crates/raw/src/highlight.rs index 1731cb254..b79733664 100644 --- a/crates/raw/src/highlight.rs +++ b/crates/raw/src/highlight.rs @@ -4,7 +4,22 @@ //! magenta/cyan (each channel limited to the smallest white-balanced clip level). //! - [`reconstruct`]: where only some channels are clipped, rebuild them from the unclipped channels using the //! local chromaticity of nearby unclipped pixels (diffused into the clipped region with a coarse-to-fine -//! normalised-convolution fill). Fully clipped pixels become neutral at the brightest plausible level. +//! push-pull fill). Fully clipped pixels become neutral at the brightest plausible level. +//! +//! Issue #548 (a bright cloud turning cyan once Highlights pulled it down, green speckles in binned +//! previews) set three rules for the colour of partly clipped pixels: +//! - an unclipped pixel's chromaticity counts with a confidence that grows with its brightness +//! (`confidence`): the colour of a clipped highlight is told by the bright pixels around it, not by +//! dark ones in front of it (wires and branches across a cloud, with their own colour and demosaicing +//! fringes, were the nearest colour inside the cloud and tinted all of it). A dim pixel fills in only as +//! much as its confidence allows, the rest comes from the coarser level, i.e. from brighter pixels +//! further out; where there are none, dim pixels still decide, pooled over a larger area; +//! - pixels within [`DEMOSAIC_REACH`] of a clipped one were demosaiced partly from clipped samples and +//! give no colour (unless nothing else does); +//! - the more channels of a pixel clip, the nearer to neutral its rebuilt colour (`rebuild`). +//! +//! A binned image ([`crate::RawImage::develop_binned_masked`]) says which channels are clipped with a +//! mask instead of by value ([`reconstruct_masked`]). use lightcraft_raster::Rgb32f; use rayon::prelude::*; @@ -20,74 +35,87 @@ pub fn clip_neutral(img: &mut Rgb32f, wb: [f32; 3], clip: f32) { } } -/// Fill `values` (per-pixel vectors) where `valid` is false from valid neighbours, coarse to fine. -fn fill_invalid(w: usize, h: usize, values: &mut [[f32; 3]], valid: &mut [bool]) { - if w == 0 || h == 0 || valid.iter().all(|&v| v) || !valid.iter().any(|&v| v) { +/// A chromaticity premultiplied by its confidence, and the confidence (0..=1): `[w·r, w·g, w·b, w]`. +/// `w = 0` is an empty entry (a clipped pixel, or nothing known yet). +type Weighted = [f32; 4]; + +/// Brightness (as a share of the clip level) from which an unclipped pixel's colour counts fully. +const FULL_CONFIDENCE: f32 = 0.8; + +/// How far an unclipped pixel's chromaticity is trusted to stand for a clipped highlight next to it: +/// `(m / (0.8 · clip))⁴` for its brightest channel `m`, at most 1 (from 80 % of the clip level up; +/// half of the clip level 0.15, a third of it 0.03, a tenth of it 0.0002). +#[inline] +fn confidence(p: &[f32; 3], clip: f32) -> f32 { + let m = (p[0].max(p[1]).max(p[2]) / (FULL_CONFIDENCE * clip)).clamp(0.0, 1.0); + let m2 = m * m; + m2 * m2 +} + +/// Fill `values` coarse to fine (push-pull): each entry keeps its own colour by its confidence `w` +/// and takes the rest, `1 − w`, from the next coarser level. With confidences of only 0 and 1 this is +/// a plain fill of the empty entries from the mean of the known ones nearby. +fn fill(w: usize, h: usize, values: &mut [Weighted]) { + if w * h <= 1 || values.iter().all(|v| v[3] >= 1.0) || !values.iter().any(|v| v[3] > 0.0) { return; } - // Build a pyramid of weighted means, fill each level from the next coarser one. let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); - let mut cv = vec![[0f32; 3]; cw * ch]; - let mut cval = vec![false; cw * ch]; + let mut cv = vec![[0f32; 4]; cw * ch]; { - let (values, valid) = (&*values, &*valid); - downsample(w, h, &mut cv, &mut cval, |i| valid[i].then(|| values[i])); + let values = &*values; + downsample(w, h, &mut cv, |i| values[i]); } - if cw * ch < w * h { - fill_invalid(cw, ch, &mut cv, &mut cval); - } else { - // cannot shrink further (1×1): nothing valid anywhere handled above - return; - } - values.par_chunks_mut(w).zip(valid.par_chunks_mut(w)).enumerate().for_each(|(y, (vrow, okrow))| { - for x in 0..w { - if !okrow[x] { - vrow[x] = upsample(&cv, cw, ch, x, y); - okrow[x] = true; + fill(cw, ch, &mut cv); + values.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + let rest = 1.0 - v[3]; + if rest > 0.0 { + let u = upsample(&cv, cw, ch, x, y); + for c in 0..4 { + v[c] += rest * u[c]; + } } } }); } -/// One pyramid step: each `2 × 2` block of a `w × h` level becomes the mean of its valid -/// entries (`at(i)`: the value at index `i` when valid) in `cv`, `cval` (`⌈w/2⌉ × ⌈h/2⌉`). -fn downsample(w: usize, h: usize, cv: &mut [[f32; 3]], cval: &mut [bool], at: impl Fn(usize) -> Option<[f32; 3]> + Sync) { +/// One pyramid step: each `2 × 2` block of a `w × h` level (`at(i)`: the entry at index `i`) becomes, in +/// `cv` (`⌈w/2⌉ × ⌈h/2⌉`), the confidence-weighted mean of its colours, with their summed confidence +/// (at most 1). +fn downsample(w: usize, h: usize, cv: &mut [Weighted], at: impl Fn(usize) -> Weighted + Sync) { let cw = w.div_ceil(2); - cv.par_chunks_mut(cw).zip(cval.par_chunks_mut(cw)).enumerate().for_each(|(y, (vrow, okrow))| { - for x in 0..cw { - let (mut s, mut n) = ([0f32; 3], 0); + cv.par_chunks_mut(cw).enumerate().for_each(|(y, row)| { + for (x, out) in row.iter_mut().enumerate() { + let mut s = [0f32; 4]; for dy in 0..2 { for dx in 0..2 { let (sx, sy) = (2 * x + dx, 2 * y + dy); - if sx < w - && sy < h - && let Some(v) = at(sy * w + sx) - { - for c in 0..3 { + if sx < w && sy < h { + let v = at(sy * w + sx); + for c in 0..4 { s[c] += v[c]; } - n += 1; } } } - if n > 0 { - vrow[x] = s.map(|v| v / n as f32); - okrow[x] = true; + if s[3] > 0.0 { + let k = s[3].min(1.0) / s[3]; + *out = s.map(|v| v * k); } } }); } /// Bilinear sample of the `cw × ch` coarse level at fine pixel `(x, y)` (twice the resolution). -fn upsample(cv: &[[f32; 3]], cw: usize, ch: usize, x: usize, y: usize) -> [f32; 3] { +fn upsample(cv: &[Weighted], cw: usize, ch: usize, x: usize, y: usize) -> Weighted { let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (cw - 1) as f32); let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (ch - 1) as f32); let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); let (x1, y1) = ((x0 + 1).min(cw - 1), (y0 + 1).min(ch - 1)); let (tx, ty) = (fx - x0 as f32, fy - y0 as f32); let g = |xx: usize, yy: usize| cv[yy * cw + xx]; - let mut v = [0f32; 3]; - for c in 0..3 { + let mut v = [0f32; 4]; + for c in 0..4 { let top = g(x0, y0)[c] + (g(x1, y0)[c] - g(x0, y0)[c]) * tx; let bot = g(x0, y1)[c] + (g(x1, y1)[c] - g(x0, y1)[c]) * tx; v[c] = top + (bot - top) * ty; @@ -95,202 +123,254 @@ fn upsample(cv: &[[f32; 3]], cw: usize, ch: usize, x: usize, y: usize) -> [f32; v } -/// White-balanced chromaticity of an unclipped pixel (`None`: clipped or too dark to tell). +/// White-balanced chromaticity of an unclipped pixel with its confidence (empty: clipped or too dark to tell). #[inline] -fn chroma_of(p: &[f32; 3], wb: [f32; 3], clip: f32) -> Option<[f32; 3]> { - if p[0] >= clip || p[1] >= clip || p[2] >= clip { - return None; +fn chroma_of(p: &[f32; 3], clipped: [bool; 3], wb: [f32; 3], clip: f32) -> Weighted { + if clipped.iter().any(|&b| b) { + return [0.0; 4]; } let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; let s = q[0] + q[1] + q[2]; - (s > 1e-4).then(|| q.map(|v| v.max(0.0) / s)) + let w = confidence(p, clip); + if s > 1e-4 && w > 0.0 { [q[0].max(0.0) / s * w, q[1].max(0.0) / s * w, q[2].max(0.0) / s * w, w] } else { [0.0; 4] } } +/// Bits of a [`reconstruct_masked`] mask entry: which channels of the pixel are clipped. +pub const CLIPPED_R: u8 = 1; +pub const CLIPPED_G: u8 = 2; +pub const CLIPPED_B: u8 = 4; + /// Reconstruct partially clipped channels. `clip` is the sensor clip level in normalised units (use slightly /// below 1.0, e.g. 0.99), `wb` the white-balance multipliers that will be applied afterwards. /// Returns the number of pixels that had at least one clipped channel. /// /// The chromaticity field is diffused coarse to fine from the unclipped pixels. A clipped pixel is -/// never valid at full resolution, so its chromaticity is always the bilinear sample of the +/// never known at full resolution, so its chromaticity is always the bilinear sample of the /// half-resolution level: we start the pyramid there, straight from the image, and never build /// the full-resolution field (same result, a quarter of the memory and far less work). +/// +/// Demosaicing spreads clipped samples into the pixels around them (AHD's green filter and colour +/// differences reach a few pixels), so their colour is not a measurement either: unclipped pixels +/// within [`DEMOSAIC_REACH`] pixels of a clipped one only give the colour when nothing else does. pub fn reconstruct(img: &mut Rgb32f, wb: [f32; 3], clip: f32) -> usize { + reconstruct_with(img, wb, clip, DEMOSAIC_REACH, |_, p| [p[0] >= clip, p[1] >= clip, p[2] >= clip]) +} + +/// How far (pixels) demosaicing carries a clipped sample into its neighbours' colour. On the issue #548 +/// iPhone frame (AHD), the cloud-edge pixels next to clipped green came out 25 % greener relative to red +/// than those 6 px out, which match the sky (bilinear: 1–2 px). +pub const DEMOSAIC_REACH: usize = 6; + +/// [`reconstruct`] with the clipped channels given by `mask` (one entry per pixel, bits +/// [`CLIPPED_R`], [`CLIPPED_G`], [`CLIPPED_B`]) instead of by the values: for a binned image +/// ([`crate::RawImage::develop_binned_masked`]), whose values are block means (a lower bound for +/// a channel with clipped samples) and whose mask says which blocks held clipped samples of each +/// colour. A mask of the wrong length leaves the image as it is (returns 0). +pub fn reconstruct_masked(img: &mut Rgb32f, wb: [f32; 3], clip: f32, mask: &[u8]) -> usize { + if mask.len() != img.data.len() { + return 0; + } + // (a block mean is no interpolation: its neighbours are measurements) + reconstruct_with(img, wb, clip, 0, |i, _| { + let m = mask.get(i).copied().unwrap_or(0); + [m & CLIPPED_R != 0, m & CLIPPED_G != 0, m & CLIPPED_B != 0] + }) +} + +/// Every pixel within `r` pixels (a square) of a `true` one of the `w × h` `marks`. +fn dilate(w: usize, h: usize, marks: &[bool], r: usize) -> Vec { + // a running count along rows, then along columns + let run = |line: &[bool], out: &mut [bool]| { + let n = line.len(); + let mut count = line.iter().take(r.min(n)).filter(|&&b| b).count(); + for (i, o) in out.iter_mut().enumerate() { + if i + r < n && line[i + r] { + count += 1; + } + if i > r && line[i - r - 1] { + count -= 1; + } + *o = count > 0; + } + }; + let mut rows = vec![false; w * h]; + rows.par_chunks_mut(w).zip(marks.par_chunks(w)).for_each(|(out, line)| run(line, out)); + // columns: per-column counts of marked rows in the window, swept down the image in strips + let mut cols = vec![false; w * h]; + let strip = 256usize; + let rows = &rows; + let parts: Vec<(usize, Vec)> = (0..w.div_ceil(strip)) + .into_par_iter() + .map(|sx| { + let (x0, x1) = (sx * strip, ((sx + 1) * strip).min(w)); + let n = x1 - x0; + let mut count = vec![0u32; n]; + for y in 0..r.min(h) { + for (c, &b) in count.iter_mut().zip(&rows[y * w + x0..y * w + x1]) { + *c += u32::from(b); + } + } + let mut out = vec![false; n * h]; + for y in 0..h { + if y + r < h { + let yy = y + r; + for (c, &b) in count.iter_mut().zip(&rows[yy * w + x0..yy * w + x1]) { + *c += u32::from(b); + } + } + if y > r { + let yy = y - r - 1; + for (c, &b) in count.iter_mut().zip(&rows[yy * w + x0..yy * w + x1]) { + *c -= u32::from(b); + } + } + for (o, &c) in out[y * n..(y + 1) * n].iter_mut().zip(&count) { + *o = c > 0; + } + } + (x0, out) + }) + .collect(); + for (x0, out) in parts { + let n = out.len() / h.max(1); + for y in 0..h { + cols[y * w + x0..y * w + x0 + n].copy_from_slice(&out[y * n..(y + 1) * n]); + } + } + cols +} + +/// [`reconstruct`] with `clipped(index, pixel)` telling the clipped channels; unclipped pixels within +/// `reach` of a clipped one give its colour only when no other pixel does. +fn reconstruct_with(img: &mut Rgb32f, wb: [f32; 3], clip: f32, reach: usize, clipped: impl Fn(usize, &[f32; 3]) -> [bool; 3] + Sync) -> usize { let (w, h) = (img.width, img.height); - let is_clipped = |p: &[f32; 3]| p[0] >= clip || p[1] >= clip || p[2] >= clip; - let count = img.data.par_iter().filter(|p| is_clipped(p)).count(); + let marks: Vec = img.data.par_iter().enumerate().map(|(i, p)| clipped(i, p).iter().any(|&b| b)).collect(); + let count = marks.iter().filter(|&&b| b).count(); if count == 0 { return 0; } // chromaticity (white-balanced ratios to the channel sum) of unclipped pixels, at half resolution let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); - let mut cv = vec![[1f32 / 3.0; 3]; cw * ch]; - let mut cval = vec![false; cw * ch]; - { + // (a 1 × 1 image has no coarser level: its one pixel is clipped and nothing is known) + let known = cw * ch < w * h; + let field = |near: Option<&[bool]>| { + let mut cv = vec![[0f32; 4]; cw * ch]; let data = &img.data; - downsample(w, h, &mut cv, &mut cval, |i| chroma_of(&data[i], wb, clip)); + downsample(w, h, &mut cv, |i| { + if near.is_some_and(|n| n.get(i).copied().unwrap_or(false)) { [0.0; 4] } else { chroma_of(&data[i], clipped(i, &data[i]), wb, clip) } + }); + cv + }; + let near = (reach > 0).then(|| dilate(w, h, &marks, reach)); + let mut cv = field(near.as_deref()); + if near.is_some() && !cv.iter().any(|v| v[3] > 0.0) { + cv = field(None); } - if cw * ch < w * h { - fill_invalid(cw, ch, &mut cv, &mut cval); + if known { + fill(cw, ch, &mut cv); } - let any_valid = cw * ch < w * h && cval.iter().any(|&v| v); - let max_level = wb.iter().cloned().fold(0.0f32, f32::max) * clip; img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { for (x, px) in row.iter_mut().enumerate() { let p = *px; - let cl = [p[0] >= clip, p[1] >= clip, p[2] >= clip]; + let cl = clipped(y * w + x, &p); if !cl.iter().any(|&b| b) { continue; } - let r = if any_valid { upsample(&cv, cw, ch, x, y) } else { [1f32 / 3.0; 3] }; - let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; - // estimate the white-balanced channel sum from the unclipped channels - let (mut sum, mut k) = (0f32, 0usize); - for c in 0..3 { - if !cl[c] && r[c] > 1e-3 { - sum += q[c] / r[c]; - k += 1; - } - } - let mut out = q; - if k == 0 { - let v = q.iter().cloned().fold(0.0f32, f32::max).max(max_level); - out = [v; 3]; - } else { - let sum = sum / k as f32; - for c in 0..3 { - if cl[c] { - out[c] = q[c].max(sum * r[c]); - } - } - } + let v = if known { upsample(&cv, cw, ch, x, y) } else { [0.0; 4] }; + let r = if v[3] > 0.0 { [v[0] / v[3], v[1] / v[3], v[2] / v[3]] } else { [1f32 / 3.0; 3] }; + let out = rebuild([p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]], cl, r, p, clip); *px = [out[0] / wb[0], out[1] / wb[1], out[2] / wb[2]]; } }); count } +/// The white-balanced pixel `q` with its clipped channels `cl` rebuilt for the chromaticity `r`; +/// `p` is the pixel before white balance (sensor units, clip level `clip`). +/// +/// The channel sum is estimated from the unclipped channels; each clipped one becomes its share of it, +/// and never less than it is (its clip level, or for a binned block the mean, a lower bound). +/// +/// The more of a pixel is clipped, the less its colour is known, and the rebuilt colour is then drawn +/// toward neutral at its brightest channel (no channel goes down, so none drops below its clip level): +/// by `(n − 1 + s) / 2` for `n` clipped channels, where `s` rises smoothly from 0 to 1 as the brightest +/// unclipped channel goes from 85 % of the clip level to the clip level. One clipped channel well +/// below the others' clip levels keeps its rebuilt colour; two clipped channels are drawn half-way +/// and more as the third nears its clip; three are neutral; and the weight never jumps where one +/// more channel clips. Issue #548: in a cloud whose green and blue clip, the measured red puts blue's +/// clip level 27 % above red, bluer than the sky around it, and whichever value green gets the cloud +/// comes out tinted (cyan with green high, lavender with green low) unless it is desaturated. +#[inline] +fn rebuild(q: [f32; 3], cl: [bool; 3], r: [f32; 3], p: [f32; 3], clip: f32) -> [f32; 3] { + let (mut sum, mut k) = (0f32, 0usize); + for c in 0..3 { + if !cl[c] && r[c] > 1e-3 { + sum += q[c] / r[c]; + k += 1; + } + } + if k == 0 { + // fully clipped: neutral at its brightest channel (when the values are the clipped samples + // themselves, that is at least every channel's white-balanced clip level) + return [q.iter().cloned().fold(0.0f32, f32::max); 3]; + } + let sum = sum / k as f32; + let out: [f32; 3] = std::array::from_fn(|c| if cl[c] { q[c].max(sum * r[c]) } else { q[c] }); + let n = cl.iter().filter(|&&b| b).count(); + let brightest = (0..3).filter(|&c| !cl[c]).map(|c| p[c] / clip).fold(0.0f32, f32::max); + let s = ((brightest - 0.85) / 0.15).clamp(0.0, 1.0); + let t = ((n as f32 - 1.0 + s * s * (3.0 - 2.0 * s)) / 2.0).clamp(0.0, 1.0); + if t <= 0.0 { + return out; + } + let v = out.iter().cloned().fold(0.0f32, f32::max); + out.map(|o| o + (v - o) * t) +} + #[cfg(test)] mod tests { use super::*; - // The previous full-resolution implementation, kept as the reference for `reconstruct`. - /// Fill `values` (per-pixel vectors) where `valid` is false from valid neighbours, coarse to fine. - fn fill_invalid_ref(w: usize, h: usize, values: &mut [[f32; 3]], valid: &mut [bool]) { - if w == 0 || h == 0 || valid.iter().all(|&v| v) || !valid.iter().any(|&v| v) { - return; - } - // Build a pyramid of weighted means, fill each level from the next coarser one. - let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); - let mut cv = vec![[0f32; 3]; cw * ch]; - let mut cval = vec![false; cw * ch]; - { - let (values, valid) = (&*values, &*valid); - cv.par_chunks_mut(cw).zip(cval.par_chunks_mut(cw)).enumerate().for_each(|(y, (vrow, okrow))| { - for x in 0..cw { - let (mut s, mut n) = ([0f32; 3], 0); - for dy in 0..2 { - for dx in 0..2 { - let (sx, sy) = (2 * x + dx, 2 * y + dy); - if sx < w && sy < h && valid[sy * w + sx] { - for c in 0..3 { - s[c] += values[sy * w + sx][c]; - } - n += 1; - } - } - } - if n > 0 { - vrow[x] = s.map(|v| v / n as f32); - okrow[x] = true; - } - } - }); - } - if cw * ch < w * h { - fill_invalid_ref(cw, ch, &mut cv, &mut cval); - } else { - // cannot shrink further (1×1): nothing valid anywhere handled above - return; - } - values.par_chunks_mut(w).zip(valid.par_chunks_mut(w)).enumerate().for_each(|(y, (vrow, okrow))| { - for x in 0..w { - if !okrow[x] { - // bilinear upsample of the coarse level - let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (cw - 1) as f32); - let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (ch - 1) as f32); - let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); - let (x1, y1) = ((x0 + 1).min(cw - 1), (y0 + 1).min(ch - 1)); - let (tx, ty) = (fx - x0 as f32, fy - y0 as f32); - let g = |xx: usize, yy: usize| cv[yy * cw + xx]; - let mut v = [0f32; 3]; - for c in 0..3 { - let top = g(x0, y0)[c] + (g(x1, y0)[c] - g(x0, y0)[c]) * tx; - let bot = g(x0, y1)[c] + (g(x1, y1)[c] - g(x0, y1)[c]) * tx; - v[c] = top + (bot - top) * ty; - } - vrow[x] = v; - okrow[x] = true; - } - } - }); - } - - /// Reconstruct partially clipped channels. `clip` is the sensor clip level in normalised units (use slightly - /// below 1.0, e.g. 0.99), `wb` the white-balance multipliers that will be applied afterwards. - /// Returns the number of pixels that had at least one clipped channel. + /// The same algorithm started at full resolution: the reference for `reconstruct`'s half-resolution start. fn reconstruct_ref(img: &mut Rgb32f, wb: [f32; 3], clip: f32) -> usize { let (w, h) = (img.width, img.height); - let n = w * h; - let is_clipped = |p: &[f32; 3]| p[0] >= clip || p[1] >= clip || p[2] >= clip; - let count = img.data.par_iter().filter(|p| is_clipped(p)).count(); + let is_clipped = |p: &[f32; 3]| [p[0] >= clip, p[1] >= clip, p[2] >= clip]; + let count = img.data.iter().filter(|p| is_clipped(p).iter().any(|&b| b)).count(); if count == 0 { return 0; } - // chromaticity (white-balanced ratios to the channel mean) of unclipped pixels - let mut chroma = vec![[1f32 / 3.0; 3]; n]; - let mut valid = vec![false; n]; - chroma.par_iter_mut().zip(valid.par_iter_mut()).zip(img.data.par_iter()).for_each(|((r, v), p)| { - if is_clipped(p) { - return; - } - let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; - let s = q[0] + q[1] + q[2]; - if s > 1e-4 { - *r = q.map(|v| v.max(0.0) / s); - *v = true; - } - }); - fill_invalid_ref(w, h, &mut chroma, &mut valid); - let max_level = wb.iter().cloned().fold(0.0f32, f32::max) * clip; - img.data.par_iter_mut().zip(chroma.par_iter()).for_each(|(px, r)| { + // the pixels near clipping, by brute force + let near: Vec = (0..w * h) + .map(|i| { + let (x, y) = ((i % w) as i64, (i / w) as i64); + let r = DEMOSAIC_REACH as i64; + (y - r..=y + r).any(|yy| { + (x - r..=x + r).any(|xx| { + xx >= 0 + && yy >= 0 + && xx < w as i64 + && yy < h as i64 + && is_clipped(&img.data[yy as usize * w + xx as usize]).iter().any(|&b| b) + }) + }) + }) + .collect(); + let mut chroma: Vec = + img.data.iter().zip(&near).map(|(p, &n)| if n { [0.0; 4] } else { chroma_of(p, is_clipped(p), wb, clip) }).collect(); + if !chroma.iter().any(|v| v[3] > 0.0) { + chroma = img.data.iter().map(|p| chroma_of(p, is_clipped(p), wb, clip)).collect(); + } + fill(w, h, &mut chroma); + for (px, v) in img.data.iter_mut().zip(&chroma) { let p = *px; - let cl = [p[0] >= clip, p[1] >= clip, p[2] >= clip]; + let cl = is_clipped(&p); if !cl.iter().any(|&b| b) { - return; - } - let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; - // estimate the white-balanced channel sum from the unclipped channels - let (mut sum, mut k) = (0f32, 0usize); - for c in 0..3 { - if !cl[c] && r[c] > 1e-3 { - sum += q[c] / r[c]; - k += 1; - } - } - let mut out = q; - if k == 0 { - let v = q.iter().cloned().fold(0.0f32, f32::max).max(max_level); - out = [v; 3]; - } else { - let sum = sum / k as f32; - for c in 0..3 { - if cl[c] { - out[c] = q[c].max(sum * r[c]); - } - } + continue; } + let r = if v[3] > 0.0 { [v[0] / v[3], v[1] / v[3], v[2] / v[3]] } else { [1f32 / 3.0; 3] }; + let out = rebuild([p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]], cl, r, p, clip); *px = [out[0] / wb[0], out[1] / wb[1], out[2] / wb[2]]; - }); + } count } @@ -383,6 +463,122 @@ mod tests { assert_eq!(img.get(0, 0), truth.get(0, 0)); } + /// Issue #548: a bright cloud whose green clips, crossed by a dark wire with a cyan fringe. The cloud + /// takes the colour of the bright sky around it, not the wire's: before, the wire was the nearest + /// unclipped colour for most of the cloud and turned it cyan. + #[test] + fn dark_detail_inside_a_highlight_does_not_tint_it() { + let wb = [2.0f32, 1.0, 1.5]; + // neutral after white balance at luminance `l` + let grey = |l: f32| [l / wb[0], l / wb[1], l / wb[2]]; + let (w, h) = (128usize, 96usize); + let wire = |x: usize, y: usize| (y as i32 - 20 - x as i32 / 2).abs() <= 1; + let disc = |x: usize, y: usize| (x as f32 - 64.0).powi(2) + (y as f32 - 48.0).powi(2) < 40.0f32.powi(2); + let img = Rgb32f::from_fn(w, h, |x, y| { + if wire(x, y) { + [0.05 / wb[0], 0.12 / wb[1], 0.12 / wb[2]] + } else if disc(x, y) { + // brighter than green's clip level: only red and blue are left + grey(1.4).map(|v| v.min(1.0)) + } else { + grey(0.85) + } + }); + let mut out = img.clone(); + assert!(reconstruct(&mut out, wb, 0.99) > 1000); + let mut worst = 0f32; + for y in 0..h { + for x in 0..w { + let p = out.get(x, y); + if disc(x, y) && !wire(x, y) { + let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; + worst = worst.max((q[1] / q[0] - 1.0).abs()).max((q[2] / q[0] - 1.0).abs()); + } + } + } + assert!(worst < 0.03, "the cloud is tinted by up to {:.0} %", worst * 100.0); + } + + /// The more channels clip, the nearer to neutral (at the brightest channel) the rebuilt colour, with no + /// step where one more channel clips. + #[test] + fn desaturates_as_more_channels_clip() { + let wb = [1.0f32; 3]; + let sky = [0.3 * 2.4, 0.33 * 2.4, 0.37 * 2.4]; + let rebuilt = |centre: [f32; 3]| { + let mut img = Rgb32f::from_fn(64, 64, |x, y| if (x as i32 - 32).abs() < 16 && (y as i32 - 32).abs() < 16 { centre } else { sky }); + reconstruct(&mut img, wb, 0.99); + img.get(32, 32) + }; + // green clipped, red and blue well below their clip level: the plain rebuild (green from the sky's ratios) + let p = rebuilt([0.5, 1.0, 0.6]); + assert_eq!((p[0], p[2]), (0.5, 0.6)); + assert!(p[1] == 1.0, "{p:?}"); + let p = rebuilt([0.33, 1.0, 0.4]); + assert!((p[1] - 0.33 / 0.3 * 0.33).abs() < 0.01 || p[1] == 1.0, "{p:?}"); + // green and blue clipped, red at half: half-way to neutral at the brightest channel (1.0) + let p = rebuilt([0.5, 1.0, 1.0]); + assert!((p[0] - 0.75).abs() < 1e-3 && p[1] >= 1.0 && p[2] >= 1.0, "{p:?}"); + // blue just below its clip level, then just above: (nearly) the same colour + let below = rebuilt([0.5, 1.0, 0.989]); + let above = rebuilt([0.5, 1.0, 0.99]); + assert!(below.iter().zip(&above).all(|(a, b)| (a - b).abs() < 0.01), "{below:?} {above:?}"); + // all three clipped: neutral + let p = rebuilt([1.0, 1.0, 1.0]); + assert!(p[0] == p[1] && p[1] == p[2], "{p:?}"); + } + + /// The dilation matches a brute-force one, across several column strips. + #[test] + fn dilate_matches_brute_force() { + let (w, h) = (600usize, 41usize); + let marks: Vec = (0..w * h).map(|i| (i * 2654435761usize) % 997 < 3).collect(); + for r in [0usize, 1, 6, 50] { + let d = dilate(w, h, &marks, r); + for y in 0..h { + for x in 0..w { + let want = + (y.saturating_sub(r)..=(y + r).min(h - 1)).any(|yy| (x.saturating_sub(r)..=(x + r).min(w - 1)).any(|xx| marks[yy * w + xx])); + assert_eq!(d[y * w + x], want, "r {r} at ({x}, {y})"); + } + } + } + } + + /// With nothing bright nearby, dim pixels still give a clipped area their colour. + #[test] + fn a_highlight_among_dim_pixels_takes_their_colour() { + let wb = [1.0f32; 3]; + let mut img = + Rgb32f::from_fn(32, 32, |x, y| if (12..20).contains(&x) && (12..20).contains(&y) { [1.0, 0.5, 0.25] } else { [0.1, 0.05, 0.025] }); + reconstruct(&mut img, wb, 0.99); + let p = img.get(16, 16); + assert!((p[0] / p[1] - 2.0).abs() < 0.01, "{p:?}"); + } + + /// A mask decides which channels are clipped, whatever their values; a mask of another size changes nothing. + #[test] + fn a_mask_marks_the_clipped_channels() { + let wb = [1.0f32; 3]; + let base = Rgb32f::from_fn(16, 16, |x, _| if x >= 8 { [0.2, 0.5, 0.25] } else { [0.4, 0.4, 0.2] }); + // the right half's red is a lower bound: its blocks held clipped samples + let mask: Vec = (0..256).map(|i| if i % 16 >= 8 { CLIPPED_R } else { 0 }).collect(); + let mut img = base.clone(); + assert_eq!(reconstruct_masked(&mut img, wb, 0.99, &mask), 128); + // red rebuilt from the left half's colour (red = green), the other channels kept + for x in 8..16 { + let p = img.get(x, 8); + assert!((p[0] - 0.5).abs() < 1e-4 && p[1] == 0.5 && p[2] == 0.25, "{p:?}"); + } + assert_eq!(img.get(0, 0), base.get(0, 0)); + let mut same = base.clone(); + assert_eq!(reconstruct_masked(&mut same, wb, 0.99, &mask[1..]), 0); + assert!(same.data == base.data); + // the thresholds alone see nothing clipped here + let mut plain = base.clone(); + assert_eq!(reconstruct(&mut plain, wb, 0.99), 0); + } + #[test] fn fully_clipped_and_no_clipping() { let mut img = Rgb32f::filled(4, 4, [1.0; 3]); diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index d15f704e0..5758a9c8d 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -7,7 +7,8 @@ //! - [`RawImage::normalized`] subtracts black, scales white to 1.0 and crops to the active area (applying DNG //! `OpcodeList1`/`OpcodeList2`); [`demosaic`] turns CFA data into camera-RGB [`Rgb32f`]; //! [`RawImage::develop`] does all of it plus `OpcodeList3` and the default crop; [`RawImage::develop_binned`] -//! produces the same at 1/k of the size straight from the mosaic (previews, thumbnails). +//! produces the same at 1/k of the size straight from the mosaic (previews, thumbnails; +//! [`RawImage::develop_binned_masked`] with the mask of blocks holding clipped samples, for [`highlight`]). //! - [`color`] implements the DNG colour model (dual-illuminant interpolation, forward matrices, white balance) //! and produces camera → linear Rec.2020 D65 matrices; [`profile`] reads and applies a DNG's own profile //! look tables and tone curve, and [`gaintable`] its gain table map (Apple ProRAW's local tone mapping). diff --git a/docs/parity.md b/docs/parity.md index c8c46ce77..31a5bf9f2 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -241,7 +241,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EDIT-HDR-MODE | HDR editing | P2 | ⬜ | | | | LR-EDIT-LIGHT-EXPOSURE | Exposure | P0 | ✅ | `ctl:light.exposure` | | | LR-EDIT-LIGHT-CONTRAST | Contrast | P0 | ✅ | `ctl:light.contrast` | | -| LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | ✅ | `ctl:light.highlights` | | +| LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | ✅ | `ctl:light.highlights`, `crates/raw/src/highlight.rs` | what Highlights pulls down in a raw's clipped areas is rebuilt by `highlight::reconstruct`: partly clipped pixels take the colour of the unclipped pixels around them, counted by how close to clipping they are (dark wires and branches across a bright cloud no longer tint it) and not from the few pixels next to clipping that demosaicing mixed with clipped samples, and are drawn toward neutral as more of their channels clip (none at one channel well below the others' clip, half-way at two, neutral at three, without steps); binned previews and thumbnails decide clipping per sample before averaging (`develop_binned_masked`), so a block with one clipped specular sample stays its mean instead of jumping to the clip level, which reconstruction turned into green speckles at 500 px (issue #548) | | LR-EDIT-LIGHT-SHADOWS | Shadows | P0 | ✅ | `ctl:light.shadows` | | | LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | `ctl:light.whites` | | | LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks` | | From 533a382619fa4267b5e2d81c5713ea688d6d57c8 Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Fri, 9 Oct 2026 22:57:15 +0200 Subject: [PATCH 09/33] Issue #345: engine task registry (activity) A registry of the background tasks in flight on Session: a job holds a TaskGuard while it runs (dropping it, also during a panic unwind, removes the row), adopts its existing cancel flag, and reports done/total in a unit the frontend words. Groundwork for one progress stack for imports, exports, preview builds and the other long jobs. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/activity.rs | 391 ++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 5 + 2 files changed, 396 insertions(+) create mode 100644 crates/engine/src/activity.rs diff --git a/crates/engine/src/activity.rs b/crates/engine/src/activity.rs new file mode 100644 index 000000000..654b6db44 --- /dev/null +++ b/crates/engine/src/activity.rs @@ -0,0 +1,391 @@ +//! Background tasks in flight (issue #345): imports, exports, preview builds, downloads, the face scan… Each one +//! registers here while it runs, so every frontend can show them in one place (the desktop app's activity stack) and +//! cancel the ones that can be cancelled (`activity.list`, `activity.cancel`). +//! +//! A job holds a [`TaskGuard`] for as long as it runs; dropping it — when the job ends, fails or its thread unwinds +//! from a panic — removes the row, so a dead worker never leaves a stuck bar. Jobs keep the cancel flag they already +//! check between files or photos: the guard adopts it ([`Cancel::Flag`]), so the stack's ✕, `activity.cancel` and the +//! job's own Cancel all set the same atomic. Numbers are worded by the frontend from `done`, `total` and [`Unit`]; a +//! detail set here is a name (a file, a model), never a sentence, so the frontend can translate everything else. + +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use serde::Serialize; + +/// The longest label or detail kept, in characters (a file name can be anything). +const MAX_TEXT: usize = 200; + +/// The background tasks in flight. Cheap to clone (one `Arc`), so worker threads can hold it. +#[derive(Clone, Default)] +pub struct Activity { + inner: Arc, +} + +#[derive(Default)] +struct Inner { + /// Oldest first. + tasks: Mutex>>, + next: AtomicU64, +} + +/// How a task can be cancelled. +pub enum Cancel { + /// Not at all (no ✕): the face scan, AI Denoise. + No, + /// With a flag of its own ([`TaskGuard::cancel_flag`]). + Yes, + /// With the job's existing flag. + Flag(Arc), +} + +/// How the frontend words `done` / `total`: "3 of 25", "12 of 340 MB", "40 %". +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Unit { + #[default] + Count, + Bytes, + Percent, +} + +impl Unit { + fn from_u8(v: u8) -> Unit { + match v { + 1 => Unit::Bytes, + 2 => Unit::Percent, + _ => Unit::Count, + } + } + + fn to_u8(self) -> u8 { + match self { + Unit::Count => 0, + Unit::Bytes => 1, + Unit::Percent => 2, + } + } +} + +/// One task as the frontends see it (`activity.list`). +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskInfo { + pub id: u64, + /// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`, + /// `faces`, `denoise`, `findMissing`. + pub kind: &'static str, + /// English; the frontend translates it. + pub label: String, + pub done: u64, + /// 0 while the amount of work isn't known yet (an indeterminate bar). + pub total: u64, + pub unit: Unit, + /// The file, photo or model being worked on, if any. + pub detail: String, + pub cancellable: bool, + /// Cancel was asked for; the job stops at its next check. + pub cancelling: bool, + pub age_ms: u64, +} + +struct Entry { + id: u64, + kind: &'static str, + label: String, + started: web_time::Instant, + done: AtomicU64, + total: AtomicU64, + unit: AtomicU8, + detail: Mutex, + cancel: Option>, + cancellable: AtomicBool, +} + +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(PoisonError::into_inner) +} + +fn capped(text: &str) -> String { + text.chars().take(MAX_TEXT).collect() +} + +impl Entry { + fn is_cancellable(&self) -> bool { + self.cancel.is_some() && self.cancellable.load(Ordering::Relaxed) + } + + fn is_cancelled(&self) -> bool { + self.cancel.as_ref().is_some_and(|c| c.load(Ordering::Relaxed)) + } + + fn progress(&self, done: u64, total: u64) { + self.total.store(total, Ordering::Relaxed); + self.done.store(if total > 0 { done.min(total) } else { done }, Ordering::Relaxed); + } + + fn detail(&self, text: &str) { + *lock(&self.detail) = capped(text); + } + + fn info(&self) -> TaskInfo { + TaskInfo { + id: self.id, + kind: self.kind, + label: self.label.clone(), + done: self.done.load(Ordering::Relaxed), + total: self.total.load(Ordering::Relaxed), + unit: Unit::from_u8(self.unit.load(Ordering::Relaxed)), + detail: lock(&self.detail).clone(), + cancellable: self.is_cancellable(), + cancelling: self.is_cancelled(), + age_ms: u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX), + } + } +} + +impl Activity { + /// Register a task; it is listed until the returned guard is dropped. `label` is English (the frontend + /// translates it). + pub fn start(&self, kind: &'static str, label: &str, cancel: Cancel) -> TaskGuard { + let id = self.inner.next.fetch_add(1, Ordering::Relaxed).saturating_add(1); + let cancel = match cancel { + Cancel::No => None, + Cancel::Yes => Some(Arc::new(AtomicBool::new(false))), + Cancel::Flag(flag) => Some(flag), + }; + let entry = Arc::new(Entry { + id, + kind, + label: capped(label), + started: web_time::Instant::now(), + done: AtomicU64::new(0), + total: AtomicU64::new(0), + unit: AtomicU8::new(Unit::Count.to_u8()), + detail: Mutex::new(String::new()), + cancellable: AtomicBool::new(cancel.is_some()), + cancel, + }); + lock(&self.inner.tasks).push(entry.clone()); + TaskGuard { activity: self.clone(), entry } + } + + /// Every task in flight, oldest first. + pub fn list(&self) -> Vec { + lock(&self.inner.tasks).iter().map(|e| e.info()).collect() + } + + /// Ask task `id` to stop (it stops at its next check). An unknown id (the task may have just ended) or a task + /// that can't be cancelled is an error. + pub fn cancel(&self, id: u64) -> Result<(), String> { + let tasks = lock(&self.inner.tasks); + let entry = tasks.iter().find(|e| e.id == id).ok_or_else(|| format!("no task {id}"))?; + match (&entry.cancel, entry.is_cancellable()) { + (Some(flag), true) => { + flag.store(true, Ordering::Relaxed); + Ok(()) + } + _ => Err(format!("{} can't be cancelled", entry.label)), + } + } + + /// Ask every cancellable task to stop; returns how many were asked. + pub fn cancel_all(&self) -> usize { + let tasks = lock(&self.inner.tasks); + let mut n = 0; + for e in tasks.iter().filter(|e| e.is_cancellable()) { + if let Some(flag) = &e.cancel { + flag.store(true, Ordering::Relaxed); + n += 1; + } + } + n + } + + /// Tasks that quitting would cut short: cancellable ones not already stopping. + pub fn running_cancellable(&self) -> Vec { + lock(&self.inner.tasks).iter().filter(|e| e.is_cancellable() && !e.is_cancelled()).map(|e| e.info()).collect() + } +} + +/// A task's place in the list, held by whoever owns the job; dropping it removes the row. +pub struct TaskGuard { + activity: Activity, + entry: Arc, +} + +impl TaskGuard { + pub fn id(&self) -> u64 { + self.entry.id + } + + /// `done` of `total` (`total` 0 = not known yet). `done` is kept at most `total`. + pub fn progress(&self, done: u64, total: u64) { + self.entry.progress(done, total); + } + + /// The file, photo or model being worked on (a name, not a sentence). + pub fn detail(&self, text: &str) { + self.entry.detail(text); + } + + pub fn set_unit(&self, unit: Unit) { + self.entry.unit.store(unit.to_u8(), Ordering::Relaxed); + } + + /// A step that must not be cut short (the Lightroom import's commit) turns cancelling off for its duration. + pub fn set_cancellable(&self, yes: bool) { + self.entry.cancellable.store(yes, Ordering::Relaxed); + } + + pub fn cancel_flag(&self) -> Option> { + self.entry.cancel.clone() + } + + pub fn is_cancelled(&self) -> bool { + self.entry.is_cancelled() + } + + /// For progress from another thread; it does not keep the row. + pub fn handle(&self) -> TaskHandle { + TaskHandle { entry: self.entry.clone() } + } +} + +impl Drop for TaskGuard { + fn drop(&mut self) { + let id = self.entry.id; + lock(&self.activity.inner.tasks).retain(|e| e.id != id); + } +} + +impl std::fmt::Debug for TaskGuard { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TaskGuard").field("id", &self.entry.id).field("kind", &self.entry.kind).finish() + } +} + +/// Progress for a task from another thread (see [`TaskGuard::handle`]). +#[derive(Clone)] +pub struct TaskHandle { + entry: Arc, +} + +impl TaskHandle { + pub fn progress(&self, done: u64, total: u64) { + self.entry.progress(done, total); + } + + pub fn detail(&self, text: &str) { + self.entry.detail(text); + } + + pub fn is_cancelled(&self) -> bool { + self.entry.is_cancelled() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + #[test] + fn guard_drop_removes_the_row() { + let a = Activity::default(); + let g = a.start("export", "Exporting", Cancel::Yes); + assert_eq!(a.list().len(), 1); + drop(g); + assert!(a.list().is_empty()); + } + + #[test] + fn guard_drop_on_panic_unwind_removes_the_row() { + let a = Activity::default(); + let a2 = a.clone(); + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + let _g = a2.start("import", "Importing", Cancel::Yes); + panic!("synthetic worker panic"); + })); + assert!(r.is_err()); + assert!(a.list().is_empty()); + } + + #[test] + fn cancel_sets_the_adopted_flag() { + let a = Activity::default(); + let flag = Arc::new(AtomicBool::new(false)); + let g = a.start("export", "Exporting", Cancel::Flag(flag.clone())); + a.cancel(g.id()).unwrap(); + assert!(flag.load(Ordering::Relaxed) && g.is_cancelled()); + assert!(a.list()[0].cancelling); + } + + #[test] + fn cancel_errors_instead_of_panicking() { + let a = Activity::default(); + assert!(a.cancel(42).is_err()); + let g = a.start("faces", "Finding faces", Cancel::No); + assert!(a.cancel(g.id()).is_err()); + let h = a.start("lightroom", "Importing Lightroom catalog", Cancel::Yes); + h.set_cancellable(false); + assert!(a.cancel(h.id()).is_err()); + assert!(!h.is_cancelled()); + } + + #[test] + fn cancel_all_counts_only_cancellable() { + let a = Activity::default(); + let (_x, _y, _z) = (a.start("export", "E", Cancel::Yes), a.start("import", "I", Cancel::Yes), a.start("faces", "F", Cancel::No)); + assert_eq!(a.cancel_all(), 2); + assert!(a.running_cancellable().is_empty()); + } + + #[test] + fn progress_is_clamped_and_detail_capped() { + let a = Activity::default(); + let g = a.start("export", "E", Cancel::Yes); + g.progress(12, 10); + g.detail(&"é".repeat(300)); + let t = &a.list()[0]; + assert_eq!((t.done, t.total), (10, 10)); + assert_eq!(t.detail.chars().count(), 200); + g.progress(5, 0); // indeterminate keeps done as given + assert_eq!(a.list()[0].total, 0); + assert_eq!(a.list()[0].unit, Unit::Count); + g.set_unit(Unit::Bytes); + assert_eq!(a.list()[0].unit, Unit::Bytes); + } + + #[test] + fn ids_are_unique_and_list_is_oldest_first() { + let a = Activity::default(); + let (g1, g2) = (a.start("export", "A", Cancel::Yes), a.start("import", "B", Cancel::Yes)); + assert!(g2.id() > g1.id()); + assert_eq!(a.list().iter().map(|t| t.id).collect::>(), vec![g1.id(), g2.id()]); + } + + #[test] + fn poisoned_lock_still_works() { + let a = Activity::default(); + let a2 = a.clone(); + let _ = std::thread::spawn(move || { + let _held = a2.inner.tasks.lock().unwrap(); + panic!("synthetic panic while holding the task list"); + }) + .join(); + assert!(a.inner.tasks.is_poisoned()); + let _g = a.start("export", "E", Cancel::Yes); + assert_eq!(a.list().len(), 1); + } + + #[test] + fn handle_updates_from_another_thread() { + let a = Activity::default(); + let g = a.start("previews", "Building previews", Cancel::Yes); + let h = g.handle(); + std::thread::spawn(move || h.progress(3, 7)).join().unwrap(); + assert_eq!((a.list()[0].done, a.list()[0].total), (3, 7)); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 22faff70f..c507f0a0a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,7 @@ #[cfg(not(feature = "denoise"))] extern crate lightcraft_denoise_core as lightcraft_denoise; +pub mod activity; pub mod availability; mod camera_preview; pub mod camera_profiles; @@ -261,6 +262,9 @@ pub struct Session { /// The last Synchronize Folder scan, which `folder.synchronize` acts on while it is current /// (see [`Session::take_folder_changes`]). pub folder_changes: Option, + /// Background tasks in flight (imports, exports, preview builds…), for the activity stack and + /// `activity.list` / `activity.cancel`. + pub activity: activity::Activity, /// The last (or running) Build Previews. pub preview_build: Option>, /// Develop defaults applied on import (persisted in prefs.json). @@ -345,6 +349,7 @@ impl Session { before: Default::default(), import_probes: Default::default(), folder_changes: None, + activity: Default::default(), preview_build: None, import_defaults: import::ImportDefaults::default(), cache_mb: 0, From 4e323ad26f1b59fad4e1973b8426c521f1a68027 Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Fri, 9 Oct 2026 23:03:51 +0200 Subject: [PATCH 10/33] Issue #345: activity.list and activity.cancel commands The registry is reachable as commands, so the UI, the control channel, the CLI and MCP list and cancel background tasks the same way. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/activity.rs | 65 +++++++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + docs/control-protocol.md | 2 + 3 files changed, 69 insertions(+) create mode 100644 crates/engine/src/cmd/activity.rs diff --git a/crates/engine/src/cmd/activity.rs b/crates/engine/src/cmd/activity.rs new file mode 100644 index 000000000..af8667728 --- /dev/null +++ b/crates/engine/src/cmd/activity.rs @@ -0,0 +1,65 @@ +//! `activity.list` / `activity.cancel`: the background tasks in flight (see [`crate::activity`]). + +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd}; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "activity.list", + "Background Tasks", + [], + None, + "{} → {tasks: [{id, kind, label, done, total, unit: count|bytes|percent, detail, cancellable, cancelling, ageMs}]} — the long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first; total 0 = not known yet", + always, + |s, _| Ok(json!({"tasks": s.activity.list()})) + ), + cmd!( + "activity.cancel", + "Cancel Background Task", + [], + None, + "{id} | {all: true} → {cancelled: n} — ask a task (or every cancellable task) to stop; it stops at its next file or photo. An unknown id (it may have just finished) or a task that can't be cancelled is an error", + always, + |s, p| { + const C: &str = "activity.cancel"; + if p.get("all").and_then(Value::as_bool) == Some(true) { + return Ok(json!({"cancelled": s.activity.cancel_all()})); + } + let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(C, "id or all"))?; + s.activity.cancel(id).map_err(|e| bad(C, e))?; + Ok(json!({"cancelled": 1})) + } + ), + ] +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + use crate::activity::Cancel; + + #[test] + fn list_and_cancel_through_execute() { + let mut s = Session::new(); + let g = s.activity.start("export", "Exporting", Cancel::Yes); + let r = s.execute("activity.list", &json!({})).unwrap(); + assert_eq!(r["tasks"][0]["kind"], "export"); + assert_eq!(r["tasks"][0]["cancellable"], true); + let r = s.execute("activity.cancel", &json!({"id": g.id()})).unwrap(); + assert_eq!(r["cancelled"], 1); + assert!(g.is_cancelled()); + } + + #[test] + fn cancel_all_and_errors() { + let mut s = Session::new(); + let (_a, _b) = (s.activity.start("export", "E", Cancel::Yes), s.activity.start("faces", "F", Cancel::No)); + assert_eq!(s.execute("activity.cancel", &json!({"all": true})).unwrap()["cancelled"], 1); + assert!(s.execute("activity.cancel", &json!({"id": 999})).is_err()); + assert!(s.execute("activity.cancel", &json!({})).is_err()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ebc82862b..e7def13d3 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -4,6 +4,7 @@ //! delete), `album.*`, `develop.*` (settings), `crop.*`, `mask.*`, `preset.*`, `version.*`, //! `edit.*` (undo/redo), and queries (`catalog.query`, `photo.inspect`, `develop.get`…). +mod activity; mod before; pub(crate) mod browse; mod color; @@ -154,6 +155,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(face_models::specs()); v.extend(face_detect::specs()); v.extend(face_recognize::specs()); + v.extend(activity::specs()); v }) } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f6af190e0..1de4a46ca 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -46,6 +46,8 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) | | `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically | | `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background` | Writes the files and returns `{files}`; with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | +| `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) | +| `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error | | `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` | | `app.quit` | — | Close the app | From af9b1063cab65d57195e4f251fc6424ea47b0ae8 Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Fri, 9 Oct 2026 23:10:36 +0200 Subject: [PATCH 11/33] Issue #345: activity stack in the top-left corner Every task in the engine's activity registry floats as a row under the top bar once it is half a second old: its name, a progress bar (a sweep while the amount of work isn't known), the count and what it is working on, and a cross for the ones that can be cancelled. Three rows, then "+N more". ui.inspect reports the same list as activity. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de-formats.json | 6 +- crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es-formats.json | 6 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/fr-formats.json | 8 +- crates/ui-egui/locales/fr.json | 3 +- crates/ui-egui/locales/ja-formats.json | 6 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br-formats.json | 6 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru-formats.json | 6 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/uk-formats.json | 6 +- crates/ui-egui/locales/uk.json | 3 +- crates/ui-egui/locales/zh-hans-formats.json | 6 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant-formats.json | 6 +- crates/ui-egui/locales/zh-hant.json | 3 +- crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/panels/activity.rs | 155 ++++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/tests_activity.rs | 132 +++++++++++++++++ docs/control-protocol.md | 2 +- 24 files changed, 361 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/src/panels/activity.rs create mode 100644 crates/ui-egui/src/tests_activity.rs diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index ea00ae3cc..c426d7bfa 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -221,5 +221,9 @@ "; {n} gained synonyms": "; mit neuen Synonymen: {n}", "Exported {n} keyword{}": "Stichwörter exportiert: {n}{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One importiert keine Liste mit ; , < oder > in einem Stichwort: {names}", - " — left out, as a keyword list can't hold them: {names}": " — ausgelassen, weil eine Stichwortliste sie nicht aufnehmen kann: {names}" + " — left out, as a keyword list can't hold them: {names}": " — ausgelassen, weil eine Stichwortliste sie nicht aufnehmen kann: {names}", + "{done} of {total}": "{done} von {total}", + "{done} of {total} MB": "{done} von {total} MB", + "{pct} %": "{pct} %", + "+{n} more": "+{n} weitere" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index c4bded93e..d40808846 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2016,5 +2016,6 @@ "Stopped by an error (see the log)": "Durch einen Fehler angehalten (siehe Protokoll)", "Not saved": "Nicht gespeichert", "A folder is being synchronized": "Ein Ordner wird gerade synchronisiert", - "Wait for the import to finish": "Warten Sie, bis der Import abgeschlossen ist" + "Wait for the import to finish": "Warten Sie, bis der Import abgeschlossen ist", + "Show less": "Weniger anzeigen" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index ce6ace8ce..4a829d826 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -221,5 +221,9 @@ "; {n} gained synonyms": "; con sinónimos nuevos: {n}", "Exported {n} keyword{}": "Palabras clave exportadas: {n}{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One no importa una lista con ; , < o > en una palabra clave: {names}", - " — left out, as a keyword list can't hold them: {names}": " — omitidas, porque una lista de palabras clave no puede contenerlas: {names}" + " — left out, as a keyword list can't hold them: {names}": " — omitidas, porque una lista de palabras clave no puede contenerlas: {names}", + "{done} of {total}": "{done} de {total}", + "{done} of {total} MB": "{done} de {total} MB", + "{pct} %": "{pct} %", + "+{n} more": "+{n} más" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index eb13321af..27c5baa70 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1861,5 +1861,6 @@ "Stopped by an error (see the log)": "Detenido por un error (ver el registro)", "Not saved": "Sin guardar", "A folder is being synchronized": "Se está sincronizando una carpeta", - "Wait for the import to finish": "Espera a que termine la importación" + "Wait for the import to finish": "Espera a que termine la importación", + "Show less": "Mostrar menos" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 6e6093292..93b36005d 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -221,5 +221,9 @@ "; {n} gained synonyms": " ; avec de nouveaux synonymes : {n}", "Exported {n} keyword{}": "Mots-clés exportés : {n}{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One n’importe pas une liste dont un mot-clé contient ; , < ou > : {names}", - " — left out, as a keyword list can't hold them: {names}": " — omis, car une liste de mots-clés ne peut pas les contenir : {names}" -} \ No newline at end of file + " — left out, as a keyword list can't hold them: {names}": " — omis, car une liste de mots-clés ne peut pas les contenir : {names}", + "{done} of {total}": "{done} sur {total}", + "{done} of {total} MB": "{done} sur {total} Mo", + "{pct} %": "{pct} %", + "+{n} more": "+{n} autres" +} diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index a978e9831..4f2df670e 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1861,5 +1861,6 @@ "Stopped by an error (see the log)": "Arrêté par une erreur (voir le journal)", "Not saved": "Non enregistré", "A folder is being synchronized": "Un dossier est en cours de synchronisation", - "Wait for the import to finish": "Attendez la fin de l'importation" + "Wait for the import to finish": "Attendez la fin de l'importation", + "Show less": "Afficher moins" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index e4976420b..7143491e3 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -221,5 +221,9 @@ "; {n} gained synonyms": "。{n}件に同義語を追加", "Exported {n} keyword{}": "{n}件のキーワードを書き出しました{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One は ; , < > を含むキーワードがあるリストを読み込みません: {names}", - " — left out, as a keyword list can't hold them: {names}": " — キーワードリストに書けないため省略: {names}" + " — left out, as a keyword list can't hold them: {names}": " — キーワードリストに書けないため省略: {names}", + "{done} of {total}": "{total}件中{done}件", + "{done} of {total} MB": "{done} / {total} MB", + "{pct} %": "{pct}%", + "+{n} more": "ほか{n}件" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 4d9c8ae7f..35b7954ad 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1863,5 +1863,6 @@ "Stopped by an error (see the log)": "エラーで停止しました(ログを参照)", "Not saved": "保存されていません", "A folder is being synchronized": "フォルダを同期中です", - "Wait for the import to finish": "読み込みが終わるまでお待ちください" + "Wait for the import to finish": "読み込みが終わるまでお待ちください", + "Show less": "表示を減らす" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index b6c1c23b4..240a84b8a 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -221,5 +221,9 @@ "; {n} gained synonyms": "; {n} receberam sinônimos", "Exported {n} keyword{}": "Palavras-chave exportadas: {n}{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — O Capture One não importa uma lista com ; , < ou > em uma palavra-chave: {names}", - " — left out, as a keyword list can't hold them: {names}": " — omitidas, pois uma lista de palavras-chave não pode contê-las: {names}" + " — left out, as a keyword list can't hold them: {names}": " — omitidas, pois uma lista de palavras-chave não pode contê-las: {names}", + "{done} of {total}": "{done} de {total}", + "{done} of {total} MB": "{done} de {total} MB", + "{pct} %": "{pct}%", + "+{n} more": "+{n} mais" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 318120d86..617b538bf 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1861,5 +1861,6 @@ "Stopped by an error (see the log)": "Interrompido por um erro (veja o log)", "Not saved": "Não salvo", "A folder is being synchronized": "Uma pasta está sendo sincronizada", - "Wait for the import to finish": "Aguarde a importação terminar" + "Wait for the import to finish": "Aguarde a importação terminar", + "Show less": "Mostrar menos" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index cd55e708f..19e3d38fd 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -221,5 +221,9 @@ "; {n} gained synonyms": "; синонимы добавлены к {n}", "Exported {n} keyword{}": "Экспортировано ключевых слов: {n}{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One не импортирует список, где в ключевом слове есть ; , < или >: {names}", - " — left out, as a keyword list can't hold them: {names}": " — пропущены: список ключевых слов не может их содержать: {names}" + " — left out, as a keyword list can't hold them: {names}": " — пропущены: список ключевых слов не может их содержать: {names}", + "{done} of {total}": "{done} из {total}", + "{done} of {total} MB": "{done} из {total} МБ", + "{pct} %": "{pct} %", + "+{n} more": "ещё {n}" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index b51355c74..e16251d72 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1574,5 +1574,6 @@ "Stopped by an error (see the log)": "Остановлено из-за ошибки (см. журнал)", "Not saved": "Не сохранено", "A folder is being synchronized": "Идёт синхронизация папки", - "Wait for the import to finish": "Дождитесь окончания импорта" + "Wait for the import to finish": "Дождитесь окончания импорта", + "Show less": "Свернуть" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 2196c6bac..6cd982f1e 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -221,5 +221,9 @@ "; {n} gained synonyms": "; отримали синоніми: {n}", "Exported {n} keyword{}": "Експортовано ключових слів: {n}{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One не імпортує список, у якому ключове слово містить ; , < або >: {names}", - " — left out, as a keyword list can't hold them: {names}": " — пропущено, бо список ключових слів не може їх містити: {names}" + " — left out, as a keyword list can't hold them: {names}": " — пропущено, бо список ключових слів не може їх містити: {names}", + "{done} of {total}": "{done} з {total}", + "{done} of {total} MB": "{done} з {total} МБ", + "{pct} %": "{pct} %", + "+{n} more": "+{n} ще" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 52a30fb7c..5ed8bbc8d 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2055,5 +2055,6 @@ "Stopped by an error (see the log)": "Зупинено через помилку (див. журнал)", "Not saved": "Не збережено", "A folder is being synchronized": "Тека синхронізується", - "Wait for the import to finish": "Дочекайтеся завершення імпорту" + "Wait for the import to finish": "Дочекайтеся завершення імпорту", + "Show less": "Показати менше" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index e006cdfd9..ecbea7d9e 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -221,5 +221,9 @@ "; {n} gained synonyms": ";{n} 个获得了同义词", "Exported {n} keyword{}": "已导出 {n} 个关键字{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One 不会导入关键字中含有 ; , < 或 > 的列表:{names}", - " — left out, as a keyword list can't hold them: {names}": " — 已省略,关键字列表无法容纳: {names}" + " — left out, as a keyword list can't hold them: {names}": " — 已省略,关键字列表无法容纳: {names}", + "{done} of {total}": "{done}/{total}", + "{done} of {total} MB": "{done}/{total} MB", + "{pct} %": "{pct}%", + "+{n} more": "还有 {n} 项" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index d654240bf..95a5a728c 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1861,5 +1861,6 @@ "Stopped by an error (see the log)": "因错误而停止(见日志)", "Not saved": "未保存", "A folder is being synchronized": "正在同步文件夹", - "Wait for the import to finish": "请等待导入完成" + "Wait for the import to finish": "请等待导入完成", + "Show less": "收起" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index c47f45691..29aa95181 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -221,5 +221,9 @@ "; {n} gained synonyms": ";{n} 個獲得了同義詞", "Exported {n} keyword{}": "已匯出 {n} 個關鍵字{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One 不會匯入關鍵字中含有 ; , < 或 > 的清單:{names}", - " — left out, as a keyword list can't hold them: {names}": " — 已省略,關鍵字清單無法容納: {names}" + " — left out, as a keyword list can't hold them: {names}": " — 已省略,關鍵字清單無法容納: {names}", + "{done} of {total}": "{done}/{total}", + "{done} of {total} MB": "{done}/{total} MB", + "{pct} %": "{pct}%", + "+{n} more": "還有 {n} 項" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index ef8160a5c..57834fb7a 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1861,5 +1861,6 @@ "Stopped by an error (see the log)": "因錯誤而停止(請見記錄)", "Not saved": "未儲存", "A folder is being synchronized": "正在同步資料夾", - "Wait for the import to finish": "請等待匯入完成" + "Wait for the import to finish": "請等待匯入完成", + "Show less": "收合" } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1209a6c7a..deff9007c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -114,6 +114,8 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "export": {"running": app.export.as_ref().map(crate::export_task::ExportTask::status), "last": app.last_export_result}, "import": app.import.as_ref().map(crate::import::ImportTask::status), "tasks": app.tasks.labels(), + // every long-running task in flight, as the activity stack shows them (`activity.list`) + "activity": app.session.activity.list(), // commands waiting on a native file dialog shown off the UI thread (`pick`) "fileDialogs": app.pending_picks.iter().map(|p| p.command.clone()).collect::>(), "memory": memory(app), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 50587c86c..64d59bc9b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -36,6 +36,8 @@ pub mod theme; pub mod titlebar; pub mod widgets; +#[cfg(test)] +mod tests_activity; #[cfg(test)] mod tests_album_picker; #[cfg(test)] @@ -302,6 +304,8 @@ pub struct LightcraftApp { /// The library failed to open at launch: the blocking window, then the temporary-session /// banner (issue #100). Cleared once a library opens. pub library_problem: Option, + /// The activity stack shows every task, not just the first few ("+N more" was clicked). + pub activity_expanded: bool, } impl LightcraftApp { @@ -367,6 +371,7 @@ impl LightcraftApp { display_applied: None, display_error: None, library_problem: None, + activity_expanded: false, model_setup: Default::default(), } } @@ -1054,6 +1059,7 @@ impl LightcraftApp { import::scan_progress(self, &ctx); lightroom_import::progress(self, &ctx); export_task::poll(self, &ctx); + panels::activity::show(self, &ctx); pick::poll(self, &ctx); panels::grid::drag_feedback(self, &ctx); panels::left::album_drag_feedback(self, &ctx); diff --git a/crates/ui-egui/src/panels/activity.rs b/crates/ui-egui/src/panels/activity.rs new file mode 100644 index 000000000..5a2e6095f --- /dev/null +++ b/crates/ui-egui/src/panels/activity.rs @@ -0,0 +1,155 @@ +//! The activity stack (issue #345): every long-running task in one place, floating in the top-left corner under the +//! top bar while something runs — a row each with its name, a progress bar, what it is working on, and ✕ for the ones +//! that can be cancelled. The tasks come from the engine's registry ([`lightcraft_engine::activity`]); ✕ runs +//! `activity.cancel` like any other frontend would. + +use egui::{Align, Layout, Margin, Rect, RichText, Sense, Stroke, pos2, vec2}; +use lightcraft_engine::activity::{TaskInfo, Unit}; +use serde_json::json; + +use crate::LightcraftApp; +use crate::i18n::{tr, tr_format}; +use crate::icons::{Icon, paint}; +use crate::theme::Tokens; +use crate::widgets::register; + +/// A task shows once it has run this long, so quick ones don't flash. +pub const SHOW_AFTER_MS: u64 = 500; +/// Rows shown before "+N more". +pub const MAX_ROWS: usize = 3; +/// The stack's width (points). +pub const WIDTH: f32 = 280.0; + +const MARGIN: i8 = 8; +const CROSS: f32 = 16.0; + +/// "3 of 25", "12 of 340 MB", "40 %"; empty while the amount of work isn't known. +pub fn count_text(t: &TaskInfo) -> String { + if t.total == 0 { + return String::new(); + } + match t.unit { + Unit::Count => tr_format!("{done} of {total}", done = t.done, total = t.total), + Unit::Bytes => tr_format!("{done} of {total} MB", done = megabytes(t.done), total = megabytes(t.total)), + Unit::Percent => tr_format!("{pct} %", pct = t.done.saturating_mul(100) / t.total), + } +} + +fn megabytes(bytes: u64) -> String { + let mb = bytes as f64 / 1_048_576.0; + if mb < 10.0 { format!("{mb:.1}") } else { format!("{:.0}", mb.floor()) } +} + +/// The row's second line: "Stopping…" once cancelled, else the count and what is being worked on. +fn detail_line(t: &TaskInfo) -> String { + if t.cancelling { + return tr("Stopping…").to_string(); + } + let count = count_text(t); + match (count.is_empty(), t.detail.is_empty()) { + (false, false) => format!("{count} · {}", t.detail), + (false, true) => count, + (true, _) => t.detail.clone(), + } +} + +/// Draw the stack (called every frame, after the panels and dialogs). +pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { + let all = app.session.activity.list(); + if all.is_empty() { + app.activity_expanded = false; + return; + } + // look again soon: a young task becomes due, bars move + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + let tasks: Vec<&TaskInfo> = all.iter().filter(|t| t.age_ms >= SHOW_AFTER_MS).collect(); + if tasks.is_empty() { + return; + } + let t = Tokens::get(ctx); + let shown = if app.activity_expanded { tasks.len() } else { tasks.len().min(MAX_ROWS) }; + let hidden = tasks.len().saturating_sub(shown); + let max_height = (ctx.content_rect().height() - t.top_bar_h - 60.0).max(80.0); + let mut cancel = None; + let mut toggle = false; + let frame = egui::Frame::NONE.fill(t.chrome).stroke(Stroke::new(1.0, t.field_border)).corner_radius(6).inner_margin(Margin::same(MARGIN)); + egui::Area::new(egui::Id::new("activity-stack")).order(egui::Order::Middle).fixed_pos(pos2(8.0, t.top_bar_h + 8.0)).show(ctx, |ui| { + frame.show(ui, |ui| { + let inner = WIDTH - 2.0 * (f32::from(MARGIN) + 1.0); + ui.set_width(inner); + ui.spacing_mut().item_spacing.y = 3.0; + egui::ScrollArea::vertical().max_height(max_height).auto_shrink([false, true]).show(ui, |ui| { + for (i, task) in tasks.iter().take(shown).enumerate() { + if i > 0 { + ui.add_space(6.0); + } + if row(ui, &t, task, inner) { + cancel = Some(task.id); + } + } + }); + if hidden > 0 || (app.activity_expanded && tasks.len() > MAX_ROWS) { + ui.add_space(4.0); + let text = if hidden > 0 { tr_format!("+{n} more", n = hidden) } else { tr("Show less").to_string() }; + let r = ui.add(egui::Label::new(RichText::new(text).font(t.font(11.0)).color(t.accent)).sense(Sense::click())); + register(ui.ctx(), "activity:more", r.rect); + toggle = r.clicked(); + } + }); + }); + if let Some(id) = cancel { + // the task may have just finished: nothing to tell the user then + let _ = app.run("activity.cancel", json!({"id": id})); + } + if toggle { + app.activity_expanded = !app.activity_expanded; + } +} + +/// One task: name and ✕, the bar, the detail line. True when ✕ was clicked. +fn row(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) -> bool { + let mut clicked = false; + let r = ui.vertical(|ui| { + ui.set_width(width); + ui.horizontal(|ui| { + let cross = task.cancellable && !task.cancelling; + let label_w = if cross { width - CROSS - 6.0 } else { width }; + ui.allocate_ui_with_layout(vec2(label_w, 18.0), Layout::left_to_right(Align::Center), |ui| { + ui.set_max_width(label_w); + ui.add(egui::Label::new(RichText::new(tr(&task.label)).font(t.font(12.5)).color(t.text)).truncate()); + }); + if cross { + let (rect, resp) = ui.allocate_exact_size(vec2(CROSS, CROSS), Sense::click()); + paint(ui.painter(), rect.shrink(2.0), Icon::Close, if resp.hovered() { t.text } else { t.icon }); + register(ui.ctx(), format!("activity:cancel:{}", task.id), rect); + clicked = resp.on_hover_text(tr("Cancel")).clicked(); + } + }); + bar(ui, t, task, width); + let line = detail_line(task); + if !line.is_empty() { + ui.add(egui::Label::new(RichText::new(line).font(t.font(11.0)).color(t.text_dim)).truncate()); + } + }); + register(ui.ctx(), format!("activity:row:{}", task.id), r.response.rect); + clicked +} + +/// The progress bar; while the amount of work isn't known, a segment sweeps across. +fn bar(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) { + let (rect, _) = ui.allocate_exact_size(vec2(width, 5.0), Sense::hover()); + let p = ui.painter(); + p.rect_filled(rect, 2.5, t.field); + let fill = if task.total > 0 { + let frac = (task.done as f64 / task.total as f64).clamp(0.0, 1.0) as f32; + Rect::from_min_size(rect.min, vec2(rect.width() * frac, rect.height())) + } else { + let phase = (ui.input(|i| i.time) * 0.7).fract() as f32; + let w = rect.width() * 0.3; + let x = rect.left() + (rect.width() + w) * phase - w; + Rect::from_min_max(pos2(x.max(rect.left()), rect.top()), pos2((x + w).min(rect.right()), rect.bottom())) + }; + if fill.width() > 0.0 { + p.rect_filled(fill, 2.5, if task.cancelling { t.text_dim } else { t.accent }); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index d87d6789e..a8d99aed2 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -1,5 +1,6 @@ //! Window regions and panels. +pub mod activity; pub mod bottombar; pub mod chips; pub mod compare; diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs new file mode 100644 index 000000000..23020ddba --- /dev/null +++ b/crates/ui-egui/src/tests_activity.rs @@ -0,0 +1,132 @@ +//! Issue #345: the activity stack — one place, top-left, for every long-running task, with ✕ to cancel. + +use std::time::Duration; + +use lightcraft_engine::activity::{Cancel, TaskInfo, Unit}; +use serde_json::json; + +use crate::headless::Headless; +use crate::panels::activity::{WIDTH, count_text}; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn demo() -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.step(); + h +} + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +fn rows(h: &Headless) -> usize { + h.app.widgets.iter().filter(|(w, _)| w.starts_with("activity:row:")).count() +} + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +/// Rows show once their task is half a second old. +fn wait_visible(h: &mut Headless) { + std::thread::sleep(Duration::from_millis(600)); + h.step(); + h.step(); +} + +/// Click a widget and let the synthetic events (move, press, release) play out. +fn click(h: &mut Headless, id: &str) { + h.step(); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": id}), T)["ok"], true, "{id}"); + for _ in 0..4 { + h.step(); + } +} + +#[test] +fn rows_appear_after_half_a_second_and_cancel_by_click() { + let mut h = demo(); + let g = h.app.session.activity.start("export", "Exporting", Cancel::Yes); + g.progress(2, 5); + h.step(); + assert!(!has(&h, &format!("activity:row:{}", g.id())), "not before 0.5 s"); + wait_visible(&mut h); + assert!(has(&h, &format!("activity:row:{}", g.id()))); + click(&mut h, &format!("activity:cancel:{}", g.id())); + assert!(g.is_cancelled()); + let inspect = h.request("ui.inspect", json!({}), T); + assert_eq!(inspect["result"]["activity"][0]["cancelling"], true, "{}", inspect["result"]["activity"]); +} + +#[test] +fn more_than_three_rows_overflow_and_expand() { + let mut h = demo(); + let guards: Vec<_> = (0..5).map(|i| h.app.session.activity.start("export", &format!("Task {i}"), Cancel::Yes)).collect(); + wait_visible(&mut h); + assert_eq!(rows(&h), 3); + assert!(has(&h, "activity:more")); + click(&mut h, "activity:more"); + assert_eq!(rows(&h), 5); + drop(guards); + h.step(); + assert_eq!(rows(&h), 0); +} + +#[test] +fn not_cancellable_rows_have_no_cross() { + let mut h = demo(); + let g = h.app.session.activity.start("faces", "Finding faces", Cancel::No); + wait_visible(&mut h); + assert!(has(&h, &format!("activity:row:{}", g.id()))); + assert!(!has(&h, &format!("activity:cancel:{}", g.id()))); +} + +#[test] +fn count_text_by_unit() { + let mut t = TaskInfo { + id: 1, + kind: "export", + label: "Exporting".into(), + done: 3, + total: 25, + unit: Unit::Count, + detail: String::new(), + cancellable: true, + cancelling: false, + age_ms: 600, + }; + assert_eq!(count_text(&t), "3 of 25"); + (t.done, t.total, t.unit) = (12 * 1_048_576, 340 * 1_048_576, Unit::Bytes); + assert_eq!(count_text(&t), "12 of 340 MB"); + (t.done, t.total, t.unit) = (400, 1000, Unit::Percent); + assert_eq!(count_text(&t), "40 %"); + t.total = 0; + assert_eq!(count_text(&t), ""); +} + +#[test] +fn stale_cancel_is_harmless() { + let mut h = demo(); + let g = h.app.session.activity.start("export", "Exporting", Cancel::Yes); + wait_visible(&mut h); + let id = g.id(); + drop(g); + let r = h.request("engine.execute", json!({"command": "activity.cancel", "params": {"id": id}}), T); + assert_eq!(r["ok"], false, "an error result, not a panic: {r}"); + h.step(); + assert!(h.app.ui.toast.is_none(), "{:?}", h.app.ui.toast); +} + +#[test] +fn long_detail_stays_inside_the_stack() { + let mut h = demo(); + let g = h.app.session.activity.start("export", "Exportieren", Cancel::Yes); + g.detail(&"IMG_".repeat(75)); + wait_visible(&mut h); + let row = rect(&h, &format!("activity:row:{}", g.id())); + assert!(row.width() <= WIDTH + 1.0, "{row:?}"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1de4a46ca..7ce115572 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -30,7 +30,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled | | `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) | | `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus | -| `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) | +| `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:`, ✕ = `activity:cancel:`, `activity:more` expands past 3 rows), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | | `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, button?, fx?, fy?}` (`button: "right"` right-clicks) / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) | | `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points | From 98f65ff0d759dae691d35db3df612c66db46d42d Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Fri, 9 Oct 2026 23:15:57 +0200 Subject: [PATCH 12/33] Issue #345: ask before quitting while tasks run Quitting while an import, an export or another task that can be cancelled is running asks first: Quit Anyway cancels them (then any unsaved changes still get their own question), Cancel keeps working. Tasks without a cross (the face scan, AI Denoise) pick up again on the next launch and don't ask. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de-formats.json | 4 +- crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es-formats.json | 4 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/fr-formats.json | 4 +- crates/ui-egui/locales/fr.json | 3 +- crates/ui-egui/locales/ja-formats.json | 4 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br-formats.json | 4 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru-formats.json | 4 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/uk-formats.json | 4 +- crates/ui-egui/locales/uk.json | 3 +- crates/ui-egui/locales/zh-hans-formats.json | 4 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant-formats.json | 4 +- crates/ui-egui/locales/zh-hant.json | 3 +- crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/lib.rs | 22 ++++++++++- crates/ui-egui/src/panels/notices.rs | 44 +++++++++++++++++---- crates/ui-egui/src/tests_activity.rs | 31 +++++++++++++++ crates/ui-egui/src/tests_quit_unsaved.rs | 2 +- docs/control-protocol.md | 2 +- 24 files changed, 135 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index c426d7bfa..3597ffdc9 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -225,5 +225,7 @@ "{done} of {total}": "{done} von {total}", "{done} of {total} MB": "{done} von {total} MB", "{pct} %": "{pct} %", - "+{n} more": "+{n} weitere" + "+{n} more": "+{n} weitere", + "1 task is still running: {names}. Quitting stops it.": "1 Aufgabe läuft noch: {names}. Beenden bricht sie ab.", + "{n} tasks are still running: {names}. Quitting stops them.": "{n} Aufgaben laufen noch: {names}. Beenden bricht sie ab." } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index d40808846..55fb5246b 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2017,5 +2017,6 @@ "Not saved": "Nicht gespeichert", "A folder is being synchronized": "Ein Ordner wird gerade synchronisiert", "Wait for the import to finish": "Warten Sie, bis der Import abgeschlossen ist", - "Show less": "Weniger anzeigen" + "Show less": "Weniger anzeigen", + "Quit while tasks are running?": "Beenden, während Aufgaben laufen?" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 4a829d826..ea5021984 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -225,5 +225,7 @@ "{done} of {total}": "{done} de {total}", "{done} of {total} MB": "{done} de {total} MB", "{pct} %": "{pct} %", - "+{n} more": "+{n} más" + "+{n} more": "+{n} más", + "1 task is still running: {names}. Quitting stops it.": "1 tarea sigue en curso: {names}. Salir la detiene.", + "{n} tasks are still running: {names}. Quitting stops them.": "{n} tareas siguen en curso: {names}. Salir las detiene." } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 27c5baa70..5623785f6 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1862,5 +1862,6 @@ "Not saved": "Sin guardar", "A folder is being synchronized": "Se está sincronizando una carpeta", "Wait for the import to finish": "Espera a que termine la importación", - "Show less": "Mostrar menos" + "Show less": "Mostrar menos", + "Quit while tasks are running?": "¿Salir con tareas en curso?" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 93b36005d..f39076898 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -225,5 +225,7 @@ "{done} of {total}": "{done} sur {total}", "{done} of {total} MB": "{done} sur {total} Mo", "{pct} %": "{pct} %", - "+{n} more": "+{n} autres" + "+{n} more": "+{n} autres", + "1 task is still running: {names}. Quitting stops it.": "1 tâche est encore en cours : {names}. Quitter l'arrête.", + "{n} tasks are still running: {names}. Quitting stops them.": "{n} tâches sont encore en cours : {names}. Quitter les arrête." } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 4f2df670e..7e772de2e 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1862,5 +1862,6 @@ "Not saved": "Non enregistré", "A folder is being synchronized": "Un dossier est en cours de synchronisation", "Wait for the import to finish": "Attendez la fin de l'importation", - "Show less": "Afficher moins" + "Show less": "Afficher moins", + "Quit while tasks are running?": "Quitter pendant que des tâches sont en cours ?" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 7143491e3..f42118d53 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -225,5 +225,7 @@ "{done} of {total}": "{total}件中{done}件", "{done} of {total} MB": "{done} / {total} MB", "{pct} %": "{pct}%", - "+{n} more": "ほか{n}件" + "+{n} more": "ほか{n}件", + "1 task is still running: {names}. Quitting stops it.": "1件のタスクが実行中です:{names}。終了すると停止します。", + "{n} tasks are still running: {names}. Quitting stops them.": "{n}件のタスクが実行中です:{names}。終了すると停止します。" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 35b7954ad..3e0e2ff3b 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1864,5 +1864,6 @@ "Not saved": "保存されていません", "A folder is being synchronized": "フォルダを同期中です", "Wait for the import to finish": "読み込みが終わるまでお待ちください", - "Show less": "表示を減らす" + "Show less": "表示を減らす", + "Quit while tasks are running?": "タスクの実行中に終了しますか?" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 240a84b8a..8bd5bcae6 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -225,5 +225,7 @@ "{done} of {total}": "{done} de {total}", "{done} of {total} MB": "{done} de {total} MB", "{pct} %": "{pct}%", - "+{n} more": "+{n} mais" + "+{n} more": "+{n} mais", + "1 task is still running: {names}. Quitting stops it.": "1 tarefa ainda está em andamento: {names}. Sair a interrompe.", + "{n} tasks are still running: {names}. Quitting stops them.": "{n} tarefas ainda estão em andamento: {names}. Sair as interrompe." } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 617b538bf..0406984a7 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1862,5 +1862,6 @@ "Not saved": "Não salvo", "A folder is being synchronized": "Uma pasta está sendo sincronizada", "Wait for the import to finish": "Aguarde a importação terminar", - "Show less": "Mostrar menos" + "Show less": "Mostrar menos", + "Quit while tasks are running?": "Sair com tarefas em andamento?" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 19e3d38fd..53a54f6cc 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -225,5 +225,7 @@ "{done} of {total}": "{done} из {total}", "{done} of {total} MB": "{done} из {total} МБ", "{pct} %": "{pct} %", - "+{n} more": "ещё {n}" + "+{n} more": "ещё {n}", + "1 task is still running: {names}. Quitting stops it.": "Ещё выполняется задача: {names}. Выход прервёт её.", + "{n} tasks are still running: {names}. Quitting stops them.": "Ещё выполняется задач: {n} — {names}. Выход прервёт их." } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index e16251d72..6f512e405 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1575,5 +1575,6 @@ "Not saved": "Не сохранено", "A folder is being synchronized": "Идёт синхронизация папки", "Wait for the import to finish": "Дождитесь окончания импорта", - "Show less": "Свернуть" + "Show less": "Свернуть", + "Quit while tasks are running?": "Выйти, пока выполняются задачи?" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 6cd982f1e..9562c8f90 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -225,5 +225,7 @@ "{done} of {total}": "{done} з {total}", "{done} of {total} MB": "{done} з {total} МБ", "{pct} %": "{pct} %", - "+{n} more": "+{n} ще" + "+{n} more": "+{n} ще", + "1 task is still running: {names}. Quitting stops it.": "1 завдання ще виконується: {names}. Вихід зупинить його.", + "{n} tasks are still running: {names}. Quitting stops them.": "Ще виконуються завдання ({n}): {names}. Вихід зупинить їх." } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 5ed8bbc8d..d46fbdcd6 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2056,5 +2056,6 @@ "Not saved": "Не збережено", "A folder is being synchronized": "Тека синхронізується", "Wait for the import to finish": "Дочекайтеся завершення імпорту", - "Show less": "Показати менше" + "Show less": "Показати менше", + "Quit while tasks are running?": "Вийти, поки виконуються завдання?" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index ecbea7d9e..cf28c11aa 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -225,5 +225,7 @@ "{done} of {total}": "{done}/{total}", "{done} of {total} MB": "{done}/{total} MB", "{pct} %": "{pct}%", - "+{n} more": "还有 {n} 项" + "+{n} more": "还有 {n} 项", + "1 task is still running: {names}. Quitting stops it.": "仍有 1 个任务在运行:{names}。退出将停止它。", + "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 个任务在运行:{names}。退出将停止它们。" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 95a5a728c..59bb0bce2 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1862,5 +1862,6 @@ "Not saved": "未保存", "A folder is being synchronized": "正在同步文件夹", "Wait for the import to finish": "请等待导入完成", - "Show less": "收起" + "Show less": "收起", + "Quit while tasks are running?": "要在任务运行时退出吗?" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 29aa95181..61e7cef81 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -225,5 +225,7 @@ "{done} of {total}": "{done}/{total}", "{done} of {total} MB": "{done}/{total} MB", "{pct} %": "{pct}%", - "+{n} more": "還有 {n} 項" + "+{n} more": "還有 {n} 項", + "1 task is still running: {names}. Quitting stops it.": "仍有 1 個工作在執行:{names}。結束將停止它。", + "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 個工作在執行:{names}。結束將停止它們。" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 57834fb7a..9ba3d6cb3 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1862,5 +1862,6 @@ "Not saved": "未儲存", "A folder is being synchronized": "正在同步資料夾", "Wait for the import to finish": "請等待匯入完成", - "Show less": "收合" + "Show less": "收合", + "Quit while tasks are running?": "要在工作執行時結束嗎?" } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index deff9007c..a8643db1e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -105,7 +105,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "status": app.ui.status, "copied": app.copied, "notices": app.notices, - "quitPrompt": app.quit_prompt, + "quitPrompt": app.quit_prompt.as_ref().map(crate::QuitPrompt::text), // how hard the background face scan may work right now, and why it is judged so (see `panels::faces::scan_pace`) "faceScan": {"pace": app.caches.faces_pace, "focused": app.caches.faces_in_front, "pending": app.caches.faces_pending, "indexed": app.caches.faces_indexed}, "unsaved": app.session.unsaved().map(|(n, e)| json!({"ops": n, "error": e})), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 64d59bc9b..bae21daca 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -191,6 +191,24 @@ pub struct Perf { pub fps: f64, } +/// Why quitting stopped to ask first (`panels::notices`). +#[derive(Clone, Debug, PartialEq)] +pub enum QuitPrompt { + /// Changes couldn't be written to disk (issue #103). + Unsaved(String), + /// Tasks that quitting would cut short are running: an import, an export… (issue #345). + Tasks(String), +} + +impl QuitPrompt { + /// The prompt's message. + pub fn text(&self) -> &str { + match self { + QuitPrompt::Unsaved(text) | QuitPrompt::Tasks(text) => text, + } + } +} + pub struct LightcraftApp { pub(crate) model_setup: model_setup::Pending, /// Per-catalog-revision caches of library-wide results the panels show every frame @@ -215,8 +233,8 @@ pub struct LightcraftApp { pub headless_host: bool, /// Warnings to show one at a time (damaged settings files…, issue #103). pub notices: Vec, - /// Quitting was stopped because changes couldn't be saved: the prompt's text. - pub quit_prompt: Option, + /// Quitting was stopped to ask first: changes couldn't be saved, or tasks are running. + pub quit_prompt: Option, /// Quit Anyway was chosen: the window may close with unsaved changes. pub quit_confirmed: bool, control_rx: Option>, diff --git a/crates/ui-egui/src/panels/notices.rs b/crates/ui-egui/src/panels/notices.rs index 62be7fe30..619efe1d0 100644 --- a/crates/ui-egui/src/panels/notices.rs +++ b/crates/ui-egui/src/panels/notices.rs @@ -1,12 +1,12 @@ //! Things the user must not miss (issue #103): settings files that were damaged or unreadable when //! the library opened (and host warnings such as a damaged `ui.json`), and quitting while changes -//! are only in memory. +//! are only in memory or while tasks that would be cut short are running (issue #345). use egui::{Align2, RichText, vec2}; -use crate::LightcraftApp; use crate::theme::Tokens; use crate::widgets::register; +use crate::{LightcraftApp, QuitPrompt}; /// Collect the library's settings-file warnings (each is shown once). pub fn logic(app: &mut LightcraftApp) { @@ -14,10 +14,25 @@ pub fn logic(app: &mut LightcraftApp) { app.notices.extend(new); } -/// The window may close now (`true`), or the quit prompt is shown (`false`): changes that -/// couldn't be written are retried first — the log, then a snapshot of everything in memory. +/// The window may close now (`true`), or the quit prompt is shown (`false`). Running tasks that +/// can be cancelled (an import, an export…) are asked about first; then changes that couldn't be +/// written are retried — the log, then a snapshot of everything in memory. pub fn may_close(app: &mut LightcraftApp) -> bool { - if app.quit_confirmed || app.session.unsaved().is_none() { + if app.quit_confirmed { + return true; + } + // before the library is touched: a Cancel here keeps working with it + let running = app.session.activity.running_cancellable(); + if !running.is_empty() { + let names = running.iter().map(|t| crate::i18n::tr(&t.label)).collect::>().join(", "); + app.quit_prompt = Some(QuitPrompt::Tasks(if running.len() == 1 { + crate::i18n::tr_format!("1 task is still running: {names}. Quitting stops it.", names = names) + } else { + crate::i18n::tr_format!("{n} tasks are still running: {names}. Quitting stops them.", n = running.len(), names = names) + })); + return false; + } + if app.session.unsaved().is_none() { return true; } let _ = app.session.persist(); @@ -29,13 +44,13 @@ pub fn may_close(app: &mut LightcraftApp) -> bool { match app.session.unsaved() { None => true, Some((n, e)) => { - app.quit_prompt = Some(crate::i18n::tr_format!( + app.quit_prompt = Some(QuitPrompt::Unsaved(crate::i18n::tr_format!( "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.", if n == 1 { "" } else { "s" }, if n == 1 { "it is" } else { "they are" }, n = n, e = e - )); + ))); false } } @@ -85,7 +100,20 @@ fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&s } pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { - if let Some(text) = app.quit_prompt.clone() { + if let Some(QuitPrompt::Tasks(text)) = app.quit_prompt.clone() { + let buttons = [("quitAnyway", "Quit Anyway", Choice::QuitAnyway), ("quitCancel", "Cancel", Choice::Cancel)]; + let Some(i) = window(ctx, "quit-tasks", "Quit while tasks are running?", &text, &buttons) else { return }; + app.quit_prompt = None; + if matches!(buttons[i].2, Choice::QuitAnyway) { + app.session.activity.cancel_all(); + // unsaved changes still get their own question + if may_close(app) { + app.ui.quit = true; + } + } + return; + } + if let Some(QuitPrompt::Unsaved(text)) = app.quit_prompt.clone() { let buttons = [ ("quitRetry", "Try Saving Again", Choice::Retry), ("quitAnyway", "Quit Anyway", Choice::QuitAnyway), diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 23020ddba..74a6ba59e 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -130,3 +130,34 @@ fn long_detail_stays_inside_the_stack() { let row = rect(&h, &format!("activity:row:{}", g.id())); assert!(row.width() <= WIDTH + 1.0, "{row:?}"); } + +#[test] +fn quit_with_a_running_task_asks_and_quit_anyway_cancels() { + let mut h = demo(); + let g = h.app.session.activity.start("export", "Exporting", Cancel::Yes); + wait_visible(&mut h); + assert!(!crate::panels::notices::may_close(&mut h.app)); + assert!(matches!(h.app.quit_prompt, Some(crate::QuitPrompt::Tasks(_))), "{:?}", h.app.quit_prompt); + h.step(); + assert!(!has(&h, "button:quitRetry")); + click(&mut h, "button:quitAnyway"); + assert!(g.is_cancelled() && h.quit_requested()); +} + +#[test] +fn quit_cancel_keeps_the_task_running() { + let mut h = demo(); + let g = h.app.session.activity.start("import", "Importing", Cancel::Yes); + assert!(!crate::panels::notices::may_close(&mut h.app)); + click(&mut h, "button:quitCancel"); + assert!(!g.is_cancelled() && h.app.quit_prompt.is_none() && !h.app.quit_confirmed); +} + +#[test] +fn cancelling_task_does_not_block_quit() { + let mut h = demo(); + let g = h.app.session.activity.start("export", "Exporting", Cancel::Yes); + h.app.session.activity.cancel(g.id()).unwrap(); + let _f = h.app.session.activity.start("faces", "Finding faces", Cancel::No); + assert!(crate::panels::notices::may_close(&mut h.app)); +} diff --git a/crates/ui-egui/src/tests_quit_unsaved.rs b/crates/ui-egui/src/tests_quit_unsaved.rs index a3da72be4..ef6fa8da4 100644 --- a/crates/ui-egui/src/tests_quit_unsaved.rs +++ b/crates/ui-egui/src/tests_quit_unsaved.rs @@ -79,7 +79,7 @@ fn quitting_with_unsaved_changes_asks_first() { assert_eq!(r["ok"], false, "{r}"); assert!(!crate::panels::notices::may_close(&mut h.app), "retried, still failing: ask"); h.step(); - let prompt = h.app.quit_prompt.clone().unwrap(); + let prompt = h.app.quit_prompt.clone().unwrap().text().to_string(); assert!(prompt.contains("1 change couldn't be written to disk") && prompt.contains("drive unplugged"), "{prompt}"); for b in ["button:quitRetry", "button:quitAnyway", "button:quitCancel"] { assert!(has(&h, b), "{b}"); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7ce115572..552f76952 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -30,7 +30,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled | | `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) | | `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus | -| `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:`, ✕ = `activity:cancel:`, `activity:more` expands past 3 rows), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) | +| `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: tasks that would be cut short are running — `button:quitAnyway` cancels them and quits, `button:quitCancel` keeps working — or unsaved changes: `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:`, ✕ = `activity:cancel:`, `activity:more` expands past 3 rows), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | | `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, button?, fx?, fy?}` (`button: "right"` right-clicks) / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) | | `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points | From dbf570c7111e4d7758b3ada5dbd221f9a45566cd Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Fri, 9 Oct 2026 23:22:53 +0200 Subject: [PATCH 13/33] Issue #345: export progress in the activity stack A background export registers in the activity stack with the photos done of the total and the file in progress; its cross and activity.cancel set the export's own cancel flag. The separate export panel at the bottom left is gone; the result toast is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/fr.json | 3 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/uk.json | 3 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant.json | 3 +- crates/ui-egui/src/export_task.rs | 49 +++++++++------------------ crates/ui-egui/src/headless.rs | 6 +++- crates/ui-egui/src/tests_activity.rs | 50 ++++++++++++++++++++++++++++ 12 files changed, 89 insertions(+), 43 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 55fb5246b..98f5190fe 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2018,5 +2018,6 @@ "A folder is being synchronized": "Ein Ordner wird gerade synchronisiert", "Wait for the import to finish": "Warten Sie, bis der Import abgeschlossen ist", "Show less": "Weniger anzeigen", - "Quit while tasks are running?": "Beenden, während Aufgaben laufen?" + "Quit while tasks are running?": "Beenden, während Aufgaben laufen?", + "Exporting contact sheet": "Kontaktabzug wird exportiert" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 5623785f6..5c6511ed4 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1863,5 +1863,6 @@ "A folder is being synchronized": "Se está sincronizando una carpeta", "Wait for the import to finish": "Espera a que termine la importación", "Show less": "Mostrar menos", - "Quit while tasks are running?": "¿Salir con tareas en curso?" + "Quit while tasks are running?": "¿Salir con tareas en curso?", + "Exporting contact sheet": "Exportando hoja de contactos" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 7e772de2e..47882a57b 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1863,5 +1863,6 @@ "A folder is being synchronized": "Un dossier est en cours de synchronisation", "Wait for the import to finish": "Attendez la fin de l'importation", "Show less": "Afficher moins", - "Quit while tasks are running?": "Quitter pendant que des tâches sont en cours ?" + "Quit while tasks are running?": "Quitter pendant que des tâches sont en cours ?", + "Exporting contact sheet": "Exportation de la planche-contact" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 3e0e2ff3b..4d9e7063b 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1865,5 +1865,6 @@ "A folder is being synchronized": "フォルダを同期中です", "Wait for the import to finish": "読み込みが終わるまでお待ちください", "Show less": "表示を減らす", - "Quit while tasks are running?": "タスクの実行中に終了しますか?" + "Quit while tasks are running?": "タスクの実行中に終了しますか?", + "Exporting contact sheet": "コンタクトシートを書き出し中" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 0406984a7..84130682f 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1863,5 +1863,6 @@ "A folder is being synchronized": "Uma pasta está sendo sincronizada", "Wait for the import to finish": "Aguarde a importação terminar", "Show less": "Mostrar menos", - "Quit while tasks are running?": "Sair com tarefas em andamento?" + "Quit while tasks are running?": "Sair com tarefas em andamento?", + "Exporting contact sheet": "Exportando folha de contato" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 6f512e405..741515e7e 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1576,5 +1576,6 @@ "A folder is being synchronized": "Идёт синхронизация папки", "Wait for the import to finish": "Дождитесь окончания импорта", "Show less": "Свернуть", - "Quit while tasks are running?": "Выйти, пока выполняются задачи?" + "Quit while tasks are running?": "Выйти, пока выполняются задачи?", + "Exporting contact sheet": "Экспорт контрольного листа" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index d46fbdcd6..ac71e7cf4 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2057,5 +2057,6 @@ "A folder is being synchronized": "Тека синхронізується", "Wait for the import to finish": "Дочекайтеся завершення імпорту", "Show less": "Показати менше", - "Quit while tasks are running?": "Вийти, поки виконуються завдання?" + "Quit while tasks are running?": "Вийти, поки виконуються завдання?", + "Exporting contact sheet": "Експорт аркуша мініатюр" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 59bb0bce2..a879ec47c 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1863,5 +1863,6 @@ "A folder is being synchronized": "正在同步文件夹", "Wait for the import to finish": "请等待导入完成", "Show less": "收起", - "Quit while tasks are running?": "要在任务运行时退出吗?" + "Quit while tasks are running?": "要在任务运行时退出吗?", + "Exporting contact sheet": "正在导出联系表" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 9ba3d6cb3..dda65b75a 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1863,5 +1863,6 @@ "A folder is being synchronized": "正在同步資料夾", "Wait for the import to finish": "請等待匯入完成", "Show less": "收合", - "Quit while tasks are running?": "要在工作執行時結束嗎?" + "Quit while tasks are running?": "要在工作執行時結束嗎?", + "Exporting contact sheet": "正在匯出聯絡表" } diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index 984a94616..b717313ba 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -1,8 +1,8 @@ //! Background export: the Export dialog, File → Export with Preset and Export with Previous hand //! their batch to a worker thread so the window stays responsive. Photos are prepared on the UI //! thread ([`lightcraft_engine::export::prepare_export`]: cheap, needs the session) and rendered, -//! encoded and written on the worker (several side by side: [`run_batch`]); a progress panel shows -//! the count and a Cancel button. +//! encoded and written on the worker (several side by side: [`run_batch`]); its row in the +//! activity stack shows the count, the file in progress and ✕ (issue #345). //! //! Needs [`crate::Services::write_shared`] (a thread-safe writer); without it (web) the batch runs //! synchronously as before. @@ -11,12 +11,11 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, channel}; use std::sync::{Arc, Mutex}; -use egui::Align2; +use lightcraft_engine::activity::{Cancel, TaskGuard}; use lightcraft_engine::export::{Destination, ExportOptions, PreparedExport, run_batch}; use serde_json::{Value, json}; use crate::LightcraftApp; -use crate::theme::Tokens; pub struct ExportTask { pub total: usize, @@ -24,6 +23,8 @@ pub struct ExportTask { pub progress: Arc>, pub cancel: Arc, rx: Receiver, String>>, + /// The export's row in the activity stack (`activity.cancel` sets `cancel`). + guard: TaskGuard, } impl ExportTask { @@ -59,11 +60,13 @@ pub fn start(app: &mut LightcraftApp, items: Vec, opts: ExportOp std::thread::Builder::new().name("export".into()).spawn(work).map_err(|e| e.to_string())?; #[cfg(target_arch = "wasm32")] work(); - app.export = Some(ExportTask { total, progress, cancel, rx }); + let guard = app.session.activity.start("export", "Exporting", Cancel::Flag(cancel.clone())); + guard.progress(0, total as u64); + app.export = Some(ExportTask { total, progress, cancel, rx, guard }); Ok(json!({"background": true, "total": total})) } -/// Export a single paginated PDF using the same progress and cancellation UI as image exports. +/// Export a single paginated PDF with the same activity row and cancellation as image exports. pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result { if app.export.is_some() { return Err("an export is already running".into()); @@ -103,11 +106,13 @@ pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result { let (done, current) = task.progress.lock().map(|g| g.clone()).unwrap_or_default(); - let (total, cancel) = (task.total, task.cancel.clone()); - let t = Tokens::get(ctx); - egui::Window::new(crate::i18n::tr("Exporting")) - .title_bar(false) - .resizable(false) - .anchor(Align2::LEFT_BOTTOM, [16.0, -56.0]) - .fixed_size([300.0, 64.0]) - .show(ctx, |ui| { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(crate::i18n::tr_format!("Exporting {} of {total}", (done + 1).min(total), total = total)) - .color(t.text), - ); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let stopping = cancel.load(Ordering::Relaxed); - if crate::widgets::text_button(ui, "exportCancel", if stopping { "Stopping…" } else { "Cancel" }, false).clicked() { - cancel.store(true, Ordering::Relaxed); - } - }); - }); - ui.add(egui::ProgressBar::new(done as f32 / total.max(1) as f32).desired_width(280.0)); - ui.label(egui::RichText::new(current).size(11.0).color(t.text_dim)); - }); - ctx.request_repaint_after(std::time::Duration::from_millis(100)); + task.guard.progress(done as u64, task.total as u64); + task.guard.detail(¤t); } Err(std::sync::mpsc::TryRecvError::Disconnected) => { app.export = None; @@ -214,7 +197,7 @@ mod contact_sheet_tests { assert_eq!(h.app.pending_picks.len(), 1); let deadline = Instant::now() + Duration::from_secs(20); loop { - // The progress window lays out text, which egui only allows inside a frame. + // The activity stack lays out text, which egui only allows inside a frame. h.step(); if h.app.last_export_result.is_some() { break; diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index e2884935a..f58fa7fe0 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1787,6 +1787,8 @@ mod tests { assert!(h.app.export.is_some(), "running in the background"); let running = h.request("ui.inspect", json!({}), t); assert_eq!(running["result"]["export"]["running"]["total"], 3, "{}", running["result"]["export"]); + // …and as a row in the activity stack (issue #345) + assert_eq!(running["result"]["activity"][0]["kind"], "export", "{}", running["result"]["activity"]); let t0 = Instant::now(); while h.app.export.is_some() && t0.elapsed() < Duration::from_secs(60) { h.step(); @@ -1795,7 +1797,9 @@ mod tests { let w = written.lock().unwrap().clone(); assert_eq!(w.len(), 3, "{w:?}"); assert!(w.iter().all(|p| p.starts_with("/lc-test-out/"))); - let last = h.request("ui.inspect", json!({}), t)["result"]["export"]["last"].clone(); + let after = h.request("ui.inspect", json!({}), t)["result"].clone(); + assert_eq!(after["activity"], json!([]), "the row is gone"); + let last = after["export"]["last"].clone(); assert_eq!(last["files"].as_array().map(Vec::len), Some(3), "{last}"); assert!(last["files"][0]["width"].as_u64().is_some_and(|w| w <= 64)); } diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 74a6ba59e..336ab5eaa 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -161,3 +161,53 @@ fn cancelling_task_does_not_block_quit() { let _f = h.app.session.activity.start("faces", "Finding faces", Cancel::No); assert!(crate::panels::notices::may_close(&mut h.app)); } + +/// A slow disk: every file takes `ms` to write (the UI must not wait for it). +fn slow_writer(ms: u64) -> crate::SharedWrite { + std::sync::Arc::new(move |_: &str, _: &[u8]| { + std::thread::sleep(Duration::from_millis(ms)); + Ok(()) + }) +} + +/// Export the first `n` photos in view through the Export dialog (small JPEGs, a made-up folder). +fn start_export(h: &mut Headless, n: usize) { + let ids: Vec = h.app.session.visible_cloned().iter().take(n).map(|p| p.0).collect(); + assert_eq!(ids.len(), n, "the demo library has {n} photos"); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": ids}}), T); + h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), T); + h.step(); + if let Some(crate::state::Dialog::Export { full_size, resize, dir, .. }) = &mut h.app.ui.dialog { + *full_size = false; + *resize = lightcraft_engine::export::Resize::long_edge(64); + *dir = "/lc-test-out".into(); + } + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.export.is_some(), "running in the background"); +} + +#[test] +fn export_shows_a_row_and_stops_on_activity_cancel() { + let mut h = demo(); + h.app.services.write_shared = Some(slow_writer(200)); + start_export(&mut h, 5); + let tasks = h.request("ui.inspect", json!({}), T)["result"]["activity"].clone(); + assert_eq!(tasks[0]["kind"], "export", "{tasks}"); + assert_eq!(tasks[0]["total"], 5, "{tasks}"); + let id = tasks[0]["id"].as_u64().unwrap(); + assert_eq!(h.request("engine.execute", json!({"command": "activity.cancel", "params": {"id": id}}), T)["ok"], true); + assert!(h.step_until(Duration::from_secs(60), |h| h.app.export.is_none())); + assert!(h.app.session.activity.list().is_empty()); + assert!(h.app.last_export_result.as_ref().is_some_and(|r| r["cancelled"] == true), "{:?}", h.app.last_export_result); + assert!(!has(&h, "button:exportCancel"), "the old window is gone"); +} + +#[test] +fn dead_export_worker_leaves_no_row() { + let mut h = demo(); + h.app.services.write_shared = Some(std::sync::Arc::new(|_: &str, _: &[u8]| -> Result<(), String> { panic!("synthetic writer panic") })); + start_export(&mut h, 2); + assert!(h.step_until(Duration::from_secs(60), |h| h.app.export.is_none())); + assert!(h.app.session.activity.list().is_empty()); +} From 10e097abf6df50908cdaf9f81909f0c894f6bab4 Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Fri, 9 Oct 2026 23:28:22 +0200 Subject: [PATCH 14/33] Issue #345: import progress in the activity stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An import (and Auto Import, and reading a folder for Local) registers in the activity stack with the files done of the total; its cross and activity.cancel set the import's cancel flag, so nothing new starts and the result still reads "Import cancelled · N added" as one undo step. The separate Importing window is gone. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/fr.json | 3 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/uk.json | 3 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant.json | 3 +- crates/ui-egui/src/import.rs | 52 +++++++++------------------- crates/ui-egui/src/lib.rs | 1 - crates/ui-egui/src/tests_activity.rs | 42 ++++++++++++++++++++++ crates/ui-egui/src/tests_offline.rs | 10 ++++-- 13 files changed, 83 insertions(+), 49 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 98f5190fe..c515ccc97 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2019,5 +2019,6 @@ "Wait for the import to finish": "Warten Sie, bis der Import abgeschlossen ist", "Show less": "Weniger anzeigen", "Quit while tasks are running?": "Beenden, während Aufgaben laufen?", - "Exporting contact sheet": "Kontaktabzug wird exportiert" + "Exporting contact sheet": "Kontaktabzug wird exportiert", + "Reading folder": "Ordner wird gelesen" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 5c6511ed4..75bb1b6e0 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1864,5 +1864,6 @@ "Wait for the import to finish": "Espera a que termine la importación", "Show less": "Mostrar menos", "Quit while tasks are running?": "¿Salir con tareas en curso?", - "Exporting contact sheet": "Exportando hoja de contactos" + "Exporting contact sheet": "Exportando hoja de contactos", + "Reading folder": "Leyendo carpeta" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 47882a57b..8ae407b29 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1864,5 +1864,6 @@ "Wait for the import to finish": "Attendez la fin de l'importation", "Show less": "Afficher moins", "Quit while tasks are running?": "Quitter pendant que des tâches sont en cours ?", - "Exporting contact sheet": "Exportation de la planche-contact" + "Exporting contact sheet": "Exportation de la planche-contact", + "Reading folder": "Lecture du dossier" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 4d9e7063b..800383ecb 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1866,5 +1866,6 @@ "Wait for the import to finish": "読み込みが終わるまでお待ちください", "Show less": "表示を減らす", "Quit while tasks are running?": "タスクの実行中に終了しますか?", - "Exporting contact sheet": "コンタクトシートを書き出し中" + "Exporting contact sheet": "コンタクトシートを書き出し中", + "Reading folder": "フォルダーを読み込み中" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 84130682f..3be7086e5 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1864,5 +1864,6 @@ "Wait for the import to finish": "Aguarde a importação terminar", "Show less": "Mostrar menos", "Quit while tasks are running?": "Sair com tarefas em andamento?", - "Exporting contact sheet": "Exportando folha de contato" + "Exporting contact sheet": "Exportando folha de contato", + "Reading folder": "Lendo pasta" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 741515e7e..40ea9150a 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1577,5 +1577,6 @@ "Wait for the import to finish": "Дождитесь окончания импорта", "Show less": "Свернуть", "Quit while tasks are running?": "Выйти, пока выполняются задачи?", - "Exporting contact sheet": "Экспорт контрольного листа" + "Exporting contact sheet": "Экспорт контрольного листа", + "Reading folder": "Чтение папки" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index ac71e7cf4..30671a82c 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2058,5 +2058,6 @@ "Wait for the import to finish": "Дочекайтеся завершення імпорту", "Show less": "Показати менше", "Quit while tasks are running?": "Вийти, поки виконуються завдання?", - "Exporting contact sheet": "Експорт аркуша мініатюр" + "Exporting contact sheet": "Експорт аркуша мініатюр", + "Reading folder": "Читання папки" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index a879ec47c..a993a5d02 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1864,5 +1864,6 @@ "Wait for the import to finish": "请等待导入完成", "Show less": "收起", "Quit while tasks are running?": "要在任务运行时退出吗?", - "Exporting contact sheet": "正在导出联系表" + "Exporting contact sheet": "正在导出联系表", + "Reading folder": "正在读取文件夹" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index dda65b75a..e13a9486f 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1864,5 +1864,6 @@ "Wait for the import to finish": "請等待匯入完成", "Show less": "收合", "Quit while tasks are running?": "要在工作執行時結束嗎?", - "Exporting contact sheet": "正在匯出聯絡表" + "Exporting contact sheet": "正在匯出聯絡表", + "Reading folder": "正在讀取檔案夾" } diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 4b1e40770..9589554f6 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -5,7 +5,8 @@ //! chosen folder, filed by day, by month, into one folder or by a custom folder template, //! optionally renamed), an album (existing or new), a preset and keywords to apply. Importing //! runs on a worker thread (files are probed, copied or moved there) and its batches join the -//! catalog between frames, with a progress window and Cancel; the whole import is one undo step. +//! catalog between frames, with a row and ✕ in the activity stack (issue #345); the whole import is +//! one undo step. use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use lightcraft_engine::import::{ImportCandidate, ScanInput, ScanOutput, ScanProgress, scan_with}; @@ -235,6 +236,8 @@ pub struct ImportTask { /// Auto Import: the selection to keep. keep_selection: Option, run: Option, + /// The import's row in the activity stack (its ✕ and `activity.cancel` set the run's cancel flag). + guard: Option, } impl ImportTask { @@ -648,7 +651,11 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { task.keep_selection = Some(app.session.selection.clone()); } match started { - Ok(run) => task.run = Some(run), + Ok(run) => { + let label = if task.browse { "Reading folder" } else { "Importing" }; + task.guard = Some(app.session.activity.start("import", label, lightcraft_engine::activity::Cancel::Flag(run.cancel.clone()))); + task.run = Some(run); + } Err(e) => { log::warn!("import: {e}"); app.toast(ctx, crate::i18n::tr_format!("Import failed: {e}", e = e)); @@ -671,6 +678,13 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { task.total = run.total.load(Ordering::Relaxed).max(task.total); task.done = run.done.load(Ordering::Relaxed); } + if let Some(guard) = &task.guard { + guard.progress(task.done as u64, task.total as u64); + // ✕ in the stack (or `activity.cancel`): no further files are started + if guard.is_cancelled() { + task.cancelled = true; + } + } if !finished { app.import = Some(task); return; @@ -812,40 +826,6 @@ fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64]) true } -/// The progress window while an import runs, with Cancel (files already copied or added stay; -/// nothing new is started). -pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) { - let Some(task) = &app.import else { return }; - let t = Tokens::get(ctx); - let frac = task.done as f32 / task.total.max(1) as f32; - let text = if task.cancelled { - crate::i18n::tr("Stopping…").to_string() - } else if task.browse { - crate::i18n::tr_format!("Reading photos… {} of {}", task.done, task.total) - } else { - crate::i18n::tr_format!("Adding photos… {} of {}", task.done, task.total) - }; - let mut cancel = false; - egui::Window::new(crate::i18n::tr("Importing")) - .title_bar(false) - .resizable(false) - .anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]) - .fixed_size([340.0, 80.0]) - .show(ctx, |ui| { - ui.label(egui::RichText::new(text).color(t.text)); - ui.add(egui::ProgressBar::new(frac).desired_width(320.0)); - let r = ui.add_enabled(!task.cancelled, egui::Button::new(crate::i18n::tr("Cancel"))); - register(ui.ctx(), "button:importCancel", r.rect); - cancel = r.clicked(); - }); - if cancel && let Some(task) = app.import.as_mut() { - task.cancelled = true; - if let Some(run) = &task.run { - run.cancel.store(true, Ordering::Relaxed); - } - } -} - /// The dialog body: options, then the candidate grid. pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bae21daca..0e9da3ec3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1072,7 +1072,6 @@ impl LightcraftApp { panels::notices::show(self, &ctx); panels::dialogs::show(self, &ctx); panels::library_problem::show(self, &ctx); - import::progress(self, &ctx); sync::progress_window(self, &ctx); import::scan_progress(self, &ctx); lightroom_import::progress(self, &ctx); diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 336ab5eaa..9cdcd8d46 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -211,3 +211,45 @@ fn dead_export_worker_leaves_no_row() { assert!(h.step_until(Duration::from_secs(60), |h| h.app.export.is_none())); assert!(h.app.session.activity.list().is_empty()); } + +/// A folder of `n` stand-in JPEGs and an app whose file probe takes 100 ms each (a slow drive). +fn slow_import(tag: &str, n: usize) -> (Headless, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("lc-activity-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for i in 0..n { + std::fs::write(dir.join(format!("IMG_{i:03}.jpg")), format!("not really a jpeg {i}")).unwrap(); + } + let mut s = lightcraft_engine::Session::new(); + s.media.file_probe = Some(std::sync::Arc::new(|p: &str| { + std::thread::sleep(Duration::from_millis(100)); + Ok(lightcraft_engine::media::ProbeInfo { + width: 60, + height: 40, + format: "JPEG".into(), + content_hash: Some(p.to_string()), + ..Default::default() + }) + })); + let mut h = Headless::new(LightcraftApp::new(s, Services { png: None, ..Default::default() }), [1200.0, 800.0], 1.0); + h.step(); + (h, dir) +} + +#[test] +fn import_cancelled_by_command_reports_cancelled() { + let n = 40; + let (mut h, dir) = slow_import("cancel", n); + let undo0 = h.app.session.undo.len(); + crate::import::start_paths(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap(); + assert!(h.step_until(Duration::from_secs(60), |h| h.app.import.as_ref().is_some_and(|t| t.imported >= 2))); + let tasks = h.app.session.activity.list(); + assert_eq!(tasks.first().map(|t| t.kind), Some("import"), "{tasks:?}"); + h.app.session.activity.cancel(tasks[0].id).unwrap(); + assert!(h.step_until(Duration::from_secs(60), |h| h.app.import.is_none())); + assert!(h.app.session.activity.list().is_empty()); + assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0.starts_with("Import cancelled")), "{:?}", h.app.ui.toast); + assert!(h.app.session.catalog.len() < n, "stopped part-way"); + assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/tests_offline.rs b/crates/ui-egui/src/tests_offline.rs index b98960495..894e1e4ec 100644 --- a/crates/ui-egui/src/tests_offline.rs +++ b/crates/ui-egui/src/tests_offline.rs @@ -123,7 +123,8 @@ fn import_reads_files_off_the_ui_thread_and_can_be_cancelled() { let dir = std::env::temp_dir().join(format!("lc-ui-slow-import-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - let n = 40; + // enough files that the import is still running when its activity row shows (after 0.5 s) + let n = 120; for i in 0..n { std::fs::write(dir.join(format!("IMG_{i:03}.jpg")), format!("not really a jpeg {i}")).unwrap(); } @@ -163,8 +164,11 @@ fn import_reads_files_off_the_ui_thread_and_can_be_cancelled() { assert!(h.app.import.is_some(), "still importing"); let status = h.app.import.as_ref().map(crate::import::ImportTask::status).unwrap_or_default(); assert!(status["total"].as_u64().unwrap_or(0) >= 8, "{status}"); - // Cancel: the files being read finish, nothing new starts - let r = h.request("ui.clickWidget", json!({"id": "button:importCancel"}), Duration::from_secs(10)); + // Cancel (the ✕ of its row in the activity stack): the files being read finish, nothing new starts + let id = h.app.session.activity.list().first().map(|t| t.id).expect("an import row"); + let cross = format!("activity:cancel:{id}"); + assert!(h.step_until(Duration::from_secs(10), |h| h.app.widgets.iter().any(|(w, _)| *w == cross)), "the row shows"); + let r = h.request("ui.clickWidget", json!({"id": cross}), Duration::from_secs(10)); assert_eq!(r["ok"], true, "{r}"); let t0 = Instant::now(); while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(60) { From 63d9b70356a779ea7183815afe3d4fe9cecdcfc2 Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Sat, 10 Oct 2026 00:00:27 +0200 Subject: [PATCH 15/33] Issue #345: activity stack polish The cross sits at the right edge of its row (the name's area kept only the text's width, so the cross followed short names). Seen in the running app; a test pins it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/activity.rs | 3 ++- crates/ui-egui/src/tests_activity.rs | 12 +++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/activity.rs b/crates/ui-egui/src/panels/activity.rs index 5a2e6095f..87a1163ff 100644 --- a/crates/ui-egui/src/panels/activity.rs +++ b/crates/ui-egui/src/panels/activity.rs @@ -115,7 +115,8 @@ fn row(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) -> bool { let cross = task.cancellable && !task.cancelling; let label_w = if cross { width - CROSS - 6.0 } else { width }; ui.allocate_ui_with_layout(vec2(label_w, 18.0), Layout::left_to_right(Align::Center), |ui| { - ui.set_max_width(label_w); + // the whole width, so the cross lands at the right edge however short the name + ui.set_width(label_w); ui.add(egui::Label::new(RichText::new(tr(&task.label)).font(t.font(12.5)).color(t.text)).truncate()); }); if cross { diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 9cdcd8d46..39074161d 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -131,6 +131,16 @@ fn long_detail_stays_inside_the_stack() { assert!(row.width() <= WIDTH + 1.0, "{row:?}"); } +#[test] +fn cross_sits_at_the_right_edge_of_its_row() { + let mut h = demo(); + let g = h.app.session.activity.start("export", "Exporting", Cancel::Yes); + wait_visible(&mut h); + let row = rect(&h, &format!("activity:row:{}", g.id())); + let cross = rect(&h, &format!("activity:cancel:{}", g.id())); + assert!(cross.right() >= row.right() - 2.0, "the cross is at the right, not after the label: {cross:?} in {row:?}"); +} + #[test] fn quit_with_a_running_task_asks_and_quit_anyway_cancels() { let mut h = demo(); @@ -184,7 +194,6 @@ fn start_export(h: &mut Headless, n: usize) { } let r = h.request("ui.dialog.confirm", json!({}), T); assert_eq!(r["ok"], true, "{r}"); - assert!(h.app.export.is_some(), "running in the background"); } #[test] @@ -192,6 +201,7 @@ fn export_shows_a_row_and_stops_on_activity_cancel() { let mut h = demo(); h.app.services.write_shared = Some(slow_writer(200)); start_export(&mut h, 5); + assert!(h.app.export.is_some(), "running in the background"); let tasks = h.request("ui.inspect", json!({}), T)["result"]["activity"].clone(); assert_eq!(tasks[0]["kind"], "export", "{tasks}"); assert_eq!(tasks[0]["total"], 5, "{tasks}"); From 1af0dd4fe3ec7b8c011df755aeceb753afd5a1aa Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Sat, 10 Oct 2026 00:14:00 +0200 Subject: [PATCH 16/33] Issue #345: folder scan in the activity stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The import review's folder scan (and reading a folder for Local) is a row in the activity stack: "Looking for photos…" while the folders are listed, then the files read of the total. Its cross drops the scan at once, without waiting for the file being read, as the old Cancel did. The separate Scanning window is gone. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/import.rs | 3 +- crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/fr.json | 3 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/uk.json | 3 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant.json | 3 +- crates/ui-egui/src/headless.rs | 9 ++++- crates/ui-egui/src/import.rs | 58 ++++++++++++---------------- crates/ui-egui/src/lib.rs | 1 - crates/ui-egui/src/tests_activity.rs | 19 +++++++++ 14 files changed, 70 insertions(+), 47 deletions(-) diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index ac5640a4b..29ea013e3 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -501,7 +501,8 @@ pub struct ScanProgress { /// Files to probe, set once the folders are expanded. pub total: std::sync::atomic::AtomicUsize, pub done: std::sync::atomic::AtomicUsize, - pub cancel: std::sync::atomic::AtomicBool, + /// Shared with the scan's row in the activity stack (its ✕ sets it). + pub cancel: std::sync::Arc, } /// The result of [`scan_with`]: the candidates, and the probes to keep for the import that follows. diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index c515ccc97..ce6c51aa1 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2020,5 +2020,6 @@ "Show less": "Weniger anzeigen", "Quit while tasks are running?": "Beenden, während Aufgaben laufen?", "Exporting contact sheet": "Kontaktabzug wird exportiert", - "Reading folder": "Ordner wird gelesen" + "Reading folder": "Ordner wird gelesen", + "Scanning folder": "Ordner wird durchsucht" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 75bb1b6e0..b85268d32 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1865,5 +1865,6 @@ "Show less": "Mostrar menos", "Quit while tasks are running?": "¿Salir con tareas en curso?", "Exporting contact sheet": "Exportando hoja de contactos", - "Reading folder": "Leyendo carpeta" + "Reading folder": "Leyendo carpeta", + "Scanning folder": "Explorando carpeta" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 8ae407b29..cdbf6ee58 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1865,5 +1865,6 @@ "Show less": "Afficher moins", "Quit while tasks are running?": "Quitter pendant que des tâches sont en cours ?", "Exporting contact sheet": "Exportation de la planche-contact", - "Reading folder": "Lecture du dossier" + "Reading folder": "Lecture du dossier", + "Scanning folder": "Analyse du dossier" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 800383ecb..60962fa56 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1867,5 +1867,6 @@ "Show less": "表示を減らす", "Quit while tasks are running?": "タスクの実行中に終了しますか?", "Exporting contact sheet": "コンタクトシートを書き出し中", - "Reading folder": "フォルダーを読み込み中" + "Reading folder": "フォルダーを読み込み中", + "Scanning folder": "フォルダーをスキャン中" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 3be7086e5..d97e23dd0 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1865,5 +1865,6 @@ "Show less": "Mostrar menos", "Quit while tasks are running?": "Sair com tarefas em andamento?", "Exporting contact sheet": "Exportando folha de contato", - "Reading folder": "Lendo pasta" + "Reading folder": "Lendo pasta", + "Scanning folder": "Verificando pasta" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 40ea9150a..f98484d86 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1578,5 +1578,6 @@ "Show less": "Свернуть", "Quit while tasks are running?": "Выйти, пока выполняются задачи?", "Exporting contact sheet": "Экспорт контрольного листа", - "Reading folder": "Чтение папки" + "Reading folder": "Чтение папки", + "Scanning folder": "Сканирование папки" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 30671a82c..2c43493fc 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2059,5 +2059,6 @@ "Show less": "Показати менше", "Quit while tasks are running?": "Вийти, поки виконуються завдання?", "Exporting contact sheet": "Експорт аркуша мініатюр", - "Reading folder": "Читання папки" + "Reading folder": "Читання папки", + "Scanning folder": "Сканування папки" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index a993a5d02..7fe1e57ff 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1865,5 +1865,6 @@ "Show less": "收起", "Quit while tasks are running?": "要在任务运行时退出吗?", "Exporting contact sheet": "正在导出联系表", - "Reading folder": "正在读取文件夹" + "Reading folder": "正在读取文件夹", + "Scanning folder": "正在扫描文件夹" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index e13a9486f..9cf791417 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1865,5 +1865,6 @@ "Show less": "收合", "Quit while tasks are running?": "要在工作執行時結束嗎?", "Exporting contact sheet": "正在匯出聯絡表", - "Reading folder": "正在讀取檔案夾" + "Reading folder": "正在讀取檔案夾", + "Scanning folder": "正在掃描檔案夾" } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index f58fa7fe0..d0104f7de 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2438,9 +2438,14 @@ mod tests { assert!(!h.app.scan.as_ref().unwrap().copy, "the running scan keeps its options"); let r = h.request("ui.inspect", json!({}), t); assert!(r["result"]["scan"].is_object(), "{r}"); - let r = h.request("ui.clickWidget", json!({"id": "button:scanCancel"}), t); + // Cancel is the ✕ of the scan's row in the activity stack (shown once it is half a second old) + let id = h.app.session.activity.list().first().map(|t| t.id).expect("a scan row"); + let cross = format!("activity:cancel:{id}"); + assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == cross)), "the row shows"); + let r = h.request("ui.clickWidget", json!({"id": cross}), t); assert_eq!(r["ok"], true, "{r}"); - assert!(h.app.scan.is_none(), "Cancel closes the scan at once"); + h.step(); + assert!(h.app.scan.is_none(), "✕ closes the scan at once"); gate.store(true, std::sync::atomic::Ordering::Relaxed); h.settle(SETTLE); assert!(h.app.ui.dialog.is_none(), "a cancelled scan opens no review"); diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 9589554f6..af0a10adb 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -394,6 +394,14 @@ pub struct ScanTask { browse: bool, /// What is being scanned (the review's source). sources: Vec, + /// The scan's row in the activity stack (its ✕ and `activity.cancel` set `progress.cancel`). + guard: lightcraft_engine::activity::TaskGuard, +} + +/// The activity row of a scan: the review's ("Scanning folder") or the Local view's ("Reading folder"). +fn scan_guard(app: &LightcraftApp, progress: &ScanProgress, browse: bool) -> lightcraft_engine::activity::TaskGuard { + let label = if browse { "Reading folder" } else { "Scanning folder" }; + app.session.activity.start("scan", label, lightcraft_engine::activity::Cancel::Flag(progress.cancel.clone())) } /// An import and a Synchronize Folder never run at once: each readies its files against the @@ -419,7 +427,8 @@ pub fn open(app: &mut LightcraftApp, paths: Vec) -> Result) -> std::thread::spawn(job); #[cfg(target_arch = "wasm32")] job(); - app.scan = Some(ScanTask { progress, rx, copy: false, browse: true, sources: Vec::new() }); + let guard = scan_guard(app, &progress, true); + app.scan = Some(ScanTask { progress, rx, copy: false, browse: true, sources: Vec::new(), guard }); app.renderer.forget_imports(); Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})) } @@ -491,7 +501,18 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { ctx.request_repaint_after(std::time::Duration::from_millis(100)); let out = match task.rx.try_recv() { Ok(o) => o, - Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Empty) => { + if task.guard.is_cancelled() { + // ✕: the worker stops at its next file; don't wait for it (a NAS read can take a while) + app.scan = None; + return; + } + let (total, done) = (task.progress.total.load(Ordering::Relaxed), task.progress.done.load(Ordering::Relaxed)); + task.guard.progress(done as u64, total as u64); + // while the folders are listed the total isn't known: say what is happening instead + task.guard.detail(if total == 0 { crate::i18n::tr("Looking for photos…") } else { "" }); + return; + } Err(_) => { app.scan = None; app.toast(ctx, crate::i18n::tr("Scan failed")); @@ -534,37 +555,6 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(d) }); } -/// The progress window while a folder is being scanned. -pub fn scan_progress(app: &mut LightcraftApp, ctx: &egui::Context) { - let Some(task) = &app.scan else { return }; - let t = Tokens::get(ctx); - let total = task.progress.total.load(Ordering::Relaxed); - let done = task.progress.done.load(Ordering::Relaxed); - let text = if total == 0 { - if task.browse { "Reading folder…" } else { "Looking for photos…" }.to_string() - } else { - crate::i18n::tr_format!("Reading photos… {done} of {total}", done = done, total = total) - }; - let mut cancel = false; - egui::Window::new(crate::i18n::tr("Scanning")) - .title_bar(false) - .resizable(false) - .anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]) - .fixed_size([340.0, 80.0]) - .show(ctx, |ui| { - ui.label(egui::RichText::new(text).color(t.text)); - ui.add(egui::ProgressBar::new(done as f32 / total.max(1) as f32).desired_width(320.0)); - let r = ui.button(crate::i18n::tr("Cancel")); - register(ui.ctx(), "button:scanCancel", r.rect); - cancel = r.clicked(); - }); - if cancel { - // the worker stops at its next file; don't wait for it (a NAS read can take a while) - task.progress.cancel.store(true, Ordering::Relaxed); - app.scan = None; - } -} - impl ScanTask { /// `{done, total}` for `ui.inspect` (total is 0 while the folders are still being listed). pub fn status(&self) -> Value { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0e9da3ec3..1b52ce0b1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1073,7 +1073,6 @@ impl LightcraftApp { panels::dialogs::show(self, &ctx); panels::library_problem::show(self, &ctx); sync::progress_window(self, &ctx); - import::scan_progress(self, &ctx); lightroom_import::progress(self, &ctx); export_task::poll(self, &ctx); panels::activity::show(self, &ctx); diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 39074161d..5e8e967d0 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -263,3 +263,22 @@ fn import_cancelled_by_command_reports_cancelled() { assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn scan_shows_a_row_and_cancel_closes_it() { + let (mut h, dir) = slow_import("scan", 120); + crate::import::open(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap(); + h.step(); + let tasks = h.app.session.activity.list(); + assert_eq!(tasks.first().map(|t| t.kind), Some("scan"), "{tasks:?}"); + assert!(tasks[0].cancellable); + h.app.session.activity.cancel(tasks[0].id).unwrap(); + // like the old Cancel: the scan is dropped at once, without waiting for the file being read + assert!(h.step_until(Duration::from_secs(10), |h| h.app.scan.is_none())); + assert!(h.app.session.activity.list().is_empty()); + for _ in 0..3 { + h.step(); + } + assert!(h.app.ui.dialog.is_none(), "no review opened"); + let _ = std::fs::remove_dir_all(&dir); +} From 9c8a72138986a8b9ec1de1d3d11b39608444c1a5 Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Sat, 10 Oct 2026 00:26:30 +0200 Subject: [PATCH 17/33] Issue #345: preview builds in the activity stack Build Previews, Build Smart Previews and Discard Smart Previews show a row with the photo count and stop from its cross; the start toast goes, the finish toast stays. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/previews.rs | 47 ++++++++++++++++++++--------- crates/engine/src/tests.rs | 36 ++++++++++++++++++++++ crates/engine/src/tests_import.rs | 6 ++++ crates/ui-egui/locales/de.json | 5 ++- crates/ui-egui/locales/es.json | 5 ++- crates/ui-egui/locales/ja.json | 5 ++- crates/ui-egui/locales/pt-br.json | 5 ++- crates/ui-egui/locales/ru.json | 5 ++- crates/ui-egui/locales/zh-hans.json | 5 ++- crates/ui-egui/locales/zh-hant.json | 5 ++- crates/ui-egui/src/lib.rs | 11 ++----- 11 files changed, 105 insertions(+), 30 deletions(-) diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index 3dacc31f2..54f8d0529 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -1,7 +1,7 @@ //! Build previews ahead of time: each photo's grid thumbnail and its loupe view (standard size, //! or 1:1), rendered into the memory + disk preview cache so browsing and the loupe are instant. -//! Runs on a background thread (the app keeps working; progress / cancel by command), or inline -//! with `wait` (CLI, MCP, tests). +//! Runs on a background thread (the app keeps working; progress / cancel by command, and a row in +//! the activity stack, issue #345), or inline with `wait` (CLI, MCP, tests). use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; @@ -9,6 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, cmd, str_param}; +use crate::activity::{Cancel, TaskGuard}; use crate::media::{RenderJob, THUMB_SIZES}; use crate::{Result, Session}; @@ -21,7 +22,8 @@ pub struct PreviewBuild { pub total: usize, pub done: AtomicUsize, pub failed: AtomicUsize, - pub cancel: AtomicBool, + /// Shared with the build's row in the activity stack (its ✕ sets it). + pub cancel: Arc, pub finished: AtomicBool, /// Damaged smart previews rebuilt (Build Smart Previews only). pub repaired: AtomicUsize, @@ -62,7 +64,19 @@ fn jobs_for(s: &mut Session, id: lightcraft_catalog::PhotoId, edge: Option>, state: &PreviewBuild) { +/// The build's row in the activity stack (`what` as in [`PreviewBuild::what`]). +fn task_for(s: &Session, state: &PreviewBuild) -> TaskGuard { + let (kind, label) = match state.what { + "" => ("previews", "Building previews"), + "smart previews" => ("smartPreviews", "Building smart previews"), + _ => ("smartPreviews", "Discarding smart previews"), + }; + let task = s.activity.start(kind, label, Cancel::Flag(state.cancel.clone())); + task.progress(0, state.total as u64); + task +} + +fn run_all(jobs: Vec>, state: &PreviewBuild, task: &TaskGuard) { for photo_jobs in jobs { if state.cancel.load(Ordering::Relaxed) { break; @@ -71,7 +85,8 @@ fn run_all(jobs: Vec>, state: &PreviewBuild) { if !ok { state.failed.fetch_add(1, Ordering::Relaxed); } - state.done.fetch_add(1, Ordering::Relaxed); + let done = state.done.fetch_add(1, Ordering::Relaxed).saturating_add(1); + task.progress(done as u64, state.total as u64); } state.finished.store(true, Ordering::Relaxed); } @@ -91,17 +106,19 @@ fn build(s: &mut Session, p: &Value) -> Result { let jobs: Vec> = ids.iter().map(|id| jobs_for(s, *id, edge)).filter(|j| !j.is_empty()).collect(); let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() }); s.preview_build = Some(state.clone()); + let task = task_for(s, &state); let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false) || cfg!(target_arch = "wasm32"); if wait { - run_all(jobs, &state); + run_all(jobs, &state, &task); return Ok(state.json()); } #[cfg(not(target_arch = "wasm32"))] { + // the row stays while the worker runs (it is dropped with the closure, however that ends) let st = state.clone(); std::thread::Builder::new() .name("lc-build-previews".into()) - .spawn(move || run_all(jobs, &st)) + .spawn(move || run_all(jobs, &st, &task)) .map_err(|e| bad(C, format!("could not start: {e}")))?; } Ok(state.json()) @@ -134,7 +151,7 @@ fn smart_run( custom: bool, discard: bool, jobs: Vec, - state: Option<&PreviewBuild>, + state: Option<(&PreviewBuild, &TaskGuard)>, ) -> std::result::Result { let mut n = SmartCounts::default(); if !discard { @@ -146,7 +163,7 @@ fn smart_run( } } for (id, path, source) in jobs { - if state.is_some_and(|st| st.cancel.load(Ordering::Relaxed)) { + if state.is_some_and(|(st, _)| st.cancel.load(Ordering::Relaxed)) { break; } if discard { @@ -173,22 +190,23 @@ fn smart_run( n.built += 1; if damaged { n.repaired += 1; - if let Some(st) = state { + if let Some((st, _)) = state { st.repaired.fetch_add(1, Ordering::Relaxed); } } } Err(e) => { n.failed.push(json!([id.0, e])); - if let Some(st) = state { + if let Some((st, _)) = state { st.failed.fetch_add(1, Ordering::Relaxed); } } } } } - if let Some(st) = state { - st.done.fetch_add(1, Ordering::Relaxed); + if let Some((st, task)) = state { + let done = st.done.fetch_add(1, Ordering::Relaxed).saturating_add(1); + task.progress(done as u64, st.total as u64); } } Ok(n) @@ -222,11 +240,12 @@ fn smart(s: &mut Session, p: &Value) -> Result { let what = if discard { "discarding smart previews" } else { "smart previews" }; let state = Arc::new(PreviewBuild { total: jobs.len(), what, ..Default::default() }); s.preview_build = Some(state.clone()); + let task = task_for(s, &state); let st = state.clone(); std::thread::Builder::new() .name("lc-smart-previews".into()) .spawn(move || { - if let Err(e) = smart_run(&dir, custom, discard, jobs, Some(&st)) { + if let Err(e) = smart_run(&dir, custom, discard, jobs, Some((&st, &task))) { log::warn!("{C}: {e}"); *st.error.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(e); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7b0f6cae7..eec58bc46 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -721,6 +721,42 @@ fn build_previews_fills_the_cache() { assert!(s.execute("library.buildPreviews", &json!({"size": "huge"})).is_err()); } +/// Wait (at most a minute) until no background task is listed. +fn wait_activity_empty(s: &Session) { + let t0 = std::time::Instant::now(); + while !s.activity.list().is_empty() { + assert!(t0.elapsed().as_secs() < 60, "tasks still listed: {:?}", s.activity.list()); + std::thread::sleep(std::time::Duration::from_millis(20)); + } +} + +/// Issue #345: a background preview build is a row in the activity stack while it runs. +#[test] +fn preview_build_shows_in_activity_until_it_finishes() { + let mut s = demo(); + let ids: Vec = s.catalog.photos().take(4).map(|p| p.id.0).collect(); + let r = s.execute("library.buildPreviews", &json!({"ids": ids, "size": "full"})).unwrap(); + let tasks = s.activity.list(); + assert_eq!(tasks.first().map(|t| (t.kind, t.total, t.label.as_str())), Some(("previews", 4, "Building previews")), "{tasks:?} {r}"); + assert!(tasks[0].cancellable); + wait_activity_empty(&s); + assert_eq!(s.execute("library.previewProgress", &json!({})).unwrap()["running"], false); +} + +/// Issue #345: `activity.cancel` stops a preview build like `library.cancelPreviews`. +#[test] +fn activity_cancel_stops_a_preview_build() { + let mut s = demo(); + let ids: Vec = s.catalog.photos().map(|p| p.id.0).collect(); + s.execute("library.buildPreviews", &json!({"ids": ids, "size": "full"})).unwrap(); + let id = s.activity.list().first().map(|t| t.id).expect("a previews row"); + s.execute("activity.cancel", &json!({"id": id})).unwrap(); + wait_activity_empty(&s); + let p = s.execute("library.previewProgress", &json!({})).unwrap(); + assert_eq!(p["cancelled"], true, "{p}"); + assert!(p["done"].as_u64() < p["total"].as_u64(), "stopped part-way: {p}"); +} + /// Colour range: a click samples the colour under it, so the mask selects that colour (white /// in the mask view) and not a different one; ⇧ adds samples, up to five. #[test] diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 784200310..84da187c0 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -497,6 +497,12 @@ fn damaged_smart_previews_are_rebuilt_and_failed_writes_leave_none() { }; assert_eq!((r["done"].as_u64(), r["failed"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(0), Some(1)), "{r}"); assert_eq!(std::fs::read(&file).unwrap(), full, "rebuilt whole in the background"); + // its row in the activity stack (issue #345) goes with it + let t0 = std::time::Instant::now(); + while !s.activity.list().is_empty() && t0.elapsed() < std::time::Duration::from_secs(10) { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(s.activity.list().is_empty(), "{:?}", s.activity.list()); let _ = std::fs::remove_dir_all(&src); let _ = std::fs::remove_dir_all(&lib); } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index ce6c51aa1..9b5f1920d 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2021,5 +2021,8 @@ "Quit while tasks are running?": "Beenden, während Aufgaben laufen?", "Exporting contact sheet": "Kontaktabzug wird exportiert", "Reading folder": "Ordner wird gelesen", - "Scanning folder": "Ordner wird durchsucht" + "Scanning folder": "Ordner wird durchsucht", + "Building previews": "Vorschauen werden erstellt", + "Building smart previews": "Smart-Vorschauen werden erstellt", + "Discarding smart previews": "Smart-Vorschauen werden verworfen" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index b85268d32..104e75965 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1866,5 +1866,8 @@ "Quit while tasks are running?": "¿Salir con tareas en curso?", "Exporting contact sheet": "Exportando hoja de contactos", "Reading folder": "Leyendo carpeta", - "Scanning folder": "Explorando carpeta" + "Scanning folder": "Explorando carpeta", + "Building previews": "Generando vistas previas", + "Building smart previews": "Generando vistas previas inteligentes", + "Discarding smart previews": "Descartando vistas previas inteligentes" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 60962fa56..02dbbc81d 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1868,5 +1868,8 @@ "Quit while tasks are running?": "タスクの実行中に終了しますか?", "Exporting contact sheet": "コンタクトシートを書き出し中", "Reading folder": "フォルダーを読み込み中", - "Scanning folder": "フォルダーをスキャン中" + "Scanning folder": "フォルダーをスキャン中", + "Building previews": "プレビューを作成中", + "Building smart previews": "スマートプレビューを作成中", + "Discarding smart previews": "スマートプレビューを破棄中" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index d97e23dd0..fa1bcac40 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1866,5 +1866,8 @@ "Quit while tasks are running?": "Sair com tarefas em andamento?", "Exporting contact sheet": "Exportando folha de contato", "Reading folder": "Lendo pasta", - "Scanning folder": "Verificando pasta" + "Scanning folder": "Verificando pasta", + "Building previews": "Gerando prévias", + "Building smart previews": "Gerando prévias inteligentes", + "Discarding smart previews": "Descartando prévias inteligentes" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index f98484d86..453b1e212 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1579,5 +1579,8 @@ "Quit while tasks are running?": "Выйти, пока выполняются задачи?", "Exporting contact sheet": "Экспорт контрольного листа", "Reading folder": "Чтение папки", - "Scanning folder": "Сканирование папки" + "Scanning folder": "Сканирование папки", + "Building previews": "Создание просмотров", + "Building smart previews": "Создание смарт-просмотров", + "Discarding smart previews": "Удаление смарт-просмотров" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 7fe1e57ff..adcc16f8f 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1866,5 +1866,8 @@ "Quit while tasks are running?": "要在任务运行时退出吗?", "Exporting contact sheet": "正在导出联系表", "Reading folder": "正在读取文件夹", - "Scanning folder": "正在扫描文件夹" + "Scanning folder": "正在扫描文件夹", + "Building previews": "正在构建预览", + "Building smart previews": "正在构建智能预览", + "Discarding smart previews": "正在丢弃智能预览" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 9cf791417..2c4eca303 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1866,5 +1866,8 @@ "Quit while tasks are running?": "要在工作執行時結束嗎?", "Exporting contact sheet": "正在匯出聯絡表", "Reading folder": "正在讀取檔案夾", - "Scanning folder": "正在掃描檔案夾" + "Scanning folder": "正在掃描檔案夾", + "Building previews": "正在建立預覽", + "Building smart previews": "正在建立智慧型預覽", + "Discarding smart previews": "正在捨棄智慧型預覽" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1b52ce0b1..03a23ef5a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -477,7 +477,7 @@ impl LightcraftApp { ctx.request_repaint_after(std::time::Duration::from_secs_f64((due - now).clamp(0.05, interval))); } - /// Announce the start and end of a Build Previews run. + /// Announce the end of a Build Previews run. fn preview_build_status(&mut self, ctx: &egui::Context) { use std::sync::atomic::Ordering; let Some(b) = self.session.preview_build.clone() else { return }; @@ -500,14 +500,7 @@ impl LightcraftApp { self.toast(ctx, msg); } } else { - if self.ui.preview_build_seen != Some((key, false)) { - self.ui.preview_build_seen = Some((key, false)); - let msg = match b.what { - "" => crate::i18n::tr_format!("Building previews for {} photos…", b.total), - what => crate::i18n::tr_format!("Working on {what} for {} photos…", b.total, what = crate::i18n::tr(what)), - }; - self.toast(ctx, msg); - } + // The activity stack shows the run while it works; poll for the end. ctx.request_repaint_after(std::time::Duration::from_millis(250)); } } From c3bb8e991689180c72cfc8d8a01e6c1fff66a239 Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Sat, 10 Oct 2026 00:36:17 +0200 Subject: [PATCH 18/33] Issue #345: Lightroom catalog import in the activity stack Inspecting and importing a Lightroom catalog show a row instead of the progress window; reading the catalog stops from its cross, adding the photos and saving the index run to the end. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 4 +- crates/ui-egui/locales/es.json | 4 +- crates/ui-egui/locales/fr.json | 7 +- crates/ui-egui/locales/ja.json | 4 +- crates/ui-egui/locales/pt-br.json | 4 +- crates/ui-egui/locales/ru.json | 4 +- crates/ui-egui/locales/uk.json | 7 +- crates/ui-egui/locales/zh-hans.json | 4 +- crates/ui-egui/locales/zh-hant.json | 4 +- crates/ui-egui/src/lib.rs | 1 - crates/ui-egui/src/lightroom_import.rs | 155 +++++++++++++++++-------- 11 files changed, 140 insertions(+), 58 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 9b5f1920d..598ace824 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2024,5 +2024,7 @@ "Scanning folder": "Ordner wird durchsucht", "Building previews": "Vorschauen werden erstellt", "Building smart previews": "Smart-Vorschauen werden erstellt", - "Discarding smart previews": "Smart-Vorschauen werden verworfen" + "Discarding smart previews": "Smart-Vorschauen werden verworfen", + "Importing Lightroom catalog": "Lightroom-Katalog wird importiert", + "Reading Lightroom catalog": "Lightroom-Katalog wird gelesen" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 104e75965..f0740bd52 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1869,5 +1869,7 @@ "Scanning folder": "Explorando carpeta", "Building previews": "Generando vistas previas", "Building smart previews": "Generando vistas previas inteligentes", - "Discarding smart previews": "Descartando vistas previas inteligentes" + "Discarding smart previews": "Descartando vistas previas inteligentes", + "Importing Lightroom catalog": "Importando catálogo de Lightroom", + "Reading Lightroom catalog": "Leyendo catálogo de Lightroom" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index cdbf6ee58..51dab1b24 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1866,5 +1866,10 @@ "Quit while tasks are running?": "Quitter pendant que des tâches sont en cours ?", "Exporting contact sheet": "Exportation de la planche-contact", "Reading folder": "Lecture du dossier", - "Scanning folder": "Analyse du dossier" + "Scanning folder": "Analyse du dossier", + "Building previews": "Génération des aperçus", + "Building smart previews": "Génération des aperçus dynamiques", + "Discarding smart previews": "Suppression des aperçus dynamiques", + "Importing Lightroom catalog": "Importation du catalogue Lightroom", + "Reading Lightroom catalog": "Lecture du catalogue Lightroom" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 02dbbc81d..a10a08e1e 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1871,5 +1871,7 @@ "Scanning folder": "フォルダーをスキャン中", "Building previews": "プレビューを作成中", "Building smart previews": "スマートプレビューを作成中", - "Discarding smart previews": "スマートプレビューを破棄中" + "Discarding smart previews": "スマートプレビューを破棄中", + "Importing Lightroom catalog": "Lightroom カタログを読み込み中", + "Reading Lightroom catalog": "Lightroom カタログを読み取り中" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index fa1bcac40..d9d07b5fd 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1869,5 +1869,7 @@ "Scanning folder": "Verificando pasta", "Building previews": "Gerando prévias", "Building smart previews": "Gerando prévias inteligentes", - "Discarding smart previews": "Descartando prévias inteligentes" + "Discarding smart previews": "Descartando prévias inteligentes", + "Importing Lightroom catalog": "Importando catálogo do Lightroom", + "Reading Lightroom catalog": "Lendo catálogo do Lightroom" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 453b1e212..3a442515a 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1582,5 +1582,7 @@ "Scanning folder": "Сканирование папки", "Building previews": "Создание просмотров", "Building smart previews": "Создание смарт-просмотров", - "Discarding smart previews": "Удаление смарт-просмотров" + "Discarding smart previews": "Удаление смарт-просмотров", + "Importing Lightroom catalog": "Импорт каталога Lightroom", + "Reading Lightroom catalog": "Чтение каталога Lightroom" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 2c43493fc..a18094f4d 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2060,5 +2060,10 @@ "Quit while tasks are running?": "Вийти, поки виконуються завдання?", "Exporting contact sheet": "Експорт аркуша мініатюр", "Reading folder": "Читання папки", - "Scanning folder": "Сканування папки" + "Scanning folder": "Сканування папки", + "Building previews": "Створення попередніх переглядів", + "Building smart previews": "Створення смарт-переглядів", + "Discarding smart previews": "Видалення смарт-переглядів", + "Importing Lightroom catalog": "Імпорт каталогу Lightroom", + "Reading Lightroom catalog": "Читання каталогу Lightroom" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index adcc16f8f..c2c29b478 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1869,5 +1869,7 @@ "Scanning folder": "正在扫描文件夹", "Building previews": "正在构建预览", "Building smart previews": "正在构建智能预览", - "Discarding smart previews": "正在丢弃智能预览" + "Discarding smart previews": "正在丢弃智能预览", + "Importing Lightroom catalog": "正在导入 Lightroom 目录", + "Reading Lightroom catalog": "正在读取 Lightroom 目录" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 2c4eca303..e808c3120 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1869,5 +1869,7 @@ "Scanning folder": "正在掃描檔案夾", "Building previews": "正在建立預覽", "Building smart previews": "正在建立智慧型預覽", - "Discarding smart previews": "正在捨棄智慧型預覽" + "Discarding smart previews": "正在捨棄智慧型預覽", + "Importing Lightroom catalog": "正在匯入 Lightroom 目錄", + "Reading Lightroom catalog": "正在讀取 Lightroom 目錄" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 03a23ef5a..af75d1958 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1066,7 +1066,6 @@ impl LightcraftApp { panels::dialogs::show(self, &ctx); panels::library_problem::show(self, &ctx); sync::progress_window(self, &ctx); - lightroom_import::progress(self, &ctx); export_task::poll(self, &ctx); panels::activity::show(self, &ctx); pick::poll(self, &ctx); diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index 8cee63e39..b05b381c9 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -8,12 +8,10 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, mpsc}; -use egui::Align2; +use lightcraft_engine::activity::{Cancel, TaskGuard}; use serde_json::{Value, json}; use crate::LightcraftApp; -use crate::theme::Tokens; -use crate::widgets::register; #[derive(Clone, Copy)] enum Kind { @@ -34,7 +32,7 @@ enum Phase { Finalizing, } -/// A Lightroom task in flight. The receiver is polled from the egui owner thread. +/// A Lightroom task in flight. The receiver is polled from the egui owner thread; the activity stack shows it. pub struct LightroomTask { kind: Kind, path: PathBuf, @@ -44,9 +42,53 @@ pub struct LightroomTask { rx: mpsc::Receiver, phase: Phase, cancelled: bool, + guard: TaskGuard, } impl LightroomTask { + #[cfg(not(target_arch = "wasm32"))] + fn new( + s: &lightcraft_engine::Session, + kind: Kind, + path: PathBuf, + cancel: Arc, + total: Arc, + done: Arc, + rx: mpsc::Receiver, + ) -> Self { + let label = match kind { + Kind::Inspect => "Reading Lightroom catalog", + Kind::Import => "Importing Lightroom catalog", + }; + let guard = s.activity.start("lightroom", label, Cancel::Flag(cancel.clone())); + let task = Self { kind, path, cancel, total, done, rx, phase: Phase::Reading, cancelled: false, guard }; + task.refresh(); + task + } + + /// Move to `phase`; only reading the catalog can stop, adding the photos runs to the end. + fn enter(&mut self, phase: Phase) { + self.phase = phase; + self.guard.set_cancellable(phase == Phase::Reading); + self.refresh(); + } + + /// Bring the activity row up to date: the count while reading, then what is being done. + fn refresh(&self) { + let detail = match (self.phase, self.kind) { + (Phase::Reading, Kind::Inspect) => "", + (Phase::Reading, Kind::Import) => crate::i18n::tr("Reading Lightroom catalog…"), + (Phase::Committing, _) => crate::i18n::tr("Adding Lightroom photos…"), + (Phase::Finalizing, _) => crate::i18n::tr("Saving Lightroom import index…"), + }; + if self.phase == Phase::Reading { + self.guard.progress(self.done.load(Ordering::Relaxed) as u64, self.total.load(Ordering::Relaxed) as u64); + } else { + self.guard.progress(0, 0); + } + self.guard.detail(detail); + } + #[cfg(not(target_arch = "wasm32"))] fn status(&self) -> Value { let kind = match self.kind { @@ -236,7 +278,7 @@ fn start_inspect(app: &mut LightcraftApp, path: PathBuf, ctx: &egui::Context) -> let (tx, rx) = mpsc::channel(); spawn_inspect(path.clone(), cancel.clone(), total.clone(), done.clone(), tx, ctx.clone())?; app.lightroom_last = None; - app.lightroom = Some(LightroomTask { kind: Kind::Inspect, path: path.clone(), cancel, total, done, rx, phase: Phase::Reading, cancelled: false }); + app.lightroom = Some(LightroomTask::new(&app.session, Kind::Inspect, path.clone(), cancel, total, done, rx)); Ok(json!({"running": true, "kind": "inspect", "path": path})) } @@ -250,7 +292,7 @@ fn start_import(app: &mut LightcraftApp, path: PathBuf, update_existing: bool, c let (tx, rx) = mpsc::channel(); spawn_prepare(job, cancel.clone(), tx, ctx.clone())?; app.lightroom_last = None; - app.lightroom = Some(LightroomTask { kind: Kind::Import, path: path.clone(), cancel, total, done, rx, phase: Phase::Reading, cancelled: false }); + app.lightroom = Some(LightroomTask::new(&app.session, Kind::Import, path.clone(), cancel, total, done, rx)); Ok(json!({"running": true, "kind": "import", "path": path})) } @@ -296,6 +338,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { let message = match task.rx.try_recv() { Ok(m) => m, Err(mpsc::TryRecvError::Empty) => { + task.refresh(); app.lightroom = Some(task); return; } @@ -337,7 +380,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { ); return; } - task.phase = Phase::Committing; + task.enter(Phase::Committing); let mut finalization = None; let mut report = None; let committed = app.session.execute_fn("library.importLightroom", |s| { @@ -347,7 +390,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { Ok(completion.report) }); let report = report.unwrap_or(Value::Null); - task.phase = Phase::Finalizing; + task.enter(Phase::Finalizing); let (tx, rx) = mpsc::channel(); let commit_error = committed.as_ref().err().map(ToString::to_string); let Some(finalization) = finalization else { @@ -409,50 +452,66 @@ pub fn wait_for(_app: &mut LightcraftApp, _ctx: &egui::Context, _timeout: std::t unsupported_wasm() } -/// The progress window for inspect/import, with cancellation. -pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) { - let Some(task) = app.lightroom.as_ref() else { return }; - let total = task.total.load(Ordering::Relaxed); - let done = task.done.load(Ordering::Relaxed); - let title = match task.kind { - Kind::Inspect => crate::i18n::tr("Inspecting Lightroom Catalog"), - Kind::Import => crate::i18n::tr("Importing Lightroom Catalog"), - }; - let text = match task.phase { - Phase::Reading if total == 0 => crate::i18n::tr("Reading Lightroom catalog…").into(), - Phase::Reading => crate::i18n::tr_format!("Reading Lightroom catalog… {done} of {total}", done = done, total = total), - Phase::Committing => crate::i18n::tr("Adding Lightroom photos…").into(), - Phase::Finalizing => crate::i18n::tr("Saving Lightroom import index…").into(), - }; - let mut cancel = false; - let t = Tokens::get(ctx); - egui::Window::new(title).title_bar(false).resizable(false).anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]).fixed_size([360.0, 92.0]).show( - ctx, - |ui| { - ui.label(egui::RichText::new(text).color(t.text)); - ui.add(egui::ProgressBar::new(done as f32 / total.max(1) as f32).desired_width(340.0)); - let r = - ui.add_enabled(task.phase == Phase::Reading && !task.cancel.load(Ordering::Relaxed), egui::Button::new(crate::i18n::tr("Cancel"))); - register(ui.ctx(), "button:lightroomCancel", r.rect); - cancel = r.clicked(); - }, - ); - if cancel { - if let Some(task) = app.lightroom.as_mut() { - task.cancel(); - } - ctx.request_repaint(); - } - if app.lightroom.is_some() { - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - } -} - #[cfg(test)] mod tests { - use super::inspect_report; + use super::*; use lightcraft_engine::lightroom_catalog::CatalogImport; + /// A task as `start_import` makes it, fed by a channel the test holds instead of a worker. + fn fake_import(h: &crate::headless::Headless) -> (LightroomTask, mpsc::Sender, Arc, Arc, Arc) { + let (tx, rx) = mpsc::channel(); + let (cancel, total, done) = (Arc::new(AtomicBool::new(false)), Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0))); + let task = LightroomTask::new(&h.app.session, Kind::Import, "fixture.lrcat".into(), cancel.clone(), total.clone(), done.clone(), rx); + (task, tx, cancel, total, done) + } + + fn demo() -> crate::headless::Headless { + let app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); + let mut h = crate::headless::Headless::new(app, [1200.0, 800.0], 1.0); + h.step(); + h + } + + #[test] + fn lightroom_row_is_not_cancellable_while_committing() { + let mut h = demo(); + let (task, tx, cancel, total, done) = fake_import(&h); + h.app.lightroom = Some(task); + total.store(10, Ordering::Relaxed); + done.store(3, Ordering::Relaxed); + h.step(); + let rows = h.app.session.activity.list(); + assert_eq!(rows.len(), 1); + assert_eq!((rows[0].kind, rows[0].label.as_str()), ("lightroom", "Importing Lightroom catalog")); + assert_eq!((rows[0].done, rows[0].total), (3, 10)); + assert!(rows[0].cancellable, "reading the catalog can stop"); + h.app.lightroom.as_mut().unwrap().enter(Phase::Committing); + let row = h.app.session.activity.list().remove(0); + assert!(!row.cancellable, "once photos are being added it runs to the end"); + assert!(h.app.session.activity.cancel(row.id).is_err()); + assert!(!cancel.load(Ordering::Relaxed)); + tx.send(Message::Finalized(Ok(()))).unwrap(); + h.step(); + assert!(h.app.lightroom.is_none()); + assert!(h.app.session.activity.list().is_empty(), "the row goes with the task"); + } + + #[test] + fn lightroom_cancel_from_the_stack_stops_the_read() { + let mut h = demo(); + let (task, tx, cancel, _, _) = fake_import(&h); + h.app.lightroom = Some(task); + h.step(); + let id = h.app.session.activity.list()[0].id; + h.app.session.activity.cancel(id).unwrap(); + assert!(cancel.load(Ordering::Relaxed), "the worker sees the stack's ✕"); + tx.send(Message::Prepared(Err("cancelled".into()))).unwrap(); + h.step(); + assert!(h.app.lightroom.is_none()); + assert_eq!(h.app.lightroom_last, Some(serde_json::json!({"cancelled": true}))); + assert!(h.app.session.activity.list().is_empty()); + } + #[test] fn inspect_report_keeps_empty_catalog_shape() { let report = inspect_report(CatalogImport { From e9e8b5a9330eae0232ae8ebdd871cf340baa418f Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Sat, 10 Oct 2026 01:13:53 +0200 Subject: [PATCH 19/33] Issue #345: photo merge in the activity stack The final HDR / panorama merge shows a row with its percentage and stage instead of the progress toast, and stops from its cross ("HDR merge cancelled"). The activity tests now let the first renders finish before they run, and the half-second check tolerates a slow frame. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/locales/de-formats.json | 3 +- crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es-formats.json | 3 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/fr-formats.json | 3 +- crates/ui-egui/locales/fr.json | 3 +- crates/ui-egui/locales/ja-formats.json | 3 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br-formats.json | 3 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru-formats.json | 3 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/uk-formats.json | 3 +- crates/ui-egui/locales/uk.json | 3 +- crates/ui-egui/locales/zh-hans-formats.json | 3 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant-formats.json | 3 +- crates/ui-egui/locales/zh-hant.json | 3 +- crates/ui-egui/src/lightroom_import.rs | 3 +- crates/ui-egui/src/merge.rs | 21 ++++++-- crates/ui-egui/src/tests_activity.rs | 53 +++++++++++++++++++-- 23 files changed, 106 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e8037a6f8..0ad4ab843 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3352,6 +3352,7 @@ dependencies = [ "lightcraft-engine", "lightcraft-faces", "lightcraft-geom", + "lightcraft-merge", "lightcraft-meta", "lightcraft-pipeline", "lightcraft-preview", diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index d67494b78..f7dbc65ab 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -28,6 +28,7 @@ log = { workspace = true } lightcraft-denoise = { workspace = true, features = ["runtime"] } lightcraft-codecs = { workspace = true } lightcraft-faces = { workspace = true } +lightcraft-merge = { workspace = true } [build-dependencies] serde_json = { workspace = true } diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 3597ffdc9..671da17d4 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -227,5 +227,6 @@ "{pct} %": "{pct} %", "+{n} more": "+{n} weitere", "1 task is still running: {names}. Quitting stops it.": "1 Aufgabe läuft noch: {names}. Beenden bricht sie ab.", - "{n} tasks are still running: {names}. Quitting stops them.": "{n} Aufgaben laufen noch: {names}. Beenden bricht sie ab." + "{n} tasks are still running: {names}. Quitting stops them.": "{n} Aufgaben laufen noch: {names}. Beenden bricht sie ab.", + "{what} merge cancelled": "{what}-Zusammenfügung abgebrochen" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 598ace824..1edf29adc 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2026,5 +2026,6 @@ "Building smart previews": "Smart-Vorschauen werden erstellt", "Discarding smart previews": "Smart-Vorschauen werden verworfen", "Importing Lightroom catalog": "Lightroom-Katalog wird importiert", - "Reading Lightroom catalog": "Lightroom-Katalog wird gelesen" + "Reading Lightroom catalog": "Lightroom-Katalog wird gelesen", + "Merging": "Zusammenfügen" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index ea5021984..9ba1c8f10 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -227,5 +227,6 @@ "{pct} %": "{pct} %", "+{n} more": "+{n} más", "1 task is still running: {names}. Quitting stops it.": "1 tarea sigue en curso: {names}. Salir la detiene.", - "{n} tasks are still running: {names}. Quitting stops them.": "{n} tareas siguen en curso: {names}. Salir las detiene." + "{n} tasks are still running: {names}. Quitting stops them.": "{n} tareas siguen en curso: {names}. Salir las detiene.", + "{what} merge cancelled": "Combinación {what} cancelada" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index f0740bd52..701c91bdb 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1871,5 +1871,6 @@ "Building smart previews": "Generando vistas previas inteligentes", "Discarding smart previews": "Descartando vistas previas inteligentes", "Importing Lightroom catalog": "Importando catálogo de Lightroom", - "Reading Lightroom catalog": "Leyendo catálogo de Lightroom" + "Reading Lightroom catalog": "Leyendo catálogo de Lightroom", + "Merging": "Combinando" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index f39076898..7ccaf5a38 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -227,5 +227,6 @@ "{pct} %": "{pct} %", "+{n} more": "+{n} autres", "1 task is still running: {names}. Quitting stops it.": "1 tâche est encore en cours : {names}. Quitter l'arrête.", - "{n} tasks are still running: {names}. Quitting stops them.": "{n} tâches sont encore en cours : {names}. Quitter les arrête." + "{n} tasks are still running: {names}. Quitting stops them.": "{n} tâches sont encore en cours : {names}. Quitter les arrête.", + "{what} merge cancelled": "Fusion {what} annulée" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 51dab1b24..59631e84a 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1871,5 +1871,6 @@ "Building smart previews": "Génération des aperçus dynamiques", "Discarding smart previews": "Suppression des aperçus dynamiques", "Importing Lightroom catalog": "Importation du catalogue Lightroom", - "Reading Lightroom catalog": "Lecture du catalogue Lightroom" + "Reading Lightroom catalog": "Lecture du catalogue Lightroom", + "Merging": "Fusion en cours" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index f42118d53..f1c06351c 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -227,5 +227,6 @@ "{pct} %": "{pct}%", "+{n} more": "ほか{n}件", "1 task is still running: {names}. Quitting stops it.": "1件のタスクが実行中です:{names}。終了すると停止します。", - "{n} tasks are still running: {names}. Quitting stops them.": "{n}件のタスクが実行中です:{names}。終了すると停止します。" + "{n} tasks are still running: {names}. Quitting stops them.": "{n}件のタスクが実行中です:{names}。終了すると停止します。", + "{what} merge cancelled": "{what}合成をキャンセルしました" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index a10a08e1e..520452a8d 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1873,5 +1873,6 @@ "Building smart previews": "スマートプレビューを作成中", "Discarding smart previews": "スマートプレビューを破棄中", "Importing Lightroom catalog": "Lightroom カタログを読み込み中", - "Reading Lightroom catalog": "Lightroom カタログを読み取り中" + "Reading Lightroom catalog": "Lightroom カタログを読み取り中", + "Merging": "合成中" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 8bd5bcae6..4a9d1168a 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -227,5 +227,6 @@ "{pct} %": "{pct}%", "+{n} more": "+{n} mais", "1 task is still running: {names}. Quitting stops it.": "1 tarefa ainda está em andamento: {names}. Sair a interrompe.", - "{n} tasks are still running: {names}. Quitting stops them.": "{n} tarefas ainda estão em andamento: {names}. Sair as interrompe." + "{n} tasks are still running: {names}. Quitting stops them.": "{n} tarefas ainda estão em andamento: {names}. Sair as interrompe.", + "{what} merge cancelled": "{what} mesclagem cancelada" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index d9d07b5fd..ad269f963 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1871,5 +1871,6 @@ "Building smart previews": "Gerando prévias inteligentes", "Discarding smart previews": "Descartando prévias inteligentes", "Importing Lightroom catalog": "Importando catálogo do Lightroom", - "Reading Lightroom catalog": "Lendo catálogo do Lightroom" + "Reading Lightroom catalog": "Lendo catálogo do Lightroom", + "Merging": "Mesclando" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 53a54f6cc..ba3560ad0 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -227,5 +227,6 @@ "{pct} %": "{pct} %", "+{n} more": "ещё {n}", "1 task is still running: {names}. Quitting stops it.": "Ещё выполняется задача: {names}. Выход прервёт её.", - "{n} tasks are still running: {names}. Quitting stops them.": "Ещё выполняется задач: {n} — {names}. Выход прервёт их." + "{n} tasks are still running: {names}. Quitting stops them.": "Ещё выполняется задач: {n} — {names}. Выход прервёт их.", + "{what} merge cancelled": "{what}: слияние отменено" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 3a442515a..6a4e4d528 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1584,5 +1584,6 @@ "Building smart previews": "Создание смарт-просмотров", "Discarding smart previews": "Удаление смарт-просмотров", "Importing Lightroom catalog": "Импорт каталога Lightroom", - "Reading Lightroom catalog": "Чтение каталога Lightroom" + "Reading Lightroom catalog": "Чтение каталога Lightroom", + "Merging": "Слияние" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 9562c8f90..c33dd0cb0 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -227,5 +227,6 @@ "{pct} %": "{pct} %", "+{n} more": "+{n} ще", "1 task is still running: {names}. Quitting stops it.": "1 завдання ще виконується: {names}. Вихід зупинить його.", - "{n} tasks are still running: {names}. Quitting stops them.": "Ще виконуються завдання ({n}): {names}. Вихід зупинить їх." + "{n} tasks are still running: {names}. Quitting stops them.": "Ще виконуються завдання ({n}): {names}. Вихід зупинить їх.", + "{what} merge cancelled": "Об’єднання {what} скасовано" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index a18094f4d..3c765a1ba 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2065,5 +2065,6 @@ "Building smart previews": "Створення смарт-переглядів", "Discarding smart previews": "Видалення смарт-переглядів", "Importing Lightroom catalog": "Імпорт каталогу Lightroom", - "Reading Lightroom catalog": "Читання каталогу Lightroom" + "Reading Lightroom catalog": "Читання каталогу Lightroom", + "Merging": "Об’єднання" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index cf28c11aa..eb429af42 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -227,5 +227,6 @@ "{pct} %": "{pct}%", "+{n} more": "还有 {n} 项", "1 task is still running: {names}. Quitting stops it.": "仍有 1 个任务在运行:{names}。退出将停止它。", - "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 个任务在运行:{names}。退出将停止它们。" + "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 个任务在运行:{names}。退出将停止它们。", + "{what} merge cancelled": "已取消 {what} 合并" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index c2c29b478..24a47421c 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1871,5 +1871,6 @@ "Building smart previews": "正在构建智能预览", "Discarding smart previews": "正在丢弃智能预览", "Importing Lightroom catalog": "正在导入 Lightroom 目录", - "Reading Lightroom catalog": "正在读取 Lightroom 目录" + "Reading Lightroom catalog": "正在读取 Lightroom 目录", + "Merging": "正在合并" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 61e7cef81..0b2ec7657 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -227,5 +227,6 @@ "{pct} %": "{pct}%", "+{n} more": "還有 {n} 項", "1 task is still running: {names}. Quitting stops it.": "仍有 1 個工作在執行:{names}。結束將停止它。", - "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 個工作在執行:{names}。結束將停止它們。" + "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 個工作在執行:{names}。結束將停止它們。", + "{what} merge cancelled": "已取消{what}合併" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index e808c3120..caf5f9bca 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1871,5 +1871,6 @@ "Building smart previews": "正在建立智慧型預覽", "Discarding smart previews": "正在捨棄智慧型預覽", "Importing Lightroom catalog": "正在匯入 Lightroom 目錄", - "Reading Lightroom catalog": "正在讀取 Lightroom 目錄" + "Reading Lightroom catalog": "正在讀取 Lightroom 目錄", + "Merging": "正在合併" } diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index b05b381c9..28101ed2c 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -468,7 +468,8 @@ mod tests { fn demo() -> crate::headless::Headless { let app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); let mut h = crate::headless::Headless::new(app, [1200.0, 800.0], 1.0); - h.step(); + // no render left running at exit (see tests_activity::demo) + h.settle(std::time::Duration::from_secs(20)); h } diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index 3849a191b..b1d066a7b 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -4,7 +4,7 @@ //! Previews and the final merge run [`lightcraft_engine::merge::MergeJob`]s on worker threads with //! progress and cancellation; the window stays responsive. Changing an option cancels the running //! preview and starts a new one. The final merge keeps running after the dialog closes (progress -//! in a toast); when it finishes, the result is written, imported and selected +//! in the activity stack); when it finishes, the result is written, imported and selected //! ([`lightcraft_engine::Session::finish_merge`]). use std::sync::atomic::{AtomicBool, Ordering}; @@ -12,6 +12,7 @@ use std::sync::mpsc::{Receiver, channel}; use std::sync::{Arc, Mutex}; use lightcraft_catalog::PhotoId; +use lightcraft_engine::activity::{Cancel, TaskGuard, Unit}; use lightcraft_engine::merge::{MergeJob, MergeOutput}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -88,6 +89,8 @@ pub struct MergeTask { pub progress: Arc>, pub cancel: Arc, rx: Receiver>, + /// The final merge's row in the activity stack (previews have none). + guard: Option, } /// Merge state of the app. @@ -140,7 +143,7 @@ fn spawn(app: &LightcraftApp, options: &MergeDialog, ids: &[PhotoId], preview: b std::thread::Builder::new().name("photo-merge".into()).spawn(work).map_err(|e| e.to_string())?; #[cfg(target_arch = "wasm32")] work(); - Ok(MergeTask { preview, job, options: options.clone(), progress, cancel, rx }) + Ok(MergeTask { preview, job, options: options.clone(), progress, cancel, rx, guard: None }) } /// Open the merge dialog for the selection. @@ -169,7 +172,11 @@ pub fn start_final(app: &mut LightcraftApp, opts: &MergeDialog) -> Result app.toast(ctx, crate::i18n::tr_format!("{what} merge cancelled", what = what)), Err(e) => { app.ui.status = e.to_string(); app.toast(ctx, crate::i18n::tr_format!("{what} merge failed: {e}", e = e, what = what)); @@ -273,8 +281,11 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { } Err(_) => { let (f, stage) = t.progress.lock().map(|g| g.clone()).unwrap_or_default(); - let now = ctx.input(|i| i.time); - app.ui.toast = Some((crate::i18n::tr_format!("Merging… {stage} {:.0}%", f * 100.0, stage = stage), now + 0.5, None)); + if let Some(guard) = &t.guard { + let f = if f.is_finite() { f.clamp(0.0, 1.0) } else { 0.0 }; + guard.progress((f * 1000.0) as u64, 1000); + guard.detail(crate::i18n::tr(&stage)); + } ctx.request_repaint_after(std::time::Duration::from_millis(100)); } } diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 5e8e967d0..db4b58297 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -14,7 +14,9 @@ const T: Duration = Duration::from_secs(20); fn demo() -> Headless { let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); let mut h = Headless::new(app, [1200.0, 800.0], 1.0); - h.step(); + // let the first renders finish: a render still running when the test binary exits can crash in the + // GPU driver's teardown (the preview pool doesn't join its workers) + h.settle(T); h } @@ -53,7 +55,10 @@ fn rows_appear_after_half_a_second_and_cancel_by_click() { let g = h.app.session.activity.start("export", "Exporting", Cancel::Yes); g.progress(2, 5); h.step(); - assert!(!has(&h, &format!("activity:row:{}", g.id())), "not before 0.5 s"); + // a frame on a loaded machine can outlast the half second: only judge a task still young after it + if h.app.session.activity.list()[0].age_ms < crate::panels::activity::SHOW_AFTER_MS { + assert!(!has(&h, &format!("activity:row:{}", g.id())), "not before 0.5 s"); + } wait_visible(&mut h); assert!(has(&h, &format!("activity:row:{}", g.id()))); click(&mut h, &format!("activity:cancel:{}", g.id())); @@ -242,7 +247,7 @@ fn slow_import(tag: &str, n: usize) -> (Headless, std::path::PathBuf) { }) })); let mut h = Headless::new(LightcraftApp::new(s, Services { png: None, ..Default::default() }), [1200.0, 800.0], 1.0); - h.step(); + h.settle(T); (h, dir) } @@ -282,3 +287,45 @@ fn scan_shows_a_row_and_cancel_closes_it() { assert!(h.app.ui.dialog.is_none(), "no review opened"); let _ = std::fs::remove_dir_all(&dir); } + +/// A library of three bracketed DNGs (−2, 0, +2 EV) on disk, all selected. +fn bracket(tag: &str) -> (Headless, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("lc-activity-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut paths = Vec::new(); + for (i, bytes) in lightcraft_merge::synth::bracket_dngs(1600, 1200, &[-2.0, 0.0, 2.0]).unwrap().into_iter().enumerate() { + let p = dir.join(format!("IMG_{i}.dng")); + std::fs::write(&p, bytes).unwrap(); + paths.push(p.to_string_lossy().to_string()); + } + let mut session = lightcraft_engine::Session::new().with_fs(); + let r = session.execute("library.import", &json!({"paths": paths})).unwrap(); + let ids: Vec = r["imported"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + session.execute("library.select", &json!({"ids": ids})).unwrap(); + let app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.settle(T); + (h, dir) +} + +#[test] +fn final_merge_shows_a_row_and_cancels() { + let (mut h, dir) = bracket("merge"); + let before = h.app.session.catalog.len(); + crate::merge::start_last(&mut h.app, "merge.hdr").unwrap(); + h.step(); + let rows = h.app.session.activity.list(); + assert_eq!(rows.len(), 1); + assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].unit), ("merge", "Merging", Unit::Percent)); + assert!(rows[0].cancellable); + assert_eq!(rows[0].total, 1000); + h.app.session.activity.cancel(rows[0].id).unwrap(); + assert!(h.step_until(T, |h| h.app.merge.final_task.is_none())); + assert!(h.app.merge.last_result.is_none(), "nothing merged"); + assert_eq!(h.app.session.catalog.len(), before, "no photo added"); + assert!(h.app.session.activity.list().is_empty()); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert_eq!(toast, "HDR merge cancelled"); + let _ = std::fs::remove_dir_all(dir); +} From de247ad54f2b3b0fcecbfab02cdeda0bde9b0279 Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Sat, 10 Oct 2026 01:20:51 +0200 Subject: [PATCH 20/33] Issue #345: model downloads in the activity stack Downloading the SAM 3, face and AI Denoise models shows a row with the model's name and the megabytes so far; its cross stops the download like the Settings button. The denoise download tests get their own scratch folders (they shared the face tests' names and could delete each other's files). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/denoise.rs | 2 +- crates/engine/src/cmd/face_models.rs | 8 ++- crates/engine/src/cmd/masks.rs | 2 +- crates/engine/src/face_download.rs | 80 ++++++++++++++++++++++++-- crates/engine/src/model_download.rs | 82 +++++++++++++++++++++++++-- crates/engine/src/segment/download.rs | 23 ++++++-- crates/engine/src/segment/mod.rs | 6 +- crates/engine/src/tests_segment.rs | 48 ++++++++++++++++ 8 files changed, 232 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/denoise.rs b/crates/engine/src/cmd/denoise.rs index e1cc0900c..6962f9407 100644 --- a/crates/engine/src/cmd/denoise.rs +++ b/crates/engine/src/cmd/denoise.rs @@ -368,7 +368,7 @@ fn download(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "that model is already installed")); } let host = lightcraft_denoise::known::host(&spec.url).to_string(); - s.denoise.downloads.start(spec, &dir).map_err(|e| bad(C, e))?; + s.denoise.downloads.start(spec, &dir, &s.activity, &k.manifest.name).map_err(|e| bad(C, e))?; Ok(json!({"started": id, "from": host})) } diff --git a/crates/engine/src/cmd/face_models.rs b/crates/engine/src/cmd/face_models.rs index 44b359e4a..3a8e401dd 100644 --- a/crates/engine/src/cmd/face_models.rs +++ b/crates/engine/src/cmd/face_models.rs @@ -356,7 +356,13 @@ fn download(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "that model is already installed")); } let host = spec.host().to_string(); - s.face_downloads.start(spec, &dir).map_err(|e| bad(C, e))?; + let name = known::all() + .into_iter() + .find(|m| m.id == spec.id) + .map(|m| m.name) + .or_else(|| s.face_catalog.entries.iter().find(|e| e.manifest.id == spec.id).map(|e| e.manifest.name.clone())) + .unwrap_or_else(|| spec.id.clone()); + s.face_downloads.start(spec, &dir, &s.activity, &name).map_err(|e| bad(C, e))?; Ok(json!({"started": id, "from": host})) } diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index d72996523..24a94cbc8 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -464,7 +464,7 @@ pub fn specs() -> Vec { ), )); } - let started = s.segmenter.start_download().map_err(ai_err)?; + let started = s.segmenter.start_download(&s.activity).map_err(ai_err)?; Ok(json!({"started": started, "installed": s.segmenter.installed(), "downloading": s.segmenter.download_status().running})) } ), diff --git a/crates/engine/src/face_download.rs b/crates/engine/src/face_download.rs index 0c6ed4ee1..df79d5630 100644 --- a/crates/engine/src/face_download.rs +++ b/crates/engine/src/face_download.rs @@ -15,6 +15,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; +use crate::activity::{Activity, Cancel, TaskHandle, Unit}; + use lightcraft_faces::known::Download; /// The folder inside the models folder where a downloaded file waits to be installed. Its name starts with a dot, so it @@ -42,15 +44,20 @@ pub enum State { struct Shared { state: Mutex, - cancel: AtomicBool, + cancel: Arc, + /// The download's row in the activity stack. + task: Option, } impl Shared { fn new(total: u64) -> Self { - Shared { state: Mutex::new(State::Running { bytes: 0, total }), cancel: AtomicBool::new(false) } + Shared { state: Mutex::new(State::Running { bytes: 0, total }), cancel: Arc::new(AtomicBool::new(false)), task: None } } fn set(&self, s: State) { + if let (State::Running { bytes, total }, Some(task)) = (&s, &self.task) { + task.progress(*bytes, *total); + } *self.state.lock().unwrap_or_else(PoisonError::into_inner) = s; } @@ -68,18 +75,27 @@ pub struct Downloads { impl Downloads { /// Start fetching `spec` into `/.downloads/`. Different models can download side by side; the same /// one is started once. - pub fn start(&mut self, spec: Download, models_dir: &Path) -> Result<(), String> { + /// The activity stack shows it (as `name`) until the thread ends; its ✕ stops it like [`Downloads::discard`]. + pub fn start(&mut self, spec: Download, models_dir: &Path, activity: &Activity, name: &str) -> Result<(), String> { if self.jobs.get(spec.id.as_str()).is_some_and(|j| matches!(j.get(), State::Running { .. })) { return Err("that model is already downloading".into()); } let staging = models_dir.join(STAGING); std::fs::create_dir_all(&staging).map_err(|e| format!("could not create the download folder: {e}"))?; - let shared = Arc::new(Shared::new(spec.size_bytes)); + let cancel = Arc::new(AtomicBool::new(false)); + let task = activity.start("download", "Downloading model", Cancel::Flag(cancel.clone())); + task.set_unit(Unit::Bytes); + task.detail(name); + task.progress(0, spec.size_bytes); + let total = spec.size_bytes; + let shared = Arc::new(Shared { state: Mutex::new(State::Running { bytes: 0, total }), cancel, task: Some(task.handle()) }); let worker = shared.clone(); let id = spec.id.clone(); std::thread::Builder::new() .name("face-model-download".into()) .spawn(move || { + // the row goes when the thread ends, however it ends + let _task = task; let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&spec, spec.base(), &staging, &worker))); if outcome.is_err() { worker.set(State::Failed("the download stopped unexpectedly".into())); @@ -267,6 +283,62 @@ mod tests { base } + /// A local server that sends `body` a few kilobytes at a time: a download that keeps running for a while. + fn serve_slowly(body: Vec) -> String { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}/m", l.local_addr().unwrap()); + std::thread::spawn(move || { + for mut s in l.incoming().flatten() { + let mut r = BufReader::new(s.try_clone().unwrap()); + loop { + let mut line = String::new(); + if r.read_line(&mut line).unwrap_or(0) == 0 || line.trim().is_empty() { + break; + } + } + let _ = s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).as_bytes()); + for chunk in body.chunks(4096) { + if s.write_all(chunk).is_err() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + } + }); + base + } + + /// Poll until `done` holds (or fail after 10 s). + fn wait(what: &str, done: impl Fn() -> bool) { + let t = std::time::Instant::now(); + while !done() { + assert!(t.elapsed() < Duration::from_secs(10), "{what}"); + std::thread::sleep(Duration::from_millis(10)); + } + } + + #[test] + fn a_running_download_shows_in_activity_and_cancels() { + use crate::activity::{Activity, Unit}; + let dir = scratch("activity"); + let bytes: Vec = (0..400_000u32).map(|i| (i % 251) as u8).collect(); + let mut spec = spec_for(&bytes); + spec.url = format!("{}/{}", serve_slowly(bytes), spec.file_name); + let activity = Activity::default(); + let mut d = Downloads::default(); + d.start(spec, &dir, &activity, "Test Model").unwrap(); + let rows = activity.list(); + assert_eq!(rows.len(), 1); + assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].detail.as_str()), ("download", "Downloading model", "Test Model")); + assert_eq!((rows[0].unit, rows[0].total), (Unit::Bytes, 400_000)); + assert!(rows[0].cancellable); + activity.cancel(rows[0].id).unwrap(); + wait("the download never stopped", || !d.running()); + assert_eq!(d.jobs["test-model"].get(), State::Cancelled); + wait("the row stayed", || activity.list().is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn fetches_checks_and_stages_a_file() { let dir = scratch("ok"); diff --git a/crates/engine/src/model_download.rs b/crates/engine/src/model_download.rs index 98707f78f..96f5eb1c7 100644 --- a/crates/engine/src/model_download.rs +++ b/crates/engine/src/model_download.rs @@ -15,6 +15,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; +use crate::activity::{Activity, Cancel, TaskHandle, Unit}; + use lightcraft_denoise::known::Download; /// The folder inside the models folder where a downloaded file waits to be installed. Its name starts with a dot, so it @@ -43,15 +45,20 @@ pub enum State { struct Shared { state: Mutex, - cancel: AtomicBool, + cancel: Arc, + /// The download's row in the activity stack. + task: Option, } impl Shared { fn new(total: u64) -> Self { - Shared { state: Mutex::new(State::Running { bytes: 0, total }), cancel: AtomicBool::new(false) } + Shared { state: Mutex::new(State::Running { bytes: 0, total }), cancel: Arc::new(AtomicBool::new(false)), task: None } } fn set(&self, s: State) { + if let (State::Running { bytes, total }, Some(task)) = (&s, &self.task) { + task.progress(*bytes, *total); + } *self.state.lock().unwrap_or_else(PoisonError::into_inner) = s; } @@ -69,18 +76,27 @@ pub struct Downloads { impl Downloads { /// Start fetching `spec` into `/.downloads/`. Different models can download side by side; the same /// one is started once. - pub fn start(&mut self, spec: Download, models_dir: &Path) -> Result<(), String> { + /// The activity stack shows it (as `name`) until the thread ends; its ✕ stops it like [`Downloads::discard`]. + pub fn start(&mut self, spec: Download, models_dir: &Path, activity: &Activity, name: &str) -> Result<(), String> { if self.jobs.get(&spec.id).is_some_and(|j| matches!(j.get(), State::Running { .. })) { return Err("that model is already downloading".into()); } let staging = models_dir.join(STAGING); std::fs::create_dir_all(&staging).map_err(|e| format!("could not create the download folder: {e}"))?; - let shared = Arc::new(Shared::new(spec.size_bytes)); + let cancel = Arc::new(AtomicBool::new(false)); + let task = activity.start("download", "Downloading model", Cancel::Flag(cancel.clone())); + task.set_unit(Unit::Bytes); + task.detail(name); + task.progress(0, spec.size_bytes); + let total = spec.size_bytes; + let shared = Arc::new(Shared { state: Mutex::new(State::Running { bytes: 0, total }), cancel, task: Some(task.handle()) }); let worker = shared.clone(); let id = spec.id.clone(); std::thread::Builder::new() .name("denoise-model-download".into()) .spawn(move || { + // the row goes when the thread ends, however it ends + let _task = task; let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&spec, &spec.url, &staging, &worker))); if outcome.is_err() { worker.set(State::Failed("the download stopped unexpectedly".into())); @@ -218,7 +234,7 @@ mod tests { use std::net::TcpListener; fn scratch(name: &str) -> PathBuf { - let d = std::env::temp_dir().join(format!("lc-face-dl-{name}-{}", std::process::id())); + let d = std::env::temp_dir().join(format!("lc-denoise-dl-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d @@ -254,6 +270,62 @@ mod tests { base } + /// A local server that sends `body` a few kilobytes at a time: a download that keeps running for a while. + fn serve_slowly(body: Vec) -> String { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}/m", l.local_addr().unwrap()); + std::thread::spawn(move || { + for mut s in l.incoming().flatten() { + let mut r = BufReader::new(s.try_clone().unwrap()); + loop { + let mut line = String::new(); + if r.read_line(&mut line).unwrap_or(0) == 0 || line.trim().is_empty() { + break; + } + } + let _ = s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).as_bytes()); + for chunk in body.chunks(4096) { + if s.write_all(chunk).is_err() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + } + }); + base + } + + /// Poll until `done` holds (or fail after 10 s). + fn wait(what: &str, done: impl Fn() -> bool) { + let t = std::time::Instant::now(); + while !done() { + assert!(t.elapsed() < Duration::from_secs(10), "{what}"); + std::thread::sleep(Duration::from_millis(10)); + } + } + + #[test] + fn a_running_download_shows_in_activity_and_cancels() { + use crate::activity::{Activity, Unit}; + let dir = scratch("activity"); + let bytes: Vec = (0..400_000u32).map(|i| (i % 251) as u8).collect(); + let mut spec = spec_for(&bytes); + spec.url = format!("{}/{}", serve_slowly(bytes), spec.file_name); + let activity = Activity::default(); + let mut d = Downloads::default(); + d.start(spec, &dir, &activity, "Test Model").unwrap(); + let rows = activity.list(); + assert_eq!(rows.len(), 1); + assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].detail.as_str()), ("download", "Downloading model", "Test Model")); + assert_eq!((rows[0].unit, rows[0].total), (Unit::Bytes, 400_000)); + assert!(rows[0].cancellable); + activity.cancel(rows[0].id).unwrap(); + wait("the download never stopped", || !d.running()); + assert_eq!(d.jobs["test-model"].get(), State::Cancelled); + wait("the row stayed", || activity.list().is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn fetches_checks_and_stages_a_file() { let dir = scratch("ok"); diff --git a/crates/engine/src/segment/download.rs b/crates/engine/src/segment/download.rs index 875d652f8..7147db09d 100644 --- a/crates/engine/src/segment/download.rs +++ b/crates/engine/src/segment/download.rs @@ -8,6 +8,8 @@ use std::sync::{Arc, Mutex}; use lightcraft_segment::fetch::{self, Options, Progress}; +use crate::activity::{Activity, Cancel, Unit}; + /// What the session sees of a download. #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)] pub struct Status { @@ -31,7 +33,7 @@ struct Text { #[derive(Default)] struct Shared { running: AtomicBool, - cancel: AtomicBool, + cancel: Arc, finished: AtomicBool, done: AtomicU64, total: AtomicU64, @@ -92,9 +94,17 @@ impl Downloader { running } - /// Start downloading `files` from `mirrors` into `dir` on a background thread. False when - /// one is already running. - pub fn start(&self, files: &'static [fetch::FileSpec], mirrors: Vec, dir: PathBuf, opts: Options) -> Result { + /// Start downloading `files` from `mirrors` into `dir` on a background thread, shown in the activity stack as + /// `name` (its ✕ cancels like [`Downloader::cancel`]). False when one is already running. + pub fn start( + &self, + files: &'static [fetch::FileSpec], + mirrors: Vec, + dir: PathBuf, + opts: Options, + activity: &Activity, + name: &str, + ) -> Result { let s = self.shared.clone(); if s.running.swap(true, Ordering::SeqCst) { return Ok(false); @@ -106,6 +116,10 @@ impl Downloader { // nothing else runs while `running` was false *lock(&s.text) = Text::default(); *lock(&self.last) = Text::default(); + let task = activity.start("download", "Downloading model", Cancel::Flag(s.cancel.clone())); + task.set_unit(Unit::Bytes); + task.detail(name); + task.progress(0, s.total.load(Ordering::SeqCst)); let guard = Running(s.clone()); let spawned = std::thread::Builder::new().name("sam3-download".into()).spawn(move || { let s = guard.0.clone(); @@ -113,6 +127,7 @@ impl Downloader { let mut on_progress = |p: &Progress| { s.done.store(p.done, Ordering::SeqCst); s.total.store(p.total, Ordering::SeqCst); + task.progress(p.done, p.total); if p.file != last_file { last_file.clone_from(&p.file); lock(&s.text).file.clone_from(&p.file); diff --git a/crates/engine/src/segment/mod.rs b/crates/engine/src/segment/mod.rs index c21873f55..ec2c4ba85 100644 --- a/crates/engine/src/segment/mod.rs +++ b/crates/engine/src/segment/mod.rs @@ -259,7 +259,7 @@ impl Segmenter { /// Start downloading the model (on a background thread; see `segment.model.download`). /// `Ok(false)` when it is installed or downloading already. - pub fn start_download(&self) -> Result { + pub fn start_download(&self, activity: &crate::activity::Activity) -> Result { if !Self::AVAILABLE { return Err("AI masks are not available in this build".into()); } @@ -273,11 +273,11 @@ impl Segmenter { if mirrors.is_empty() { return Err(lightcraft_segment::fetch::no_mirrors_message()); } - self.download.start(lightcraft_segment::fetch::SAM3_FILES, mirrors, dir, lightcraft_segment::fetch::options()) + self.download.start(lightcraft_segment::fetch::SAM3_FILES, mirrors, dir, lightcraft_segment::fetch::options(), activity, "SAM 3") } #[cfg(not(feature = "sam"))] { - let _ = dir; + let _ = (dir, activity); Ok(false) } } diff --git a/crates/engine/src/tests_segment.rs b/crates/engine/src/tests_segment.rs index 59aee18c3..ccca1cb23 100644 --- a/crates/engine/src/tests_segment.rs +++ b/crates/engine/src/tests_segment.rs @@ -230,3 +230,51 @@ fn object_clicks_are_capped() { assert!(!s.segmenter.busy()); let _ = std::fs::remove_dir_all(&dir); } + +#[cfg(feature = "sam")] +#[test] +fn a_running_model_download_shows_in_activity_and_cancels() { + use std::io::{BufRead, BufReader, Write}; + // a local mirror that sends its files slowly, so the download is still running when it is cancelled + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}/sam3", l.local_addr().unwrap()); + std::thread::spawn(move || { + for mut c in l.incoming().flatten() { + let mut r = BufReader::new(c.try_clone().unwrap()); + let mut line = String::new(); + while r.read_line(&mut line).unwrap_or(0) > 2 { + line.clear(); + } + let _ = c.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1000000\r\nConnection: close\r\n\r\n"); + for _ in 0..250 { + if c.write_all(&[b' '; 4000]).is_err() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + } + }); + let dir = tmp("download-activity"); + let mirrors = dir.with_extension("activity-mirrors.txt"); + std::fs::write(&mirrors, format!("# test mirror\n{base}\n")).unwrap(); + let mut s = Session::with_demo(); + s.segmenter.dir = Some(dir.clone()); + s.segmenter.mirrors_file = Some(mirrors.clone()); + assert_eq!(s.execute("segment.model.download", &json!({"acknowledged": true})).unwrap()["started"], true); + let rows = s.activity.list(); + assert_eq!(rows.len(), 1); + assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].detail.as_str()), ("download", "Downloading model", "SAM 3")); + assert_eq!(rows[0].unit, crate::activity::Unit::Bytes); + assert!(rows[0].cancellable); + s.execute("activity.cancel", &json!({"id": rows[0].id})).unwrap(); + let t = Instant::now(); + while s.segmenter.download_status().running || !s.activity.list().is_empty() { + assert!(t.elapsed() < Duration::from_secs(10), "the download never stopped"); + std::thread::sleep(Duration::from_millis(10)); + } + let e = s.segmenter.download_status().error.unwrap_or_default(); + assert!(e.contains("cancelled"), "{e}"); + assert!(!s.segmenter.installed()); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_file(&mirrors); +} From 3f3c2049873366be3aa619494b15fe1716697f05 Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Sat, 10 Oct 2026 01:57:12 +0200 Subject: [PATCH 21/33] Issue #345: face scan in the activity stack While photos are left to search for faces the stack shows "Finding faces" with how many are done (no cross: the scan is switched off in Settings). The engine keeps the scan's peak, so Settings and the stack agree. Also translates the "Downloading model" label. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/face_recognize.rs | 23 +++++++++++++++- crates/engine/src/faces_index.rs | 4 +++ crates/engine/src/tests_face_recognize.rs | 32 +++++++++++++++++++++++ crates/ui-egui/locales/de.json | 4 ++- crates/ui-egui/locales/es.json | 4 ++- crates/ui-egui/locales/ja.json | 4 ++- crates/ui-egui/locales/pt-br.json | 4 ++- crates/ui-egui/locales/ru.json | 4 ++- crates/ui-egui/locales/zh-hans.json | 4 ++- crates/ui-egui/locales/zh-hant.json | 4 ++- crates/ui-egui/src/panels/faces.rs | 5 ++-- 11 files changed, 82 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/face_recognize.rs b/crates/engine/src/cmd/face_recognize.rs index 9efdb146d..e099f13ec 100644 --- a/crates/engine/src/cmd/face_recognize.rs +++ b/crates/engine/src/cmd/face_recognize.rs @@ -515,6 +515,21 @@ mod imp { })) } + /// The scan's row in the activity stack: there while `pending` photos are left, its whole the most that were left at + /// once since the scan last finished (which it returns; 0 when none are left). It can't be stopped from there: the + /// scan is switched off in Settings. + fn track(s: &mut Session, pending: u64) -> u64 { + if pending == 0 { + s.faces.task = None; + s.faces.peak = 0; + return 0; + } + s.faces.peak = s.faces.peak.max(pending); + let task = s.faces.task.get_or_insert_with(|| s.activity.start("faces", "Finding faces", crate::activity::Cancel::No)); + task.progress(s.faces.peak.saturating_sub(pending), s.faces.peak); + s.faces.peak + } + /// `faces.pump`: cheap to call every frame. Takes in what the background worker has finished and, when it is idle, /// hands it the next photo with faces to embed. Does nothing (and says so) until recognition is switched on in /// Settings with a model chosen. @@ -528,14 +543,17 @@ mod imp { finish_downloads(s); let enabled = s.face_models_dir.clone().is_some_and(|d| enabled_cached(s, &d)); if !enabled { + track(s, 0); return Ok(json!({"active": false})); } if s.faces.retry_at.is_some_and(|t| Instant::now() < t) { + track(s, 0); return Ok(json!({"active": false})); } let Ok(embedder) = current_with(s, C, false) else { // the chosen model is gone or broken: look again in a few seconds, not every frame s.faces.retry_at = Some(Instant::now() + Duration::from_secs(3)); + track(s, 0); return Ok(json!({"active": false})); }; s.faces.retry_at = None; @@ -573,10 +591,13 @@ mod imp { break; } } + let pending = s.faces.queue.len() + s.faces.in_flight.len(); + let peak = track(s, pending as u64); Ok(json!({ "active": true, "inFlight": s.faces.in_flight.len(), - "pendingPhotos": s.faces.queue.len() + s.faces.in_flight.len(), + "pendingPhotos": pending, + "peak": peak, "indexedFaces": s.faces.index.len(), "searchedPhotos": s.faces.scanned.len(), "workers": target, diff --git a/crates/engine/src/faces_index.rs b/crates/engine/src/faces_index.rs index c250f8683..fd4d9f2e7 100644 --- a/crates/engine/src/faces_index.rs +++ b/crates/engine/src/faces_index.rs @@ -434,6 +434,10 @@ pub(crate) struct FacesState { pub scanned: Scanned, /// Photos that could not be searched this session (unreadable files): left alone until the next launch. pub scan_failed: std::collections::HashSet, + /// The scan's row in the activity stack, while photos are left. + pub task: Option, + /// The most photos left at once since the scan last finished (the row's whole). + pub peak: u64, } impl FacesState { diff --git a/crates/engine/src/tests_face_recognize.rs b/crates/engine/src/tests_face_recognize.rs index 2fd53c679..8d14d3c61 100644 --- a/crates/engine/src/tests_face_recognize.rs +++ b/crates/engine/src/tests_face_recognize.rs @@ -487,3 +487,35 @@ fn every_scanned_face_is_shown_by_the_same_kind_of_box() { assert!((v.x0 - tight.x0).abs() < 1e-3 && (v.y0 - tight.y0).abs() < 1e-3, "{v:?}"); let _ = std::fs::remove_dir_all(&d); } + +#[test] +fn face_scan_row_follows_pending_photos() { + let d = temp("activity"); + let (mut s, _) = setup(&d, 64); + let (a, b) = two_photos(&s); + set_regions(&mut s, a, vec![region(0.1, None), region(0.5, None)]); + set_regions(&mut s, b, vec![region(0.3, None)]); + let faces_rows = |s: &Session| s.activity.list().into_iter().filter(|t| t.kind == "faces").collect::>(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + let (mut last, mut shown) = (json!(null), false); + while std::time::Instant::now() < deadline { + last = s.execute("faces.pump", &json!({})).unwrap(); + let rows = faces_rows(&s); + if last["pendingPhotos"].as_u64().unwrap_or(0) > 0 { + // while photos are left: one row, not cancellable, its whole the most photos left at once + assert_eq!(rows.len(), 1, "{last}"); + assert_eq!((rows[0].label.as_str(), rows[0].cancellable), ("Finding faces", false)); + assert_eq!(rows[0].total, last["peak"].as_u64().unwrap(), "{last}"); + assert_eq!(rows[0].done, rows[0].total - last["pendingPhotos"].as_u64().unwrap()); + shown = true; + } + if last["active"] == true && last["pendingPhotos"] == 0 && last["indexedFaces"] == 3 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + assert!(shown, "the scan never showed a row: {last}"); + assert_eq!((last["pendingPhotos"].clone(), last["peak"].clone()), (json!(0), json!(0)), "{last}"); + assert!(faces_rows(&s).is_empty(), "the row goes when the scan is done"); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 1edf29adc..85800315f 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2027,5 +2027,7 @@ "Discarding smart previews": "Smart-Vorschauen werden verworfen", "Importing Lightroom catalog": "Lightroom-Katalog wird importiert", "Reading Lightroom catalog": "Lightroom-Katalog wird gelesen", - "Merging": "Zusammenfügen" + "Merging": "Zusammenfügen", + "Downloading model": "Modell wird heruntergeladen", + "Finding faces": "Gesichter werden gesucht" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 701c91bdb..457287d63 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1872,5 +1872,7 @@ "Discarding smart previews": "Descartando vistas previas inteligentes", "Importing Lightroom catalog": "Importando catálogo de Lightroom", "Reading Lightroom catalog": "Leyendo catálogo de Lightroom", - "Merging": "Combinando" + "Merging": "Combinando", + "Downloading model": "Descargando modelo", + "Finding faces": "Buscando caras" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 520452a8d..036a222fa 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1874,5 +1874,7 @@ "Discarding smart previews": "スマートプレビューを破棄中", "Importing Lightroom catalog": "Lightroom カタログを読み込み中", "Reading Lightroom catalog": "Lightroom カタログを読み取り中", - "Merging": "合成中" + "Merging": "合成中", + "Downloading model": "モデルをダウンロード中", + "Finding faces": "顔を検索中" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index ad269f963..112a5e8a4 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1872,5 +1872,7 @@ "Discarding smart previews": "Descartando prévias inteligentes", "Importing Lightroom catalog": "Importando catálogo do Lightroom", "Reading Lightroom catalog": "Lendo catálogo do Lightroom", - "Merging": "Mesclando" + "Merging": "Mesclando", + "Downloading model": "Baixando modelo", + "Finding faces": "Procurando rostos" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 6a4e4d528..f350df403 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1585,5 +1585,7 @@ "Discarding smart previews": "Удаление смарт-просмотров", "Importing Lightroom catalog": "Импорт каталога Lightroom", "Reading Lightroom catalog": "Чтение каталога Lightroom", - "Merging": "Слияние" + "Merging": "Слияние", + "Downloading model": "Загрузка модели", + "Finding faces": "Поиск лиц" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 24a47421c..578637f03 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1872,5 +1872,7 @@ "Discarding smart previews": "正在丢弃智能预览", "Importing Lightroom catalog": "正在导入 Lightroom 目录", "Reading Lightroom catalog": "正在读取 Lightroom 目录", - "Merging": "正在合并" + "Merging": "正在合并", + "Downloading model": "正在下载模型", + "Finding faces": "正在查找人脸" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index caf5f9bca..09a648c7a 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1872,5 +1872,7 @@ "Discarding smart previews": "正在捨棄智慧型預覽", "Importing Lightroom catalog": "正在匯入 Lightroom 目錄", "Reading Lightroom catalog": "正在讀取 Lightroom 目錄", - "Merging": "正在合併" + "Merging": "正在合併", + "Downloading model": "正在下載模型", + "Finding faces": "正在尋找臉孔" } diff --git a/crates/ui-egui/src/panels/faces.rs b/crates/ui-egui/src/panels/faces.rs index 3117eb607..13b4b1329 100644 --- a/crates/ui-egui/src/panels/faces.rs +++ b/crates/ui-egui/src/panels/faces.rs @@ -669,8 +669,9 @@ pub fn pump(app: &mut LightcraftApp, ctx: &egui::Context) { app.caches.faces_active = v["active"] == true; app.caches.faces_indexed = v["indexedFaces"].as_u64().unwrap_or(0); app.caches.faces_pending = v["pendingPhotos"].as_u64().unwrap_or(0); - // the progress bar's whole: the most photos that were left at once since the scan last finished - app.caches.faces_peak = if app.caches.faces_pending == 0 { 0 } else { app.caches.faces_peak.max(app.caches.faces_pending) }; + // the progress bar's whole: the most photos that were left at once since the scan last finished (the engine keeps + // it, so Settings and the activity stack agree) + app.caches.faces_peak = v["peak"].as_u64().unwrap_or(0); // Frames are drawn only when something asks for one, so nothing here wakes the window needlessly: while there is work it // asks to be called again in 50 ms (the progress in Settings, the next photos for the workers); with recognition on and // nothing to do it looks again every few seconds (a few wake-ups a minute); with recognition off it asks for nothing From 753ca97a4d21e32ecbf275e0fdaa1a50745e73c2 Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Sat, 10 Oct 2026 02:15:11 +0200 Subject: [PATCH 22/33] Issue #345: AI Denoise in the activity stack While a photo's AI Denoise picture is being made or waits its turn, the stack shows "AI Denoise" with the running photo's tiles as a percentage and its file name (no cross: what is made follows the Denoise amounts and Settings). The row goes when the queue is idle. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/denoise.rs | 1 + crates/engine/src/denoise.rs | 28 ++++++++++++++++++++++++++ crates/engine/src/tests_denoise.rs | 32 ++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/crates/engine/src/cmd/denoise.rs b/crates/engine/src/cmd/denoise.rs index 6962f9407..75c6c0a7a 100644 --- a/crates/engine/src/cmd/denoise.rs +++ b/crates/engine/src/cmd/denoise.rs @@ -614,6 +614,7 @@ fn status(s: &mut Session, p: &Value) -> Result { /// `denoise.pump {pace?}`: cheap to call every frame. Takes in what the background job has finished and keeps one going. fn pump(s: &mut Session, p: &Value) -> Result { s.denoise_pump(Pace::parse(p.get("pace").and_then(Value::as_str))); + s.denoise_track(); Ok(json!({ "active": s.denoise.active.is_some(), "running": s.denoise_running_json(), diff --git a/crates/engine/src/denoise.rs b/crates/engine/src/denoise.rs index 01bcdc784..30df142ca 100644 --- a/crates/engine/src/denoise.rs +++ b/crates/engine/src/denoise.rs @@ -890,6 +890,8 @@ pub(crate) struct State { pub generation: u64, /// Photos made since the session started. made: u64, + /// AI Denoise's row in the activity stack, while a photo is being made or waits its turn. + task: Option, } impl Default for State { @@ -909,6 +911,7 @@ impl Default for State { failed: HashMap::new(), generation: 0, made: 0, + task: None, } } } @@ -1354,6 +1357,31 @@ impl Session { self.denoise.running.is_some() || (!self.denoise.queue.is_empty() && self.denoise.active.is_some()) } + /// Keep AI Denoise's row in the activity stack up to date (each `denoise.pump`): there while a photo is being made or + /// waits its turn, with the running photo's tiles and its file name; gone when the queue is idle. It has no ✕: what is + /// made follows the photos' Denoise amounts and Settings ▸ AI Denoise. + pub(crate) fn denoise_track(&mut self) { + if !self.denoise_busy() { + self.denoise.task = None; + return; + } + let task = self.denoise.task.get_or_insert_with(|| { + let t = self.activity.start("denoise", "AI Denoise", crate::activity::Cancel::No); + t.set_unit(crate::activity::Unit::Percent); + t + }); + match &self.denoise.running { + Some(r) => { + task.progress(r.progress.done.load(Ordering::Relaxed) as u64, r.progress.total.load(Ordering::Relaxed) as u64); + task.detail(self.catalog.photo(r.photo).map_or("", |p| p.file_name.as_str())); + } + None => { + task.progress(0, 0); + task.detail(""); + } + } + } + /// Seconds the running job has been going, and its progress. pub(crate) fn denoise_running_json(&self) -> Value { match &self.denoise.running { diff --git a/crates/engine/src/tests_denoise.rs b/crates/engine/src/tests_denoise.rs index ef87747fc..a7d1c7c77 100644 --- a/crates/engine/src/tests_denoise.rs +++ b/crates/engine/src/tests_denoise.rs @@ -757,3 +757,35 @@ fn the_denoise_switch_validates_arguments_and_accepts_explicit_targets_without_s drop(x.s); let _ = std::fs::remove_dir_all(x.dir); } + +#[test] +fn denoise_row_while_queue_is_busy() { + let mut x = setup("activity", true); + let rows = |s: &Session| s.activity.list().into_iter().filter(|t| t.kind == "denoise").collect::>(); + let r = x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap(); + assert!(rows(&x.s).is_empty(), "nothing to make, no row: {r}"); + set_amount(&mut x.s, 100.0); + let mut shown = false; + for _ in 0..600 { + let r = x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap(); + let rows = rows(&x.s); + // (what the pump left running or queued is what the row reflects until the next pump) + if x.s.denoise_busy() { + assert_eq!(rows.len(), 1, "{r}"); + assert_eq!((rows[0].label.as_str(), rows[0].cancellable, rows[0].unit), ("AI Denoise", false, crate::activity::Unit::Percent), "{r}"); + if !r["running"].is_null() { + assert_eq!(rows[0].detail, "ramp.dng", "{r}"); + } + shown = true; + } else { + assert!(rows.is_empty(), "{r}"); + } + if r["ready"].as_u64() >= Some(1) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + assert!(shown, "the photo never showed a row"); + assert!(rows(&x.s).is_empty(), "the row goes once the photo is made"); + let _ = std::fs::remove_dir_all(&x.dir); +} From 1c89caa4a160fea8b0811fe46dc423f23377aac8 Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Sat, 10 Oct 2026 02:20:31 +0200 Subject: [PATCH 23/33] Issue #345: Find Missing Photos in the activity stack Find Missing Photos shows a row (a sweep: the amount of work isn't known, no cross) while it searches; the auto-import folder listing, which runs every few seconds, stays quiet. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 3 ++- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/tasks.rs | 12 ++++++++--- crates/ui-egui/src/tests_activity.rs | 31 ++++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index af75d1958..f2ba2f258 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -868,7 +868,8 @@ impl LightcraftApp { let undo0 = app.session.undo.len(); app.import = Some(import::ImportTask::new(paths, params, undo0, false).auto()); }; - if let Err(e) = tasks::spawn(self, LABEL, work, done) { + // listed every few seconds: quiet, no row in the activity stack + if let Err(e) = tasks::spawn(self, LABEL, None, work, done) { log::warn!("{e}"); } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2c63a3d30..c9b397f60 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1212,7 +1212,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option; struct Task { label: String, rx: Receiver, + /// Its row in the activity stack (none for quiet tasks); goes with the task. + _guard: Option, } /// The background tasks in flight. @@ -39,10 +42,12 @@ impl Tasks { } /// Run `work` on a worker thread, then `done(app, ctx, result)` on the UI thread (in the browser -/// build, which has no threads, `work` runs at once and `done` on the next frame). +/// build, which has no threads, `work` runs at once and `done` on the next frame). With `kind`, the +/// activity stack shows `label` meanwhile, without a count and without ✕ (the work can't be stopped). pub fn spawn( app: &mut LightcraftApp, label: &str, + kind: Option<&'static str>, work: impl FnOnce() -> T + Send + 'static, done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static, ) -> Result<(), String> { @@ -59,7 +64,8 @@ pub fn spawn( std::thread::Builder::new().name(format!("lc-task-{label}")).spawn(job).map_err(|e| format!("{label}: could not start: {e}"))?; #[cfg(target_arch = "wasm32")] job(); - app.tasks.running.push(Task { label: label.to_string(), rx }); + let guard = kind.map(|k| app.session.activity.start(k, label, lightcraft_engine::activity::Cancel::No)); + app.tasks.running.push(Task { label: label.to_string(), rx, _guard: guard }); Ok(()) } diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index db4b58297..05479e683 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -329,3 +329,34 @@ fn final_merge_shows_a_row_and_cancels() { assert_eq!(toast, "HDR merge cancelled"); let _ = std::fs::remove_dir_all(dir); } + +#[test] +fn find_missing_shows_an_indeterminate_row() { + let mut h = demo(); + let dir = std::env::temp_dir().join(format!("lc-activity-findmissing-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + // run directly, not through a request (which runs frames): the search of an empty folder is over at once, but its + // result is applied between frames, and the row is there until then + let r = crate::menus::run_ui_command(&mut h.app, "file.findMissing", &json!({"folder": dir.to_string_lossy()})).unwrap().unwrap(); + assert_eq!(r["background"], true, "{r}"); + let rows = h.app.session.activity.list(); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!((rows[0].kind, rows[0].label.as_str()), ("findMissing", "Find Missing Photos")); + assert_eq!((rows[0].total, rows[0].cancellable), (0, false), "indeterminate, no ✕"); + assert!(h.step_until(T, |h| h.app.tasks.is_empty())); + assert!(h.app.session.activity.list().is_empty(), "the row goes with the task"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn auto_import_listing_has_no_row() { + let mut h = demo(); + let dir = std::env::temp_dir().join(format!("lc-activity-autoimport-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + h.app.session.import_defaults.auto_folder = Some(dir.to_string_lossy().to_string()); + // the watched folder is listed every few seconds, quietly + assert!(h.step_until(Duration::from_secs(10), |h| h.app.tasks.is_running("Auto Import"))); + assert!(h.app.session.activity.list().is_empty(), "{:?}", h.app.session.activity.list()); + h.app.session.import_defaults.auto_folder = None; + let _ = std::fs::remove_dir_all(dir); +} From e974a87cfb23197224af11acc88ee8e9058228c2 Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Sat, 10 Oct 2026 02:53:57 +0200 Subject: [PATCH 24/33] Issue #345: docs and parity for the activity stack LR-BEHAV-BG-TASKS now cites activity.list / activity.cancel and lists every job on the stack; the import and Build Previews rows no longer describe progress windows and toasts; a what's-new entry. Fixes the stack not growing when tasks arrive while it already shows a row (an egui area lays out in last frame's size, and the scroll area took that as all the room, so later rows were cut off), and adds the Russian "Quit Anyway". Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/fr.json | 4 +++- crates/ui-egui/locales/ru.json | 3 ++- crates/ui-egui/locales/uk.json | 4 +++- crates/ui-egui/src/panels/activity.rs | 4 ++++ crates/ui-egui/src/tests_activity.rs | 27 +++++++++++++++++++++++++++ docs/parity.md | 6 +++--- docs/whats-new.md | 9 +++++++++ 7 files changed, 51 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 59631e84a..e69c34700 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1872,5 +1872,7 @@ "Discarding smart previews": "Suppression des aperçus dynamiques", "Importing Lightroom catalog": "Importation du catalogue Lightroom", "Reading Lightroom catalog": "Lecture du catalogue Lightroom", - "Merging": "Fusion en cours" + "Merging": "Fusion en cours", + "Downloading model": "Téléchargement du modèle", + "Finding faces": "Recherche des visages" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index f350df403..e3c07e206 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1587,5 +1587,6 @@ "Reading Lightroom catalog": "Чтение каталога Lightroom", "Merging": "Слияние", "Downloading model": "Загрузка модели", - "Finding faces": "Поиск лиц" + "Finding faces": "Поиск лиц", + "Quit Anyway": "Всё равно выйти" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 3c765a1ba..1c5e86488 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2066,5 +2066,7 @@ "Discarding smart previews": "Видалення смарт-переглядів", "Importing Lightroom catalog": "Імпорт каталогу Lightroom", "Reading Lightroom catalog": "Читання каталогу Lightroom", - "Merging": "Об’єднання" + "Merging": "Об’єднання", + "Downloading model": "Завантаження моделі", + "Finding faces": "Пошук облич" } diff --git a/crates/ui-egui/src/panels/activity.rs b/crates/ui-egui/src/panels/activity.rs index 87a1163ff..8a41a04ff 100644 --- a/crates/ui-egui/src/panels/activity.rs +++ b/crates/ui-egui/src/panels/activity.rs @@ -74,6 +74,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let mut toggle = false; let frame = egui::Frame::NONE.fill(t.chrome).stroke(Stroke::new(1.0, t.field_border)).corner_radius(6).inner_margin(Margin::same(MARGIN)); egui::Area::new(egui::Id::new("activity-stack")).order(egui::Order::Middle).fixed_pos(pos2(8.0, t.top_bar_h + 8.0)).show(ctx, |ui| { + // An area lays out in the size it had last frame, and the scroll area would take that as all the room there + // is: the stack would never grow when rows are added. Give it the whole height below the top bar instead + // (the scroll area keeps to `max_height`). + ui.set_max_height((ctx.content_rect().height() - t.top_bar_h - 16.0).max(80.0)); frame.show(ui, |ui| { let inner = WIDTH - 2.0 * (f32::from(MARGIN) + 1.0); ui.set_width(inner); diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 05479e683..f54c12463 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -76,6 +76,12 @@ fn more_than_three_rows_overflow_and_expand() { assert!(has(&h, "activity:more")); click(&mut h, "activity:more"); assert_eq!(rows(&h), 5); + // expanded, the stack grows to show them all + let stack = h.view.ctx.memory(|m| m.area_rect(egui::Id::new("activity-stack"))).expect("the stack is drawn"); + for g in &guards { + let r = rect(&h, &format!("activity:row:{}", g.id())); + assert!(stack.contains_rect(r), "row {} at {r:?} is cut off by the stack at {stack:?}", g.id()); + } drop(guards); h.step(); assert_eq!(rows(&h), 0); @@ -360,3 +366,24 @@ fn auto_import_listing_has_no_row() { h.app.session.import_defaults.auto_folder = None; let _ = std::fs::remove_dir_all(dir); } + +#[test] +fn rows_added_later_grow_the_stack() { + let mut h = demo(); + let a = h.app.session.activity.start("export", "Exporting", Cancel::Yes); + a.progress(1, 24); + a.detail("LC01347.jpg"); + wait_visible(&mut h); + // two more tasks while the stack already shows one row: it grows to show them, nothing is cut off + let b = h.app.session.activity.start("previews", "Building previews", Cancel::Yes); + let c = h.app.session.activity.start("lightroom", "Reading Lightroom catalog", Cancel::Yes); + wait_visible(&mut h); + for _ in 0..5 { + h.step(); + } + let stack = h.view.ctx.memory(|m| m.area_rect(egui::Id::new("activity-stack"))).expect("the stack is drawn"); + for g in [&a, &b, &c] { + let r = rect(&h, &format!("activity:row:{}", g.id())); + assert!(stack.contains_rect(r), "row {} at {r:?} is cut off by the stack at {stack:?}", g.id()); + } +} diff --git a/docs/parity.md b/docs/parity.md index ad82e3d25..247e995cb 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -133,7 +133,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread with a progress window and Cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, with a progress window and Cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342) | +| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342) | | LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported | | LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | | | LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk) | @@ -516,7 +516,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | | | LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs`, `crates/ui-egui/src/panels/profiles.rs`, `crates/ui-egui/src/panels/right.rs` (`versions`), `crates/ui-egui/src/panels/detail.rs` | presets, profile menu, profile browser and versions preview in the loupe | | LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | | -| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `crates/preview/src/lib.rs` (`JobPool`), `crates/ui-egui/src/export_task.rs`, `crates/ui-egui/src/import.rs` | renders off the UI thread; exports started from the UI run on a worker thread with a progress panel and Cancel (`ui.inspect` → `export`), several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); imports show a progress window. No combined activity centre | +| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports, Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise and Find Missing Photos each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | | LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); no tiling beyond the buffer limit (CPU) | @@ -762,7 +762,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) | | LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike | | LRC-LIB-CONVERT | Convert to DNG | P2 | ✅ | `cmd:photo.convertToDng`, `crates/engine/src/cmd/convert.rs` | Photo ▸ Convert to DNG: lossless DNG next to the raw with the settings embedded, photo (and its virtual copies) relinked; originals kept; undoable; the DNG is decoded and compared with the raw data, written to a temp file, synced, read back and only then given a free name (never replacing a file) — a failed write leaves no DNG and the photo on its raw (issue #106) | -| LRC-LIB-PREVIEWS | Build / discard previews | P1 | ✅ | `cmd:library.buildPreviews`, `cmd:library.previewProgress`, `cmd:library.cancelPreviews`, `cmd:library.clearPreviews`, `crates/engine/src/cmd/previews.rs`, `crates/preview/src/lib.rs` | File ▸ Previews: build standard-sized (Settings → Performance size) or 1:1 previews of the selected / visible photos in the background (progress toasts, stop), discard the cache; disk thumbnail + view cache; no smart previews (offline proxies) | +| LRC-LIB-PREVIEWS | Build / discard previews | P1 | ✅ | `cmd:library.buildPreviews`, `cmd:library.previewProgress`, `cmd:library.cancelPreviews`, `cmd:library.clearPreviews`, `crates/engine/src/cmd/previews.rs`, `crates/preview/src/lib.rs` | File ▸ Previews: build standard-sized (Settings → Performance size) or 1:1 previews of the selected / visible photos in the background (a row in the activity stack, ✕ to stop), discard the cache; disk thumbnail + view cache; no smart previews (offline proxies) | | LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ✅ | `cmd:view.slideshow` | see LR-VIEW-SLIDESHOW | | LRC-DEV-SNAPSHOTS | Named snapshots | P1 | ✅ | `cmd:version.create`, `cmd:version.restore` | = versions | | LRC-DEV-HISTORY | Full history panel | P1 | ✅ | `cmd:history.list`, `cmd:history.restore`, `cmd:history.clear` | row menu: copy step to before, create version from step, clear history | diff --git a/docs/whats-new.md b/docs/whats-new.md index 8ae60a7e4..5372401c4 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,15 @@ ## October 2026 +### Activity stack +- Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and + folder scans, exports, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan, + AI Denoise and Find Missing Photos. Each row has the task's name, a progress bar, the count and what it is working + on; ✕ stops the tasks that can stop. Three rows show, then "+N more". The separate export panel and the progress + windows and toasts are gone. +- Quitting while a task that can be stopped is running asks first ("Quit Anyway" stops it). +- Agents list and stop tasks with `activity.list` and `activity.cancel`; `ui.inspect` reports them as `activity`. + ### Smart album rules - The rule editor's field menu is grouped: Rating, Pick Flag, Color Label and Any Searchable Text at the top, then submenus for Source, File, Date, Keywords & People, Description, Camera Info, Location, Size, Develop and Assisted From 8c3c586b8031d674931e21eca790d8dafce45e20 Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Sat, 10 Oct 2026 03:13:53 +0200 Subject: [PATCH 25/33] Issue #345: review fixes for the activity stack A cross clicked in the frame its task ended no longer puts "no task N" in the status bar: the row's cancel goes to the engine directly instead of through the app's command runner, which reports every error there. The face-scan row test scans demo photos no other scan test uses, so it no longer joins the scan deadlock of #630 (with one of the existing pump tests it hung 13 of 20 runs; now 0 of 20). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_face_recognize.rs | 6 +++++- crates/ui-egui/src/panels/activity.rs | 9 +++++++-- crates/ui-egui/src/tests_activity.rs | 6 ++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/tests_face_recognize.rs b/crates/engine/src/tests_face_recognize.rs index 8d14d3c61..aabcb03c3 100644 --- a/crates/engine/src/tests_face_recognize.rs +++ b/crates/engine/src/tests_face_recognize.rs @@ -492,7 +492,11 @@ fn every_scanned_face_is_shown_by_the_same_kind_of_box() { fn face_scan_row_follows_pending_photos() { let d = temp("activity"); let (mut s, _) = setup(&d, 64); - let (a, b) = two_photos(&s); + // the last two demo photos, not the first two the other scan tests use: two sessions scanning the same demo scene + // at once can deadlock (issue #630), and this test is about the row, not that + let mut ids = s.catalog.photos().map(|p| p.id).collect::>(); + ids.sort(); + let (a, b) = (ids[ids.len() - 2], ids[ids.len() - 1]); set_regions(&mut s, a, vec![region(0.1, None), region(0.5, None)]); set_regions(&mut s, b, vec![region(0.3, None)]); let faces_rows = |s: &Session| s.activity.list().into_iter().filter(|t| t.kind == "faces").collect::>(); diff --git a/crates/ui-egui/src/panels/activity.rs b/crates/ui-egui/src/panels/activity.rs index 8a41a04ff..7b0d449c6 100644 --- a/crates/ui-egui/src/panels/activity.rs +++ b/crates/ui-egui/src/panels/activity.rs @@ -102,14 +102,19 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }); }); if let Some(id) = cancel { - // the task may have just finished: nothing to tell the user then - let _ = app.run("activity.cancel", json!({"id": id})); + self::cancel(app, id); } if toggle { app.activity_expanded = !app.activity_expanded; } } +/// ✕ on task `id`'s row. The task may have just finished or stopped being cancellable: nothing to tell the user then, +/// so the command goes to the engine directly (`LightcraftApp::run` would put its error in the status bar). +pub(crate) fn cancel(app: &mut LightcraftApp, id: u64) { + let _ = app.session.execute("activity.cancel", &json!({"id": id})); +} + /// One task: name and ✕, the bar, the detail line. True when ✕ was clicked. fn row(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) -> bool { let mut clicked = false; diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index f54c12463..ca2a6de7e 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -130,6 +130,12 @@ fn stale_cancel_is_harmless() { assert_eq!(r["ok"], false, "an error result, not a panic: {r}"); h.step(); assert!(h.app.ui.toast.is_none(), "{:?}", h.app.ui.toast); + // the same through the row's ✕ (clicked in the frame the task ended): no message in the status bar either + h.app.ui.status = "Ready".into(); + crate::panels::activity::cancel(&mut h.app, id); + h.step(); + assert_eq!(h.app.ui.status, "Ready"); + assert!(h.app.ui.toast.is_none(), "{:?}", h.app.ui.toast); } #[test] From 7a65cc6340fb4a37b66ed8794dc53eb1fa932738 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 03:16:54 -0400 Subject: [PATCH 26/33] LR-CLOUD-SMARTPREVIEW: look refresh only for per-file-look raws, once per LOOK_VERSION, serialised with builds Only proxies of raws that take the per-file camera look (Photo::relative_wb) go stale: JPEG/PNG/DNG proxies are never rebuilt for the look. The opening scan runs once per LOOK_VERSION (a look-version marker in the smart previews folder), not at every library open; Build Smart Previews still refreshes stale proxies. Each proxy is checked and written under one lock, so the background refresh and library.smartPreviews never write the same file at once. LOOK_VERSION 2: main's DRO / ILCE-7CR camera-look changes (#528, #583, #568, #616). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/camera_preview.rs | 5 +- crates/engine/src/cmd/previews.rs | 58 ++++++++++++------ crates/engine/src/smart.rs | 23 ++++++++ crates/engine/src/tests_import.rs | 92 ++++++++++++++++++++++++----- docs/camera-preview-colour.md | 8 ++- docs/parity.md | 2 +- 6 files changed, 148 insertions(+), 40 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index d4e5c204f..0ef258f0e 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -20,7 +20,10 @@ use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTa /// **Bump this whenever a change makes the fit give a different result for the same file** (the /// fit, its gates, the fallbacks, the camera profiles' effect on it). Do not bump it for changes /// that leave the fitted look alone. `RENDER_CACHE_VERSION` covers renders; this covers proxies. -pub const LOOK_VERSION: u32 = 1; +/// +/// 1: the fit as of #499's follow-up; 2: Sony DRO (tone curve lowered to Sony's curve without DRO) +/// and the ILCE-7CR profile (#528, #583, #568, #616). +pub const LOOK_VERSION: u32 = 2; #[derive(Clone, Debug)] pub(crate) struct CameraLook { diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index 09075fd89..8ebbfc59f 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -108,9 +108,16 @@ fn build(s: &mut Session, p: &Value) -> Result { } /// One photo's smart preview to build or discard: (photo, its file in the smart previews folder, -/// how to load the original at preview size). +/// how to load the original at preview size, whether the photo takes the per-file camera look — +/// `camera_preview::file_local_look`, [`lightcraft_catalog::Photo::relative_wb`]: only those +/// proxies go stale when the fit changes). #[cfg(not(target_arch = "wasm32"))] -type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option); +type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option, bool); + +/// Held while one proxy is checked and (re)written, so Build Smart Previews and the opening look +/// refresh ([`refresh_stale_smart_previews`]), on their own threads, never write the same file at once. +#[cfg(not(target_arch = "wasm32"))] +static SMART_WRITE: std::sync::Mutex<()> = std::sync::Mutex::new(()); /// What Build / Discard Smart Previews did. #[cfg(not(target_arch = "wasm32"))] @@ -163,10 +170,11 @@ fn smart_run( std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; } } - for (id, path, source) in jobs { + for (id, path, source, look) in jobs { if state.is_some_and(|st| st.cancel.load(Ordering::Relaxed)) { break; } + let _one = SMART_WRITE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if discard { if std::fs::remove_file(&path).is_ok() { n.removed += 1; @@ -176,7 +184,7 @@ fn smart_run( // full drive) is (re)built, and so is one written by an older camera-look fit (see // `camera_preview::LOOK_VERSION`) when its original can be read let damaged = path.exists(); - let stale = damaged && crate::smart::is_stale(&path); + let stale = look && damaged && crate::smart::is_stale(&path); if damaged && !stale && crate::smart::is_valid(&path) { n.built += 1; } else { @@ -246,7 +254,7 @@ fn smart(s: &mut Session, p: &Value) -> Result { } let path = dir.join(crate::smart::file_name(&ph)); let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview)); - jobs.push((id, path, source)); + jobs.push((id, path, source, ph.relative_wb())); } let custom = s.smart_previews_dir.is_some(); if background { @@ -272,18 +280,24 @@ fn smart(s: &mut Session, p: &Value) -> Result { ) } -/// Bring smart previews written by an older camera-look fit up to date, once per library open: -/// on a worker thread (the UI never waits on the drives), each stale proxy is rebuilt from its -/// original when that can be read and kept as it is when it can't (its header still says it is -/// stale, so the next open with the original online rebuilds it). Only existing proxies are -/// touched: none is built, and a missing folder is never created. Returns the thread, if started. +/// Bring smart previews written by an older camera-look fit up to date, once per fit version: when +/// the smart previews folder has not been checked at this `LOOK_VERSION` (its marker, +/// [`crate::smart::look_checked`]), a worker thread (the UI never waits on the drives) looks at the +/// proxies of the raws that take the per-file camera look, the only ones the fit reaches (JPEG, +/// PNG… proxies are never rebuilt), and rebuilds the stale ones from their originals. One whose +/// original can't be read (offline) is kept as it is, still marked stale in its header: Build +/// Smart Previews rebuilds it once the original is back. Only existing proxies are touched: none +/// is built, and a missing folder is never created. Returns the thread, if started. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option> { let dir = s.media.smart_dir.clone()?; + if !dir.is_dir() || crate::smart::look_checked(&dir) { + return None; + } let max_edge = crate::media::SourceLevel::Preview.max_edge(); - let photos: Vec<_> = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. })).collect(); + let photos: Vec<_> = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && p.relative_wb()).collect(); let jobs: Vec = - photos.iter().map(|p| (p.id, dir.join(crate::smart::file_name(p)), Some(s.media.origin_ref(&p.source, max_edge)))).collect(); + photos.iter().map(|p| (p.id, dir.join(crate::smart::file_name(p)), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect(); std::thread::Builder::new() .name("lc-smart-look".into()) .spawn(move || refresh_stale(&dir, jobs)) @@ -294,14 +308,20 @@ pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option) { - let stale: Vec = jobs.into_iter().filter(|(_, path, _)| crate::smart::is_stale(path)).collect(); - if stale.is_empty() { - return; + let stale: Vec = jobs.into_iter().filter(|(_, path, _, _)| crate::smart::is_stale(path)).collect(); + if !stale.is_empty() { + // `custom`: the folder is there already, and must not be recreated if it went away since + match smart_run(dir, true, false, stale, None) { + Ok(n) => log::info!("smart previews: {} refreshed to the current camera look, {} kept (original offline)", n.refreshed, n.stale_kept), + Err(e) => { + // not marked: the next opening tries again + log::warn!("smart previews: look refresh: {e}"); + return; + } + } } - // `custom`: the folder is there already, and must not be recreated if it went away since - match smart_run(dir, true, false, stale, None) { - Ok(n) => log::info!("smart previews: {} refreshed to the current camera look, {} kept (original offline)", n.refreshed, n.stale_kept), - Err(e) => log::warn!("smart previews: look refresh: {e}"), + if let Err(e) = crate::smart::mark_look_checked(dir) { + log::warn!("smart previews: {e}"); } } diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs index 1421558c2..cda0baa62 100644 --- a/crates/engine/src/smart.rs +++ b/crates/engine/src/smart.rs @@ -218,6 +218,29 @@ pub fn is_stale(path: &Path) -> bool { .is_some_and(|h| header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION)) } +/// The marker file in a smart previews folder: the `LOOK_VERSION` its proxies were last checked +/// at ([`look_checked`]). +const LOOK_MARKER: &str = "look-version"; + +/// Whether the smart previews folder `dir` has been checked for stale proxies at this build's +/// camera-look fit version (or a newer one). A missing or unreadable marker counts as not. +pub fn look_checked(dir: &Path) -> bool { + use std::io::Read; + let Ok(f) = std::fs::File::open(dir.join(LOOK_MARKER)) else { return false }; + let mut text = String::new(); + if f.take(64).read_to_string(&mut text).is_err() { + return false; + } + text.trim().parse::().is_ok_and(|v| v >= u64::from(crate::camera_preview::LOOK_VERSION)) +} + +/// Record that the proxies in `dir` were checked at this build's camera-look fit version. +pub fn mark_look_checked(dir: &Path) -> Result<(), String> { + let path = dir.join(LOOK_MARKER); + lightcraft_catalog::safe_file::write_atomic(&path, format!("{}\n", crate::camera_preview::LOOK_VERSION).as_bytes()) + .map_err(|e| format!("{}: {e}", path.display())) +} + /// Load the proxy at `path`. pub fn load(path: &Path) -> Result { let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 59bc3f404..3ade04c34 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -1170,24 +1170,37 @@ fn look_loader( /// The follow-up to the camera tone curve change (#499): a smart preview keeps the look the fit /// gave it, so one written by an older fit is rebuilt when its original is online, kept while the -/// original is offline, and not rebuilt again once current. +/// original is offline, and not rebuilt again once current. Only raws that take the per-file +/// camera look go stale (a PNG's proxy never does), and the opening scan runs once per fit version. #[test] fn smart_previews_from_an_older_look_fit_are_refreshed() { use std::sync::atomic::{AtomicUsize, Ordering}; let src = temp_dir("smartlook-src"); let lib = temp_dir("smartlook-lib"); write_png(&src.join("a.png"), 3); + write_png(&src.join("b.png"), 9); let mut s = Session::new().with_fs(); s.open_library(&lib, false).unwrap(); - s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); - let id = s.active().unwrap(); - let file = s.media.smart_dir.clone().unwrap().join(crate::smart::file_name(s.catalog.photo(id).unwrap())); + s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap(); + let find = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id; + let (id, png) = (find(&s, "a.png"), find(&s, "b.png")); + // `a` stands for a raw that takes the per-file camera look (the test loader decodes its pixels) + let mut raw = (**s.catalog.photo(id).unwrap()).clone(); + raw.format = "ARW".into(); + raw.kind = lightcraft_catalog::MediaKind::Raw; + s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap(); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap(); + assert!(s.catalog.photo(id).unwrap().relative_wb() && !s.catalog.photo(png).unwrap().relative_wb()); + s.execute("library.select", &json!({"ids": [id.0, png.0], "active": id.0})).unwrap(); + let dir = s.media.smart_dir.clone().unwrap(); + let file = dir.join(crate::smart::file_name(s.catalog.photo(id).unwrap())); + let png_file = dir.join(crate::smart::file_name(s.catalog.photo(png).unwrap())); let (old, new) = (test_tone(1.5), test_tone(3.5)); let calls = std::sync::Arc::new(AtomicUsize::new(0)); let plain = s.media.file_loader.clone().unwrap(); s.media.file_loader = Some(look_loader(plain.clone(), old, calls.clone())); let r = s.execute("library.smartPreviews", &json!({})).unwrap(); - assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(1), Some(0)), "{r}"); + assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(2), Some(0)), "{r}"); assert_eq!(smart_header(&file)["look_version"], json!(crate::camera_preview::LOOK_VERSION), "the writer stamps the fit version"); assert!(!crate::smart::is_stale(&file)); @@ -1196,39 +1209,61 @@ fn smart_previews_from_an_older_look_fit_are_refreshed() { calls.store(0, Ordering::SeqCst); s.media.file_loader = Some(look_loader(plain.clone(), new, calls.clone())); let r = s.execute("library.smartPreviews", &json!({})).unwrap(); - assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(1), Some(0)), "{r}"); + assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(2), Some(0)), "{r}"); assert!(std::fs::read(&file).unwrap() == before, "a current proxy is not rebuilt"); assert_eq!(calls.load(Ordering::SeqCst), 0); - // an old header (no field), original offline: kept unchanged, and still marked stale - edit_smart_header(&file, |h| { - h.as_object_mut().unwrap().remove("look_version"); - }); + // old headers (no field), originals offline: kept unchanged, and still marked stale + for f in [&file, &png_file] { + edit_smart_header(f, |h| { + h.as_object_mut().unwrap().remove("look_version"); + }); + } assert!(crate::smart::is_stale(&file), "no field = version 0"); let old_bytes = std::fs::read(&file).unwrap(); + let png_bytes = std::fs::read(&png_file).unwrap(); std::fs::rename(&src, src.with_extension("offline")).unwrap(); s.media.forget(id); + s.media.forget(png); let r = s.execute("library.smartPreviews", &json!({})).unwrap(); - assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64(), r["staleKept"].as_u64()), (Some(1), Some(0), Some(1)), "{r}"); + assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64(), r["staleKept"].as_u64()), (Some(2), Some(0), Some(1)), "{r}"); assert!(r["failed"].as_array().unwrap().is_empty(), "{r}"); assert!(std::fs::read(&file).unwrap() == old_bytes, "offline: the stored preview and curve are untouched"); assert!(crate::smart::is_stale(&file), "and it stays marked for the next time"); assert_eq!(crate::smart::decode(&old_bytes).unwrap().1, Some(old), "the old look is still there"); - // ... and the opening refresh keeps it too + // ... and the opening refresh keeps it too, then marks the folder checked + assert!(!crate::smart::look_checked(&dir)); crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap(); assert!(std::fs::read(&file).unwrap() == old_bytes); + assert!(crate::smart::look_checked(&dir)); - // the original comes back: the library's next opening rebuilds it with the current look + // the original comes back: the scan has run at this version, so the next opening leaves it; + // Build Smart Previews rebuilds it with the current look std::fs::rename(src.with_extension("offline"), &src).unwrap(); calls.store(0, Ordering::SeqCst); - crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap(); - assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut s).is_none(), "checked once per version"); + assert!(std::fs::read(&file).unwrap() == old_bytes); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["refreshed"].as_u64(), calls.load(Ordering::SeqCst)), (Some(1), 1), "{r}"); assert_eq!(smart_header(&file)["look_version"], json!(crate::camera_preview::LOOK_VERSION)); assert_eq!(crate::smart::decode(&std::fs::read(&file).unwrap()).unwrap().1, Some(new), "rebuilt with the current look"); assert!(!crate::smart::is_stale(&file)); + assert!(std::fs::read(&png_file).unwrap() == png_bytes, "a PNG's proxy is never rebuilt for the look"); + + // a folder checked at an older version (or not at all) is scanned once at the next opening + edit_smart_header(&file, |h| h["look_version"] = json!(0)); + std::fs::write(dir.join("look-version"), b"0\n").unwrap(); + assert!(!crate::smart::look_checked(&dir)); + s.media.forget(id); + calls.store(0, Ordering::SeqCst); + crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 1, "only the raw's original is read"); + assert!(!crate::smart::is_stale(&file)); + assert!(std::fs::read(&png_file).unwrap() == png_bytes); + assert!(crate::smart::look_checked(&dir)); // once only let rebuilt = std::fs::read(&file).unwrap(); - crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap(); + assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut s).is_none()); let r = s.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["refreshed"].as_u64(), calls.load(Ordering::SeqCst)), (Some(0), 1), "{r}"); assert!(std::fs::read(&file).unwrap() == rebuilt); @@ -1243,6 +1278,31 @@ fn smart_previews_from_an_older_look_fit_are_refreshed() { let _ = std::fs::remove_dir_all(&lib); } +/// The look marker: missing, damaged or older than this build's fit version counts as unchecked. +#[test] +fn smart_preview_look_marker_is_read_safely() { + let dir = temp_dir("smartlook-marker"); + assert!(!crate::smart::look_checked(&dir), "no marker"); + let cur = crate::camera_preview::LOOK_VERSION; + for (text, checked) in [ + (String::new(), false), + ("banana".into(), false), + ("-1".into(), false), + (format!("{}", cur.saturating_sub(1)), false), + ("9".repeat(200), false), + (format!("{cur}\n"), true), + (format!(" {}\n", cur + 1), true), + ] { + std::fs::write(dir.join("look-version"), &text).unwrap(); + assert_eq!(crate::smart::look_checked(&dir), checked, "{text:?}"); + } + std::fs::remove_file(dir.join("look-version")).unwrap(); + crate::smart::mark_look_checked(&dir).unwrap(); + assert!(crate::smart::look_checked(&dir)); + assert!(crate::smart::mark_look_checked(&dir.join("missing")).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} + /// A hostile or damaged `look_version` never panics: anything that is not a non-negative integer /// counts as 0 (rebuilt when the original is online); a version from a newer build is left alone. #[test] diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index accbeac15..83ce9d8d6 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -107,9 +107,11 @@ The fit therefore has a version, `LOOK_VERSION` in `crates/engine/src/camera_pre A proxy older than the constant is brought up to date by the existing Build Smart Previews path (`smart_run`), which also repairs damaged proxies: -- when the library is opened, a background thread looks at the existing proxies and rebuilds the stale ones from their originals (no proxy is created, nothing is shown, the UI never waits on a drive); -- Build Smart Previews does the same for the photos it is run on (`refreshed` in its result); -- when the original can't be read (offline drive), the proxy and its curve are kept exactly as they are. The old version in its header is the mark: the next opening, or the next Build Smart Previews, with the original online rebuilds it. A rebuilt proxy carries the current version, so nothing is rebuilt twice. A proxy is only ever rebuilt from the original, never from another proxy. +- only proxies of raws that take the per-file look (`file_local_look`: ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW, ORF…) go stale; the fit never reaches JPEG, PNG, TIFF or DNG proxies, which are never rebuilt for it; +- when the library is opened and its smart previews folder has not been checked at this `LOOK_VERSION` (the `look-version` marker file in the folder), a background thread looks at those proxies, rebuilds the stale ones from their originals and then writes the marker, so the scan runs once per version bump, not at every opening (no proxy is created, nothing is shown, the UI never waits on a drive); +- Build Smart Previews does the same for the photos it is run on (`refreshed` in its result), whatever the marker says; +- when the original can't be read (offline drive), the proxy and its curve are kept exactly as they are (`staleKept`). The old version in its header is the mark: the next Build Smart Previews with the original online rebuilds it. A rebuilt proxy carries the current version, so nothing is rebuilt twice. A proxy is only ever rebuilt from the original, never from another proxy; +- the two never write the same proxy at once: each proxy is checked and written under one lock. ## Nikon crop and preview colour metadata diff --git a/docs/parity.md b/docs/parity.md index 5cec6f874..fc96c75d7 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -502,7 +502,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-CLOUD-SYNC | Cloud sync | OOS | 🚫 | | | -| LR-CLOUD-SMARTPREVIEW | Editable proxies | P2 | ✅ | `cmd:library.smartPreviews`, `cmd:library.smartPreviewsLocation`, `cmd:photo.smartPreview`, `crates/engine/src/smart.rs`, `crates/ui-egui/src/panels/settings.rs` | File ▸ Previews ▸ Build / Discard Smart Previews (on a worker thread, progress and Stop like Build Previews): ~1 MB proxies in the library; with the original offline the photo renders, edits and exports (at proxy size) from its proxy; Info shows the status; the proxy folder is chosen per library (Settings → Performance → Smart previews, saved in the library): the effective path, count and size are shown, changing it needs a choice for the proxies already built (move / leave / delete), and an unavailable or unwritable folder is an error, never a fallback to the library drive; proxies are written atomically and a damaged one (cut short by a crash or a full drive) doesn't count as built, is rebuilt (`repaired`) and is never moved over a good one (issue #106). Each proxy records the version of the camera-look fit that wrote it (`look_version`, `LOOK_VERSION` in `crates/engine/src/camera_preview.rs`, bumped whenever the fit changes); one from an older fit is rebuilt from its original when the library is opened or Build Smart Previews runs, and kept as it is while the original is offline (`docs/camera-preview-colour.md`). The thumbnail cache stays in the library | +| LR-CLOUD-SMARTPREVIEW | Editable proxies | P2 | ✅ | `cmd:library.smartPreviews`, `cmd:library.smartPreviewsLocation`, `cmd:photo.smartPreview`, `crates/engine/src/smart.rs`, `crates/ui-egui/src/panels/settings.rs` | File ▸ Previews ▸ Build / Discard Smart Previews (on a worker thread, progress and Stop like Build Previews): ~1 MB proxies in the library; with the original offline the photo renders, edits and exports (at proxy size) from its proxy; Info shows the status; the proxy folder is chosen per library (Settings → Performance → Smart previews, saved in the library): the effective path, count and size are shown, changing it needs a choice for the proxies already built (move / leave / delete), and an unavailable or unwritable folder is an error, never a fallback to the library drive; proxies are written atomically and a damaged one (cut short by a crash or a full drive) doesn't count as built, is rebuilt (`repaired`) and is never moved over a good one (issue #106). Each proxy records the version of the camera-look fit that wrote it (`look_version`, `LOOK_VERSION` in `crates/engine/src/camera_preview.rs`, bumped whenever the fit changes); only raws with the per-file camera look go stale (JPEG / PNG / DNG proxies are never rebuilt for it); one from an older fit is rebuilt from its original by a background scan the first time the library is opened at a new version (a `look-version` marker in the proxy folder keeps it to once per version) and by Build Smart Previews, and kept as it is while the original is offline (`docs/camera-preview-colour.md`). The thumbnail cache stays in the library | | LR-AI-UPDATE-INDICATOR | AI-settings update indicator | P2 | ⬜ | | | | LR-AI-CREDITS | Generative credits | OOS | 🚫 | | | From 56dfc8d0275697b9bb73071ee555ee2747b3a216 Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Sat, 10 Oct 2026 09:17:45 +0200 Subject: [PATCH 27/33] Issue #345: Synchronize Folder and the contact sheet in the activity stack Synchronize Folder (#604) shows its scan ("Looking for changes", while its dialog is open) and its run ("Synchronizing folder") as rows: the scan's cross stops it and closes the dialog, the run's stops it like Cancel did (what was done stays, as one undo step; the end says Stopped). Its progress window is gone. The contact-sheet PDF export (#563), which went through the export task already, gets a test for its row. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 4 +- crates/ui-egui/locales/es.json | 4 +- crates/ui-egui/locales/fr.json | 4 +- crates/ui-egui/locales/ja.json | 4 +- crates/ui-egui/locales/pt-br.json | 4 +- crates/ui-egui/locales/ru.json | 4 +- crates/ui-egui/locales/uk.json | 4 +- crates/ui-egui/locales/zh-hans.json | 4 +- crates/ui-egui/locales/zh-hant.json | 4 +- crates/ui-egui/src/export_task.rs | 20 ++++++++++ crates/ui-egui/src/lib.rs | 1 - crates/ui-egui/src/sync.rs | 58 +++++++++++++---------------- crates/ui-egui/src/tests_sync.rs | 52 +++++++++++++++++++++++++- docs/parity.md | 4 +- docs/whats-new.md | 4 +- 15 files changed, 127 insertions(+), 48 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 85800315f..aa24e0f10 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2029,5 +2029,7 @@ "Reading Lightroom catalog": "Lightroom-Katalog wird gelesen", "Merging": "Zusammenfügen", "Downloading model": "Modell wird heruntergeladen", - "Finding faces": "Gesichter werden gesucht" + "Finding faces": "Gesichter werden gesucht", + "Synchronizing folder": "Ordner wird synchronisiert", + "Looking for changes": "Änderungen werden gesucht" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 457287d63..c4f2e8357 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1874,5 +1874,7 @@ "Reading Lightroom catalog": "Leyendo catálogo de Lightroom", "Merging": "Combinando", "Downloading model": "Descargando modelo", - "Finding faces": "Buscando caras" + "Finding faces": "Buscando caras", + "Synchronizing folder": "Sincronizando carpeta", + "Looking for changes": "Buscando cambios" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index e69c34700..f3422bc0a 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1874,5 +1874,7 @@ "Reading Lightroom catalog": "Lecture du catalogue Lightroom", "Merging": "Fusion en cours", "Downloading model": "Téléchargement du modèle", - "Finding faces": "Recherche des visages" + "Finding faces": "Recherche des visages", + "Synchronizing folder": "Synchronisation du dossier", + "Looking for changes": "Recherche des modifications" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 036a222fa..a7a3a32ef 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1876,5 +1876,7 @@ "Reading Lightroom catalog": "Lightroom カタログを読み取り中", "Merging": "合成中", "Downloading model": "モデルをダウンロード中", - "Finding faces": "顔を検索中" + "Finding faces": "顔を検索中", + "Synchronizing folder": "フォルダを同期中", + "Looking for changes": "変更を確認中" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 112a5e8a4..c3d9a8d78 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1874,5 +1874,7 @@ "Reading Lightroom catalog": "Lendo catálogo do Lightroom", "Merging": "Mesclando", "Downloading model": "Baixando modelo", - "Finding faces": "Procurando rostos" + "Finding faces": "Procurando rostos", + "Synchronizing folder": "Sincronizando pasta", + "Looking for changes": "Procurando alterações" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index e3c07e206..bf9974740 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1588,5 +1588,7 @@ "Merging": "Слияние", "Downloading model": "Загрузка модели", "Finding faces": "Поиск лиц", - "Quit Anyway": "Всё равно выйти" + "Quit Anyway": "Всё равно выйти", + "Synchronizing folder": "Синхронизация папки", + "Looking for changes": "Поиск изменений" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 1c5e86488..c73c05b93 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2068,5 +2068,7 @@ "Reading Lightroom catalog": "Читання каталогу Lightroom", "Merging": "Об’єднання", "Downloading model": "Завантаження моделі", - "Finding faces": "Пошук облич" + "Finding faces": "Пошук облич", + "Synchronizing folder": "Синхронізація папки", + "Looking for changes": "Пошук змін" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 578637f03..21fa4e4e9 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1874,5 +1874,7 @@ "Reading Lightroom catalog": "正在读取 Lightroom 目录", "Merging": "正在合并", "Downloading model": "正在下载模型", - "Finding faces": "正在查找人脸" + "Finding faces": "正在查找人脸", + "Synchronizing folder": "正在同步文件夹", + "Looking for changes": "正在查找更改" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 09a648c7a..d2e0a30c2 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1874,5 +1874,7 @@ "Reading Lightroom catalog": "正在讀取 Lightroom 目錄", "Merging": "正在合併", "Downloading model": "正在下載模型", - "Finding faces": "正在尋找臉孔" + "Finding faces": "正在尋找臉孔", + "Synchronizing folder": "正在同步資料夾", + "Looking for changes": "正在尋找變更" } diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index b717313ba..a54d1718f 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -215,6 +215,26 @@ mod contact_sheet_tests { assert_eq!(h.app.last_export_result.as_ref().unwrap()["files"][0]["photos"], 1); } + #[test] + fn contact_sheet_export_shows_a_row_and_its_cross_stops_it() { + let mut app = LightcraftApp::new( + lightcraft_engine::Session::with_demo(), + crate::Services { write_shared: Some(Arc::new(|_, _| Ok(()))), ..Default::default() }, + ); + let ids: Vec<_> = app.session.catalog.photos().take(24).map(|p| p.id.0).collect(); + start_contact_sheet(&mut app, &json!({"path": "Sheet.pdf", "ids": ids})).unwrap(); + let rows = app.session.activity.list(); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].total), ("export", "Exporting contact sheet", 24)); + assert!(rows[0].cancellable); + app.session.activity.cancel(rows[0].id).unwrap(); + assert!(app.export.as_ref().unwrap().cancel.load(Ordering::Relaxed), "the stack's ✕ stops the sheet"); + let task = app.export.take().unwrap(); + assert!(task.rx.recv_timeout(Duration::from_secs(20)).unwrap().is_err(), "a stopped sheet is not written"); + drop(task); + assert!(app.session.activity.list().is_empty(), "the row goes with the export"); + } + #[test] fn cancelled_contact_sheet_never_calls_writer() { let mut app = LightcraftApp::new( diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f2ba2f258..6b5033e0c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1066,7 +1066,6 @@ impl LightcraftApp { panels::notices::show(self, &ctx); panels::dialogs::show(self, &ctx); panels::library_problem::show(self, &ctx); - sync::progress_window(self, &ctx); export_task::poll(self, &ctx); panels::activity::show(self, &ctx); pick::poll(self, &ctx); diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs index 244f4308c..cd4a267af 100644 --- a/crates/ui-egui/src/sync.rs +++ b/crates/ui-egui/src/sync.rs @@ -1,11 +1,13 @@ //! Synchronize Folder in the app: the folder is scanned on a worker thread (a network share can //! take minutes; no frame waits for it) while its dialog is open, and the dialog then says what //! changed and what to do about it. Synchronize runs `folder.synchronize`, which acts on exactly -//! the scan the dialog showed (see `lightcraft_engine::sync`). +//! the scan the dialog showed (see `lightcraft_engine::sync`). Both the scan and the run show a row in the activity +//! stack (issue #345); its ✕ stops them like the dialog's Cancel. use std::sync::Arc; use std::sync::atomic::Ordering; +use lightcraft_engine::activity::{Cancel, TaskGuard}; use lightcraft_engine::sync::{FolderChanges, SyncChoice, SyncCommit, SyncInput, SyncJob, SyncProgress, SyncStep, scan_with}; use serde_json::{Value, json}; @@ -18,6 +20,8 @@ pub struct SyncTask { path: String, progress: Arc, rx: std::sync::mpsc::Receiver, + /// Its row in the activity stack; ✕ cancels the scan and closes the dialog. + guard: TaskGuard, } /// Open the dialog for folder `path` (called `name` in it) and start scanning. @@ -34,7 +38,9 @@ pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Resu std::thread::spawn(job); #[cfg(target_arch = "wasm32")] job(); - app.sync = Some(SyncTask { path: path.to_string(), progress, rx }); + let guard = app.session.activity.start("sync", "Looking for changes", Cancel::Flag(progress.files.cancel.clone())); + guard.detail(name); + app.sync = Some(SyncTask { path: path.to_string(), progress, rx, guard }); let d = SyncChoice::default(); app.ui.dialog = Some(Dialog::SynchronizeFolder { path: path.to_string(), @@ -78,6 +84,14 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { cancel(app); return; } + if task.guard.is_cancelled() { + // ✕ on its row: a scan stopped part-way has nothing to show + cancel(app); + app.ui.dialog = None; + return; + } + let (done, total) = task.progress.counts(); + task.guard.progress(done as u64, total as u64); ctx.request_repaint_after(std::time::Duration::from_millis(100)); let changes = match task.rx.try_recv() { Ok(c) => c, @@ -210,7 +224,9 @@ pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result { std::thread::spawn(job); #[cfg(target_arch = "wasm32")] job(); - app.sync_run = Some(SyncRun { name: name.clone(), progress, crashed, rx, commit: Some(commit), stopping: false, not_saved: Vec::new() }); + let guard = app.session.activity.start("sync", "Synchronizing folder", Cancel::Flag(progress.files.cancel.clone())); + guard.detail(name); + app.sync_run = Some(SyncRun { name: name.clone(), progress, crashed, rx, commit: Some(commit), stopping: false, not_saved: Vec::new(), guard }); Ok(json!({"started": true})) } @@ -227,6 +243,8 @@ pub struct SyncRun { commit: Option, /// Cancel was pressed: nothing new is started; what was readied is still committed. stopping: bool, + /// Its row in the activity stack (its ✕ is Cancel). + guard: TaskGuard, } impl SyncRun { @@ -253,6 +271,11 @@ impl Drop for SyncRun { pub fn poll_run(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(run) = app.sync_run.as_mut() else { return }; ctx.request_repaint_after(std::time::Duration::from_millis(100)); + if run.guard.is_cancelled() { + run.stopping = true; + } + let (done, total) = run.counts(); + run.guard.progress(done as u64, total as u64); let mut steps = Vec::new(); let finished = loop { match run.rx.try_recv() { @@ -317,32 +340,3 @@ pub fn poll_run(app: &mut LightcraftApp, ctx: &egui::Context) { app.toast_for(ctx, format!("{name}: {text}"), 12.0); } } - -/// The progress window while a synchronize runs, with Cancel (what was done stays, as one undo -/// step; nothing new is started). -pub fn progress_window(app: &mut LightcraftApp, ctx: &egui::Context) { - let Some(run) = &app.sync_run else { return }; - let t = Tokens::get(ctx); - let (done, total) = run.counts(); - let text = - if run.stopping { crate::i18n::tr("Stopping…").to_string() } else { format!("{} {done} / {total}", crate::i18n::tr("Synchronizing…")) }; - let frac = if total == 0 { 0.0 } else { done as f32 / total as f32 }; - let stopping = run.stopping; - let mut cancel = false; - egui::Window::new(crate::i18n::tr("Synchronize Folder")) - .id(egui::Id::new("sync-progress")) - .title_bar(false) - .resizable(false) - .anchor(egui::Align2::CENTER_BOTTOM, [0.0, -80.0]) - .fixed_size([340.0, 80.0]) - .show(ctx, |ui| { - ui.label(egui::RichText::new(text).color(t.text)); - ui.add(egui::ProgressBar::new(frac).desired_width(320.0)); - let r = ui.add_enabled(!stopping, egui::Button::new(crate::i18n::tr("Cancel"))); - crate::widgets::register(ui.ctx(), "button:syncCancel", r.rect); - cancel = r.clicked(); - }); - if cancel && let Some(run) = app.sync_run.as_mut() { - run.cancel(); - } -} diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests_sync.rs index 0fa430891..b25ef6070 100644 --- a/crates/ui-egui/src/tests_sync.rs +++ b/crates/ui-egui/src/tests_sync.rs @@ -9,7 +9,7 @@ //! * Ticking "Remove missing photos" also moves those to Recently Deleted. //! * Cancel leaves the library as it was. //! * Synchronize hands the work to the background at once: the dialog closes, the app keeps -//! answering, and the photos arrive over the next frames under a progress window, as one undo +//! answering, and the photos arrive over the next frames under a row in the activity stack, as one undo //! step. use std::time::Duration; @@ -218,7 +218,7 @@ fn a_stopped_synchronize_says_it_was_stopped_and_stays_at_most_one_step() { let steps = h.app.session.undo.len(); let dlg = h.app.ui.dialog.take().expect("the dialog is open"); assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_ok()); - // the progress window's Cancel, before any frame committed anything + // Cancel (what the row's ✕ does), before any frame committed anything h.app.sync_run.as_mut().expect("running").cancel(); assert!(h.step_until(T, |h| h.app.sync_run.is_none()), "it ends"); let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); @@ -254,3 +254,51 @@ fn a_synchronize_and_an_import_never_run_at_once() { let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &d); assert!(r.is_err() && h.app.sync_run.is_none(), "no synchronize while importing: {r:?}"); } + +#[test] +fn a_synchronize_shows_a_row_and_its_cross_stops_it() { + let dir = Scratch::new("row"); + let mut h = changed_folder(&dir); + open_and_scan(&mut h, &dir.path("trip")); + let dlg = h.app.ui.dialog.take().expect("the dialog is open"); + assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_ok()); + let rows = h.app.session.activity.list(); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!((rows[0].kind, rows[0].label.as_str()), ("sync", "Synchronizing folder")); + assert!(rows[0].detail.ends_with("trip"), "the folder as the dialog named it: {:?}", rows[0].detail); + assert!(rows[0].cancellable); + // the row's ✕ (as `activity.cancel`): what was done stays, and the end says it was stopped + h.app.session.activity.cancel(rows[0].id).unwrap(); + assert!(h.step_until(T, |h| h.app.sync_run.is_none()), "it ends"); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("Stopped"), "{toast:?}"); + assert!(h.app.session.activity.list().is_empty(), "the row goes with the run"); +} + +#[test] +fn the_folders_scan_shows_a_row_and_its_cross_closes_the_dialog() { + let dir = Scratch::new("scanrow"); + let mut h = changed_folder(&dir); + // a scan that can't finish until the test lets it: reading the new file waits on a flag + let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let g = gate.clone(); + h.app.session.media.file_probe = Some(std::sync::Arc::new(move |_: &str| { + while !g.load(std::sync::atomic::Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(5)); + } + Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() }) + })); + crate::sync::open(&mut h.app, &dir.path("trip"), "trip", false).unwrap(); + let rows = h.app.session.activity.list(); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].detail.as_str()), ("sync", "Looking for changes", "trip")); + assert!(rows[0].cancellable); + h.app.session.activity.cancel(rows[0].id).unwrap(); + h.step(); + assert!(h.app.sync.is_none(), "✕ stops the scan"); + assert!(h.app.ui.dialog.is_none(), "and closes its dialog: there is nothing to show"); + gate.store(true, std::sync::atomic::Ordering::Relaxed); + assert!(h.app.session.activity.list().is_empty()); + h.settle(SETTLE); + assert!(h.app.ui.dialog.is_none(), "a stopped scan opens nothing later"); +} diff --git a/docs/parity.md b/docs/parity.md index 247e995cb..4c05ae08a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -516,7 +516,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | | | LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs`, `crates/ui-egui/src/panels/profiles.rs`, `crates/ui-egui/src/panels/right.rs` (`versions`), `crates/ui-egui/src/panels/detail.rs` | presets, profile menu, profile browser and versions preview in the loupe | | LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | | -| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports, Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise and Find Missing Photos each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) | +| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise and Find Missing Photos each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | | LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); no tiling beyond the buffer limit (CPU) | @@ -746,7 +746,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | -| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames under a progress window with Cancel, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | +| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: no rating 0–5 is 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index 5372401c4..11f01b07e 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -4,7 +4,7 @@ ### Activity stack - Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and - folder scans, exports, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan, + folder scans, exports (and the contact sheet PDF), Synchronize Folder, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan, AI Denoise and Find Missing Photos. Each row has the task's name, a progress bar, the count and what it is working on; ✕ stops the tasks that can stop. Three rows show, then "+N more". The separate export panel and the progress windows and toasts are gone. @@ -101,7 +101,7 @@ file is gone, photos whose file was renamed or moved within the folder, and XMP sidecars changed by other apps; then choose what to do: import the new photos and relink the moved ones (the defaults), move the missing ones to Recently Deleted, read the metadata updates. A folder on a disk that isn't connected is reported as such. Synchronizing - runs in the background with a progress window and Cancel; everything it does is one undo step, and no file on disk + runs in the background with a row in the activity stack (✕ stops it); everything it does is one undo step, and no file on disk is touched. Agents use `folder.scanChanges` and `folder.synchronize`. ### Folder colour labels From 33f971c3f90159caa97f4a53dd75c48c6261179c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 03:28:15 -0400 Subject: [PATCH 28/33] M11.4: restore main's contributors.json (regenerated by a local build by mistake) Co-Authored-By: Claude Opus 5.5 --- contributors/contributors.json | 313 ++++++++++++++++++++++++++------- 1 file changed, 250 insertions(+), 63 deletions(-) diff --git a/contributors/contributors.json b/contributors/contributors.json index 1072607cb..eaed75d58 100644 --- a/contributors/contributors.json +++ b/contributors/contributors.json @@ -2,23 +2,23 @@ "schema": 1, "repo": "storytold/lightcraft", "branch": "main", - "head": "88ea1a804b35709e1fe436e3792adf0428b97daa", - "head_date": "2026-10-09T13:05:04Z", - "commits": 729, + "head": "c545cc5e842a74d65f32187d9fdee86ada3099de", + "head_date": "2026-10-10T05:40:18Z", + "commits": 1033, "unattributed_commits": 0, "contributors": [ { "login": "echelon", "people_entry": true, "github_name": null, - "prs": 83, - "commits": 348, - "lines_added": 127920, - "lines_deleted": 8745, + "prs": 85, + "commits": 370, + "lines_added": 131038, + "lines_deleted": 9013, "binary_added": 38, "binary_deleted": 8, "first_commit": "2026-09-30T14:04:12Z", - "last_commit": "2026-10-09T13:05:04Z" + "last_commit": "2026-10-10T05:56:02Z" }, { "login": "leipsfur", @@ -63,14 +63,14 @@ "login": "midasdf", "people_entry": false, "github_name": null, - "prs": 5, - "commits": 5, - "lines_added": 755, - "lines_deleted": 80, + "prs": 6, + "commits": 6, + "lines_added": 884, + "lines_deleted": 93, "binary_added": 1, "binary_deleted": 0, "first_commit": "2026-10-05T16:07:26Z", - "last_commit": "2026-10-09T06:04:40Z" + "last_commit": "2026-10-09T18:24:49Z" }, { "login": "voshart", @@ -85,6 +85,19 @@ "first_commit": "2026-10-05T16:23:27Z", "last_commit": "2026-10-09T02:31:04Z" }, + { + "login": "akvaithi", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 5, + "lines_added": 1799, + "lines_deleted": 114, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-05T16:53:56Z", + "last_commit": "2026-10-05T19:55:59Z" + }, { "login": "arata-1972", "people_entry": false, @@ -128,14 +141,14 @@ "login": "Exalted5373", "people_entry": false, "github_name": null, - "prs": 5, - "commits": 11, - "lines_added": 1988, - "lines_deleted": 132, + "prs": 9, + "commits": 15, + "lines_added": 4316, + "lines_deleted": 309, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-06T21:01:23Z", - "last_commit": "2026-10-09T01:33:43Z" + "last_commit": "2026-10-09T21:58:32Z" }, { "login": "treyreynolds", @@ -258,14 +271,14 @@ "login": "radialmonster", "people_entry": false, "github_name": null, - "prs": 26, - "commits": 53, - "lines_added": 7227, - "lines_deleted": 717, + "prs": 35, + "commits": 64, + "lines_added": 8732, + "lines_deleted": 822, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-07T12:33:51Z", - "last_commit": "2026-10-09T11:31:46Z" + "last_commit": "2026-10-10T02:01:36Z" }, { "login": "bogusyogi", @@ -284,14 +297,14 @@ "login": "nledford", "people_entry": false, "github_name": null, - "prs": 10, - "commits": 88, - "lines_added": 9299, - "lines_deleted": 1024, + "prs": 19, + "commits": 305, + "lines_added": 24796, + "lines_deleted": 2896, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-07T17:33:38Z", - "last_commit": "2026-10-09T00:59:12Z" + "last_commit": "2026-10-09T23:05:22Z" }, { "login": "MusseJusse", @@ -349,7 +362,7 @@ "login": "DWAA1660", "people_entry": false, "github_name": null, - "prs": 2, + "prs": 3, "commits": 2, "lines_added": 206, "lines_deleted": 42, @@ -544,14 +557,14 @@ "login": "MarekSpodBiedry", "people_entry": false, "github_name": null, - "prs": 5, - "commits": 6, - "lines_added": 336, - "lines_deleted": 29, + "prs": 6, + "commits": 7, + "lines_added": 351, + "lines_deleted": 36, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-08T13:44:47Z", - "last_commit": "2026-10-09T08:45:45Z" + "last_commit": "2026-10-09T17:29:39Z" }, { "login": "peiyan0", @@ -635,14 +648,14 @@ "login": "sanyamsingh001", "people_entry": true, "github_name": null, - "prs": 1, - "commits": 1, - "lines_added": 431, - "lines_deleted": 19, + "prs": 4, + "commits": 5, + "lines_added": 841, + "lines_deleted": 31, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-09T03:19:47Z", - "last_commit": "2026-10-09T03:19:47Z" + "last_commit": "2026-10-10T01:46:07Z" }, { "login": "onemannoplan", @@ -674,40 +687,170 @@ "login": "HarrisXiu", "people_entry": false, "github_name": null, - "prs": 1, - "commits": 1, - "lines_added": 69, - "lines_deleted": 1, + "prs": 6, + "commits": 6, + "lines_added": 1247, + "lines_deleted": 80, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-09T09:34:51Z", - "last_commit": "2026-10-09T09:34:51Z" + "last_commit": "2026-10-10T05:46:24Z" }, { "login": "rock-meister", "people_entry": true, "github_name": "rock-meister", - "prs": 3, - "commits": 4, - "lines_added": 263, - "lines_deleted": 44, + "prs": 4, + "commits": 5, + "lines_added": 291, + "lines_deleted": 46, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-09T10:15:52Z", - "last_commit": "2026-10-09T11:26:47Z" + "last_commit": "2026-10-09T15:00:07Z" }, { "login": "QuirkyTurtle94", "people_entry": false, "github_name": null, - "prs": 1, - "commits": 1, - "lines_added": 153, - "lines_deleted": 39, + "prs": 5, + "commits": 5, + "lines_added": 475, + "lines_deleted": 77, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-09T11:08:11Z", - "last_commit": "2026-10-09T11:08:11Z" + "last_commit": "2026-10-09T14:32:04Z" + }, + { + "login": "AER00", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 2, + "lines_added": 207, + "lines_deleted": 21, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T12:48:11Z", + "last_commit": "2026-10-09T13:57:38Z" + }, + { + "login": "AlexChambers", + "people_entry": false, + "github_name": null, + "prs": 11, + "commits": 16, + "lines_added": 7202, + "lines_deleted": 954, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T13:59:07Z", + "last_commit": "2026-10-10T05:40:18Z" + }, + { + "login": "bitshiftza", + "people_entry": false, + "github_name": null, + "prs": 2, + "commits": 2, + "lines_added": 342, + "lines_deleted": 97, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T15:03:06Z", + "last_commit": "2026-10-09T18:14:49Z" + }, + { + "login": "vbasky", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 622, + "lines_deleted": 10, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T17:43:31Z", + "last_commit": "2026-10-09T17:43:31Z" + }, + { + "login": "kelanna", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 1169, + "lines_deleted": 24, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T17:51:06Z", + "last_commit": "2026-10-09T17:51:06Z" + }, + { + "login": "dmatviichuk", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 2292, + "lines_deleted": 44, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T18:02:42Z", + "last_commit": "2026-10-09T18:02:42Z" + }, + { + "login": "GarrettGR", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 5, + "lines_deleted": 5, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T19:37:15Z", + "last_commit": "2026-10-09T19:37:15Z" + }, + { + "login": "dylanmtaylor", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 38, + "lines_deleted": 44, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T00:04:24Z", + "last_commit": "2026-10-10T00:04:24Z" + }, + { + "login": "sawizzle", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 2, + "lines_added": 23, + "lines_deleted": 3, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T00:06:24Z", + "last_commit": "2026-10-10T04:50:51Z" + }, + { + "login": "KitsuneSemCalda", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 11, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T01:50:45Z", + "last_commit": "2026-10-10T01:50:45Z" }, { "login": "BuonBreda", @@ -735,6 +878,19 @@ "first_commit": null, "last_commit": null }, + { + "login": "enzoflayac", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 0, + "lines_added": 0, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": null, + "last_commit": null + }, { "login": "GuintherKovalski", "people_entry": false, @@ -761,6 +917,19 @@ "first_commit": null, "last_commit": null }, + { + "login": "pikkemaat", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 0, + "lines_added": 0, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": null, + "last_commit": null + }, { "login": "stano-bustor", "people_entry": false, @@ -794,9 +963,9 @@ "company": "Anthropic", "model": "Claude Opus", "version": "5.5", - "commits": 475, - "lines_added": 147839, - "lines_deleted": 10702 + "commits": 747, + "lines_added": 178263, + "lines_deleted": 14199 }, { "id": "anthropic/claude-sonnet/5.5", @@ -812,18 +981,18 @@ "company": "Anthropic", "model": "Claude", "version": "", - "commits": 65, - "lines_added": 8385, - "lines_deleted": 952 + "commits": 66, + "lines_added": 8450, + "lines_deleted": 956 }, { "id": "openai/codex", "company": "OpenAI", "model": "Codex", "version": "", - "commits": 33, - "lines_added": 14418, - "lines_deleted": 1647 + "commits": 38, + "lines_added": 17120, + "lines_deleted": 1703 }, { "id": "anthropic/claude-fable/5.1", @@ -870,6 +1039,15 @@ "lines_added": 1895, "lines_deleted": 294 }, + { + "id": "openai/gpt-6-luna", + "company": "OpenAI", + "model": "GPT-6 Luna", + "version": "", + "commits": 1, + "lines_added": 68, + "lines_deleted": 19 + }, { "id": "openai/gpt/6", "company": "OpenAI", @@ -878,6 +1056,15 @@ "commits": 1, "lines_added": 2864, "lines_deleted": 261 + }, + { + "id": "opencode/opencode", + "company": "opencode", + "model": "opencode", + "version": "", + "commits": 1, + "lines_added": 459, + "lines_deleted": 5 } ] } From cad9782ca1ce67b2bf19d7267f88ec56579ac02b Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Sat, 10 Oct 2026 09:31:32 +0200 Subject: [PATCH 29/33] Issue #345: long-running tasks use the activity stack (rule and guide) CLAUDE.md gains the rule (a TaskGuard for every job that runs off the UI thread and can take more than a moment; no progress window, panel or toast of its own), and docs/background-tasks.md says how: kind, label, progress, units, detail, the three ways to cancel, examples and tests. Photo Merge's and the face scan's docs no longer describe the old progress toast. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +++ docs/background-tasks.md | 65 ++++++++++++++++++++++++++++++++++++++++ docs/faces.md | 2 +- docs/merge.md | 2 +- docs/parity.md | 2 +- 5 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 docs/background-tasks.md diff --git a/CLAUDE.md b/CLAUDE.md index 577458965..814bc5cf8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md` - **Pure Rust** in the product. No C/C++ dependencies. - **Layering** (`plan/architecture.md` §3, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd. - **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id. Every slider is a `develop` control spec. +- **Long-running work shows in the activity stack** ([`docs/background-tasks.md`](docs/background-tasks.md)): anything + that runs off the UI thread and can take more than a moment holds a `TaskGuard` from `session.activity.start(kind, + label, cancel)` for its whole run. It adopts the cancel flag the job already checks (`Cancel::Flag`), or uses + `Cancel::No` when the job can't stop. No progress window, panel or progress toast of its own; its tests assert the row. - **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match. - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm). - **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment. diff --git a/docs/background-tasks.md b/docs/background-tasks.md new file mode 100644 index 000000000..576b3868f --- /dev/null +++ b/docs/background-tasks.md @@ -0,0 +1,65 @@ +# Long-running tasks and the activity stack + +Everything that takes a while runs off the UI thread and shows up in **one** place: the activity stack in the +top-left corner under the top bar (issue #345). Each task has a row with its name, a progress bar (a sweep while +the amount of work isn't known), the count and what it's working on, and ✕ if it can stop. Quitting asks first while +a task that can stop is running. Agents see the same list through `activity.list` / `activity.cancel`, and +`ui.inspect` reports it as `activity`. + +The registry is `lightcraft_engine::activity` (`crates/engine/src/activity.rs`, `Session::activity`). The stack is +`crates/ui-egui/src/panels/activity.rs`. Every job that can last more than a moment uses it; **don't add a progress +window, panel or progress toast of your own**. A dialog may still show progress inline (the Synchronize Folder +dialog counts files while it scans), and a toast at the end saying what happened is fine. + +## Adding a task + +```rust +use lightcraft_engine::activity::{Cancel, Unit}; + +// when the job starts, with the cancel flag the worker already checks between files +let guard = app.session.activity.start("export", "Exporting", Cancel::Flag(cancel.clone())); +guard.progress(0, total as u64); + +// every frame (or from the worker, through `guard.handle()`) +guard.progress(done as u64, total as u64); +guard.detail(&file_name); +``` + +- **Keep the guard for exactly as long as the job runs.** Put it in the struct the UI polls (`ExportTask`, `ScanTask`, + `SyncRun`…), or move it into the worker thread. Dropping it removes the row, also when the worker panics, so a + dead job never leaves a stuck bar. A worker that only reports progress takes a `TaskHandle` (`guard.handle()`, + `Clone + Send`). +- **`kind`** is a stable camelCase id that agents filter by (`export`, `import`, `scan`, `sync`, `previews`, + `download`, `faces`…). **`label`** is a short English name in sentence case ("Building previews"). The stack shows it + translated, so add it to every catalog in `crates/ui-egui/locales/`. +- **Progress:** `progress(done, total)`. A `total` of 0 means the amount of work isn't known yet, and the bar sweeps. + `set_unit(Unit::Bytes)` makes the count read "12 of 340 MB"; `Unit::Percent` reads "40 %". The default `Unit::Count` + reads "3 of 25". +- **Detail:** what the job is on (a file name, a model's name). Detail set by engine code is a name, never a sentence. + UI code may set translated text. +- **Cancel:** + - `Cancel::Flag(Arc)` adopts the flag the job already checks. The row's ✕, `activity.cancel` and the + job's own Cancel then all set the same atomic. + - `Cancel::Yes`: the job polls `guard.is_cancelled()` itself. + - `Cancel::No`: the job can't stop; it gets no ✕ and doesn't make Quit ask. + - `set_cancellable(false)` for a phase that has to finish once started (a Lightroom import adding its photos). +- **Quiet periodic work** shows no row: the auto-import folder listing runs every few seconds through + `tasks::spawn(.., None, ..)`. + +## Examples in the code + +| Job | Where | Shows how to | +|---|---|---| +| Export, contact sheet | `crates/ui-egui/src/export_task.rs` | adopt the job's flag; update the row each frame | +| Build Previews | `crates/engine/src/cmd/previews.rs` | move the guard into the worker thread | +| Model downloads | `crates/engine/src/face_download.rs` | report progress through a `TaskHandle` in shared state | +| Lightroom catalog import | `crates/ui-egui/src/lightroom_import.rs` | stop being cancellable for a phase that must finish | +| Face scan, AI Denoise | `crates/engine/src/cmd/face_recognize.rs`, `crates/engine/src/denoise.rs` | a continuous job with no ✕, kept in session state | +| Find Missing Photos | `crates/ui-egui/src/tasks.rs` | a generic background task with a row (`kind: Some(..)`) | + +## Testing + +Assert the row (`session.activity.list()`: kind, label, total, `cancellable`), that `activity.cancel(id)` stops the +job, and that the row is gone once the job ends. `crates/ui-egui/src/tests_activity.rs` has the helpers, and the +job-specific tests show the pattern (`tests_sync.rs`, `export_task.rs`, `tests_denoise.rs`…). A row only appears +on screen once its task is 0.5 s old, so tests that click its ✕ wait for `activity:cancel:` first. diff --git a/docs/faces.md b/docs/faces.md index 3b1692c6a..996c82ca5 100644 --- a/docs/faces.md +++ b/docs/faces.md @@ -248,7 +248,7 @@ Turning recognition on starts a scan of the whole library, once, in the backgrou the running ones, so a worker that finishes has its next photo at once. - **Earlier scan speed (a different runner).** A mixed raw and JPEG library of 184 photos (the raws read through their embedded previews, 73 photos searched for faces) took about 29 seconds on that machine at full pace: roughly 6 photos a second, so the first scan of 10,000 photos - is a matter of half an hour; after that only new photos are looked at. Settings ▸ Faces shows how many are left, with a progress bar. After you accept a model's terms + is a matter of half an hour; after that only new photos are looked at. Settings ▸ Faces shows how many are left, with a progress bar, and the activity stack shows "Finding faces" meanwhile. After you accept a model's terms you are returned to that tab, so the download and then the scan can be watched there; the main window has no status bar. - Opening another library starts a fresh scan state; nothing learned about one library is used in another. diff --git a/docs/merge.md b/docs/merge.md index 600eb840f..e78c356fb 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -9,7 +9,7 @@ All algorithms are our own implementations from published papers (cited in the m - **App:** select the photos, then *Photo Merge ▸ HDR…* (`Ctrl+H`), *Panorama…* (`Ctrl+M`) or *HDR Panorama…* from the photo context menu (or the `dialog.merge*` commands). The dialog previews the merge at ≤ 1024 px and re-runs the preview when an option changes; *Merge* runs the full merge - in the background (progress in a toast). The result is written next to the first photo as + in the background (a row in the activity stack shows its progress, with ✕ to stop it). The result is written next to the first photo as `-HDR.dng`, `-Pano.dng` or `-HDR-Pano.dng`, imported and selected. - **Commands** (CLI, MCP, control channel): - `merge.hdr {ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false, preview=false, showOverlay=false, previewPath?}` diff --git a/docs/parity.md b/docs/parity.md index 4c05ae08a..d767ea2d6 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -516,7 +516,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | | | LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs`, `crates/ui-egui/src/panels/profiles.rs`, `crates/ui-egui/src/panels/right.rs` (`versions`), `crates/ui-egui/src/panels/detail.rs` | presets, profile menu, profile browser and versions preview in the loupe | | LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | | -| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise and Find Missing Photos each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) | +| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise and Find Missing Photos each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | | LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); no tiling beyond the buffer limit (CPU) | From 3752207ad1dd1964d3a9ed6a348e462a642f7a2b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 03:45:37 -0400 Subject: [PATCH 30/33] LR-BEHAV-BG-TASKS: the preview-build activity test builds small previews (timed out on a loaded machine) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index eec58bc46..cea8475e6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -735,7 +735,7 @@ fn wait_activity_empty(s: &Session) { fn preview_build_shows_in_activity_until_it_finishes() { let mut s = demo(); let ids: Vec = s.catalog.photos().take(4).map(|p| p.id.0).collect(); - let r = s.execute("library.buildPreviews", &json!({"ids": ids, "size": "full"})).unwrap(); + let r = s.execute("library.buildPreviews", &json!({"ids": ids, "size": "standard", "edge": 256})).unwrap(); let tasks = s.activity.list(); assert_eq!(tasks.first().map(|t| (t.kind, t.total, t.label.as_str())), Some(("previews", 4, "Building previews")), "{tasks:?} {r}"); assert!(tasks[0].cancellable); From 4a7eb6b338b7a84340ca99ac9f2f0363ef2f7a7a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 03:57:57 -0400 Subject: [PATCH 31/33] LR-IMP-FORMATS: #573's centred crop and #498's DefaultCrop helper side by side (merge put the flag on the wrong function) Co-Authored-By: Claude Opus 5.5 --- crates/raw/src/vendor/arw.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index b9a05a493..d80c67f84 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -486,11 +486,7 @@ fn word16_order(strip: &[u8], bits: u32, file: lightcraft_tiff::ByteOrder) -> li } /// The raw IFD's DNG-style `DefaultCropOrigin` / `DefaultCropSize`, clipped to the `w × h` frame. -/// -/// `centred` places that recorded-size window in the middle of the frame (on even offsets, keeping the CFA phase) -/// instead of at the left: the packed 12-bit frame (DSLR-A900) has no padding at either edge, and its camera JPEG -/// is centred on the frame. -fn dng_default_crop(raw: &Ifd, w: usize, h: usize, centred: bool) -> Option { +fn dng_default_crop(raw: &Ifd, w: usize, h: usize) -> Option { if let (Some([x, y]), Some([cw, ch])) = (raw.u64s(t::DEFAULT_CROP_ORIGIN).as_deref(), raw.u64s(t::DEFAULT_CROP_SIZE).as_deref()) && *cw > 0 && *ch > 0 @@ -502,7 +498,11 @@ fn dng_default_crop(raw: &Ifd, w: usize, h: usize, centred: bool) -> Option, exif_size: Option<(u64, u64)>, w: usize, h: usize) -> Rect { +/// +/// `centred` places that recorded-size window in the middle of the frame (on even offsets, keeping the CFA phase) +/// instead of at the left: the packed 12-bit frame (DSLR-A900) has no padding at either edge, and its camera JPEG +/// is centred on the frame. +fn default_crop(raw: &Ifd, mn: Option<&makernote::MakerNote>, exif_size: Option<(u64, u64)>, w: usize, h: usize, centred: bool) -> Rect { let full = Rect::new(0, 0, w, h); if let Some(crop) = dng_default_crop(raw, w, h) { return crop; From 2ffe2bcea2663d3eef51594adac92626a21e2df9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 04:35:13 -0400 Subject: [PATCH 32/33] LR-BEHAV-BG-TASKS: the preview-build activity test builds small previews (timed out on a loaded machine) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index eec58bc46..cea8475e6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -735,7 +735,7 @@ fn wait_activity_empty(s: &Session) { fn preview_build_shows_in_activity_until_it_finishes() { let mut s = demo(); let ids: Vec = s.catalog.photos().take(4).map(|p| p.id.0).collect(); - let r = s.execute("library.buildPreviews", &json!({"ids": ids, "size": "full"})).unwrap(); + let r = s.execute("library.buildPreviews", &json!({"ids": ids, "size": "standard", "edge": 256})).unwrap(); let tasks = s.activity.list(); assert_eq!(tasks.first().map(|t| (t.kind, t.total, t.label.as_str())), Some(("previews", 4, "Building previews")), "{tasks:?} {r}"); assert!(tasks[0].cancellable); From 964d9338bafac524146ecf24a1fab54bd22b052e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 14:25:15 -0400 Subject: [PATCH 33/33] Progress docs: target-app parity, RAW coverage vs Adobe, gaps, alpha gate, readiness by audience (#788) Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, model-by-model RAW coverage against Adobe's camera list (docs/raw/), and format, hardware, UI and localization parity docs. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 18 +- CLAUDE.md | 18 +- README.md | 22 +- ROADMAP.md | 352 +++--- crates/meta/src/xmp.rs | 2 +- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/shortcuts.rs | 2 +- docs/architecture.md | 70 ++ docs/file-format-parity.md | 79 ++ docs/gaps.md | 77 ++ docs/hardware-parity.md | 50 + docs/localization-parity.md | 92 ++ docs/{parity.md => parity-checklist.md} | 111 +- docs/process-versions.md | 2 +- docs/raw-parity.md | 253 ++++ docs/raw/adobe-cameras.tsv | 1450 +++++++++++++++++++++++ docs/raw/coverage.py | 172 +++ docs/raw/coverage.tsv | 1448 ++++++++++++++++++++++ docs/raw/evidence.tsv | 249 ++++ docs/raw/usage-weights.tsv | 114 ++ docs/roadmap.md | 113 ++ docs/target-app-parity.md | 304 +++++ docs/ui-parity.md | 81 ++ docs/whats-new.md | 144 +-- docs/xmp-interop.md | 2 +- xtask/src/main.rs | 2 +- xtask/src/parity.rs | 4 +- 27 files changed, 4837 insertions(+), 396 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/file-format-parity.md create mode 100644 docs/gaps.md create mode 100644 docs/hardware-parity.md create mode 100644 docs/localization-parity.md rename docs/{parity.md => parity-checklist.md} (94%) create mode 100644 docs/raw-parity.md create mode 100644 docs/raw/adobe-cameras.tsv create mode 100644 docs/raw/coverage.py create mode 100644 docs/raw/coverage.tsv create mode 100644 docs/raw/evidence.tsv create mode 100644 docs/raw/usage-weights.tsv create mode 100644 docs/roadmap.md create mode 100644 docs/target-app-parity.md create mode 100644 docs/ui-parity.md diff --git a/AGENTS.md b/AGENTS.md index b199c7f96..8cd199ff6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,12 +5,18 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers). 2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots). -3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and - by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that *exist*; the real - gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render - fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a - ✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note. -4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary. +3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, every dimension) and, for + method and evidence, [`docs/target-app-parity.md`](docs/target-app-parity.md). The checklist counts features that + *exist*; the real gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model + verification, render fidelity against Lightroom, AI models, HDR / video / Classic modules (raw detail: + [`docs/raw-parity.md`](docs/raw-parity.md)). A ✅ row is not proof of parity — if you find a ✅ feature that is + wrong or incomplete, downgrade it to 🟡 with a note. +4. **Pick work** from [`docs/gaps.md`](docs/gaps.md) (ranked by user impact; `B` marks beta blockers). The row-by-row + tracker is [`docs/parity-checklist.md`](docs/parity-checklist.md) (one row per feature, menu item and shortcut). + When you land a feature, update its row(s) and the gap entry in the same commit, and the numbers in `ROADMAP.md` / + `docs/target-app-parity.md` when a listed gap closes (bump each doc's *Last updated* line and revision history, per + craftrules `standards/progress-docs.md`); `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and + path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary. 5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's. `plan/` is gitignored (local-only). diff --git a/CLAUDE.md b/CLAUDE.md index 814bc5cf8..38f5bde4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,12 +5,18 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers). 2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots). -3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) → *Where we stand* (honest assessment by dimension and - by kind of user) and *Where we're going* (ordered priorities). The checklist counts features that *exist*; the real - gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render - fidelity against Lightroom, AI models, HDR / video / Classic modules. A ✅ row is not proof of parity — if you find a - ✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note. -4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary. +3. **Know where we stand:** read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, every dimension) and, for + method and evidence, [`docs/target-app-parity.md`](docs/target-app-parity.md). The checklist counts features that + *exist*; the real gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model + verification, render fidelity against Lightroom, AI models, HDR / video / Classic modules (raw detail: + [`docs/raw-parity.md`](docs/raw-parity.md)). A ✅ row is not proof of parity — if you find a ✅ feature that is + wrong or incomplete, downgrade it to 🟡 with a note. +4. **Pick work** from [`docs/gaps.md`](docs/gaps.md) (ranked by user impact; `B` marks beta blockers). The row-by-row + tracker is [`docs/parity-checklist.md`](docs/parity-checklist.md) (one row per feature, menu item and shortcut). + When you land a feature, update its row(s) and the gap entry in the same commit, and the numbers in `ROADMAP.md` / + `docs/target-app-parity.md` when a listed gap closes (bump each doc's *Last updated* line and revision history, per + craftrules `standards/progress-docs.md`); `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and + path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary. 5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's. `plan/` is gitignored (local-only). diff --git a/README.md b/README.md index 217092fbf..d395a104e 100644 --- a/README.md +++ b/README.md @@ -297,18 +297,20 @@ lightcraft --control 7980 ~/Pictures/trip ## Feature status -LightCraft is young and moving fast. **Where we honestly stand** (details in the [roadmap](ROADMAP.md#where-we-stand)): +LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (details in the [roadmap](ROADMAP.md) and +[docs/target-app-parity.md](docs/target-app-parity.md)): -- **By feature count we're at ~79%** of Lightroom (core features 98%), tracked row by row in - [docs/parity.md](docs/parity.md). -- **As a day-to-day Lightroom replacement we're nearer 60–70%.** It's great for JPEG/DNG and most Nikon / Sony / - older-Canon raws on one machine. +- **By feature count we're at ~81%** of Lightroom (core features 98%), tracked row by row in + [docs/parity-checklist.md](docs/parity-checklist.md). +- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~49% for a typical working photographer and ~64% for + casual use.** It's great for JPEG/DNG and most Sony / Panasonic / + Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera. - **The biggest gaps:** - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; - **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md); - **AI masks and denoise:** subject and sky selection are classical heuristics; - **on-screen HDR display, video and the Classic Print / Book / Map modules.** -- **What's next:** see [where we're going](ROADMAP.md#where-were-going). +- **What's next:** see [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md). | Area | Status | |---|---| @@ -318,10 +320,10 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the | Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) | | Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ | | Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ | -| Camera colour: DNG files use their own matrices | ✅ DNG · 🟡 own Sony/Fujifilm profiles; measured calibration database missing | +| Camera colour: DNG files use their own matrices | ✅ DNG · 🚧 own Sony/Fujifilm profiles; measured calibration database missing | | Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ | -| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF and compressed SRW decode ⬜ | -| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ | +| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed, incl. lossy after split), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed, 12.8 bpp), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🚧 · other CR3 variants, compressed ORF / SRW and Nikon HE NEF decode ⬜ ([raw parity](docs/raw-parity.md)) | +| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights), Super Resolution ⬜ | | Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 | | Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ | | HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; export as ISO 21496-1 gain map JPEG, PQ AVIF or 32-bit float TIFF | ✅ · HDR display ⬜ | @@ -332,7 +334,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the | Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files and Panasonic / Leica RW2 / RWL distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ | | Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ | | GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 | -| AI: segmentation masks, AI denoise, super resolution, faces; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) | +| AI: segmentation masks, AI denoise, super resolution, faces; video | 🚧 (see [gaps](docs/gaps.md)) | | Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 | ✅ works today · 🚧 in progress · ⬜ not started diff --git a/ROADMAP.md b/ROADMAP.md index 9bb4744b9..c1cfbcf5a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,242 +1,166 @@ # LightCraft roadmap -Milestones toward full Adobe Lightroom parity (cloud Lightroom first, then every Lightroom Classic module), with wall-clock -estimates for continuous (24/7) agent-driven development with 4–6 parallel agents. Estimates are calibrated on the sibling -projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revised as milestones land. +**Stage: alpha** · next: beta, ~14 points and ~340–600 h away -## Where we stand +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Lightroom Classic 15.5 and the installed Lightroom 9.6; restructured to the progress-docs standard) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 -*Honest assessment, 2026-10-08. Agents: read this before picking work. The checklist in -[`docs/parity.md`](docs/parity.md) counts features that **exist**; this section is about whether a photographer can -**switch** from Lightroom. Update it when a gap below closes.* +One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work +from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in +[docs/target-app-parity.md](docs/target-app-parity.md). -**In one line:** the checklist says **79.7%** (P0 98.2%, P1 95.6%, P2 41.6%), but measured by whether a working -photographer could replace Lightroom without noticing, we are at roughly **60–70%**. The remaining gap is mostly -**quality of results and camera coverage**, not missing buttons. +## Headline numbers -Caveat on the checklist: ✅ is set by whoever lands a feature, and nobody has systematically checked rows against -Lightroom's behaviour or output. Bugs keep turning up in ✅ areas (CR2 colour-filter phase on some Canon models #85, -duplicate Local entries #22, black GPU exports on an Intel iGPU #78). - -### By dimension - -| Dimension | Estimate | What's true today | Biggest gaps | -|---|---:|---|---| -| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim | -| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, uncompressed Samsung SRW (NX5 to NX1100, EX1, WB2000), RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini), Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Nikon High Efficiency NEF (HE / HE★: Z 8, Z 9, Z 6III, Z f; JPEG XS-based, preview only, #193). Per-model verification is thin (~55 corpus files vs >1,000 models) | -| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); 52 models whose JPEG fit is rejected start from matrices fitted to measured spectral sensitivities; other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) | -| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) | -| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering | -| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic), paginated contact-sheet PDF export (A4/Letter, layout/captions, current edits, 150 dpi sRGB) | **Map view, Book, full Print module, Slideshow module, Web, publish services**: contact sheets work, custom print packages and printer colour management remain missing | -| **HDR & video** | ~25% | HDR editing (headroom limit, SDR rendition, Visualize HDR); gain map JPEG, PQ AVIF and float TIFF export | On-screen HDR display, HLG, HDR settings from crs XMP; video play/trim/edit/export | -| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load | - -### By kind of user - -| User | Readiness | What blocks them | +| | Value | Kind | |---|---:|---| -| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks | -| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); embedded distortion is supported for Panasonic / Leica RW2 and the Sony ILCE-7RM4A (Bayer ARW, 24–105 / 200–600 tested); other lens metadata needs coverage and validation | -| Fujifilm raw shooter | ~65% | Uncompressed and lossless/lossy compressed RAF decoded; camera colour calibration remains missing. Verified on 13 bodies, including 14/16-bit GFX; see `docs/raf-compression.md` | -| Canon CR3 / Olympus compressed-raw shooter | ~45% | CR3 lossless Bayer and version 0x100/0x200 C-RAW develop from sensor data (M50, R100, R8 verified pixel for pixel); other CRX variants and compressed ORF open as embedded JPEG previews. Camera colour and broader model verification remain | -| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering | -| Relies on AI (masks, denoise) | ~25% | Object / Describe masks via optional SAM 3 (download not yet hosted); Bayer AI denoise needs separately installed weights; X-Trans/linear RGB, model policy and quality parity remain open | +| **Feature breadth** (checklist) | **81.1%** of 517 rows (P0 98.0%, P1 95.1%, P2 46.9%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount | +| **Feature breadth** (weighted by use) | **~77%** | estimated | +| **Ready for real work** (full target) | **~61%** (56–66%) | estimated: weighted sum over the dimensions | +| **Mainstream practitioner** | **~49%** (44–54%) | estimated | +| **Essentials user** | **~64%** (59–69%) | estimated | +| Remaining to **beta** | **~340–600 h** (Opus 5.5 agent hours, ~60% parallel) | estimated | +| Remaining to **full parity** | **~1,000–1,800 h** (~70% parallel) | estimated | -## Where we're going +Each audience with its own hours (Opus 5.5 agent wall-clock hours to ~95%): -Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*. +| Audience | Ready | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | +|---|---:|---:|---| +| Full target (ready for real work) | **~61%** | **1,000–1,800 h** (~70% parallelizes) | raw colour, lens and camera data; AI models; Classic output modules; localization; plug-in ecosystem | +| Mainstream practitioner | **~49%** | **450–840 h** (~60% parallelizes) | camera colour and render fidelity; CR3 / ORF / HE NEF; lens database; subject / sky masks; Lightroom XMP exchange; stability | +| Essentials user | **~64%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send | -1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use - matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2, - Pentax PEF, Samsung SRW and Olympus ORF have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF - since issue #310, not yet checked on the corpus); 52 models whose fit is rejected fall back to matrices fitted to - measured spectral sensitivities (rawtoaces-data); then validate fidelity. -2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, NEF - lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source, - no LGPL dependency); compressed NEF (#86) is the template. -3. **Verified camera coverage** (LR-IMP-CAMERA-COVERAGE, P0): a CC0 sample per model in the corpus, each decoded and - checked for plausible colour; fix per-model bugs (#85). -4. **Render fidelity suite** (LR-BEHAV-RENDER-FIDELITY, P1): measure our output against Lightroom on the same CC0 raws - (references stay in the local `plan/`), then tune against the numbers. -5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1): DNG, Panasonic / Leica RW2 and Sony ILCE-7RM4A Bayer ARW - embedded distortion are supported; broaden validation and decode remaining vignetting / lateral CA metadata. -6. **AI model strategy** (maintainer decision): which permissively licensed models (or our own training) for - segmentation masks and denoise. Bayer denoise inference is implemented in pure Rust with an opt-in GPL model; release policy and independently licensed weights remain open. Unblocks M12 and Enhance. Face detection and recognition run on our own checked Rust engine. Object / Describe masks now - run Meta's SAM 3 in pure Rust (optional, user-consented download under the non-OSI SAM License; CDN mirrors still - to be set up, docs/ai-masks.md); Subject / Sky / People, Raw Details and Super Resolution are still open. -7. **Then:** HDR (Q), the Classic output modules (expand contact-sheet PDF export into full Print, then Map view, Book, Slideshow), video (R), localisation - and accessibility. +The gap between breadth and readiness is quality and coverage, not missing buttons: camera colour is +estimated rather than measured, CR3 decodes on three verified Canon bodies, compressed ORF and HE NEF +open as previews, rendering is tuned by eye, AI masks are heuristics, there is no lens database, and +tests run in CI on FreeBSD only. **Why alpha:** core workflows run end to end, but readiness is below +the ~75% beta bar (it is above the ~40% alpha bar) and Lightroom's main inputs, raw files, still have blocking gaps (Canon, ~30% of +raw users, decodes CR3 on three verified bodies). It passes the alpha gate: all six core workflows +(import, cull, develop, save and reopen, batch looks, export) work end to end on macOS +([docs/roadmap.md](docs/roadmap.md#alpha-gate)). The three readiness numbers and their weights and discounts are in +[target-app-parity.md](docs/target-app-parity.md#ready-for-real-work-mainstream-practitioner-and-essentials-user). +Hours are calibrated on this repo's PR history; see +[target-app-parity.md](docs/target-app-parity.md#how-hours-are-calibrated). -Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29), per-library smart-preview folder, -Local roots / cleanup, grid and catalog performance at 85k photos (#35, #37), failed-save reporting, GPU export -hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help and folder templates (#31, #32). +## By dimension -Library folders now have records of their own in the catalog (`Catalog.folder_records`, keyed by folder path; colour -labels first). Photos still store full paths, so moving a whole disk or folder relinks each photo. If that becomes a -problem (an "Update Folder Location" for a renamed or replaced drive, journal growth on large moves), the next step is -the Classic model: photos point to a folder id (root + relative path), and the records keyed by path become those rows. +| Dimension | Ready | Breadth | Remaining | Doc | +|---|---:|---:|---:|---| +| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) | +| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | +| UI/UX fidelity | ~70% | ~85% | 40–70 h | [ui-parity](docs/ui-parity.md) | +| File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) | +| Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) | +| Localization | ~52% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) | +| Performance | ~70% | ~80% | 30–50 h | [target-app-parity](docs/target-app-parity.md#by-dimension) | +| Stability | ~60% | — | 30–60 h | [target-app-parity](docs/target-app-parity.md#by-dimension) | +| Platforms | ~60% | macOS, Windows, Linux, FreeBSD, web | 25–45 h | [target-app-parity](docs/target-app-parity.md#by-dimension) | +| Ecosystem / plug-ins | ~15% | ~25% | 40–80 h | [target-app-parity](docs/target-app-parity.md#by-dimension) | +| AI features | ~20% | ~30% | 120–220 h (overlaps features) | [target-app-parity](docs/target-app-parity.md#ai) | -Image navigation now supports native pinch zoom and two-finger pan in Detail, Compare and Reference views. -The whole-image preview still has a configurable size cap (2,560 px default), so 100% on larger photos does not -yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-VIEW-ZOOM). +## Features -## Milestones - -**Status legend:** ✅ done · 🚧 in progress · ⬜ not started - -| # | Milestone | Scope (summary) | Estimate (h) | Status | -|---|---|---|---|---| -| M0 | Skeleton + visual shell | workspace, xtask CI + layering, geom/color/raster, develop model, pipeline v0, catalog v0, engine commands, Lightroom-look UI (grid, loupe, filmstrip, Edit panel), control channel, MCP, web build | 3–5 | ✅ | -| M1 | Library core | import (JPEG/PNG/TIFF/WebP), EXIF/XMP, persistent catalog (op log + snapshots), albums, ratings/flags/labels, filter/search/sort, thumbnail cache, 100k-photo grid | 6–10 | ✅ (100k-photo grid scale test pending) | -| M2 | Pipeline v1 (quality) | WB temp/tint, profiles, local tone mapping (highlights/shadows), curves, HSL, point colour, colour grading, texture/clarity/dehaze, vignette, grain, B&W, auto tone/WB, histogram, before/after | 10–15 | ✅ (look tuning vs our references ongoing) | -| M3 | RAW I | TIFF/DNG (LJ92, deflate, tiles, opcodes), demosaic (AHD/PPG/bilinear), highlight recovery, DNG colour model, CR2, NEF, ARW, embedded previews | 10–15 | 🚧 (DNG, CR2, ARW, NEF uncompressed + Huffman lossless/lossy, embedded previews ✅; NEF lossy-after-split ⬜) | -| M4 | Crop, geometry, optics | crop tool + overlays, straighten, Upright (auto/level/vertical/full/guided), manual transforms, CA, defringe, manual lens corrections | 6–10 | ✅ | -| M5 | Performance | source pyramids, wgpu compute pipeline (CPU oracle), draft/full renders, prefetch, budgets (16 ms slider updates on 24 MP) | 10–15 | 🚧 (stage cache, source pyramid, wgpu pipeline, prefetch, memory budget ✅; colour NR at half resolution, GPU histogram ⬜) | -| M6 | Masking | brush, linear/radial gradients, colour/luminance/depth range, add/subtract/intersect/invert, all local adjustments, masks panel | 8–12 | 🚧 (brush/linear/radial/colour/luminance range, add/subtract/intersect, masks panel ✅; depth range, AI masks ⬜) | -| M7 | Detail | sharpening + masking preview, luminance/colour NR, Denoise, Raw Details, Super Resolution | 6–10 | 🚧 (sharpening, luminance/colour NR ✅; AI Denoise 🟡 (Bayer, optional weights; policy/fidelity open); Raw Details, Super Resolution ⬜) | -| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) | -| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ | -| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs, HDR gain map JPEG / PQ AVIF ✅; JXL encode ⬜) | -| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (CR3 lossless CRX and C-RAW, RAF uncompressed and lossless/lossy compressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF, RW2 and RAF preview fitting; measured database still missing), CR3, compressed ORF ⬜) | -| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ | -| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ | -| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ | -| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) | -| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/Spanish/German/Russian/Ukrainian localisation 🟡; accessibility and further locales ⬜) | - -## Parity estimate (feature count updated 2026-10-08; effort estimate from 2026-10-02) - -**By feature count** — from `docs/parity.md` (one row per Lightroom feature, menu item and shortcut; `cargo xtask -parity` prints this line on every run, so it stays current): - -| Scope | Weighted completion | Rows | 2026-10-02 | +| Area | Ready | Breadth | Remaining | |---|---:|---:|---:| -| P0 (core) | **98.2%** | 200 | 98.7% | -| P1 (important parity) | **95.6%** | 149 | 94.4% | -| P2 (later / AI / niche, incl. Classic modules) | **41.6%** | 160 | 29.4% | -| **All in-scope rows** | **79.7%** | 509 | 75.6% | +| Import and library | 80% | 90% | 25–45 h | +| RAW decoding and camera coverage | 76% | 81% | 120–220 h | +| Colour, camera profiles, render fidelity | 35% | 65% | 80–150 h | +| Global develop adjustments | 70% | 93% | 30–60 h | +| Optics, lens profiles, crop and geometry | 50% | 80% | 40–80 h | +| Masking | 45% | 67% | 60–110 h | +| Remove, heal, Enhance | 30% | 45% | 70–130 h | +| Presets, profiles, versions, sync | 85% | 95% | 8–15 h | +| Export | 75% | 89% | 15–30 h | +| Merge and HDR | 60% | 85% | 20–35 h | +| Views, compare, zoom, culling | 70% | 90% | 15–30 h | +| Classic output modules (Map, Book, Slideshow, Print, publish, tethering) | 10% | 15% | 90–150 h | +| Video | 0% | 0% | 25–45 h | -The 2026-10-05 count includes three new 🟡 rows that make quality gaps visible (camera colour calibration, verified -camera coverage, render fidelity), which is why P0 dipped slightly. +Weights and evidence: [target-app-parity.md](docs/target-app-parity.md#features). -✅ counts 1, 🟡 ½, ⬜ 0; out-of-scope rows (cloud sharing, Adobe accounts…) are left out. +## Languages -**By remaining effort** — rows are not equal: a shortcut and the whole Book module are one row each, and what is -left is the heavy part (AI, video, Classic output modules, undocumented raw codecs). Remaining work in **Opus agent-hours** -(one agent working continuously; calibrated on this project — a single lead agent closed ≈ 45 tracker rows of -UI/feature work in ≈ 5 h on 2026-10-01, and ≈ 60 more (preset import incl. `.lrtemplate` / DNG / zip and masks, smart-album -rule editor, auto sync, keyword and label sets, import options and DNG conversion, smart previews, external-editor round -trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel agents built M0–M13 in ≈ 25 active hours): +Measured from `crates/ui-egui/locales` (share of 2,304 catalog messages translated); none is `full` +while lower-layer errors stay English. Detail: [localization-parity.md](docs/localization-parity.md). -| Work package | Tracker rows | Agent-hours | Risk | +| Language | Code | Translated | Status | |---|---|---:|---| -| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low | -| Raw codecs: CR3 (CRX), compressed ORF, NEF lossy-after-split, HEIC/AVIF decode | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs | -| Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection | -| Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium | -| AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training | -| HDR editing, display, visualisation and export | Q. HDR, LR-EXP-HDR | 15–25 | medium | -| Classic modules: Map, Book, Slideshow module, Print, publish services, tethering, soft proofing | ≈ 50 | 60–100 | medium (large, well-understood) | -| Smaller P2 items: Enhance dialog, export to Photos, help / what's new, accessibility, localisation, sidecar variants | ≈ 15 | 12–25 | low | -| Look tuning, performance budgets (colour NR at half resolution on CPU + GPU, 100k-photo library), packaging, hardening | — | 25–45 | medium | -| **Total remaining** | | **≈ 270–505** | | +| English | en | 100% | full (source) | +| Simplified Chinese | zh-hans | 87% | partial | +| Spanish | es | 86% | partial | +| Hindi | hi | 0% | none (no Devanagari shaping) | +| Arabic | ar | 0% | none (no RTL / shaping) | +| French | fr | 88% | partial | +| Portuguese (Brazil) | pt-br | 85% | partial | +| Indonesian | id | 0% | none | +| Japanese | ja | 87% | partial | +| German | de | 91% | partial | +| Korean | ko | 0% | none (no Hangul face) | +| Vietnamese | vi | 0% | none | -Spent so far ≈ 105–145 agent-hours, so **by effort the project is ≈ 20–35% of the way to complete Lightroom + Classic -parity**, and ≈ 45–60% of the way for cloud-Lightroom parity without AI and the Classic modules (remaining ≈ 130–255 h, -most of it the raw codecs, lens data, video and HDR). +Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%). -**Wall clock:** ≈ 270–505 h for one agent working alone; with 4–6 parallel agents (≈ 70% parallel efficiency, merges and -CI under load cost the rest) **≈ 65–125 h of continuous work**. The AI package and the raw codecs carry most of the -uncertainty: they can finish faster if suitable permissive models / documentation turn up, or stall on licensing. +## Upcoming -## Totals +Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/gaps.md](docs/gaps.md). -Remaining from 2026-10-02 evening (see *Parity estimate* above for the breakdown): +1. Camera colour of our own: measured calibration for the popular models (50–90 h) +2. CR3 for every Canon body (25–45 h) +3. Render-fidelity suite against Lightroom, then tuning (30–60 h) +4. Full-resolution zoom (8–15 h) +5. AI masks: Subject / Sky / People (40–70 h + model decision) +6. Compressed ORF and HE NEF (28–55 h) +7. Lens profile database of our own (40–80 h) +8. Tests in CI on macOS, Windows and Linux (6–12 h) +9. Per-model raw verification (20–35 h) -| Target | Remaining agent-hours | Wall clock, 4–6 parallel agents | -|---|---:|---:| -| Cloud-Lightroom parity without AI or Classic modules | ≈ 130–255 h | ≈ 35–65 h | -| Full parity incl. AI, video and the Classic modules | ≈ 270–505 h | ≈ 65–125 h | +## Progress log -The milestone estimates in the table above were made before work started and are kept for calibration (M0–M13 took -≈ 25 active hours with parallel agents against an estimate of ≈ 110–170 h for those milestones). +- 2026-10-10 (RAW): RAW measured model by model against Adobe's 1,446-model list (234 verified, 943 + unverified, 233 preview-only, 36 unsupported; ~84% of photographers' cameras decode, ~40% on a + verified body). Full ~61%, mainstream ~49%, essentials ~64%; beta ~14 points away. +- 2026-10-10 (final): full ready is a weighted sum over the dimensions (~59%; method aligned with + the standard); mainstream practitioner ~51% (450–840 h), essentials user ~63% (160–300 h); + alpha gate passes. +- 2026-10-10: full re-measure for the progress-docs standard: stage alpha, ready ~55%, checklist + 81.1%; new docs (target-app-parity, gaps, roadmap, architecture, raw / file-format / hardware / UI + / localization parity); `docs/parity.md` renamed `docs/parity-checklist.md`. Landed today: Nikon + lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map JPEG / PQ AVIF / float TIFF export + (#646), Lightroom-like Auto (#628), activity stack. +- 2026-10-09: Sony ILCE-7CR camera colour: bundled profile from 125 photos; on 26 withheld photos mean + ΔE vs the camera JPEG 4.07 → 3.29. HEIC in release builds (#609), Samsung uncompressed SRW, + contact-sheet PDF export, Ukrainian and French UI, release v0.5.0. +- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact + full-sensor regressions on M50 / R100 / R8; guarded CR3 camera-JPEG colour fitting + ([cr3.md](docs/cr3.md)). +- 2026-10-08: Fujifilm lossless / lossy compressed RAF: pure-Rust striped decoder, full sensor arrays + verified on 17 CC0 and 7 supplied files (13 bodies). Fujifilm camera colour: bundled X-H2S / X-T4 + profiles from 201 / 545 photos; on 150 withheld files mean ΔE X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34. + AI RAW denoise (#384), Lightroom catalog import, German, Spanish and Russian UI. +- 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format, established on + 178 CC0 files from 118 bodies; RW2 file-local camera look (140 of 174 files). Faces: recognition in + pure Rust and People view. +- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues: compressed NEF, Move import, + folder templates, Local roots and cleanup, 85k-photo grid and catalog performance, background + compaction, failed-save errors, GPU export hardening. Added the honest *Where we stand* assessment + and tracker rows for camera colour, camera coverage and render fidelity. +- 2026-10-02: parity estimate added (`cargo xtask parity` prints weighted completion); milestones + refreshed. +- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman), + ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0 + samples. +- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG / CR2 / ARW; + README showcase. ≈ 8 h elapsed. +- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging + ecosystem) complete. -## Risks that coding hours alone don't retire +## Revision history -- **AI features** (subject/sky/people masks, generative remove) need model weights with licences we can ship; classical - fallbacks first. No permissively licensed sky-segmentation or raw-denoise model was found — we may need to train our own. -- **Camera colour and lens data** is a data problem: we never use Adobe's matrices, DCPs or LCPs. DNG-embedded data first, - then our own calibration; long-tail camera/lens coverage grows over time. -- **Raw-format sources:** decided 2026-10-05: decoders are written from *prose* format descriptions (even ones - published alongside GPL code); decoder source is never read. Still open: freedom-to-operate review for local - Laplacian filters, PatchMatch and HEVC (HEIC). -- **Look parity** with Adobe's default rendering is tuned by eye today; the planned fidelity suite (LR-BEHAV-RENDER-FIDELITY) - turns it into measured comparisons against local-only Lightroom references. Retuning changes existing edits, so it - ships as a new process version (`docs/process-versions.md`): photos keep their look until they are updated. - -## Raw format coverage and known gaps - -Sony ILCE-7M4 downsized lossless ARW (subsampled YCbCr 4:2:0 / 4:2:2 tiles) now decodes to linear RGB; -private 3:2 & 4:3 examples verified through native rendering & full-resolution 16-bit export. - -CR3 lossless Bayer and version 0x100/0x200 C-RAW sensor decoding is verified pixel-exactly on Canon M50, R100 and R8; any other CR3, or one that fails to decode, opens from its embedded JPEG as before. See [`docs/cr3.md`](docs/cr3.md) for coding limits, reference provenance and colour limitations. - -Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed, -LJ92, lossy JPEG / Smart Previews, Deflate, float, linear, DNG 1.7 JPEG XL tiles: lossless ones bit-exact on synthetic files, -lossy ones unclipped in the declared linear primaries, checked on one real file), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the encrypted -`SR2SubIFD`, decrypted with a keystream recovered by black-box analysis, `crates/raw/src/vendor/sr2.rs`, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and -X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8, -packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from -118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Supported containers yield their embedded JPEG preview when present (including CR3). The engine uses it for RAW variants it cannot decode yet; HEVC-only CR3 previews are not decoded. - -Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete): -- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**. - -**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2, PEF, Samsung SRW and Olympus ORF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. 52 models whose fit is rejected (or that have no usable JPEG) start from matrices fitted to their measured spectral sensitivities instead (rawtoaces-data, Apache-2.0; `crates/raw/src/spectral.rs`). Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged -`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer -matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our -own chart-based calibration (M11.4). Adobe matrices are never used. - -## Lightroom Classic catalog migration - -File → Import Lightroom Catalog… reads `.lrcat` plus committed WAL pages directly through a pure-Rust -SQLite reader. Originals stay in place; ratings, flags, labels, XMP metadata, hierarchical keywords, -collections/sets, virtual copies and supported develop settings migrate into LightCraft. Existing edits -are preserved by default, reimport identities are persistent, and source settings/history/snapshots are -archived before catalog mutation. Missing originals remain available for relinking. - -Rendering is approximate: unsupported Adobe profiles/AI/process settings are reported and archived; -history/snapshots remain source data, and smart collections import current membership. The Lightroom -database is read-only; LightCraft owns subsequent edits. See `docs/lightroom-catalog-import.md`. - -## Log -- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging ecosystem) complete. -- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG/CR2/ARW; README showcase. ≈ 8 h elapsed. -- 2026-10-02: parity estimate added (xtask parity prints weighted completion); milestone statuses refreshed. -- 2026-10-08: Fujifilm camera colour: guarded JPEG matching and relative WB, plus bundled X-H2S/X-T4 profiles from 201/545 photos. On 150 withheld files the pooled profiles improve mean ΔE slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); dark X-H2S samples 8.74 → 2.25. Measured calibration remains open. -- 2026-10-08: Fujifilm lossless/lossy compressed RAF: pure-Rust striped decoder; full sensor arrays verified against a black-box reference on 17 CC0 files and 7 supplied X-H2S / X-T4 files (13 camera bodies, 14/16-bit Bayer and X-Trans). Camera colour calibration remains a separate gap. -- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman), ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0 samples. -- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues landed: compressed NEF, Move import, folder templates, - Local roots and cleanup, 85k-photo grid and catalog performance, background compaction, failed-save errors, GPU - export hardening, clippy 1.99. Added the honest *Where we stand* assessment and *Where we're going* priorities; added - tracker rows for camera colour, camera coverage and render fidelity. -- 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format (compressed 4/6/8, packed 2/5/7, - 16-bit words), black levels, in-camera aspect crops and mapped-out defects established on 178 CC0 files from 118 - bodies; RW2 gets the file-local camera look (accepted on 140 of 174 files with a preview). -- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact full-sensor regressions on M50/R100/R8; any CR3 decode error falls back to the embedded JPEG; guarded CR3 camera-JPEG colour fitting with independent framing validation (`docs/cr3.md`). Unverified CRX variants and broader model verification remain. -- 2026-10-09: Sony ILCE-7CR camera colour: bundled profile from 125 photos. On 26 withheld photos mean ΔE vs the camera JPEG 4.07 → 3.29; all 131 photos accepted, where 8 fell back to the neutral matrix without it. Measured calibration remains open. - -## Chinese and Japanese interfaces, and text watermarks - -English, Simplified Chinese, Traditional Chinese (Taiwan) and Japanese interface languages are persisted in UI state, and the -language table (`crates/ui-egui/src/i18n.rs`) drives the menus, settings and fonts, so another -language is a table entry plus two catalogs (docs/localization.md). Core menus and panels are -translated; untranslated text retains English. Ukrainian covers all existing catalog keys, command/control/rule labels -and format messages, with count-neutral labels and numeric dates; technical errors from lower layers remain English. -CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI: -BIZ UDPGothic; watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made -with the optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept -`vertical: true` in export JSON/presets and expose an orientation selector. Japanese -characters stay upright in top-to-bottom columns, with newlines starting columns to the -left. This is basic lettering, without tate-chu-yoko, ruby, kinsoku, or general vertical -OpenType shaping. The same coverage renderer serves 8/16/32-bit exports. +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | +| 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away | +| 2026-10-10 | minor | Mainstream practitioner and essentials user numbers; full ready 55% → 46% (method written down); beta 340–600 h | +| 2026-10-10 | minor | Alpha gate added (core-workflow check): passes, stays alpha | +| 2026-10-10 | major | Restructured to the progress-docs standard; re-measured; *Where we stand*, *Parity estimate* and *Totals* moved to docs/target-app-parity.md, milestones and risks to docs/roadmap.md, raw coverage to docs/raw-parity.md, catalog migration to docs/file-format-parity.md, CJK notes to docs/localization-parity.md | +| 2026-10-08 | minor | *Where we stand* by dimension and by kind of user | +| 2026-10-02 | major | Parity estimate and effort table | +| 2026-09-30 | major | Created | diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index b8a541096..2a4d753dc 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -418,7 +418,7 @@ fn parse_one_region(item: &XmpValue, px_dims: Option<(f64, f64)>) -> Option impl Iterator { LANGUAGE_COMMANDS.iter().chain(UI_COMMANDS) } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index e3af3c66a..2abea5fa4 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -192,7 +192,7 @@ pub fn format(m: Modifiers, k: Key) -> Option { /// Secondary key bindings for commands that already exist: `(shortcut, command id, params JSON)`. /// They complement the primary shortcut declared on the command (Lightroom-desktop keys that our -/// primary keymap assigns elsewhere, see docs/parity.md → Shortcuts). Shown in Help → Keyboard Shortcuts. +/// primary keymap assigns elsewhere, see docs/ui-parity.md → Shortcuts). Shown in Help → Keyboard Shortcuts. pub const ALIASES: &[(&str, &str, &str)] = &[ ("Cmd+D", "library.selectNone", "{}"), ("Shift+E", "dialog.export", "{}"), diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 000000000..c50066a10 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,70 @@ +# Architecture + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the code; the full design notes stay in the local `plan/architecture.md`) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 + +How LightCraft is built today. The longer design document (`plan/architecture.md`) is local-only +(`plan/` is gitignored); this page is the committed, current summary. Sizes are Rust lines counted +on 2026-10-10 (≈ 202,000 tracked lines in 505 files: 24 library crates, 3 apps and `xtask`). + +## Layers + +`cargo xtask layers` (part of `cargo xtask ci`, `xtask/src/layers.rs`) enforces that nothing below +L5 depends on egui, eframe, winit or rfd, and that each crate only uses lower layers. + +| Layer | Crates (lines) | Role | +|---|---|---| +| L0 | `geom` (1.4k), `color` (0.9k), `raster` (0.8k), `tiff` (2.0k), `fetch` (1.4k), `sysmem` (0.1k) | Matrices and warps, colour spaces and transfer functions, float image buffers, TIFF/IFD read and write, pinned HTTPS model downloads, the one `unsafe` FFI call (`malloc_zone_pressure_relief`) | +| L1 | `raw` (22.3k), `codecs` (7.3k), `meta` (4.3k), `develop` (2.5k), `denoise-core` (2.2k), `denoise` (2.7k), `faces` (5.3k), `scenes` (1.0k), `heif` (2.1k, optional) | Raw decoders and demosaic, DNG colour model; standard codecs and encoders; EXIF/XMP/IPTC; the non-destructive edit model and control specs; AI denoise; face detection and recognition; procedural demo photos; HEIC | +| L2 | `pipeline` (9.8k) | The develop pipeline: scene-referred float, resolution independent; the CPU reference (oracle) for everything else | +| L3 | `catalog` (11.0k), `gpu` (5.1k), `preview` (1.3k), `merge` (3.8k), `segment` (2.3k) | Library model and persistence; wgpu compute twin of the pipeline; thumbnail/preview cache and job pool; HDR and panorama merge; SAM 3 segmentation | +| L4 | `engine` (53.2k) | Session, command registry (everything is a command), history, render and export jobs, import, Lightroom catalog and preset import, faces index, view-models | +| L5 | `ui-egui` (46.9k), `mcp` (2.3k) | The Lightroom-style egui interface (swappable); the MCP server | +| L6 | `apps/lightcraft` (2.8k), `apps/lightcraft-cli` (1.5k), `apps/lightcraft-web` (3.4k), `xtask` (2.7k) | Desktop app (eframe + wgpu, native menus, control server), CLI (render, commands, calibrate, snapshot, MCP), WASM build, tooling | + +## Data model + +- **Photo** records in `catalog` hold the original's path, content hash, metadata, ratings, flags, + labels, keywords, faces and the current `develop::Settings`. Albums, smart albums (rule trees), + stacks, virtual copies, versions and folder records live alongside. +- **Persistence** (`crates/catalog/src/journal.rs`): an append-only journal of human-readable + operations plus periodic snapshots, compacted in the background; torn appends and crashes are + tested (`tests_torn_append.rs`, `tests_journal.rs`). A lock file guards against two writers. +- **Edits** are a `develop::Settings` value (every slider is a control spec with id, range and + default), normalized to image coordinates so previews and exports match. Process versions + (`docs/process-versions.md`) keep old edits rendering the same after a retune. +- **Sidecars**: XMP read and write with Adobe `crs:` interop (`docs/xmp-interop.md`); Lightroom + Classic `.lrcat` import through a pure-Rust SQLite reader (`docs/lightroom-catalog-import.md`). + +## Rendering + +1. **Decode** (`raw`, `codecs`): raw files to linear camera RGB (demosaic, black/white levels, + opcodes, highlight handling) and a camera-to-XYZ colour model; standard images to linear light + through their ICC profile. +2. **Develop** (`pipeline`): white balance, profile, exposure and tone (local Laplacian + highlights/shadows), curves, HSL / colour mixer, point colour, colour grading, texture, clarity, + dehaze, detail (sharpening, NR, AI denoise result), optics (lens profile, CA, defringe, + vignette), geometry (crop, Upright, transforms), masks with local adjustments, heal/clone, grain, + effects. Stage caching and a source pyramid make slider drags cheap. +3. **GPU** (`gpu`): the same stages in WGSL on Metal / DX12 / Vulkan, checked against the CPU path + (within 1/255); GPU errors fall back to the CPU (`docs/gpu-pipeline.md`). +4. **Display and export**: display-profile conversion (`docs/display-profiles.md`); export through + one encoder for app, CLI, MCP and web (`lightcraft_engine::export`). + +## Agent control + +Every action is an engine command (id, label, menu path, shortcut, params, enabled, run). The UI, +the CLI, the JSON-lines control channel (`docs/control-protocol.md`) and MCP (`docs/mcp.md`) all +dispatch by id, and headless snapshots render the UI without a window. + +## Quality gates + +`cargo xtask ci`: fmt, clippy `-D warnings` with the never-crash lints, the HEIF build, tests, +`parity` (every id and path cited in [parity-checklist.md](parity-checklist.md) exists), `layers`, +`assets` (every asset attributed, no Adobe formats) and the WASM build. Other workflows build and +test FreeBSD and Windows ARM64, lint packaging, and build releases. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created from the code (crate sizes, layers, data model, rendering, control, gates) | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md new file mode 100644 index 000000000..32d307cfb --- /dev/null +++ b/docs/file-format-parity.md @@ -0,0 +1,79 @@ +# File format parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; formats measured from the code and the target's Info.plist) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 + +Every format Lightroom reads or writes, with our support, fidelity and tests. Camera raw formats are +deep enough to have their own document: [raw-parity.md](raw-parity.md). + +**How measured:** the target's document types come from the installed Lightroom 9.6 +`Info.plist` (`CFBundleDocumentTypes`: catalog `mcat`, `xmp`, toolkit / module / library bundles, and +the raw-and-image list: jpg, tif, dng, heic and 27 raw extensions) plus Lightroom Classic's documented +import and export formats. Ours come from `crates/codecs/src/sniff.rs`, `crates/codecs/src/encode.rs`, +`crates/engine/src/export.rs`, `crates/engine/src/import.rs` and the tests named below. + +## Images (non-raw) + +| Format | Target reads | Target writes | LightCraft reads | LightCraft writes | Notes / tests | +|---|---|---|---|---|---| +| JPEG | ✅ | ✅ | ✅ (parallel decoder, ICC, EXIF orientation) | ✅ quality, size limit, ICC, metadata | `crates/codecs/src/jpeg.rs`, export tests | +| TIFF | ✅ 8/16/32-bit | ✅ 8/16-bit, LZW/ZIP | ✅ 1–64-bit integer, 16–64-bit float, bilevel, CCITT fax, WhiteIsZero | ✅ 8/16-bit, 32-bit float linear, none / LZW / ZIP | `crates/tiff`, `crates/codecs/src/tiff_codec.rs` | +| PNG | ✅ | ✅ | ✅ | ✅ | | +| PSD / PSB | ✅ (maximized-compatibility composite) | ✅ (Classic: PSD export, edit-in round trip) | ✅ merged composite | ⬜ (external-editor round trip writes 16-bit TIFF) | `crates/codecs/src/psd.rs` | +| HEIC / HEIF | ✅ | ⬜ (Classic) / HDR only | ✅ in every release build (`crates/heif`, heic-rs) | ⬜ | `heif` feature on by packaging scripts | +| AVIF | ✅ | ✅ (HDR) | ⬜ recognised, not decoded (no permissive pure-Rust AV1 decoder) | ✅ 8/10-bit, PQ HDR | | +| JPEG XL | ✅ | ✅ | ✅ (`jxl-oxide`) incl. DNG 1.7 JXL tiles | ⬜ | | +| WebP | not in its document types | ⬜ | ✅ | ✅ | beyond target | +| GIF, BMP | ⬜ | ⬜ | ✅ | ⬜ | beyond target | +| DNG (from raw) | ✅ | ✅ incl. lossy, embedded original | ✅ | ✅ uncompressed or LJ92; ⬜ lossy DNG, embedded original | `crates/raw/src/dngwrite.rs` | +| Render to DNG (15.5) | — | ✅ | — | ⬜ | new in Classic 15.5 | +| Original + sidecar | — | ✅ | — | ✅ | | +| HDR gain map JPEG (ISO 21496-1) | ✅ | ✅ | ✅ (gain map read) | ✅ | `crates/codecs/src/gainmap.rs` | +| PQ / HLG HDR | ✅ | ✅ AVIF, JXL, TIFF | — | 🟡 PQ AVIF, float TIFF; no HLG | | +| Contact sheet / print PDF | — | ✅ (Print module: PDF / JPEG / printer) | — | 🟡 paginated contact-sheet PDF (A4 / Letter, 150 dpi, sRGB) | `crates/engine/src/contact_sheet.rs` | +| Video (MP4, MOV, AVCHD…) | ✅ playback, trim, edit | ✅ export | 🟡 catalogued (kind, duration) | ⬜ | see video gap | + +## Colour spaces and profiles + +| Feature | Target | LightCraft | +|---|---|---| +| Export colour spaces | sRGB, Adobe RGB, Display P3, ProPhoto, Rec.2020, custom ICC | ✅ sRGB, Display P3, Adobe RGB-compatible, ProPhoto, Rec.2020 with gamut mapping (CPU and GPU), embedded ICC; ⬜ custom ICC output profiles; AVIF stays sRGB | +| Input ICC (JPEG / TIFF / PNG) | ✅ | ✅ matrix / TRC and LUT profiles (moxcms) | +| Soft proofing with printer profiles | ✅ | 🟡 | + +## Metadata, sidecars, presets and catalogs + +| Format | Target | LightCraft | Notes | +|---|---|---|---| +| XMP sidecars (`crs:` develop settings, ratings, keywords, regions) | read / write | ✅ read / write, manual (⌘S) and automatic | [xmp-interop.md](xmp-interop.md) | +| EXIF / IPTC / XMP embedded on export | ✅ | ✅ with metadata filters | `crates/meta` | +| Develop presets `.xmp` | ✅ | ✅ import (mapping of supported settings) | | +| Legacy presets `.lrtemplate` | ✅ (converted) | ✅ import | | +| Camera / creative profiles (`.dcp`, `.xmp` profiles) | ✅ | ⬜ deliberately (Adobe profile formats are never used); `.cube` LUT profiles ✅ | | +| Lens profiles `.lcp` | ✅ | ⬜ deliberately | | +| Other apps' presets | — | ✅ Luminar `.lmp` / `.mplumpack`, `.zip` bundles | beyond target | +| Lightroom Classic catalog `.lrcat` | native | 🟡 import (ratings, flags, labels, keywords, collections, virtual copies, supported develop settings; read-only pure-Rust SQLite incl. WAL) | [lightroom-catalog-import.md](lightroom-catalog-import.md) | +| Lightroom (cloud) library `.lrlibrary` | native | ⬜ | cloud library format, local cache only | +| Write back to `.lrcat` | native | ⬜ (one-way migration by design) | | +| Keyword lists (text) | import / export | ✅ | | +| GPX track logs | ✅ | ✅ | `crates/meta/src/gpx.rs` | +| Export presets, metadata presets, filename templates | ✅ (`.lrtemplate`) | ✅ own JSON | not interchangeable | +| Lightroom plug-ins (`.lrplugin`, `.lrdevplugin`) | ✅ | ⬜ | see ecosystem in [target-app-parity.md](target-app-parity.md) | + +## Scores + +| Part | Weight | Ready for real work | Kind | +|---|---:|---:|---| +| Standard image import / export | 35% | ~85% | estimated (table above; missing: AVIF read, JXL / PSD write, lossy DNG) | +| Sidecars, metadata, presets | 25% | ~85% | estimated | +| Lightroom catalog migration | 25% | ~50% | estimated (one-way, approximate rendering of unsupported settings) | +| HDR, print, video output | 15% | ~35% | estimated | +| **Non-raw file formats** | 100% | **~70%** | estimated; raw is in [raw-parity.md](raw-parity.md) (~50%) | + +Remaining: **30–55 h** (AVIF decode 8–15 h if a permissive decoder is written or found; JXL encode +4–8 h; PSD write 4–8 h; lossy DNG and Render to DNG 4–8 h; HLG 3–5 h; catalog import depth 8–12 h). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created from the code and the installed Lightroom's document types | diff --git a/docs/gaps.md b/docs/gaps.md new file mode 100644 index 000000000..64b1cb8ac --- /dev/null +++ b/docs/gaps.md @@ -0,0 +1,77 @@ +# Gaps: where LightCraft falls short of Lightroom + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; absorbs the checklist's Top gaps and the ROADMAP's honest assessment, re-ranked after a full re-measure) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 + +Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and +how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is +working on). When you close a gap, delete or shrink its entry, update its rows in +[parity-checklist.md](parity-checklist.md), and the numbers in [target-app-parity.md](target-app-parity.md) +and [ROADMAP.md](../ROADMAP.md) if they move. + +Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app-parity.md#how-hours-are-calibrated)). +`B` marks a gap that blocks **beta**. + +## Ranked + +| # | Gap | Evidence | User impact | Hours | Doc | +|---:|---|---|---|---:|---| +| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 4 bundled profiles or 52 spectral matrices; others use a neutral matrix | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 0 chart-calibrated models vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) | +| 2 B | **CR3 beyond three verified bodies** (LR-IMP-FORMATS): CRX variants other than those verified on M50 / R100 / R8 fall back to the preview; CR3 sRAW / mRAW and HEIF CR3 too | measured: Canon is the largest bucket (33% of photographers) and its share whose camera fully works is the lowest of the big four, 72%; 17 of 22 popular Canon bodies write CR3, 2 verified (raw-parity.md) | +6.5 points of photographers whose camera works, the biggest single raw gain | 25–45 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) | +| 3 B | **Render fidelity unmeasured** (LR-BEHAV-RENDER-FIDELITY): tone, highlights, texture / clarity / dehaze, NR and sharpening are tuned by eye | no side-by-side suite; a retune needs a new process version (only V1 exists) | edits imported from Lightroom look different; the look "isn't Lightroom's" | 30–60 | [raw-parity](raw-parity.md), [process-versions](process-versions.md) | +| 4 B | **Original-pixel detail at 100%** (LR-VIEW-ZOOM, P0): the whole-image preview is capped (2,560 px default); compare, overlays and soft proofing lack sharp zoom windows (#323) | checklist row 🟡 | checking focus, the first thing culling needs, isn't reliable on large files | 8–15 | [ui-parity](ui-parity.md) | +| 5 B | **AI masks: Subject, Sky, People, Background, Landscape** are heuristics; Object / Describe run SAM 3 but its download has no mirror configured | `crates/segment`; ai-masks.md; MASK rows 67% | masking is how most Lightroom users edit today | 40–70 + model decision | [target-app-parity](target-app-parity.md#ai) | +| 6 B | **Compressed ORF** (every Olympus / OM body since ~2008) | measured: 66 of 78 Olympus / OM models preview-only; `orf.rs` refuses; OM bucket 3% of photographers, 3% of them fully work | +2.6 points; best gain per hour of any raw gap (~0.2) | 8–15 | [raw-parity](raw-parity.md) | +| 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | +| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony ILCE-7RM4A distortion | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | +| 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64) | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 234 of 1,446 Adobe models verified (16%), ~40% of photographers on a verified body; Nikon 5 bodies verified, Canon 11. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | +| 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | +| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | +| 14 | **HDR display** (EDR / Windows HDR); HLG; HDR settings from `crs` XMP | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | +| 15 | **Print module**: custom packages, printer colour management (contact-sheet PDF only) | LRC-PRINT rows | Classic users who print | 25–40 | [target-app-parity](target-app-parity.md#features) | +| 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) | +| 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) | +| 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) | +| 19 | **Plug-in ecosystem**: no Lightroom SDK equivalent (export / publish plug-ins, metadata plug-ins) | ⬜ | pro workflows built on plug-ins (LR/Enfuse, Jeffrey's exporters, Negative Lab Pro) | 40–80 + owner decision | [target-app-parity](target-app-parity.md#by-dimension) | +| 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, ERF, KDC, DCR, MEF, GPR, old FinePix | raw-parity.md | small user share each | 45–85 | [raw-parity](raw-parity.md) | +| 21 | **Localization**: 9 of 15 shipped languages partial (74–99% of catalog messages), lower-layer errors English; Hindi, Arabic, Indonesian, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 55–90 (shipped to full); 145–255 (five new) | [localization-parity](localization-parity.md) | +| 22 | **Pen pressure**, OS monitor profiles, follow-window colour | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) | +| 23 | **Accessibility** audit with screen readers; canvas tools keyboard-reachable | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 10–20 | [ui-parity](ui-parity.md) | +| 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) | +| 25 | **File writes**: JXL encode, PSD export, lossy DNG, Render to DNG; AVIF import | file-format-parity.md | interchange edge cases | 25–45 | [file-format-parity](file-format-parity.md) | +| 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | +| 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple only | ROADMAP, issues | non-Mac users find bugs first | 15–30 | [hardware-parity](hardware-parity.md) | +| 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5–F8) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) | + +Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them, +**~340–600 h** to beta (incl. 40–80 h for the ~92 open core-path user issues) (see [target-app-parity.md](target-app-parity.md#stage)). + +## Gaps by kind + +- **Feature:** 5, 8, 11, 12, 13, 15, 16, 18, 19 +- **UI / UX:** 4, 23, 24, 28 +- **File format:** 2, 6, 7, 10, 13, 20, 25 (raw ones in [raw-parity.md](raw-parity.md)) +- **Hardware:** 14, 17, 22, 27 +- **Localization:** 21 +- **Quality / fidelity:** 1, 3 +- **Stability and platforms:** 9, 26, 27 + +## Closed recently (kept for calibration) + +- 2026-10-10: Nikon lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map / PQ AVIF / float + TIFF export (#646), activity stack (#345). +- 2026-10-09: HEIC in release builds (#609), Samsung uncompressed SRW (#459), contact-sheet PDF (#563), + Ukrainian UI (#564), Sony ILCE-7CR profile. +- 2026-10-08: CR3 CRX lossless and C-RAW (#279), Fujifilm compressed RAF (#282), AI RAW denoise (#384), + Lightroom catalog import (#218), German, Spanish, Russian UI. +- 2026-10-07: RW2 every format (#215), faces recognition in pure Rust (#174). +- 2026-10-05: compressed NEF (#86). The older history is in [ROADMAP.md](../ROADMAP.md#progress-log). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | RAW gaps re-measured model by model (raw-parity.md): CR3, compressed ORF and verification entries carry measured shares; HE NEF no longer a beta blocker (+1.1 points for 20–40 h); verification entry absorbs the cheap per-body fixes (#769, #702, A200 series) | +| 2026-10-10 | minor | Beta total raised to 340–600 h to include the ~92 open core-path user issues | +| 2026-10-10 | major | Created from the checklist's Top gaps (whose items 1–2 had been duplicated by a merge) and ROADMAP's Where we stand; re-ranked; added evidence, impact, hours and beta flags; added platform-CI, lens-database, HE NEF and localization gaps | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md new file mode 100644 index 000000000..3f1defc57 --- /dev/null +++ b/docs/hardware-parity.md @@ -0,0 +1,50 @@ +# Hardware parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the code, gpu-pipeline.md and the parity checklist) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 + +What Lightroom does with the machine's hardware, per platform, and what LightCraft does. Sources: +[gpu-pipeline.md](gpu-pipeline.md), [display-profiles.md](display-profiles.md), [denoise.md](denoise.md), +[ai-masks.md](ai-masks.md), `crates/gpu`, `crates/engine/src/devices.rs`, `crates/ui-egui/src/panels/second.rs`. + +| Feature | Target | LightCraft: macOS | Windows | Linux / BSD | Web | Status | +|---|---|---|---|---|---|---| +| GPU for display and editing | Metal / DX12, "Use GPU for display / image processing / export" | wgpu Metal: geometry, resize, blur, WB, NR, guided filter, dehaze, masks, finish; CPU fallback per render | DX12 only (Vulkan opt-in, #136) | Vulkan | CPU only (no WebGPU yet) | 🟡 defringe, spot removal, heuristic masks, long brushes and high-bit exports stay on CPU | +| GPU export | ✅ | ✅ (6000 × 4000 in ~0.3 s vs ~1.2 s CPU) | ✅ | ✅ | — | ✅ (black-export fix on an Intel iGPU, #78) | +| AI on GPU / neural engine | Metal / DirectML / Core ML, model per feature | AI denoise on GPU or CPU (setting); SAM 3 on Metal via candle; faces on CPU | CPU / GPU denoise | CPU / GPU denoise | — | 🟡 no Core ML / Neural Engine / DirectML | +| HDR (EDR) display | ✅ (macOS EDR, Windows HDR) | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ (LR-VIEW-HDR-DISPLAY) | +| Monitor profile | automatic per display, follows the window | 🟡 picked by hand | 🟡 | 🟡 | — | 🟡 OS profile not read; no follow across monitors | +| Second window / second monitor | ✅ (Classic) | ✅ (⌘F11) | ✅ | ✅ | — | ✅ (Classic secondary-window keys missing) | +| Pen pressure / tilt (brush masks) | ✅ pressure for size / flow | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | +| Trackpad gestures | pinch zoom, pan | ✅ pinch, two-finger pan | 🟡 (winit) | 🟡 | 🟡 | ✅ on macOS | +| Card readers / cameras as import sources | ✅ (devices panel, eject after import) | ✅ mounted volumes with DCIM | ✅ | ✅ | — | 🟡 no eject-after-import check, no MTP / PTP phones | +| Tethered capture | ✅ Classic (Canon, Nikon, Sony, Leica) | ⬜ | ⬜ | ⬜ | — | ⬜ | +| Printers | ✅ Print module, printer colour management | ⬜ (PDF contact sheets only) | ⬜ | ⬜ | — | ⬜ | +| Control surfaces (MIDI / Loupedeck via plug-ins) | via SDK plug-ins | control channel and MCP can drive every command | same | same | — | 🟡 different mechanism; no MIDI mapping | +| Multi-core CPU | ✅ | ✅ worker pools, parallel JPEG decode, parallel import copy | ✅ | ✅ | single thread | ✅ | +| Memory pressure | managed | ✅ memory budget, `crates/sysmem` returns allocator pages on macOS | ✅ budget | ✅ budget | — | ✅ | + +## Score + +**~40% ready** (estimated): the GPU editing path is real and fast (measured: 24 MP exposure drag +3.9 ms on GPU vs 22–42 ms CPU; NR drag 18 ms vs 301–471 ms), but HDR display, pen pressure, +tethering, printing, automatic monitor profiles and neural-engine inference are missing. GPU +correctness is proven on Apple GPUs; other vendors rely on user reports. + +## Remaining (Opus 5.5 agent hours) + +| Work | Hours | Needs | +|---|---:|---| +| HDR display (EDR on macOS, Windows HDR, Linux where possible) | 10–20 | an HDR display to verify (human) | +| OS monitor profile, follow window across displays | 5–10 | multi-monitor setup | +| Pen pressure for brush masks (winit tablet events or a pure-Rust tablet layer) | 6–12 | a tablet | +| Remaining stages on GPU (defringe, spot removal, masks) | 10–20 | | +| Tethered capture (PTP over USB in pure Rust, Canon / Nikon / Sony first) | 25–45 | cameras (human) | +| Printing with colour management | 15–25 | printers (human) | +| Neural-engine / DirectML inference for AI features | 10–20 | depends on the AI model decision | +| **Total** | **80–150 h** (the core set for beta, HDR display + monitor profile + pen pressure: 20–40 h) | | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created | diff --git a/docs/localization-parity.md b/docs/localization-parity.md new file mode 100644 index 000000000..18aae618a --- /dev/null +++ b/docs/localization-parity.md @@ -0,0 +1,92 @@ +# Localization parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first per-language measurement from the catalogs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 + +How far each interface language is from `full`. How to add or maintain a language is in +[localization.md](localization.md); per-language notes are in `localization-.md` +([zh-hans](localization-zh-hans.md), [zh-hant](localization-zh-hant.md), [ja](localization-ja.md), +[pt-br](localization-pt-br.md), [es](localization-es.md), [de](localization-de.md), +[ru](localization-ru.md)). + +## The target + +Lightroom 9.6 (installed on the owner's Mac) ships 16 interface languages, listed from its bundle's +`.lproj` folder names: English, German, Spanish, French, Italian, Japanese, Korean, Norwegian Bokmål, +Dutch, Polish, Portuguese (Brazil), Russian, Swedish, Thai, Simplified Chinese and Traditional +Chinese. It does **not** ship Hindi, Arabic, Indonesian or Vietnamese, so for those LightCraft would +go beyond the target. + +## How the numbers are measured + +**Measured** (a Python count over `crates/ui-egui/locales/*.json`, 2026-10-10): + +- The denominator is every message any catalog knows: the union of the plain catalogs' keys (2,074 + English source strings: menus, commands, controls, panels, dialogs, tooltips, presets, What's New + lines) plus the 230 messages of the formats catalogs (`*-formats.json`: text with runtime values and + date patterns), 2,304 messages in all. +- A message counts as translated when the catalog has a non-empty value that differs from the English + key. Some identical values are correct (`ISO`, `OK` in German), so the figure slightly understates + coverage; on the other hand, strings that never reach a catalog (technical errors raised in lower + crates, some `format!` text outside `tr_format!`) are not in the denominator at all. That is why no + language is `full` even at 99%. +- The UI has 563 distinct `tr("…")` literals and 198 `tr_format!` messages in `crates/ui-egui` and + `apps/`; command, control, menu and preset labels reach the catalogs through `display_label`. + +Script support is checked against the code: `language_table!` in `crates/ui-egui/src/i18n.rs` lists +scripts `Latn`, `Cyrl`, `Hans`, `Hant`, `Jpan` only. The UI is egui, which has no bidirectional layout +and no complex-script shaping, so Arabic (RTL, joining) and Devanagari (conjuncts, reordering) cannot +render correctly today. + +Native-speaker review: no catalog has had a formal review. Several catalogs have edits from community +contributors whose commits only touch that language (ja, zh-hans, zh-hant, fr, de, ru, uk, pt-br); +that is promising but is not recorded as review, so the column says `no (community edits)`. + +## The twelve key languages + +| # | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | To `full` | +|---|---|---|---:|---|---|---|---|---:| +| 1 | English | `en` | 2,304 / 2,304 (100%, source) | all; Help ▸ What's New and the shortcut sheet; no full user manual | Latin (Inter) | n/a | **full** | 0 h | +| 2 | Simplified Chinese | `zh-hans` | 1,994 / 2,304 (87%) | menus, panels, dialogs, presets, profiles, dates; lower-layer errors English | Hans via craft-fonts (Noto Sans CJK SC); IME through winit; no vertical text in UI (not in target either) | no (community edits) | partial | 6–10 h | +| 3 | Spanish | `es` | 1,972 / 2,304 (86%) | as zh-hans | Latin | no (community edits) | partial | 6–10 h | +| 4 | Hindi | `hi` | 0 (0%) | none | **Devanagari shaping missing** (egui has no complex shaping) | no | none (target: not shipped) | 50–90 h (shaping 25–50 h, shared with Arabic) | +| 5 | Arabic | `ar` | 0 (0%) | none | **RTL layout and Arabic shaping missing** | no | none (target: not shipped) | 60–110 h (RTL mirroring 30–50 h + shaping, shared with Hindi) | +| 6 | French | `fr` | 2,021 / 2,304 (88%) | as zh-hans | Latin | no (community edits) | partial | 5–9 h | +| 7 | Portuguese (Brazil) | `pt-br` | 1,966 / 2,304 (85%) | as zh-hans | Latin | no (community edits) | partial | 6–10 h | +| 8 | Indonesian | `id` | 0 (0%) | none | Latin (Inter covers it) | no | none (target: not shipped) | 10–16 h | +| 9 | Japanese | `ja` | 1,995 / 2,304 (87%) | as zh-hans; vertical export watermarks | Jpan via craft-fonts (BIZ UDPGothic / UDMincho); IME through winit | no (community edits) | partial | 6–10 h | +| 10 | German | `de` | 2,104 / 2,304 (91%) | as zh-hans; What's New translated line by line | Latin | no (community edits) | partial | 4–8 h | +| 11 | Korean | `ko` | 0 (0%) | none | **no Korean (Hangul) face**: needs a non-Adobe OFL face in craft-fonts (Noto CJK is allowed only for Chinese) | no | none | 14–22 h | +| 12 | Vietnamese | `vi` | 0 (0%) | none | Latin with stacked diacritics (Inter covers it; check combining marks) | no | none (target: not shipped) | 10–16 h | + +## Other languages LightCraft ships (3) + +| Language | Code | UI strings translated | Script | Native review | Status | To `full` | +|---|---|---:|---|---|---|---:| +| Traditional Chinese (Taiwan) | `zh-hant` | 1,993 / 2,304 (87%) | Hant; uses the Simplified Chinese face until craft-fonts has a TC face | no (community edits) | partial | 8–14 h (incl. the font) | +| Russian | `ru` | 1,704 / 2,304 (74%) | Cyrillic (Inter) | no (community edits) | partial | 8–12 h | +| Ukrainian | `uk` | 2,287 / 2,304 (99%) | Cyrillic (Inter) | no (community edits) | partial (lower-layer errors English) | 2–4 h | + +Target languages LightCraft lacks: Italian, Korean, Dutch, Polish, Swedish, Norwegian Bokmål, Thai +(Thai needs shaping and line breaking without spaces). Each Latin-script one is ~10–16 h; Thai +~20–35 h on top of the shaping work. + +## Shared work before any language is `full` (counted once, ~20–35 h) + +- Route lower-layer error messages (catalog, raw, codecs, engine) through translatable message ids + instead of English `format!` strings (~12–20 h). +- A check in `cargo xtask ci` that every user-visible literal reaches a catalog (~4–8 h). +- A user manual / help pages beyond What's New and the shortcut sheet (Lightroom links to online + help; ~4–8 h for a translatable in-app help index). +- Native-speaker review passes (human; cannot be done by agents). + +## Totals + +- Bring the 9 shipped non-English catalogs to `full`: ~55–90 h (incl. the shared work). +- Add the 5 missing key languages: ~145–255 h, most of it RTL and complex shaping in egui. +- Reach the target's 16 languages: + ~60–100 h (Italian, Dutch, Polish, Swedish, Norwegian, Thai). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created: per-language counts measured from the catalogs, the twelve key languages, script support, estimates | diff --git a/docs/parity.md b/docs/parity-checklist.md similarity index 94% rename from docs/parity.md rename to docs/parity-checklist.md index d0bdc35c2..099411089 100644 --- a/docs/parity.md +++ b/docs/parity-checklist.md @@ -1,7 +1,15 @@ -# Lightroom parity tracker +# Lightroom parity checklist -A living checklist of every Lightroom feature LightCraft aims to match, what we have, and what is missing. Agents and -humans pick work from **[Top gaps](#top-gaps)**; whoever lands a feature updates its row in the same commit. +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 + +The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we +have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good +enough to replace Lightroom is assessed in [target-app-parity.md](target-app-parity.md), and the work +list is [gaps.md](gaps.md). Whoever lands a feature updates its row in the same commit. + +The rows are maintained by hand; the summary block is generated (`cargo xtask parity --write`), and +`cargo xtask parity` (part of `cargo xtask ci`) checks that every command id, control id and path +cited here still exists. Formerly docs/parity.md. - **Ids** come from the (local, gitignored) reference in `plan/lightroom/`: `LR-…` = feature catalog (03), `MENU-…` = menu items (04), `KEY-…` / `KEYC-…` = keyboard shortcuts (06, desktop / Classic), `LRC-…` = Classic @@ -13,9 +21,6 @@ humans pick work from **[Top gaps](#top-gaps)**; whoever lands a feature updates id (`lightcraft-cli controls`), plus source files. A trailing `*` matches a prefix (`ctl:mixer.*`). - Feature names and notes are our own words. No Adobe text, screenshots or assets belong here. -`cargo xtask parity` checks that every `cmd:`/`ctl:` id and file path below still exists (part of `cargo xtask ci`) -and prints the summary; `cargo xtask parity --write` refreshes the summary table below. - ## Summary @@ -42,92 +47,21 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | S. Export (EXP) | 15 | 2 | 1 | 0 | 7/7 (100%) | 7/7 (100%) | | T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | -| V. Preferences (PREF) | 7 | 1 | 2 | 2 | 1/1 (100%) | 5/5 (100%) | +| V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | | Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 32 | 11 | 39 | 9 | — | 22/24 (92%) | -| **Total** | 393 | 39 | 84 | 36 | 192/200 (96%) | 142/152 (93%) | +| Lightroom Classic extras | 33 | 15 | 35 | 9 | — | 23/25 (92%) | +| **Total** | 397 | 49 | 73 | 36 | 192/200 (96%) | 143/155 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.9%** of 516 in-scope rows — P0 98.0% of 200 · P1 95.7% of 152 · P2 42.9% of 163. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.2%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 47.2% of 163. -## Top gaps +## Top gaps and shortcut notes -Ordered by user impact, then tier, then effort. The checklist above counts features that *exist*; these are the gaps -that decide whether a photographer can switch (see the honest assessment in [ROADMAP.md](../ROADMAP.md#where-we-stand)). -Take the first one nobody is working on. - -1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW, Nikon NEF and Panasonic RW2 now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate` for ARW and NEF; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12; RW2: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0); Nikon CropArea and untagged-preview ColorSpace metadata are respected; measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data. -2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed RAF / ORF, Nikon -1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate` for ARW, NEF and RAF; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12; RW2: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0; RAF: 23 of 24 references accepted with bundled X-H2S/X-T4 profiles; 150 withheld-file tests improve mean ΔE slightly over per-file fitting); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data. -2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed ORF, Nikon -1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW, Nikon NEF and Panasonic RW2 now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate` for ARW and NEF; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12; RW2: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data. -2. **LR-IMP-FORMATS** (P0): CR3 lossless Bayer and version 0x100/0x200 C-RAW now decodes (M50/R100/R8 full sensor exact). Remaining unverified CRX variants, compressed RAF / ORF, Nikon - Canon sRAW; AVIF decode (HEIC decodes behind the codecs' non-default `heif` feature: - the optional `lightcraft-heif` crate on heic-rs, the same decoder and policy as PhotoCraft). Clean-room, from prose descriptions only (see - `crates/raw/src/vendor/nefc.rs` for how compressed NEF was done). Until decoded, such photos are `preview_only`. -3. **LR-IMP-CAMERA-COVERAGE** (P0): per-model verification; grow the CC0 corpus and fix per-model bugs (like the CR2 - colour-filter layout, fixed in #85 by reading the file's own tag). -4. **LR-BEHAV-RENDER-FIDELITY** (P1): a side-by-side fidelity suite against Lightroom renders (kept local in `plan/`), - then tune tone, highlights, texture/clarity/dehaze, NR and sharpening against it. A retune that changes how existing - settings render ships as a new process version, so existing edits keep their look (`docs/process-versions.md`). -4b. **LR-VIEW-ZOOM** (P0): finish the sharp zoomed views of issue #323: windows for compare, overlays and soft proofing - (Before/After has them); check sharpness and the wide-stage margin on real raw photos (only synthetic scenes so far). -5. **LR-EDIT-OPTICS-PROFILE** (P1): a lens-profile database of our own (corrections embedded in DNG files and the - distortion correction Panasonic / Leica cameras record in RW2 / RWL files work today; issue #256). Sony ILCE-7RM4A Bayer - ARW 16-sample tables in native 3:2 framing now feed the same distortion path; broader coverage and Sony - vignetting / lateral CA remain (see `docs/sony-lens-corrections.md`). -6. **AI masks and Enhance** (LR-MASK-SUBJECT / SKY / PEOPLE / OBJECTS, LR-EDIT-DETAIL-DENOISE, SUPERRES, LENSBLUR): - Object and Describe masks run SAM 3 in pure Rust (`crates/segment`); the weights (SAM License) are never bundled: - the app offers a consented, verified download, but **LightCraft's CDN mirrors are not configured yet** (the - built-in list is empty, so today users need their own mirror or a manual install). Subject / Sky / People could - use the same model with fixed prompts; denoise / super-resolution models remain a maintainer decision. -7. **HDR** (Q. HDR, LR-EXP-HDR), **video** (R. Video), **Classic output modules** (Map view, Book, Slideshow module, - Print, publish): large, well understood, lower priority than 1–5. Contact-sheet PDF export now covers a - basic Print workflow; custom picture packages, printer integration and print colour management remain open. -8. **LR-VIEW-ZOOM** (P0): pinch zoom and two-finger pan now work, with stable image cursors between gesture events. - The whole-image preview is still capped by - Settings → Performance → Preview size (2,560 px by default). Render visible regions at native resolution so - 100% can reliably show original-pixel detail on larger photos. - -Recognition runtime gap closed (LR-PREF-PEOPLE): the pinned SFace and AuraFace models now run on the shared pure-Rust -CPU engine and agree with an independent reference runtime on public crops. Live-face threshold calibration, small-face -coverage and separated clusters remain open; see [faces.md](faces.md). - -## Shortcuts: conflicts and missing bindings - -Compared `plan/lightroom/06-shortcuts.md` (desktop part) with our bindings: command specs in `crates/engine/src/cmd/`, -`UI_COMMANDS` in `crates/ui-egui/src/menus.rs` and secondary bindings (`ALIASES`) in `crates/ui-egui/src/shortcuts.rs`. -`no_conflicting_bindings` (same file) fails when one key fires two actions. - -**Fixed (M16.1):** added Lightroom-desktop keys as secondary bindings for existing commands — ⌘D Select None, ⇧E -Export dialog, Space Toggle zoom, ⇧M Create Version, ⇧X Reject + advance, ⇧U Unflag + advance. `⇧Y` fired both -Before/After Split and the History panel; History lost the binding. `W` and `⇧⌘I` also fired the engine command -under the UI command that wraps it (a no-op error); the UI command now wins. - -**Deliberate differences (our key → Lightroom desktop key)** — each is a conflict with another binding we have: - -| Action | Ours | Lightroom desktop | Why | -|---|---|---|---| -| Pick flag | P | Z | Z = toggle zoom (Classic convention); P is the Classic pick key | -| Photos panel (left) | ⌘⇧L | P | P = pick | -| Expand/collapse edit sections | ⌘⌥1–5 | ⌘1–6 | ⌘0 = Zoom to Fit, ⌘1 = Zoom 100 %; Geometry lives in the Crop panel | -| Histogram | ⌘⇧H | ⌘0 | ⌘0 = Zoom to Fit | -| Square Grid | ⇧G | (none; ⇧G = Guided Upright) | Guided Upright is a button in the Crop panel | -| Export dialog | ⌘⇧E (+ ⇧E) | ⇧E | ⌘⇧E is "Edit in Photoshop" there; no external-editor command yet | -| Crop overlay cycle | ⇧O | O | O = mask overlay; ⇧O (mask colour / overlay orientation) unused otherwise | -| Create Version | ⌘⇧S (+ ⇧M) | ⇧M (Windows: Ctrl+⇧S) | — | -| Select None | ⌘⇧A (+ ⌘D) | ⌘D | — | - -**Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. Remaining Classic gaps: rating `[` / `]`, flag cycling and filter-bar keys. - -**Still missing / broken:** -- No command yet: F1 help (verify the rest of the old list: full screen, settings, stacks, - visualize spots and merges have commands now). -- `H` opens Remove; Lightroom also uses it (Classic) to hide pins — pins toggle from View → Show Mask Pins. -- ⌘M / ⌘H / ⌘Q / ⌘W rely on the platform window defaults (unverified). +Moved on 2026-10-10: the ranked gaps are in [gaps.md](gaps.md); shortcut conflicts, deliberate +differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). @@ -644,7 +578,7 @@ visible menu bar yet: the menu model is only exposed through the control channel ## Z. Keyboard shortcuts (desktop) From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained in -[Shortcuts: conflicts and missing bindings](#shortcuts-conflicts-and-missing-bindings). +[UI parity → Shortcuts](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| @@ -837,3 +771,10 @@ dialog offers Horizontal/Vertical and multiline text. Japanese glyphs fall back Mincho faces (BIZ UDMincho; on the web build, BIZ UDPGothic) when built with `CRAFT_FONTS_DIR`. Vertical lettering uses upright em cells and right-to-left newline columns; Latin stays upright. Advanced Japanese composition remains open. Regression coverage: `export::tests::japanese_watermarks_support_vertical_columns`, `japanese_watermark_options_and_legacy_defaults`, `watermarks_work_without_craft_fonts`. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Renamed from `parity.md` (progress-docs standard); Top gaps moved to gaps.md (its duplicated items 1–2 from a bad merge resolved there), shortcut notes moved to ui-parity.md. Rows unchanged; a recount of the rows gives 81.1% of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%), so the generated summary above is due a `cargo xtask parity --write` | +| 2026-09-30 – 2026-10-10 | — | Rows added and updated with each feature (see `git log -- docs/parity.md docs/parity-checklist.md`) | diff --git a/docs/process-versions.md b/docs/process-versions.md index f9831b7a5..73bd7dcde 100644 --- a/docs/process-versions.md +++ b/docs/process-versions.md @@ -32,7 +32,7 @@ Today there is one process, **V1**: LightCraft's rendering from before process v passing: settings saved without the field must stay V1, not become the new latest. That is the field's `#[serde(default = "ProcessVersion::legacy")]`; the struct-level `Default` gives new settings the latest instead. -4. Render a few procedural scenes on both processes and compare, and update `docs/parity.md`. +4. Render a few procedural scenes on both processes and compare, and update `docs/parity-checklist.md`. ## What gets which process diff --git a/docs/raw-parity.md b/docs/raw-parity.md new file mode 100644 index 000000000..ca5cbcfdc --- /dev/null +++ b/docs/raw-parity.md @@ -0,0 +1,253 @@ +# RAW parity: cameras, formats, demosaic, colour and lens profiles + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (model-by-model measurement against Adobe's published camera list; use-weighted coverage; variant matrix; colour and lens sections; ranked next cameras) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) + +Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera +does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by +model. The numbers feed [target-app-parity.md](target-app-parity.md); individual shortfalls are +ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), +[raf-compression.md](raf-compression.md), [sony-lens-corrections.md](sony-lens-corrections.md), +[camera-preview-colour.md](camera-preview-colour.md), [denoise.md](denoise.md). + +## Headline + +| Measure | Value | Kind | +|---|---:|---| +| Adobe-listed camera models | **1,446** | measured: [raw/adobe-cameras.tsv](raw/adobe-cameras.tsv) | +| Decoded from sensor data and **verified** on a real file | **234** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | +| Decoder path should handle it, **unverified** | **943** (65%) | measured from the format rules (632 of them DNG, mostly phones) | +| **Preview-only** (embedded JPEG) | **233** (16%) | measured | +| **Unsupported** (not imported, refused or decodes wrongly) | **36** (2%) | measured | +| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 81 / 641 / 24 / 8 of 754 | measured | +| **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~84%** | estimated: measured classes × written usage weights × per-format confidence | +| …of which on a body we have verified | **~40%** | estimated, same weights | +| Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **4 bundled profiles** + per-file fits + 52 spectral matrices | measured (bundle listing, Adobe list, `assets/camera-profiles`) | +| Lens profiles: Adobe vs ours | **3,635 vs 0** (in-file corrections only) | measured | +| **RAW readiness** (decoding 60%, colour 25%, lens 15%) | **~56%** (range 50–60%) | estimated, see [Scores](#scores) | + +Re-measure with `python3 docs/raw/coverage.py` (standard library only): it classifies every Adobe +model from the format rules in the script (which mirror `crates/raw/src`) plus the per-model evidence, +rewrites [raw/coverage.tsv](raw/coverage.tsv) and prints the tables below; `--check` fails when the +committed table is stale. + +## How it was measured + +- **Adobe's list**: helpx "Camera Raw supported cameras" (the live page refuses scripted requests, so + the Wayback Machine capture of 2026-10-03 was parsed): 1,446 camera rows from 40 makers after + dropping profile-only rows; maker, model, extension(s), Camera Matching profile, minimum Camera Raw + version. The year a model was added is approximated from that Camera Raw major version. +- **What we decode**: the decoders and their refusals in `crates/raw/src` (`lib.rs` routing, `vendor/*.rs` + coding variants, `crx.rs` CRX limits, `orf.rs` / `srw.rs` compressed refusals), the import list + `lightcraft_engine::import::EXTENSIONS`, and `KNOWN_UNSUPPORTED` in `crates/raw/tests/corpus.rs`. Doc + claims were not used. +- **Verified** means a real file from that body decoded from sensor data in: the corpus test over the + `RAW_SAMPLES` manifest (`xtask/src/main.rs`, 101 CC0 files; bodies whose sample is in + `KNOWN_UNSUPPORTED` excluded), `crates/raw/tests/cr3_corpus.rs` (3 bodies, exact), the RAF reference + check ([raf-compression.md](raf-compression.md)), and the raw.pixls.us bulk checks recorded in issue + #535 (all 100 Sony bodies there; 87 decode) and PR #215 (every Panasonic / Leica RW2 / RWL / RAW body + there, 178 files from 118 bodies). Bulk-check body lists were matched to Adobe names through the + raw.pixls.us folder names. Checks without a body list (≈ 60 NEFs, 35 CR2 bodies for the CFA layout) + are not counted as verified. +- **Usage weights** ([raw/usage-weights.tsv](raw/usage-weights.tsv)): maker buckets from 2025 market + data (Nikkei production: Canon ~36%, Sony ~29%, Nikon ~14%, Fujifilm ~12% of mirrorless; BCN retail: + Sony 29.9%, Canon 27.4%, Nikon 15.1%), adjusted for the DSLR installed base and for the makers named + in this repo's 325 user issues (Sony 33, Nikon 31, Canon 26, phones 23, Fujifilm 9, Panasonic / Leica + 8, OM / Olympus 6, Pentax / Ricoh 6). Inside each bucket, 80% goes to the popular bodies of the last + ~8 years (listed in the file), 20% to the bucket's other Adobe models. +- **Per-format confidence** for unverified bodies (the chance their files decode correctly): DNG, CR2, + RAF, RW2 0.95; NEF, ARW, PEF 0.9; ORF, SRW, SR2 0.8; **CR3 0.6** (the CRX decoder refuses several + coding variants and only three bodies are verified; maintainers report the R7 working). + +## By maker (measured) + +| Maker | Adobe models | verified | unverified | preview | unsupported | Main reason for preview / unsupported | +|---|---:|---:|---:|---:|---:|---| +| Sony | 149 | 86 | 51 | 9 | 3 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 and DSC-F828 refused (regression #702); SRF not imported | +| Canon | 134 | 11 | 100 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported | +| Samsung | 128 | 1 | 118 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) | +| Apple | 113 | 1 | 112 | 0 | 0 | — (DNG / ProRAW) | +| Panasonic | 104 | 93 | 11 | 0 | 0 | — | +| Nikon | 99 | 5 | 94 | 0 | 0 | — (HE / HE★ is a per-shot setting, preview-only) | +| Fujifilm | 97 | 16 | 55 | 26 | 0 | pre-X FinePix RAF without a raw IFD (estimated split) | +| Google | 88 | 1 | 87 | 0 | 0 | — | +| Olympus | 71 | 3 | 9 | 59 | 0 | **compressed ORF** | +| Leica | 61 | 13 | 48 | 0 | 0 | — | +| Xiaomi, Oppo, LG, Huawei, OnePlus, Motorola, Nokia | 139 | 0 | 139 | 0 | 0 | — (DNG) | +| Pentax / Ricoh | 60 | 4 | 56 | 0 | 0 | — | +| Hasselblad | 34 | 0 | 2 | 32 | 0 | Hasselblad-compressed 3FR / FFF | +| Phase One / Leaf / Mamiya | 59 | 0 | 0 | 43 | 16 | IIQ, MOS preview; TIFF raws and MEF not imported | +| Casio | 26 | 0 | 26 | 0 | 0 | — (DNG) | +| DJI | 21 | 0 | 21 | 0 | 0 | — (DNG) | +| OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** | +| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 0 | 14 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview | +| **All** | **1,446** | **234 (16%)** | **943 (65%)** | **233 (16%)** | **36 (2%)** | | + +## By use (estimated) + +| Bucket | Weight | Camera fully works | On a verified body | Why | +|---|---:|---:|---:|---| +| Canon | 33% | 72% | 16% | 17 of the 22 popular bodies write CR3; 2 of them verified, the rest at 0.6 | +| Sony | 27% | 98% | 91% | bulk-checked | +| Nikon | 16% | 91% | 12% | decoders cover every NEF coding but HE; few bodies verified | +| Fujifilm | 10% | 91% | 45% | | +| Panasonic | 4% | 100% | 98% | bulk-checked | +| OM / Olympus | 3% | 3% | 1% | compressed ORF | +| Phones | 3% | 93% | 1% | DNG | +| Pentax / Ricoh | 1.5% | 94% | 14% | | +| Leica | 1% | 95% | 5% | | +| DJI | 1% | 95% | 0% | | +| Medium format and others | 0.5% | 23% | 0% | IIQ, 3FR, X3F | +| **Weighted** | 100% | **84%** | **40%** | | + +About 5 in 6 raw photographers' cameras decode, but only about 2 in 5 use a body we have actually +checked, and Canon, the largest bucket, is the weakest of the big four. + +## Variant matrix (depth per format) + +✅ decoded and checked on real files · 🟡 decoded, partial or unverified · ⬜ preview-only or refused. + +| Format | Variant | Status | Evidence | +|---|---|---|---| +| CR2 | lossless JPEG slices | ✅ | 8 corpus bodies; CFA phase from `CR2CFAPattern` (#85, 35 bodies); masked-column black level | +| CR2 | sRAW / mRAW (YCbCr) | ✅ | `vendor/cr2/sraw.rs`, 31 samples from 16 models | +| CR3 | CRX lossless, version 0x100 | ✅ | M50, R100, R8 exact against a reference | +| CR3 | C-RAW 0x100 (horizontal tiles), 0x200 (single 14-bit tile, adaptive QP) | ✅ | same 3 bodies exact | +| CR3 | other CRX variants (vertical tiles, partial subbands, other plane flags, 0x200 multi-tile) | ⬜ | refused in `crx.rs`, falls back to the preview; which bodies write them is unmeasured | +| CR3 | sRAW / mRAW | ⬜ | 4-plane Bayer only | +| CR3 | HEIF / HDR PQ | ⬜ | HEVC tracks recognised, not decoded | +| CR3 | dual-pixel raw | ⬜ | never substituted | +| CRW | all | ⬜ | preview | +| NEF | uncompressed, lossless, lossy type 1 / 2, lossy after split | ✅ | D5100, D7000, D7500, Z f corpus; ~60 NEFs by hand; #633 | +| NEF | packed 14-bit stored under a compressed tag (Z 6II uncompressed) | ⬜ | #769; fix in PR #671 (not merged) | +| NEF | High Efficiency / HE★ (JPEG XS) | ⬜ | named, preview (#193); needs a JPEG XS decoder | +| ARW | uncompressed, cRAW (ARW2), lossless LJ92 (L / M / S) | ✅ | 86 bodies (#535); M/S black level 1024 | +| ARW | packed 12-bit (A900) | ✅ | one CC0 file; A850 unmeasured | +| ARW | 12-bit compressed of DSLR-A200…A390 | ⬜ | preview (#535) | +| ARW | A100 ARW; SRF (F828, V3) | ⬜ | refused (#702); SRF not imported | +| ARW | ARQ pixel shift | ⬜ | no code | +| SR2 | DSC-R1 | ✅ | CC0 3221 sample | +| RAF | uncompressed, lossless and lossy compressed; Bayer and X-Trans; GFX 14/16-bit | ✅ | 24 files from 13 bodies exact | +| RAF | pre-X FinePix without a raw IFD | ⬜ | `raf.rs` refuses (26 Adobe models, estimated split) | +| ORF | uncompressed 16-bit, 12-bit packed, two-field 12-bit, 12.8 bpp | ✅ | E-1, E-400, XZ-2 corpus; #651 | +| ORF | **compressed** (every Olympus / OM body since ~2008) | ⬜ | `orf.rs` refuses; the E-M1 and E-M10 III corpus files are `KNOWN_UNSUPPORTED` | +| RW2 / RWL / RAW | formats 2, 4, 5, 6, 7, 8 and 16-bit words | ✅ | 118 bodies (#215) | +| PEF | uncompressed, Huffman, packed 12-bit | ✅ | K10D, K-3, K-5 IIs | +| PEF / DNG | Pentax pixel shift | ⬜ | frames not combined | +| SRW | uncompressed, 12-bit packed | ✅ | EX1 corpus; NX10, NX20 | +| SRW | compressed 32770 / 32772 / 32773 | ⬜ | refused | +| DNG | uncompressed, LJ92, lossy JPEG / Smart Preview, Deflate incl. float, LinearRaw, tiles / strips, row / column interleave | ✅ | 6 corpus DNGs; 4 ILCE-6400 JXL DNGs | +| DNG | JPEG XL (DNG 1.7, lossless and lossy) | ✅ | synthetic bit-exact; real lossless and lossy files | +| DNG | Apple ProRAW (incl. semantic mattes) | ✅ | iPhone 12 Pro corpus; ProfileGainTableMap read but off (ΔE00 19 against Lightroom with it on) | +| DNG | phone DNGs (Pixel, Galaxy, Xiaomi…) | 🟡 | Pixel 2 XL only; white-level pile-up fix #548 | +| DNG | GoPro GPR (VC-5) | ⬜ | refused | +| 3FR / FFF | DNG-like layouts | 🟡 | decoded when uncompressed or LJ92 | +| 3FR / FFF | Hasselblad compression | ⬜ | preview | +| IIQ, MOS, MRW, ERF, KDC, X3F | all | ⬜ | preview | +| DCR, MEF, MFW, DXO, GPR, TIFF raws | all | ⬜ | not imported | + +## Demosaic, levels, crops and lens metadata + +| Stage | Target | LightCraft | Status | +|---|---|---|---| +| Bayer demosaic | Adobe's own, plus AI "Raw Details" | AHD (default), PPG, bilinear (`crates/raw/src/demosaic`) | 🟡 quality unmeasured against Lightroom; no Raw Details | +| X-Trans demosaic | ✅ | in-house edge-weighted directional | 🟡 unmeasured | +| Foveon | ✅ (older bodies) | — | ⬜ | +| Monochrome / LinearRaw | ✅ | handled, no demosaic | ✅ | +| Pixel shift (Sony ARQ, Pentax, OM high-res, Nikon) | ✅ (Sony, Pentax, Nikon) | — | ⬜ | +| Black and white levels | per model, calibrated | read from each file (masked columns, maker notes, SR2SubIFD, CMP1) | 🟡 per-body bugs keep surfacing: A450–A700 SR2 black level and M/S ARW pedestal (#535, fixed); 5D Mark IV CR2 renders dark (#455, open); white-level pile-up in phone DNGs (#548, fixed) | +| Crop masks and aspect | ✅ | DefaultCrop, Sony recorded size, Canon AspectInfo, Panasonic in-camera crops, Nikon CropArea | 🟡 7RM3 / 7RM4 crop origin 8–32 px off (cosmetic) | +| Lens metadata | ✅ read for profile selection | EXIF lens fields read; used only for the in-file corrections below | 🟡 | +| Highlight reconstruction | ✅ | neutral clip, chromaticity diffusion (`highlight.rs`) | 🟡 tuned by eye | +| AI denoise | ✅ Bayer and X-Trans, model bundled | Bayer only, user-installed weights, pure-Rust CPU / GPU | 🟡 no X-Trans, no shippable weights | +| Super Resolution | ✅ | — | ⬜ | +| HDR / panorama merge to raw DNG | ✅ | linear DNG (float16 HDR) with the source's colour tags | ✅ | +| Convert to DNG | ✅ | uncompressed or LJ92 tiles (`dngwrite.rs`) | ✅ (no lossy DNG) | + +## Colour + +| Item | Adobe | LightCraft | +|---|---|---| +| Default profile per model | Adobe Standard for every model (1,500 profile names in the bundle) | the DNG's own matrices; else a pooled profile (**4 bundled**: ILCE-7CR, ILCE-7M4, X-H2S, X-T4); else a guarded per-file fit to the camera's embedded JPEG; else spectral matrices (**52 models**, rawtoaces-data); else a neutral matrix | +| Camera Matching looks | 481 models (plus phone-maker looks) | none (the per-file JPEG fit imitates the camera's own look) | +| Creative profiles | Adobe Color, Portrait, Landscape, Vivid, Monochrome… | our own looks (Color, Neutral, Vivid, Landscape, Portrait, Monochrome) | +| DCP / dual-illuminant model | ✅ | dual-illuminant DNG colour (ColorMatrix / ForwardMatrix 1-2, CameraCalibration, AnalogBalance), HueSatMap, LookTable, ProfileToneCurve; external `.dcp` never loaded (clean-room) | +| White balance in Kelvin | ✅ for every model | absolute Kelvin only where matrices exist; per-file fits give relative WB (#296, #730) | + +**How far the default rendering is from Adobe's** (CIE ΔE; measured where stated): + +- Against the camera JPEG (what the fit targets): the Sony bulk check (#535) accepted fits on 81 of 87 + bodies, median ΔE 5.1, ΔL\* +0.2; rejected fits fall back to a neutral matrix at median ΔE 18.5, + ΔL\* −11, chroma ×0.39. RW2 140 of 174 files accepted, median 5.0. NEF 13 of 13, 2.9–7.8. RAF 23 of + 24, pooled X-H2S 1.62 / X-T4 2.34. SRW 2.2–7.6. +- Against Lightroom itself (few): Sony DRO median ΔE76 7.5 → 3.3; Olympus E-1 vs Lightroom Classic + ΔE00 11.25 → 8.30; ProRAW with its gain map ΔE00 19 (why the map is off). No measurement against + Adobe Standard across models exists. +- User evidence: 33 open issues about raw colour or rendering (wrong colours, dark, flat, green, + desaturated), the largest group of open core-path issues. + +**Colour score ~35%** (estimated): most decoded files start close to the camera's own look (ΔE ~5), +about 10% start far off (ΔE ~18), nothing is calibrated, Kelvin WB is missing for fitted cameras, and +the distance to Adobe Standard is unmeasured. In the readiness numbers this becomes a colour factor +of ×0.85 for a working photographer (they correct colour by hand, at a cost) and ×0.75 for the +essentials user (the default look is what they keep). + +## Lens profiles + +| Item | Adobe | LightCraft | +|---|---|---| +| Profile database | 3,635 profile files, 56 makers, auto-selected | **0** (Adobe LCPs never used; no database of our own) | +| In-file corrections | ✅ | DNG opcodes (WarpRectilinear, FixVignetteRadial, GainMap…) applied; Panasonic / Leica RW2 distortion (79 photos, 13 bodies, 11 lenses); Sony tag 0x7037 distortion for the ILCE-7RM4A (2 lenses) | +| Vignetting / lateral CA from maker notes | ✅ | ⬜ | +| Manual distortion, vignetting, CA, defringe | ✅ | ✅ | + +**Lens score ~10%** (estimated): automatic correction reaches roughly Panasonic / Leica, phone and +drone DNG users (~9% of photographers by the weights above); Adobe reaches nearly everyone. + +## Scores + +| Part | Weight | Score | Basis | +|---|---:|---:|---| +| Decoding | 60% | **76%** | use-weighted 84% × 0.9 for per-body decode bugs still surfacing (levels, crops, WB: #535, #455, #461, #702) | +| Colour | 25% | **35%** | see [Colour](#colour) | +| Lens profiles | 15% | **10%** | see [Lens profiles](#lens-profiles) | +| **RAW readiness** | 100% | **~56%** (range 50–60%) | 45.6 + 8.75 + 1.5; demosaic and highlight quality unmeasured | + +The previous estimate (earlier on 2026-10-10) was ~50%, with decoding at ~78% weighted by maker +from the format list alone. Measured model by model, decoding comes out about the same; the change is +the written decomposition, not new decoding. + +## Remaining work, ranked by photographers unlocked per hour + +Hours are Opus 5.5 agent hours, calibrated on this repo's raw PRs: RW2 every format 6.5 h (#215), +compressed RAF 8.6 h (#282), CR3 CRX 10 h (#279), NEF lossy after split 4.7 h (#633), ORF 12.8 bpp +1.7 h (#651), HEIC 7 h (#609). "Gain" is the change in the use-weighted share whose camera fully +works, from [raw/usage-weights.tsv](raw/usage-weights.tsv). + +| # | Camera / variant | Gain (points of photographers) | Hours | Gain per hour | Needs | +|---:|---|---:|---:|---:|---| +| 1 | Compressed ORF (every Olympus / OM body since ~2008) | +2.6 | 8–15 | ~0.20 | CC0 samples (have 2) | +| 2 | CR3: remaining CRX variants, then verify the 17 popular CR3 bodies | +6.5 | 25–45 | ~0.19 | samples per body (raw.pixls.us has most) | +| 3 | Verify popular Nikon, Fujifilm, Canon CR2 and Pentax bodies (one CC0 file each, fix what breaks) | +2.0 | 8–15 | ~0.17 | public samples | +| 4 | Nikon packed 14-bit under a compressed tag (Z 6II uncompressed, #769) | +0.2 | 1–2 | ~0.13 | PR #671 exists | +| 5 | Sony DSLR-A200…A390 / A850 12-bit compressed ARW | +0.4 | 3–6 | ~0.08 | samples on raw.pixls.us | +| 6 | Pre-X FinePix RAF | +0.6 | 4–8 | ~0.08 | samples | +| 7 | Sony A100 and DSC-F828 regression (#702) | +0.1 | 1–2 | ~0.07 | samples on raw.pixls.us | +| 8 | Canon CRW (2000–2004 bodies) | +1.2 by the uniform tail weight, far less by real use | 6–12 | ≤ 0.1 | samples | +| 9 | Nikon HE / HE★ NEF (Z 8, Z 9, Z 6III, Z f, Z50II, Z5II set to HE) | +1.1 | 20–40 | ~0.04 | a pure-Rust JPEG XS decoder | +| 10 | Compressed SRW, Hasselblad 3FR, IIQ, X3F, MRW, KDC, ERF, DCR, MEF, GPR | +0.6 together | 45–85 | ~0.01 | samples; low use | + +Beyond decoding: + +| Gap | Hours | Needs | +|---|---:|---| +| Measured colour calibration for the ~60 popular bodies, Kelvin WB, render-fidelity suite against Lightroom | 80–150 | chart shots per camera (a human with cameras) or licensable measured data | +| Lens profile database of our own (fitting tools, data format, top 100 lenses), maker-note vignetting / CA | 40–80 | calibration shots (a human with lenses) | +| Demosaic and highlight quality measured against Lightroom; X-Trans AI denoise | 15–30 | | +| **Total RAW** | **255–480 h** (decoding list above 120–220 h; ~60% parallelizes) | | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | +| 2026-10-10 | major | Created: formats, makers, demosaic, colour and lens checklists; target counts from the installed Lightroom 9.6 bundle listing | diff --git a/docs/raw/adobe-cameras.tsv b/docs/raw/adobe-cameras.tsv new file mode 100644 index 000000000..a80946ca8 --- /dev/null +++ b/docs/raw/adobe-cameras.tsv @@ -0,0 +1,1450 @@ +# Adobe Camera Raw / Lightroom supported cameras, parsed from helpx.adobe.com/camera-raw/desktop/dng-and-file-formats/camera-raw-plug-supported-cameras.html +# (Wayback Machine capture 20261003133011; the live page refuses scripted requests). Facts only: maker, model, extension, +# whether Adobe ships a Camera Matching profile, minimum Camera Raw version, and the year that ACR major version shipped (approximate date added). +maker camera extensions camera_matching acr_version year_added +Apple iPhone 17e Front Camera DNG No 18.3 2025 +Apple iPhone 17e Rear Camera DNG No 18.3 2025 +Apple iPhone 17 Front Camera DNG No 18.0 2025 +Apple iPhone 17 Ultrawide Camera DNG No 18.0 2025 +Apple iPhone 17 Wide Camera DNG No 18.0 2025 +Apple iPhone 17 Pro Front Camera DNG No 18.0 2025 +Apple iPhone 17 Pro Telephoto Camera DNG No 18.0 2025 +Apple iPhone 17 Pro Ultrawide Camera DNG No 18.0 2025 +Apple iPhone 17 Pro Wide Camera DNG No 18.0 2025 +Apple iPhone 17 Pro Max Front Camera DNG No 18.0 2025 +Apple iPhone 17 Pro Max Telephoto Camera DNG No 18.0 2025 +Apple iPhone 17 Pro Max Ultrawide Camera DNG No 18.0 2025 +Apple iPhone 17 Pro Max Wide Camera DNG No 18.0 2025 +Apple iPhone Air Front Camera DNG No 18.0 2025 +Apple iPhone Air Wide Camera DNG No 18.0 2025 +Apple iPhone 16e Front Camera DNG No 17.3 2024 +Apple iPhone 16e Wide Camera DNG No 17.3 2024 +Apple iPhone 16 Front Camera DNG No 17.0 2024 +Apple iPhone 16 Ultrawide Camera DNG No 17.0 2024 +Apple iPhone 16 Wide Camera DNG No 17.0 2024 +Apple iPhone 16 Plus Front Camera DNG No 17.0 2024 +Apple iPhone 16 Plus Ultrawide Camera DNG No 17.0 2024 +Apple iPhone 16 Plus Wide Camera DNG No 17.0 2024 +Apple iPhone 16 Pro Front Camera DNG No 17.0 2024 +Apple iPhone 16 Pro Telephoto Camera DNG No 17.0 2024 +Apple iPhone 16 Pro Ultrawide Camera DNG No 17.0 2024 +Apple iPhone 16 Pro Wide Camera DNG No 17.0 2024 +Apple iPhone 16 Pro Max Front Camera DNG No 17.0 2024 +Apple iPhone 16 Pro Max Telephoto Camera DNG No 17.0 2024 +Apple iPhone 16 Pro Max Ultrawide Camera DNG No 17.0 2024 +Apple iPhone 16 Pro Max WIde Camera DNG No 17.0 2024 +Apple iPhone 15 Plus Ultrawide Camera DNG No 16.0 2023 +Apple iPhone 15 Pro Telephoto Camera DNG Apple ProRAW 16.0 2023 +Apple iPhone 15 Pro Front Camera DNG Apple ProRAW 16.0 2023 +Apple iPhone 15 Pro Wide Camera DNG Apple ProRAW 16.0 2023 +Apple iPhone 15 Pro Ultrawide Camera DNG Apple ProRAW 16.0 2023 +Apple iPhone 15 Pro Max Front Camera DNG Apple ProRAW 16.0 2023 +Apple iPhone 15 Pro Max Telephoto Camera DNG Apple ProRAW 16.0 2023 +Apple iPhone 15 Pro Max Ultrawide Camera DNG Apple ProRAW 16.0 2023 +Apple iPhone 15 Pro Max Wide Camera DNG Apple ProRAW 16.0 2023 +Apple iPhone 14 Front Camera DNG No 15.0 2022 +Apple iPhone 14 Ultrawide Camera DNG No 15.0 2022 +Apple iPhone 14 Wide Camera DNG No 15.0 2022 +Apple iPhone 14 Plus Front Camera DNG No 15.0 2022 +Apple iPhone 14 Plus Ultrawide Camera DNG No 15.0 2022 +Apple iPhone 14 Plus Wide Camera DNG No 15.0 2022 +Apple iPhone 14 Pro Front Camera DNG Apple ProRAW 15.0 2022 +Apple iPhone 14 Pro Telephoto Camera DNG Apple ProRAW 15.0 2022 +Apple iPhone 14 Pro Ultrawide Camera DNG Apple ProRAW 15.0 2022 +Apple iPhone 14 Pro Wide Camera DNG Apple ProRAW 15.0 2022 +Apple iPhone 14 Pro Max Front Camera DNG Apple ProRAW 15.0 2022 +Apple iPhone 14 Pro Max Telephoto Camera DNG Apple ProRAW 15.0 2022 +Apple iPhone 14 Pro Max Ultrawide Camera DNG Apple ProRAW 15.0 2022 +Apple iPhone 14 Pro Max Wide Camera DNG Apple ProRAW 15.0 2022 +Apple iPhone 13 Front Camera DNG No 14.0 2021 +Apple iPhone 13 Rear Ultrawide Camera DNG No 14.0 2021 +Apple iPhone 13 Rear Wide Camera DNG No 14.0 2021 +Apple iPhone 13 mini Front Camera DNG No 14.0 2021 +Apple iPhone 13 mini Rear Ultrawide Camera DNG No 14.0 2021 +Apple iPhone 13 mini Rear Wide Camera DNG No 14.0 2021 +Apple iPhone 13 Pro Front Camera DNG Apple ProRAW 14.0 2021 +Apple iPhone 13 Pro Rear Telephoto Camera DNG Apple ProRAW 14.0 2021 +Apple iPhone 13 Pro Rear Ultrawide Camera DNG Apple ProRAW 14.0 2021 +Apple iPhone 13 Pro Rear Wide Camera DNG Apple ProRAW 14.0 2021 +Apple iPhone 13 Pro Max Front Camera DNG Apple ProRAW 14.0 2021 +Apple iPhone 13 Pro Max Rear Telephoto Camera DNG Apple ProRAW 14.0 2021 +Apple iPhone 13 Pro Max Rear Ultrawide Camera DNG Apple ProRAW 14.0 2021 +Apple iPhone 13 Pro Max Rear Wide Camera DNG Apple ProRAW 14.0 2021 +Apple iPhone 12 Rear Wide Camera DNG No 13.1 2020 +Apple iPhone 12 Mini Rear Wide Camera DNG No 13.1 2020 +Apple iPhone 12 Ultrawide camera DNG No 14.1 2021 +Apple iPhone 12 Mini Ultrawide camera DNG No 14.1 2021 +Apple iPhone 12 Pro Front Camera DNG No 13.1 2020 +Apple iPhone 12 Pro Rear Wide Camera DNG Apple ProRAW 13.1 2020 +Apple iPhone 12 Pro Rear Telephoto Camera DNG Apple ProRAW 13.1 2020 +Apple iPhone 12 Pro Rear Ultra Wide Camera DNG Apple ProRAW 13.1 2020 +Apple iPhone 12 Pro Max Front Camera DNG No 13.1 2020 +Apple iPhone 12 Pro Max Rear Wide Camera DNG Apple ProRAW 13.1 2020 +Apple iPhone 12 Pro Max Rear Telephoto Camera DNG Apple ProRAW 13.1 2020 +Apple iPhone 12 Pro Max Rear Ultra Wide Camera DNG Apple ProRAW 13.1 2020 +Apple iPhone 11 DNG No 12.0 2019 +Apple iPhone 11 Pro DNG No 12.0 2019 +Apple iPhone 11 Pro Max DNG No 12.0 2019 +Apple iPad Pro 9.7" (Wifi and Cellular) DNG No 9.7 2015 +Apple iPad Pro 11-inch (2nd generation) DNG No 12.3 2019 +Apple iPad Pro 11-inch (3rd generation) DNG No 13.4 2020 +Apple iPad Pro 11-inch (4th generation) Wide Camera DNG No 15.1 2022 +Apple iPad Pro 11-inch (2018 model) DNG No 11.1 2018 +Apple iPad Pro 11-inch M4 (2024 version) Wide Camera DNG No 16.4 2023 +Apple iPad Air 11-inch M2 (2024 version) Wide Camera DNG No 16.4 2023 +Apple iPad Pro 12.9-inch (4th generation) DNG No 12.3 2019 +Apple iPad Pro 12.9-inch (5th generation) DNG No 13.4 2020 +Apple iPad Pro 12.9-inch (6th generation) Wide Camera DNG No 15.1 2022 +Apple iPad Pro 12.9-inch (2018 model) DNG No 11.1 2018 +Apple iPad Pro 13-inch M4 (2024 version) Wide Camera DNG No 16.4 2023 +Apple iPad mini (6th Generation) Rear Camera DNG No 14.0 2021 +Apple iPhone 6s DNG No 9.7 2015 +Apple iPhone 6s Plus DNG No 9.7 2015 +Apple iPhone 7 DNG No 9.7 2015 +Apple iPhone 7 Plus DNG No 9.7 2015 +Apple iPhone 8 DNG No 10.1 2017 +Apple iPhone 8 Plus DNG No 10.1 2017 +Apple iPhone SE (2020 version) DNG No 12.3 2019 +Apple iPhone SE DNG No 9.7 2015 +Apple iPhone X DNG No 10.1 2017 +Apple iPhone XR DNG No 11.1 2018 +Apple iPhone XS DNG No 11.1 2018 +Apple iPhone XS Max DNG No 11.1 2018 +Apple iPad Air (5th generation) Rear Camera DNG No 14.3 2021 +Apple iPhone SE (3rd generation) Front Camera DNG No 14.3 2021 +Apple iPhone SE (3rd generation) Rear Camera DNG No 14.3 2021 +Apple iPad (10th Generation) Wide Camera DNG No 15.1 2022 +Apple iPad mini (A17 Pro) Rear Camera DNG No 17.0.1 2024 +Canon EOS-1D TIF Yes 1.0 2003 +Canon EOS-1D C CR2 Yes 7.4 2012 +Canon EOS-1D Mark II CR2 Yes 2.2 2003 +Canon EOS-1D Mark II N CR2 Yes 3.3 2005 +Canon EOS-1D Mark III CR2 Yes 4.1 2007 +Canon EOS-1D Mark IV CR2 Yes 5.6 2008 +Canon EOS-1Ds TIF Yes 1.0 2003 +Canon EOS-1Ds Mark II CR2 Yes 3.0 2005 +Canon EOS-1Ds Mark III CR2 Yes 4.3 2007 +Canon EOS-1D X CR2 Yes 7.1 2012 +Canon EOS-1D X Mark II CR2 Yes 9.5 2015 +Canon EOS-1D X Mark III CR3 Yes 12.2 2019 +Canon EOS 5D CR2 Yes 3.3 2005 +Canon EOS 5D Mark II CR2 Yes 5.2 2008 +Canon EOS-5D Mark III CR2 Yes 7.1 2012 +Canon EOS-5D Mark IV (** see note) CR2 Yes 9.7 2015 +Canon EOS 5DS CR2 Yes 9.0 2015 +Canon EOS 5DS R CR2 Yes 9.0 2015 +Canon EOS 6D CR2 Yes 7.3 2012 +Canon EOS 6D Mark II CR2 Yes 9.12 2015 +Canon EOS 7D CR2 Yes 5.6 2008 +Canon EOS 7D Mark II CR2 Yes 8.7 2013 +Canon EOS 10D CRW Yes 2.0 2003 +Canon EOS 20D CR2 Yes 2.4 2003 +Canon EOS 20Da CR2 Yes 3.3 2005 +Canon EOS 30D CR2 Yes 3.4 2005 +Canon EOS 40D CR2 Yes 4.2 2007 +Canon EOS 50D CR2 Yes 5.1 2008 +Canon EOS 60D CR2 Yes 6.2 2010 +Canon EOS 60Da CR2 Yes 7.1 2012 +Canon EOS 70D CR2 Yes 8.2 2013 +Canon EOS 77D (EOS 9000D) CR2 Yes 9.10 2015 +Canon EOS 80D CR2 Yes 9.5 2015 +Canon EOS 90D CR3 Yes 11.4 2018 +Canon EOS 200D (EOS Kiss X9, EOS Rebel SL2) CR2 Yes 9.12 2015 +Canon EOS 250D (EOS Rebel SL3, EOS 200D II, EOS Kiss X10) CR3 Yes 11.3 2018 +Canon EOS D30 CRW Yes 1.0 2003 +Canon EOS D60 CRW Yes 1.0 2003 +Canon EOS 300D (EOS Digital Rebel, EOS Kiss Digital) CRW Yes 2.0 2003 +Canon EOS 100D (EOS Digital Rebel SL1, EOS Kiss X7) CR2 Yes 7.4 2012 +Canon EOS 500D (EOS Digital Rebel T1i, EOS Kiss X3 Digital) CR2 Yes 5.4 2008 +Canon EOS 550D (EOS Digital Rebel T2i, EOS Kiss X4 Digital) CR2 Yes 6.1 2010 +Canon EOS 1100D (EOS Digital Rebel T3, EOS Kiss X50 Digital) CR2 Yes 6.4 2010 +Canon EOS 600D (EOS Digital Rebel T3i, EOS Kiss X5 Digital) CR2 Yes 6.4 2010 +Canon EOS 650D (EOS Digital Rebel T4i, EOS Kiss X4i) CR2 Yes 7.1 2012 +Canon EOS 700D (EOS Digital Rebel T5i, EOS Kiss X7i) CR2 Yes 7.4 2012 +Canon EOS 1200D (EOS Digital Rebel T5, EOS Kiss X70) CR2 Yes 8.4 2013 +Canon EOS 1300D (EOS Digital Rebel T6, EOS Kiss X80) CR2 Yes 9.5 2015 +Canon EOS 1500D (EOS Rebel T7, EOS Kiss X90, EOS 2000D) CR2 Yes 10.3 2017 +Canon EOS 3000D (EOS Rebel T100, EOS 4000D) CR2 Yes 10.3 2017 +Canon EOS 750D (EOS Digital Rebel T6i, EOS Kiss X8i) CR2 Yes 9.0 2015 +Canon EOS 760D (EOS Digital Rebel T6s, EOS Kiss 8000D) CR2 Yes 9.0 2015 +Canon EOS 800D (EOS Digital Rebel T7i, EOS Kiss X9i) CR2 Yes 9.10 2015 +Canon EOS 850D (EOS Kiss X10i, EOS Rebel T8i) CR3 Yes 12.2.1 2019 +Canon EOS 1000D (EOS Digital Rebel XS, EOS Kiss F) CR2 Yes 5.1 2008 +Canon EOS 450D (EOS Digital Rebel XSi, EOS Kiss Digital X2) CR2 Yes 4.4 2007 +Canon EOS 350D (EOS Digital Rebel XT, EOS Kiss Digital N) CR2, CRW Yes 3.1 2005 +Canon EOS 400D (EOS Digital Rebel XTi, EOS Kiss Digital X) CR2 Yes 3.6 2005 +Canon EOS C50 CR3 Yes 18.0 2025 +Canon EOS M CR2 Yes 7.2 2012 +Canon EOS M2 CR2 Yes 8.3 2013 +Canon EOS M200 CR3 Yes 12.1 2019 +Canon EOS M3 CR2 Yes 9.0 2015 +Canon EOS M5 CR2 Yes 9.8 2015 +Canon EOS M50 Mark II CR3 Yes 13.1 2020 +Canon EOS M6 CR2 Yes 9.10 2015 +Canon EOS M6 Mark II CR3 Yes 11.4 2018 +Canon EOS M10 CR2 Yes 9.3 2015 +Canon EOS M50 (EOS Kiss M) CR3 Yes 10.3 2017 +Canon EOS M100 CR2 Yes 10.1 2017 +Canon EOS R (** see note) CR3 Yes 11.0 2018 +Canon EOS R1 CR3 Yes 16.5 2023 +Canon EOS R3 CR3 Yes 14.0 2021 +Canon EOS R5 CR3 Yes 12.4 2019 +Canon EOS R5 C CR3 Yes 14.3 2021 +Canon EOS R5 Mark II CR3 Yes 16.4 2023 +Canon EOS R6 CR3 Yes 12.4 2019 +Canon EOS R6 V CR3 Yes 18.4 2025 +Canon EOS R6 Mark II CR3 Yes 15.0 2022 +Canon EOS R6 Mark III CR3 Yes 18.0 2025 +Canon EOS R7 CR3 Yes 14.4 2021 +Canon EOS R8 Mark II CR3 Yes 18.5 2025 +Canon EOS R10 CR3 Yes 14.4 2021 +Canon EOS Ra CR3 Yes 12.1 2019 +Canon EOS RP CR3 Yes 11.2.1 2018 +Canon EOS R8 CR3 Yes 15.2 2022 +Canon EOS R50 CR3 Yes 15.2 2022 +Canon EOS R50 V CR3 Yes 17.3 2024 +Canon EOS R100 CR3 Yes 15.3 2022 +Canon PowerShot 600 CRW No 1.0 2003 +Canon PowerShot A5 CRW No 1.0 2003 +Canon PowerShot A50 CRW No 1.0 2003 +Canon PowerShot G1 CRW No 1.0 2003 +Canon PowerShot G1 X CR2 Yes 7.1 2012 +Canon PowerShot G1 X Mark II CR2 Yes 8.5 2013 +Canon PowerShot G1 X Mark III CR2 Yes 10.1 2017 +Canon PowerShot G2 CRW No 1.0 2003 +Canon PowerShot G3 CRW No 1.0 2003 +Canon PowerShot G3 X CR2 Yes 9.1.1 2015 +Canon PowerShot G5 CRW No 2.0 2003 +Canon PowerShot G5 X CR2 Yes 9.3 2015 +Canon PowerShot G5 X Mark II CR3 Yes 11.4 2018 +Canon PowerShot G6 CRW Yes 2.4 2003 +Canon PowerShot G7 X CR2 Yes 8.7 2013 +Canon PowerShot G7 X Mark II CR2 Yes 9.6 2015 +Canon PowerShot G7 X Mark III CR3 Yes 11.4 2018 +Canon PowerShot G9 CR2 Yes 4.3 2007 +Canon PowerShot G9X CR2 Yes 9.3 2015 +Canon PowerShot G9 X Mark II CR2 Yes 9.9 2015 +Canon PowerShot G10 CR2 Yes 5.2 2008 +Canon PowerShot G11 CR2 Yes 5.6 2008 +Canon PowerShot G12 CR2 Yes 6.3 2010 +Canon PowerShot G15 CR2 Yes 7.3 2012 +Canon PowerShot G16 CR2 Yes 8.2 2013 +Canon PowerShot Pro 1 CRW No 2.2 2003 +Canon PowerShot Pro70 CRW No 1.0 2003 +Canon PowerShot Pro90 IS CRW No 1.0 2003 +Canon PowerShot S30 CRW No 1.0 2003 +Canon PowerShot S40 CRW No 1.0 2003 +Canon PowerShot S45 CRW No 1.0 2003 +Canon PowerShot S50 CRW No 2.0 2003 +Canon PowerShot S60 CRW No 2.3 2003 +Canon PowerShot S90 CR2 Yes 5.6 2008 +Canon PowerShot S95 CR2 Yes 6.3 2010 +Canon PowerShot S70 CRW No 2.4 2003 +Canon PowerShot S100 CR2 Yes 6.6 2010 +Canon PowerShot S100V CR2 No 6.7 2010 +Canon PowerShot S110 CR2 Yes 7.3 2012 +Canon PowerShot S120 CR2 Yes 8.3 2013 +Canon PowerShot SX1 IS CR2 Yes 5.4 2008 +Canon PowerShot SX50 CR2 Yes 7.3 2012 +Canon PowerShot SX60 HS CR2 Yes 8.7 2013 +Canon PowerShot SX70 HS CR3 Yes 11.1 2018 +Canon PowerShot V1 CR3 Yes 17.2 2024 +Casio EXILIM EX-10 DNG No 8.3 2013 +Casio EXILIM EX-100 DNG No 8.4 2013 +Casio EXILIM EX-100F DNG No 9.1.1 2015 +Casio EXILIM EX-100PRO DNG No 8.7 2013 +Casio EXILIM EX-FC300S DNG No 7.3 2012 +Casio EXILIM EX-FH100 DNG No 6.2 2010 +Casio EXILIM EX-ZR700 DNG No 7.4 2012 +Casio EXILIM EX-ZR710 DNG No 7.4 2012 +Casio EXILIM EX-ZR750 DNG No 7.4 2012 +Casio EXILIM EX-ZR800 DNG No 8.2 2013 +Casio EXILIM EX-ZR850 DNG No 8.4 2013 +Casio EXILIM EX-ZR1000 DNG No 7.3 2012 +Casio EXILIM EX-ZR1100 DNG No 8.2 2013 +Casio EXILIM EX-ZR1200 DNG No 8.2 2013 +Casio EXILIM EX-ZR1300 DNG No 8.5 2013 +Casio EXILIM EX-ZR1500 DNG No 8.5 2013 +Casio EXILIM EX-ZR3000 DNG No 9.1.1 2015 +Casio EXILIM EX-ZR3100 DNG No 9.5.1 2015 +Casio EXILIM EX-ZR3200 DNG No 9.9 2015 +Casio EXILIM EX-ZR3500 DNG No 9.0 2015 +Casio EXILIM EX-ZR3600 DNG No 9.5.1 2015 +Casio EXILIM EX-ZR3700 DNG No 9.9 2015 +Casio EXILIM EX-ZR4000 DNG No 9.7 2015 +Casio EXILIM EX-ZR4100 DNG No 10.0 2017 +Casio EXILIM EX-ZR5000 DNG No 9.7 2015 +Casio EXILIM EX-ZR5100 DNG No 10.0 2017 +Contax N Digital RAW No 2.3 2003 +DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) DNG No 13.2 2020 +DJI DJI AIR 2S DNG No 13.3 2020 +DJI DJI Mini 2 DNG No 13.3 2020 +DJI DJI Air 3 Wide Camera DNG No 16.0.1 2023 +DJI DJI Air 3 Telephoto Camera DNG No 16.0.1 2023 +DJI DJI Mini 3 Pro DNG No 16.0.1 2023 +DJI DJI Mini 4 Pro DNG No 16.0.1 2023 +DJI DJI Mavic 3 Classic (Hasselblad L2D-20c) DNG No 16.0.1 2023 +DJI DJI Mavic 3 Cine Super Telephoto Camera DNG No 16.0.1 2023 +DJI DJI Mavic 3 Cine (Hasselblad L2D-20c) DNG No 16.0.1 2023 +DJI DJI Mavic 3 Pro Super Telephoto Camera DNG No 16.0.1 2023 +DJI DJI Mavic 3 Pro Telephoto Camera DNG No 16.0.1 2023 +DJI DJI Mavic 3 Pro (Hasselblad L2D-20c) DNG No 16.0.1 2023 +DJI DJI Mavic 3 (Hasselblad L2D-20c) DNG No 16.0.1 2023 +DJI DJI Osmo Pocket 3 D DNG No 16.0.1 2023 +DJI DJI Air 3S Telephoto Camera DNG No 17.1 2024 +DJI DJI Air 3S Wide Camera DNG No 17.1 2024 +DJI DJI Osmo Action 5 Pro DNG No 17.1 2024 +DJI DJI Mavic 4 Pro (DJI FC9284) DNG No 18.4 2025 +DJI DJI Mavic 4 Pro (DJI FC9287) DNG No 18.4 2025 +DJI DJI Mavic 4 Pro (Hasselblad L3D-100c) DNG No 18.4 2025 +DxO DxO ONE DNG No 9.2 2015 +Epson R-D1 ERF No 2.3 2003 +Epson R-D1s ERF No 3.4 2005 +Epson R-D1x ERF No 5.4 2008 +Fujifilm GFX 50R RAF Yes 10.5 2017 +Fujifilm GFX 50S RAF Yes 9.9 2015 +Fujifilm GFX 50S II RAF Yes 13.4 2020 +Fujifilm GFX 100 RAF Yes 11.4 2018 +Fujifilm GFX 100 II RAF Yes 15.5 2022 +Fujifilm GFX 100 II IR RAF Yes 17.5 2024 +Fujifilm GFX 100 IR RAF Yes 13.2 2020 +Fujifilm GFX 100S RAF Yes 13.2 2020 +Fujifilm GFX 100S II RAF Yes 16.2 2023 +Fujifilm GFX 100RF (Final Support) RAF Yes 17.3 2024 +Fujifilm FinePix E900 RAF No 3.3 2005 +Fujifilm FinePix F505 EXR RAF No 7.1 2012 +Fujifilm FinePix F550 EXR RAF No 6.4 2010 +Fujifilm FinePix F600 EXR RAF No 6.5 2010 +Fujifilm FinePix F605 EXR RAF No 7.1 2012 +Fujifilm FinePix F700 RAF No 2.1 2003 +Fujifilm FinePix F770 EXR RAF No 7.1 2012 +Fujifilm FinePix F775 EXR RAF No 7.1 2012 +Fujifilm FinePix F800 EXR RAF No 7.2 2012 +Fujifilm FinePix F900 EXR RAF No 7.4 2012 +Fujifilm FinePix HS10 RAF No 6.2 2010 +Fujifilm FinePix HS20 EXR RAF No 6.4 2010 +Fujifilm FinePix HS22 EXR RAF No 8.2 2013 +Fujifilm FinePix HS30 EXR RAF No 7.1 2012 +Fujifilm FinePix HS33 EXR RAF No 7.1 2012 +Fujifilm FinePix HS35 EXR RAF No 8.2 2013 +Fujifilm FinePix HS50 EXR RAF No 7.4 2012 +Fujifilm FinePix IS-1 RAF No 4.2 2007 +Fujifilm FinePix IS Pro RAF No 5.1 2008 +Fujifilm FinePix S1 RAF No 8.5 2013 +Fujifilm FinePix S100fs RAF No 4.4 2007 +Fujifilm FinePix S2 Pro RAF No 1.0 2003 +Fujifilm FinePix S20 Pro RAF No 2.3 2003 +Fujifilm FinePix S200 EXR RAF No 6.4 2010 +Fujifilm FinePix S205 EXR RAF No 8.2 2013 +Fujifilm FinePix S3 Pro RAF No 3.0 2005 +Fujifilm FinePix S5 Pro RAF No 4.1 2007 +Fujifilm FinePix S5000 Z RAF No 2.1 2003 +Fujifilm FinePix S5200 (FinePix S5600) RAF No 3.3 2005 +Fujifilm FinePix S6000fd (FinePix S6500fd) RAF No 3.6 2005 +Fujifilm FinePix S7000 Z RAF No 2.1 2003 +Fujifilm FinePix S9000 (FinePix S9500) RAF No 3.3 2005 +Fujifilm FinePix S9100 (FinePix S9600) RAF No 3.6 2005 +Fujifilm FinePix SL1000 RAF No 7.4 2012 +Fujifilm FinePix X10 RAF Yes 6.6 2010 +Fujifilm FinePix X100 RAF Yes 6.4 2010 +Fujifilm FinePix X-M1 RAF Yes 8.2 2013 +Fujifilm FinePix X-S1 RAF Yes 7.0 2012 +Fujifilm X100F RAF Yes 9.9 2015 +Fujifilm X100S RAF Yes 7.4 2012 +Fujifilm X100T RAF Yes 8.7 2013 +Fujifilm X100V** RAF Yes 12.1 2019 +Fujifilm X100VI RAF Yes 16.2 2023 +Fujifilm X20 RAF Yes 7.4 2012 +Fujifilm X30 RAF Yes 8.7 2013 +Fujifilm X70 RAF Yes 9.4 2015 +Fujifilm X-A1 RAF Yes 8.2 2013 +Fujifilm X-A2 RAF Yes 9.0 2015 +Fujifilm X-A3 RAF Yes 9.8 2015 +Fujifilm X-A5 RAF Yes 10.2 2017 +Fujifilm X-A7 RAF Yes 11.4.1 2018 +Fujifilm X-A10 RAF Yes 9.9 2015 +Fujifilm X-A20 RAF Yes 10.2 2017 +Fujifilm X-E1 RAF Yes 7.2 2012 +Fujifilm X-E2 RAF Yes 8.3 2013 +Fujifilm X-E2S RAF Yes 9.4 2015 +Fujifilm X-E3 RAF Yes 10.0 2017 +Fujifilm X-E4 RAF Yes 13.2 2020 +Fujifilm X-E5 RAF Yes 17.4 2024 +Fujifilm X-H1 RAF Yes 10.2 2017 +Fujifilm X-H2 IR RAF Yes 17.5 2024 +Fujifilm X-H2S RAF Yes 14.4 2021 +Fujifilm X-M5 RAF Yes 17.0 2024 +Fujifilm X-Pro1 RAF Yes 7.1 2012 +Fujifilm X-Pro2 RAF Yes 9.4 2015 +Fujifilm X-Pro3** RAF Yes 12.0 2019 +Fujifilm X-S10 RAF Yes 13.0 2020 +Fujifilm X-S20 RAF Yes 15.3 2022 +Fujifilm X-T1 RAF Yes 8.4 2013 +Fujifilm X-T1 IR RAF Yes 9.3 2015 +Fujifilm X-T2 RAF Yes 9.6 2015 +Fujifilm X-T3 RAF Yes 11.0 2018 +Fujifilm X-T4** RAF Yes 12.2 2019 +Fujifilm X-T5 RAF Yes 15.0 2022 +Fujifilm X-T10 RAF Yes 9.1 2015 +Fujifilm X-T20 RAF Yes 9.9 2015 +Fujifilm X-T30 RAF Yes 11.2 2018 +Fujifilm X-T30 II RAF Yes 14.0 2021 +Fujifilm X-T30 III RAF Yes 15.0 2022 +Fujifilm X-T50 RAF Yes 16.3 2023 +Fujifilm X-T100 RAF Yes 10.4 2017 +Fujifilm X-T200 RAF Yes 12.1 2019 +Fujifilm XF1 RAF Yes 7.2 2012 +Fujifilm XF10 RAF Yes 10.4 2017 +Fujifilm XQ1 RAF Yes 8.3 2013 +Fujifilm XQ2 RAF Yes 9.0 2015 +Fujifilm X-H2 RAF Yes 14.5 2021 +Google Pixel DNG No 9.8 2015 +Google Pixel XL DNG No 9.8 2015 +Google Pixel 2 DNG No 10.1 2017 +Google Pixel 2 XL DNG No 10.1 2017 +Google Pixel 3 DNG No 11.1 2018 +Google Pixel 3 Front Standard Camera DNG No 11.3 2018 +Google Pixel 3 Front Wide Camera DNG No 11.3 2018 +Google Pixel 3 XL DNG No 11.1 2018 +Google Pixel 3 XL Front Standard Camera DNG No 11.3 2018 +Google Pixel 3 XL Front Wide Camera DNG No 11.3 2018 +Google Pixel 4 DNG No 12.1 2019 +Google Pixel 4 XL DNG No 12.1 2019 +Google Pixel 5 Front Camera DNG No 13.1 2020 +Google Pixel 5 Rear Main Camera DNG No 13.1 2020 +Google Pixel 5 Rear Ultra Wide Camera DNG No 13.1 2020 +Google Pixel 6 Front Camera DNG No 14.1 2021 +Google Pixel 6 Rear Main Camera DNG No 14.1 2021 +Google Pixel 6 Rear Ultrawide Camera DNG No 14.1 2021 +Google Pixel 6 Pro Front Camera DNG No 14.1 2021 +Google Pixel 6 Pro Rear Main Camera DNG No 14.1 2021 +Google Pixel 6 Pro Rear Telephoto Camera DNG No 14.1 2021 +Google Pixel 6 Pro Rear Ultrawide Camera DNG No 14.1 2021 +Google Pixel 7 Front Camera DNG No 15.0 2022 +Google Pixel 7 Ultrawide Camera DNG No 15.0 2022 +Google Pixel 7 Wide Camera DNG No 15.0 2022 +Google Pixel 7 Pro Front Camera DNG No 15.0 2022 +Google Pixel 7 Pro Telephoto Camera DNG No 15.0 2022 +Google Pixel 7 Pro Ultrawide Camera DNG No 15.0 2022 +Google Pixel 7 Pro Wide Camera DNG No 15.0 2022 +Google Pixel 4a Front Camera DNG No 15.3 2022 +Google Pixel 4a Rear Wide Camera DNG No 15.3 2022 +Google Pixel 5a Front Camera DNG No 15.3 2022 +Google Pixel 5a Rear Main Camera DNG No 15.3 2022 +Google Pixel 5a Rear Ultra Wide Camera DNG No 15.3 2022 +Google Pixel 6a Front Camera DNG No 15.3 2022 +Google Pixel 6a Rear Main Camera DNG No 15.3 2022 +Google Pixel 6a Rear Ultrawide Camera DNG No 15.3 2022 +Google Pixel 7a Front Camera DNG No 15.4.1 2022 +Google Pixel 7a Ultrawide Camera DNG No 15.4.1 2022 +Google Pixel 7a Wide Camera DNG No 15.4.1 2022 +Google Pixel 8 Front Camera* DNG No 16.0.1 2023 +Google Pixel 8 Ultrawide Camera* DNG No 16.0.1 2023 +Google Pixel 8 Wide Camera* DNG No 16.0.1 2023 +Google Pixel 8 Pro Front Camera* DNG No 16.0.1 2023 +Google Pixel 8 Pro Telephoto Camera* DNG No 16.0.1 2023 +Google Pixel 8 Pro Ultrawide Camera* DNG No 16.0.1 2023 +Google Pixel 8 Pro Wide Camera* DNG No 16.0.1 2023 +Google Pixel 8a Front Camera DNG No 16.4 2023 +Google Pixel 8a Ultrawide Camera DNG No 16.4 2023 +Google Pixel 8a Wide Camera DNG No 16.4 2023 +Google Pixel 9 Front Camera DNG No 16.5 2023 +Google Pixel 9 Ultrawide Camera DNG No 16.5 2023 +Google Pixel 9 Wide Camera DNG No 16.5 2023 +Google Pixel 9 Pro Front Camera DNG No 16.5 2023 +Google Pixel 9 Pro Telephoto Camera DNG No 16.5 2023 +Google Pixel 9 Pro Ultrawide Camera DNG No 16.5 2023 +Google Pixel 9 Pro Wide Camera DNG No 16.5 2023 +Google Pixel 9 Pro XL Front Camera DNG No 16.5 2023 +Google Pixel Pixel 9 Pro XL Telephoto Camera DNG No 16.5 2023 +Google Pixel Pixel 9 Pro XL Ultrawide Camera DNG No 16.5 2023 +Google Pixel 9 Pro XL Wide Camera DNG No 16.5 2023 +Google Pixel 9 Pro Fold Front Camera DNG No 16.5 2023 +Google Pixel 9 Pro Fold Telephoto Camera DNG No 16.5 2023 +Google Pixel 9 Pro Fold Ultrawide Camera DNG No 16.5 2023 +Google Pixel 9 Pro Fold Wide Camera DNG No 16.5 2023 +Google Pixel 9a Front Camera DNG Google Pixel 17.4 2024 +Google Pixel 9a Ultrawide Camera DNG Google Pixel 17.4 2024 +Google Pixel 9a Wide Camera DNG Google Pixel 17.4 2024 +Google Pixel 10 Pro Front Camera DNG No 18.0 2025 +Google Pixel 10 Pro Telephoto Camera DNG No 18.0 2025 +Google Pixel 10 Pro Ultrawide Camera DNG No 18.0 2025 +Google Pixel 10 Pro Wide Camera DNG No 18.0 2025 +Google Pixel 10 Pro XL Front Camera DNG No 18.0 2025 +Google Pixel 10 Pro XL Telephoto Camera DNG No 18.0 2025 +Google Pixel 10 Pro XL Ultrawide Camera DNG No 18.0 2025 +Google Pixel 10 Pro XL Wide Camera DNG No 18.0 2025 +Google Pixel 10 Front Camera DNG Google Pixel 18.1.1 2025 +Google Pixel 10 Telephoto Camera DNG Google Pixel 18.1.1 2025 +Google Pixel 10 Ultrawide Camera DNG Google Pixel 18.1.1 2025 +Google Pixel 10 Wide Camera DNG Google Pixel 18.1.1 2025 +Google Pixel 10 Pro Fold Front Camera DNG Google Pixel 18.1.1 2025 +Google Pixel 10 Pro Fold Telephoto Camera DNG Google Pixel 18.1.1 2025 +Google Pixel 10 Pro Fold Ultrawide Camera DNG Google Pixel 18.1.1 2025 +Google Pixel 10 Pro Fold Wide Camera DNG Google Pixel 18.1.1 2025 +Google Pixel Fold Inner Camera DNG Google Pixel 15.5 2022 +Google Pixel Fold Telephoto Camera DNG Google Pixel 15.5 2022 +Google Pixel Fold Ultrawide Camera DNG Google Pixel 15.5 2022 +Google Pixel Fold Wide Camera DNG Google Pixel 15.5 2022 +GoPro FUSION GPR No 10.1 2017 +GoPro HERO5 Black GPR No 9.7 2015 +GoPro HERO6 Black GPR No 10.1 2017 +GoPro HERO7 Black GPR No 11.1 2018 +GoPro HERO10 Black GPR No 14.1 2021 +GoPro HERO12 Black GPR No 17.0 2024 +GoPro HERO13 Black GPR No 17.0 2024 +GoPro MISSION 1 GPR No 18.5 2025 +GoPro MISSION 1 PRO GPR No 18.5 2025 +Hasselblad 503CWD 3FR No 5.4 2008 +Hasselblad A6D-100c 3FR Yes 10.1 2017 +Hasselblad CF-22 3FR No 5.4 2008 +Hasselblad CF-22MS 3FR No 5.4 2008 +Hasselblad CF-39 3FR No 5.4 2008 +Hasselblad CF-39MS 3FR No 5.4 2008 +Hasselblad CFH-22 3FR No 5.4 2008 +Hasselblad CFH-39 3FR No 5.4 2008 +Hasselblad CFV 3FR No 5.4 2008 +Hasselblad CFV-50c 3FR No 5.4 2008 +Hasselblad H2D DNG No 2.3 2003 +Hasselblad H2D-22 3FR No 5.4 2008 +Hasselblad H2D-39 3FR No 5.4 2008 +Hasselblad H3D-22 3FR No 5.4 2008 +Hasselblad H3D-39 3FR No 5.4 2008 +Hasselblad H3DII-22 3FR No 5.4 2008 +Hasselblad H3DII-31 3FR No 5.4 2008 +Hasselblad H3DII-39 3FR No 5.4 2008 +Hasselblad H3DII-39MS 3FR No 5.4 2008 +Hasselblad H3DII-50 3FR No 5.4 2008 +Hasselblad H4D-40 3FR No 6.4 2010 +Hasselblad H4D-60 3FR Yes 6.5 2010 +Hasselblad H5D-40 3FR No 7.4 2012 +Hasselblad H5D-50 3FR No 7.4 2012 +Hasselblad H5D-50c 3FR, FFF (** see note) Yes 8.4 2013 +Hasselblad H5D-60 3FR No 8.1 2013 +Hasselblad H6D-100c 3FR No 9.7 2015 +Hasselblad HV ARW Yes 8.4 2013 +Hasselblad Lunar 3FR Yes 7.4 2012 +Hasselblad Stellar II 3FR Yes 9.0 2015 +Hasselblad X1D 3FR Yes 9.8 2015 +Hasselblad X2D II 100C (**) 3FR Yes 17.5 2024 +Hasselblad X1D II 50C 3FR Yes 11.4 2018 +Hasselblad X2D 100C 3FR Yes 14.3 2021 +Huawei Honor 20 Pro Rear Main Camera DNG No 12.0 2019 +Huawei Honor 20 Pro Rear Telephoto Camera DNG No 12.0 2019 +Huawei Honor 20 Pro Rear Wide Camera DNG No 12.0 2019 +Huawei Mate 10 Pro DNG No 12.0 2019 +Huawei P30 Rear Main Camera DNG No 12.0 2019 +Huawei P30 Rear Telephoto Camera DNG No 12.0 2019 +Huawei P30 Rear Wide Camera DNG No 12.0 2019 +Huawei P30 Pro Rear Main Camera DNG No 12.0 2019 +Huawei P30 Pro Rear Telephoto Camera DNG No 12.0 2019 +Huawei P30 Pro Rear Wide Camera DNG No 12.0 2019 +Huawei P9 Lite DNG No 11.1 2018 +Insta360 Ace Pro 2 DNG No 18.0 2025 +Insta360 GO Ultra DNG No 18.0 2025 +Kodak DCS 14n DCR No 2.1 2003 +Kodak DCS 720x DCR No 2.1 2003 +Kodak DCS 760 DCR No 2.1 2003 +Kodak DCS Pro 14nx DCR No 2.2 2003 +Kodak DCS Pro SLR/n DCR No 2.2 2003 +Kodak EasyShare P712 DCR No 3.5 2005 +Kodak EasyShare P850 KDC No 3.3 2005 +Kodak EasyShare P880 KDC No 3.3 2005 +Kodak EasyShare Z1015 IS KDC No 5.1 2008 +Kodak EasyShare Z980 KDC No 5.4 2008 +Kodak EasyShare Z981 RAW No 6.1 2010 +Kodak EasyShare Z990 RAW No 6.4 2010 +Konica Minolta Alpha-5 Digital (China) MRW No 3.2 2005 +Konica Minolta Alpha Sweet Digital (Japan) MRW No 3.2 2005 +Konica Minolta DiMAGE 5 MRW No 1.0 2003 +Konica Minolta DiMAGE 7 MRW No 1.0 2003 +Konica Minolta DiMAGE 7i MRW No 1.0 2003 +Konica Minolta DiMAGE 7Hi MRW No 1.0 2003 +Konica Minolta DiMAGE A1 MRW No 2.0 2003 +Konica Minolta DiMAGE A2 MRW No 2.2 2003 +Konica Minolta DiMAGE A200 MRW No 2.4 2003 +Konica Minolta Maxxum 5D Dynax 5D MRW No 3.2 2005 +Konica Minolta Maxxum 7D Dynax 7D MRW No 2.4 2003 +Leaf AFi II 6 MOS No 5.1 2008 +Leaf AFi II 7 MOS No 5.1 2008 +Leaf Aptus 17 MOS No 4.2 2007 +Leaf Aptus 22 MOS No 3.2 2005 +Leaf Aptus 54s MOS No 4.2 2007 +Leaf Aptus 65 MOS No 3.4 2005 +Leaf Aptus 75 MOS No 3.4 2005 +Leaf Aptus 75s MOS No 4.2 2007 +Leaf Aptus-II 5 MOS No 5.6 2008 +Leaf Aptus-II 6 MOS No 5.1 2008 +Leaf Aptus-II 7 MOS No 5.1 2008 +Leaf Aptus-II 8 MOS No 6.1 2010 +Leaf Aptus-II 10R MOS No 6.1 2010 +Leaf Aptus-II 12 MOS No 6.5 2010 +Leaf Valeo 6 MOS No 2.0 2003 +Leaf Valeo 11 MOS No 2.0 2003 +Leaf Valeo 17 MOS No 3.2 2005 +Leaf Valeo 22 MOS No 2.1 2003 +Leaf Credo 40 IIQ No 7.2 2012 +Leaf Credo 50 IIQ No 8.7 2013 +Leaf Credo 60 IIQ No 7.2 2012 +Leaf Credo 80 IIQ No 7.1 2012 +Leica C (Typ 112) RWL No 8.2 2013 +Leica CL DNG No 10.1 2017 +Leica C-Lux DNG Yes 10.4 2017 +Leica D-Lux 2 RAW No 3.2 2005 +Leica D-Lux 3 RAW No 3.6 2005 +Leica D-Lux 4 RWL No 5.2 2008 +Leica D-Lux 5 RWL No 6.4 2010 +Leica D-Lux 6 RWL No 7.2 2012 +Leica D-Lux 7 RWL Yes 11.1 2018 +Leica D-Lux (Typ 109) RWL No 8.7 2013 +Leica D-Lux 8 DNG No 16.4 2023 +Leica D-Lux 8 "100 Years of Leica" DNG No 18.0 2025 +Leica Digilux 2 RAW No 2.1 2003 +Leica Digilux 3 RAW No 3.6 2005 +Leica Digital-Modul-R DNG No 2.3 2003 +Leica Leitz Phone 3 DNG No 16.3 2023 +Leica M8 DNG Yes 2.3 2003 +Leica M9 DNG No 5.6 2008 +Leica M10 DNG No 9.8 2015 +Leica M10-D DNG No 11.1 2018 +Leica M10-P DNG No 11.1 2018 +Leica M10-R DNG No 12.3 2019 +Leica M11-D DNG No 16.3 2023 +Leica M (Typ 262) DNG No 9.4 2015 +Leica M-D (Typ 262) DNG No 9.6 2015 +Leica M-E DNG No 7.3 2012 +Leica M-EV1 DNG No 17.5 2024 +Leica M-P DNG No 8.7 2013 +Leica M Monochrom DNG No 7.1 2012 +Leica M Monochrom (Typ 246) DNG No 9.0 2015 +Leica M10 Monochrom DNG No 12.2 2019 +Leica M11 DNG No 13.4 2020 +Leica M11 MONOCHROM DNG No 15.0 2022 +Leica M11-P DNG No 16.0 2023 +Leica Q (Typ 116) DNG No 9.1.1 2015 +Leica Q2 DNG No 11.2 2018 +Leica Q2 Monochrom DNG No 13.0 2020 +Leica Q3 DNG No 15.3 2022 +Leica Q3 43 DNG No 16.4 2023 +Leica Q3 Monochrom DNG No 18.0 2025 +Leica S2 DNG No 6.2 2010 +Leica S (Typ 007) DNG No 9.2 2015 +Leica S3 DNG No 12.2.1 2019 +Leica SL (Typ 601) DNG No 9.3 2015 +Leica SL2 DNG No 12.1 2019 +Leica SL2-S DNG No 13.0 2020 +Leica SL3 DNG No 16.2 2023 +Leica SL3-P DNG No 18.2 2025 +Leica SL3-S DNG No 17.0 2024 +Leica T (Typ 701) DNG No 8.3 2013 +Leica TL DNG No 9.8 2015 +Leica TL2 DNG No 9.12 2015 +Leica V-LUX 1 RAW No 3.6 2005 +Leica V-LUX 2 RAW No 6.4 2010 +Leica V-LUX 3 RAW No 6.6 2010 +Leica V-LUX 4 RWL No 7.2 2012 +Leica V-LUX 5 RWL Yes 11.4 2018 +Leica V-LUX (Typ 114) RWL No 8.7 2013 +Leica X2 RWL No 7.1 2012 +Leica X (Typ 113) DNG No 8.7 2013 +Leica X-U (Typ 113) DNG No 9.4 2015 +LG G7 ThinQ DNG No 11.1 2018 +LG G8 ThinQ Front Camera DNG No 12.2 2019 +LG G8 ThinQ Rear Main Camera DNG No 12.2 2019 +LG G8 ThinQ Rear Wide Camera DNG No 12.2 2019 +LG V40 ThinQ Front Standard Camera DNG No 11.3 2018 +LG V40 ThinQ Rear Main Camera DNG No 11.3 2018 +LG V40 ThinQ Rear Wide Camera DNG No 11.3 2018 +LG V50s ThinQ Front Camera DNG No 12.2 2019 +LG V50s ThinQ Rear Main Camera DNG No 12.2 2019 +LG V50s ThinQ Rear Wide Camera DNG No 12.2 2019 +LG V60 ThinQ Front Camera DNG No 13.1 2020 +LG V60 ThinQ Rear Main Camera DNG No 13.1 2020 +LG Velvet Front Camera DNG No 13.1 2020 +LG Velvet Rear Main Camera DNG No 13.1 2020 +LG Velvet Rear Wide Camera DNG No 13.1 2020 +LG Wing Rear Main Camera DNG No 13.1 2020 +LG Wing Front Camera DNG No 13.1 2020 +LG Wing Rear Wide Camera DNG No 13.1 2020 +Light L16 DNG No 10.1 2017 +Mamiya Mamiya DM22 MOS No 5.6 2008 +Mamiya Mamiya DM28 MOS No 5.6 2008 +Mamiya Mamiya DM33 MOS No 5.6 2008 +Mamiya Mamiya DM56 MOS No 5.6 2008 +Mamiya Mamiya M18 TIF, IIQ No 5.6 2008 +Mamiya Mamiya M22 TIF, IIQ No 5.6 2008 +Mamiya Mamiya M31 TIF, IIQ No 5.6 2008 +Mamiya Mamiya DM40 MOS No 6.1 2010 +Mamiya ZD MEF, MFW No 3.3 2005 +Motorola Moto G Power (2021) Front Camera DNG No 14.0 2021 +Motorola Moto G Power (2021) Rear Macro Camera DNG No 14.0 2021 +Motorola Moto G Power (2021) Rear Wide Camera DNG No 14.0 2021 +Nikon 1 J1 NEF Yes 6.6 2010 +Nikon 1 J2 NEF Yes 7.2 2012 +Nikon 1 J3 NEF Yes 7.4 2012 +Nikon 1 J4 NEF Yes 8.5 2013 +Nikon 1 J5 NEF Yes 9.1 2015 +Nikon 1 S1 NEF Yes 7.4 2012 +Nikon 1 S2 NEF No 8.5 2013 +Nikon 1 V1 NEF Yes 6.6 2010 +Nikon 1 V2 NEF Yes 7.3 2012 +Nikon 1 V3 NEF Yes 8.5 2013 +Nikon COOLPIX 5000 NEF No 1.0 2003 +Nikon COOLPIX 5400 NEF No 2.3 2003 +Nikon COOLPIX 5700 NEF No 1.0 2003 +Nikon COOLPIX 8400 NEF No 3.2 2005 +Nikon COOLPIX 8700 NEF No 2.2 2003 +Nikon COOLPIX 8800 NEF No 3.2 2005 +Nikon COOLPIX A NEF Yes 7.4 2012 +Nikon COOLPIX A1000 NRW No 11.2 2018 +Nikon COOLPIX B700 NRW Yes 9.6 2015 +Nikon COOLPIX P330 NRW No 7.4 2012 +Nikon COOLPIX P340 NRW No 8.4 2013 +Nikon COOLPIX P1000 NRW Yes 11.1 2018 +Nikon COOLPIX P1100 NRW Yes 17.2 2024 +Nikon COOLPIX P6000 NRW No 5.1 2008 +Nikon COOLPIX P7000 NRW No 6.3 2010 +Nikon COOLPIX P7100 NRW No 6.5 2010 +Nikon COOLPIX P7700 NRW No 7.2 2012 +Nikon COOLPIX P7800 NRW No 8.3 2013 +Nikon COOLPIX P950 NEF Yes 12.2 2019 +Nikon D1 NEF Yes 1.0 2003 +Nikon D1H NEF Yes 1.0 2003 +Nikon D1X NEF Yes 1.0 2003 +Nikon D2H NEF Yes 2.1 2003 +Nikon D2Hs NEF Yes 3.2 2005 +Nikon D2X NEF Yes 3.1 2005 +Nikon D2Xs NEF Yes 3.5 2005 +Nikon D3 NEF Yes 4.3 2007 +Nikon D3s NEF Yes 5.6 2008 +Nikon D3X NEF Yes 5.3 2008 +Nikon D4 NEF Yes 7.1 2012 +Nikon D4S NEF Yes 8.4 2013 +Nikon D5 NEF Yes 9.5 2015 +Nikon D40 NEF Yes 3.7 2005 +Nikon D40x NEF Yes 4.0 2007 +Nikon D50 NEF Yes 3.2 2005 +Nikon D60 NEF Yes 4.4 2007 +Nikon D6 NEF Yes 12.2.1 2019 +Nikon D70 NEF Yes 3.0 2005 +Nikon D70S NEF Yes 3.2 2005 +Nikon D80 NEF Yes 3.6 2005 +Nikon D90 NEF Yes 5.1 2008 +Nikon D100 NEF Yes 1.0 2003 +Nikon D200 NEF Yes 3.3 2005 +Nikon D300 NEF Yes 4.3 2007 +Nikon D300s NEF Yes 5.5 2008 +Nikon D500 NEF Yes 9.5 2015 +Nikon D600 NEF Yes 7.3 2012 +Nikon D610 NEF Yes 8.3 2013 +Nikon D700 NEF Yes 5.1 2008 +Nikon D750 NEF Yes 8.7 2013 +Nikon D780 NEF Yes 12.2 2019 +Nikon D800 NEF Yes 7.1 2012 +Nikon D800E NEF Yes 7.1 2012 +Nikon D810 NEF Yes 8.6 2013 +Nikon D810A NEF Yes 9.1 2015 +Nikon D850 NEF Yes 9.12.1 2015 +Nikon D3000 NEF Yes 5.5 2008 +Nikon D3100 NEF Yes 6.3 2010 +Nikon D3200 NEF Yes 7.1 2012 +Nikon D3300 NEF Yes 8.4 2013 +Nikon D3400 NEF Yes 9.7 2015 +Nikon D3500 NEF Yes 11.1 2018 +Nikon D5000 NEF Yes 5.4 2008 +Nikon D5100 NEF Yes 6.4 2010 +Nikon D5200 NEF Yes 7.3 2012 +Nikon D5300 NEF Yes 8.3 2013 +Nikon D5500 NEF Yes 9.0 2015 +Nikon D5600 NEF Yes 9.8 2015 +Nikon D7000 NEF Yes 6.3 2010 +Nikon D7100 NEF Yes 7.4 2012 +Nikon D7200 NEF Yes 9.0 2015 +Nikon D7500 NEF Yes 9.12 2015 +Nikon Df NEF Yes 8.3 2013 +Nikon Z 30 NEF Yes 14.4 2021 +Nikon Z 50 NEF Yes 12.1 2019 +Nikon Z 50 II NEF Yes 17.1 2024 +Nikon Z 5 NEF Yes 12.4 2019 +Nikon Z 5 2 NEF Yes 17.3 2024 +Nikon Z 5 2C NEF Yes 18.5 2025 +Nikon Z 6 NEF Yes 11.1 2018 +Nikon Z 6 II NEF Yes 13.1 2020 +Nikon Z 6 III NEF Yes 16.3 2023 +Nikon Z 7 NEF Yes 11.0 2018 +Nikon Z 7 II NEF Yes 13.1 2020 +Nikon Z 8 NEF Yes 15.3 2022 +Nikon Z 9 (*See note) NEF Yes 14.0 2021 +Nikon Z fc NEF Yes 13.4 2020 +Nikon Z f NEF Yes 15.5 2022 +Nikon ZR NEF Yes 17.5.1 2024 +Nokia Lumia 1020 DNG No 8.3 2013 +Nokia HMD Global Nokia 9 PureView Front Camera DNG No 11.3 2018 +Nokia HMD Global Nokia 9 PureView Rear Camera DNG No 11.3 2018 +OM Digital Solutions OM System OM-1 ORF Yes 14.2 2021 +OM Digital Solutions OM System OM-5 ORF Yes 15.0 2022 +OM Digital Solutions OM System TG - 7 ORF Yes 16.0 2023 +OM Digital Solutions OM System OM-1 MARK II ORF Yes 16.2 2023 +OM Digital Solutions OM System OM-3 ORF Yes 17.2 2024 +OM Digital Solutions OM-5 Mark II ORF/ORI Yes 17.4.1 2024 +OM Digital Solutions OM-3 Astro ORF/ORI Yes 18.2 2025 +Olympus AIR A01 ORF Yes 9.1.1 2015 +Olympus Camedia C-5050 Zoom ORF No 1.0 2003 +Olympus Camedia C-5060 Zoom ORF No 2.1 2003 +Olympus Camedia C-7070 Wide Zoom ORF No 3.1 2005 +Olympus Camedia C-8080 Wide Zoom ORF No 2.2 2003 +Olympus E-1 ORF No 2.1 2003 +Olympus E-3 ORF No 4.3 2007 +Olympus E-5 ORF Yes 6.3 2010 +Olympus E-10 ORF No 1.0 2003 +Olympus E-20 ORF No 1.0 2003 +Olympus E-30 ORF No 5.3 2008 +Olympus E-420 ORF No 5.0 2008 +Olympus E-450 ORF No 5.4 2008 +Olympus E-520 ORF No 5.0 2008 +Olympus E-600 ORF No 6.1 2010 +Olympus E-620 ORF No 5.4 2008 +Olympus EVOLT E-300 ORF No 3.1 2005 +Olympus EVOLT E-330 ORF No 3.4 2005 +Olympus EVOLT E-400 ORF No 3.6 2005 +Olympus EVOLT E-410 ORF No 4.1 2007 +Olympus EVOLT E-500 ORF No 3.3 2005 +Olympus EVOLT E-510 ORF No 4.2 2007 +Olympus OM-D E-M1 ORF Yes 8.3 2013 +Olympus OM-D E-M1X ORF Yes 11.2 2018 +Olympus OM-D E-M1 Mark II ORF Yes 9.9 2015 +Olympus OM-D E-M1 Mark III ORF Yes 12.2 2019 +Olympus OM-D E-M5 ORF Yes 7.1 2012 +Olympus OM-D E-M5 Mark II ORF Yes 9.0 2015 +Olympus OM-D E-M5 Mark III ORF Yes 12.0 2019 +Olympus OM-D E-M10 ORF Yes 8.5 2013 +Olympus OM-D E-M10 Mark II ORF Yes 9.2 2015 +Olympus OM-D E-M10 Mark III ORF Yes 10.0 2017 +Olympus OM-D E-M10 Mark III S ORF Yes 12.4 2019 +Olympus OM-D E-M10 Mark IV ORF Yes 12.4 2019 +Olympus PEN E-P1 ORF Yes 5.5 2008 +Olympus PEN E-P2 ORF Yes 5.6 2008 +Olympus PEN E-P3 ORF Yes 6.5 2010 +Olympus PEN E-P5 ORF Yes 8.1 2013 +Olympus PEN E-P7 ORF Yes 13.3 2020 +Olympus PEN E-PL1 ORF Yes 6.1 2010 +Olympus PEN E-PL1s ORF Yes 6.4 2010 +Olympus PEN E-PL10 ORF Yes 12.0 2019 +Olympus PEN E-PL2 ORF Yes 6.4 2010 +Olympus PEN E-PL3 ORF Yes 6.5 2010 +Olympus PEN E-PL6 ORF Yes 8.1 2013 +Olympus PEN E-PL5 ORF Yes 7.3 2012 +Olympus PEN E-PL7 ORF Yes 8.7 2013 +Olympus PEN E-PL8 ORF Yes 9.8 2015 +Olympus PEN E-PL9 ORF Yes 10.2 2017 +Olympus PEN E-PM1 ORF Yes 6.5 2010 +Olympus PEN E-PM2 ORF Yes 7.3 2012 +Olympus PEN-F ORF Yes 9.5 2015 +Olympus SP-310 ORF No 3.3 2005 +Olympus SP-320 ORF No 3.4 2005 +Olympus SP-350 ORF No 3.3 2005 +Olympus SP-500 UZ ORF No 3.3 2005 +Olympus SP-510 UZ ORF No 3.6 2005 +Olympus SP-550 UZ ORF No 4.1 2007 +Olympus SP-560 UZ ORF No 4.3 2007 +Olympus SP-565 UZ ORF No 5.1 2008 +Olympus SP-570 UZ ORF No 4.4 2007 +Olympus Stylus 1 ORF Yes 8.3 2013 +Olympus Stylus 1s ORF Yes 8.7 2013 +Olympus Stylus SH-2 ORF Yes 9.0 2015 +Olympus Stylus SH-3 ORF Yes 9.5 2015 +Olympus Stylus Tough TG-4 ORF Yes 9.0 2015 +Olympus Stylus Tough TG-5 ORF Yes 9.12 2015 +Olympus Stylus Tough TG-6 ORF Yes 11.3 2018 +Olympus Stylus XZ-2 iHS ORF Yes 7.3 2012 +Olympus Stylus XZ-10 ORF Yes 7.4 2012 +Olympus XZ-1 ORF Yes 6.4 2010 +OnePlus 7T Front Camera DNG No 12.2 2019 +OnePlus 7T Rear Main Camera DNG No 12.2 2019 +OnePlus 8 Front Camera DNG No 13.1 2020 +OnePlus 8 Rear Main Camera DNG No 13.1 2020 +OnePlus 8 Pro Front Camera DNG No 13.1 2020 +OnePlus 8 Pro Rear Main Camera DNG No 13.1 2020 +OnePlus 12 Telephoto Camera DNG No 16.1.1 2023 +OnePlus 12 Ultrawide Camera DNG No 16.1.1 2023 +OnePlus 12 Wide Camera DNG No 16.1.1 2023 +Oppo Find X7 Telephoto Camera DNG No 16.1.1 2023 +Oppo Find X7 Ultrawide Camera DNG No 16.1.1 2023 +Oppo Find X7 Wide Camera DNG No 16.1.1 2023 +Oppo Find X7 Ultra Super Telephoto Camera DNG No 16.1.1 2023 +Oppo Find X7 Ultra Telephoto Camera DNG No 16.1.1 2023 +Oppo Find X7 Ultra Ultrawide Camera DNG No 16.1.1 2023 +Oppo Find X7 Ultra Wide Camera DNG No 16.1.1 2023 +Oppo Find X8 Ultra Super Telephoto Camera DNG No 18.0 2025 +Oppo Find X8 Ultra Telephoto Camera DNG No 18.0 2025 +Oppo Find X8 Ultra Ultrawide Camera DNG No 18.0 2025 +Oppo Find X8 Ultra Wide Camera DNG No 18.0 2025 +Oppo Find X8 Telephoto Camera DNG No 18.2 2025 +Oppo Find X8 Ultrawide Camera DNG No 18.2 2025 +Oppo Find X8 Wide Camera DNG No 18.2 2025 +Oppo Find X8s Telephoto Camera DNG No 18.2 2025 +Oppo Find X8s Ultrawide Camera DNG No 18.2 2025 +Oppo Find X8s Wide Camera DNG No 18.2 2025 +Oppo Find X8s+ Telephoto Camera DNG No 18.2 2025 +Oppo Find X8s+ Ultrawide Camera DNG No 18.2 2025 +Oppo Find X8s+ Wide Camera DNG No 18.2 2025 +Oppo Find X8 Pro Super Telephoto Camera DNG No 18.2 2025 +Oppo Find X8 Pro Telephoto Camera DNG No 18.2 2025 +Oppo Find X8 Pro Ultrawide Camera DNG No 18.2 2025 +Oppo Find X8 Pro Wide Camera DNG No 18.2 2025 +Oppo Find X9 Telephoto Camera DNG No 18.3.1 2025 +Oppo Find X9 Ultrawide Camera DNG No 18.3.1 2025 +Oppo Find X9 Wide Camera DNG No 18.3.1 2025 +Oppo Find X9 Pro Telephoto Camera DNG No 18.3.1 2025 +Oppo Find X9 Pro Ultrawide Camera DNG No 18.3.1 2025 +Oppo Find X9 Pro Wide Camera DNG No 18.3.1 2025 +Oppo Find X9s Pro Telephoto Camera DNG No 18.3.1 2025 +Oppo Find X9s Pro Ultrawide Camera DNG No 18.3.1 2025 +Oppo Find X9s Pro Wide Camera DNG No 18.3.1 2025 +Oppo Find X9 Ultra Super Telephoto Camera DNG No 18.3.1 2025 +Oppo Find X9 Ultra Telephoto Camera DNG No 18.3.1 2025 +Oppo Find X9 Ultra Ultrawide Camera DNG No 18.3.1 2025 +Oppo Find X9 Ultra Wide Camera DNG No 18.3.1 2025 +Panasonic LUMIX AG-GH4 RW2 No 8.5 2013 +Panasonic LUMIX DC-FZ80 (DC-FZ82, DC-FZ85) RW2 Yes 9.9 2015 +Panasonic LUMIX DC-FZ80D (DC-FZ82D, DC-FZ85D) RW2 Yes 16.4 2023 +Panasonic LUMIX DC-FZ81 (DC-FZ83) RW2 Yes 9.10.1 2015 +Panasonic LUMIX DC-FZ1000M2 (DC-FZ10002) RW2 Yes 11.2.1 2018 +Panasonic LUMIX DC-G100 (DC-G110) RW2 Yes 12.4 2019 +Panasonic LUMIX DC-G9 RW2 Yes 10.1 2017 +Panasonic LUMIX DC-G99 (DC-G90, DC-G91, DC-G95) RW2 Yes 11.3 2018 +Panasonic LUMIX DC-GF9 (DC-GX850, DC-GX800) RW2 Yes 9.9 2015 +Panasonic LUMIX DC-GF10 (DC-GF90) RW2 Yes 10.2 2017 +Panasonic LUMIX DC-GH5 RW2 Yes 9.9 2015 +Panasonic LUMIX DC-GH5M2 RW2 Yes 13.3 2020 +Panasonic LUMIX DC-GH5s RW2 Yes 10.2 2017 +Panasonic LUMIX DC-GH6 RW2 Yes 14.3 2021 +Panasonic LUMIX DC-GH7 RW2 Yes 16.4 2023 +Panasonic LUMIX DC-GX880 RW2 Yes 11.4 2018 +Panasonic LUMIX DC-LX100 II RW2 Yes 11.0 2018 +Panasonic LUMIX DC-S1 RW2 Yes 11.2.1 2018 +Panasonic LUMIX DC-S1H RW2 Yes 11.4.1 2018 +Panasonic LUMIX DC-S1R RW2 Yes 11.2.1 2018 +Panasonic LUMIX DC-S5 RW2 Yes 13.0 2020 +Panasonic LUMIX DC-S9 RW2 Yes 16.4 2023 +Panasonic LUMIX DC-ZS70 (DC-TZ90, DC-TZ91, DC-TZ92, DC-TZ93) RW2 Yes 9.10.1 2015 +Panasonic LUMIX DC-ZS80 (DC-TZ95, DC-TZ96, DC-TZ97) RW2 Yes 11.2.1 2018 +Panasonic LUMIX DMC-CM1 RW2 No 8.7 2013 +Panasonic LUMIX DMC-CM10 RW2 No 9.5 2015 +Panasonic LUMIX DMC-FZ1000 RW2 Yes 8.6 2013 +Panasonic LUMIX DMC-FX150 RW2 No 5.2 2008 +Panasonic LUMIX DMC-FZ8 RAW No 4.0 2007 +Panasonic LUMIX DMC-FZ18 RAW No 4.2 2007 +Panasonic LUMIX DMC-FZ28 RW2 No 5.2 2008 +Panasonic LUMIX DMC-FZ30 RAW No 3.2 2005 +Panasonic LUMIX DMC-FZ35 RW2 No 5.5 2008 +Panasonic LUMIX DMC-FZ38 RW2 No 5.6 2008 +Panasonic LUMIX DMC-FZ40 (DMC-FZ45) RW2 No 6.2 2010 +Panasonic LUMIX DMC-FZ50 RAW No 3.5 2005 +Panasonic LUMIX DMC-FZ70 RW2 No 8.2 2013 +Panasonic LUMIX DMC-FZ72 RW2 No 8.2 2013 +Panasonic LUMIX DMC-FZ100 RW2 No 6.2 2010 +Panasonic LUMIX DMC-FZ150 RW2 No 6.5 2010 +Panasonic LUMIX DMC-FZ200 RW2 No 7.2 2012 +Panasonic LUMIX DMC-FZ2500 (DMC-FZ2000, DMC-FZH1) RW2 Yes 9.8 2015 +Panasonic LUMIX DMC-FZ300 RW2 Yes 9.1.1 2015 +Panasonic LUMIX DMC-FZ330 RW2 Yes 9.1.1 2015 +Panasonic LUMIX DMC-G1 RW2 No 5.2 2008 +Panasonic LUMIX DMC-G2 RW2 No 6.1 2010 +Panasonic LUMIX DMC-G3 RW2 No 6.5 2010 +Panasonic LUMIX DMC-G5 RW2 No 7.2 2012 +Panasonic LUMIX DMC-G6 RW2 No 8.1 2013 +Panasonic LUMIX DMC-G7 RW2 Yes 9.1 2015 +Panasonic LUMIX DMC-G8 (DMC-G80, DMC-G81, DMC-G85) RW2 Yes 9.7 2015 +Panasonic LUMIX DMC-G10 RW2 No 6.1 2010 +Panasonic LUMIX DMC-GF1 RW2 No 5.5 2008 +Panasonic LUMIX DMC-GF2 RW2 No 6.3 2010 +Panasonic LUMIX DMC-GF3 RW2 No 6.5 2010 +Panasonic LUMIX DMC-GF5 RW2 No 7.1 2012 +Panasonic LUMIX DMC-GF6 RW2 No 7.4 2012 +Panasonic LUMIX DMC-GF7 RW2 No 9.0 2015 +Panasonic LUMIX DMC-GF8 RW2 No 9.5 2015 +Panasonic LUMIX DMC-GH1 RW2 No 5.4 2008 +Panasonic LUMIX DMC-GH2 RW2 No 6.3 2010 +Panasonic LUMIX DMC-GH3 RW2 No 7.3 2012 +Panasonic LUMIX DMC-GH4 RW2 Yes 8.6 2013 +Panasonic LUMIX DMC-GM1 RW2 No 8.3 2013 +Panasonic LUMIX DMC-GM1S RW2 No 8.7 2013 +Panasonic LUMIX DMC-GM5 RW2 No 8.7 2013 +Panasonic LUMIX DMC-GX1 RW2 No 6.6 2010 +Panasonic LUMIX DMC-GX7 RW2 No 8.2 2013 +Panasonic LUMIX DMC-GX8 RW2 Yes 9.1.1 2015 +Panasonic LUMIX DMC-GX85 (DMC-GX80, DMC-GX7MK2) RW2 Yes 9.6 2015 +Panasonic LUMIX DC-GX9 (DC-GX7MK3) RW2 Yes 10.3 2017 +Panasonic LUMIX DMC-L1 RAW No 3.5 2005 +Panasonic LUMIX DMC-L10 RAW No 4.3 2007 +Panasonic LUMIX DMC-LC1 RAW No 2.1 2003 +Panasonic LUMIX DMC-LF1 RW2 No 8.1 2013 +Panasonic LUMIX DMC-LX1 RAW No 3.2 2005 +Panasonic LUMIX DMC-LX2 RAW No 3.6 2005 +Panasonic LUMIX DMC-LX3 RW2 No 5.2 2008 +Panasonic LUMIX DMC-LX5 RW2 No 6.2 2010 +Panasonic LUMIX DMC-LX7 RW2 No 7.2 2012 +Panasonic LUMIX DMC-LX9 (DMC-LX10, DMC-LX15) RW2 Yes 9.7 2015 +Panasonic LUMIX DMC-LX100 RW2 Yes 8.7 2013 +Panasonic LUMIX DMC-TZ82 RW2 Yes 9.8 2015 +Panasonic LUMIX DMC-ZS40 (DMC-TZ60, DMC-TZ61) RW2 No 8.4 2013 +Panasonic LUMIX DMC-ZS50 (DMC-TZ70, DMC-TZ71) RW2 No 9.0 2015 +Panasonic LUMIX DMC-ZS60 (DMC-TZ80, DMC-TZ81, DMC-TZ85) RW2 Yes 9.4 2015 +Panasonic LUMIX DMC-ZS100 (DMC-ZS110, DMC-TZ100, DMC-TZ101, DMC-TZ110, DMC-TX1) RW2 Yes 9.5 2015 +Panasonic LUMIX ZS200 (DC-TX2, DC-TZ200, DC-TZ202, DC-TZ220, DC-ZS220) RW2 Yes 10.3 2017 +Panasonic LUMIX DC-G95D RW2 Yes 14.5 2021 +Panasonic LUMIX DC-G99D RW2 Yes 14.4 2021 +Panasonic LUMIX DC-TX2D (DC-TZ200D, DC-TZ202D, DC-TZ220D, DC-ZS200D, DC-ZS220D) RW2 Yes 15.0 2022 +Panasonic LUMIX DC-ZS80D (DC-TZ95D, DC-TZ96D) RW2 Yes 15.1 2022 +Panasonic LUMIX DC-S5M2 RW2 Yes 15.2 2022 +Panasonic LUMIX DC-S5M2X RW2 Yes 15.3 2022 +Panasonic LUMIX DCG9M2 RW2 Yes 16.0 2023 +Panasonic LUMIX DC-G100D RW2 Yes 16.1 2023 +Panasonic LUMIX DC-S5D RW2 Yes 17.0 2024 +Panasonic LUMIX DC - G97 (DC-G99M2) RW2 Yes 17.2 2024 +Panasonic LUMIX DC - TZ99 (DC - ZS99) RW2 Yes 17.2 2024 +Panasonic LUMIX DC-S1RM2 (Final Support) RW2 Yes 17.3 2024 +Panasonic LUMIX DC-S1M2ES (Final Support) RW2 Yes 17.4 2024 +Panasonic LUMIX DC-S1M2 RW2 Yes 17.4 2024 +Panasonic LUMIX DC-ZS300 (DC-TZ300, DC-TX3) RW2 Yes 18.3 2025 +Panasonic LUMIX DC-L10 RW2 Yes 18.3 2025 +Parrot Anafi DNG No 10.5 2017 +Parrot ANAFI Ai DNG No 13.4 2020 +Pentax 645D DNG, PEF No 6.2 2010 +Pentax 645Z DNG, PEF Yes 8.5 2013 +Pentax *ist D PEF No 2.1 2003 +Pentax *ist DL PEF No 3.3 2005 +Pentax *ist DL2 PEF No 3.4 2005 +Pentax *ist DS PEF No 3.2 2005 +Pentax *ist DS2 PEF No 3.3 2005 +Pentax K-01 DNG No 7.1 2012 +Pentax K-1 DNG, PEF Yes 9.5.1 2015 +Pentax K-1 II (* see note) DNG, PEF Yes 10.4 2017 +Pentax K-3 DNG, PEF No 8.3 2013 +Pentax K-3 II († see note) DNG, PEF Yes 9.1.1 2015 +Pentax K-5 DNG, PEF No 6.3 2010 +Pentax K-5 II DNG, PEF No 7.3 2012 +Pentax K-5 IIs DNG, PEF No 7.3 2012 +Pentax K-7 PEF Yes 5.4 2008 +Pentax K-3 Mark III DNG, PEF Yes 13.3 2020 +Pentax K-3 Mark III Monochrome DNG/PEF No 15.4 2022 +Pentax K-30 DNG No 7.2 2012 +Pentax K-50 DNG No 8.2 2013 +Pentax K-70 PEF No 9.8 2015 +Pentax K-500 DNG No 8.2 2013 +Pentax KP DNG, PEF No 9.10 2015 +Pentax K-r PEF No 6.3 2010 +Pentax K-S1 DNG, PEF No 8.7 2013 +Pentax K-S2 DNG, PEF No 9.1 2015 +Pentax K-x PEF No 5.6 2008 +Pentax K10D DNG, PEF Yes 3.7 2005 +Pentax K20D DNG, PEF Yes 4.4 2007 +Pentax K100D PEF No 3.6 2005 +Pentax K100D Super PEF No 4.2 2007 +Pentax K110D PEF No 3.6 2005 +Pentax K200D DNG, PEF Yes 4.4 2007 +Pentax K2000 K-m PEF No 5.1 2008 +Pentax KF PEF, DNG No 15.1 2022 +Pentax MX-1 DNG No 7.4 2012 +Pentax Q DNG No 6.5 2010 +Pentax Q7 DNG No 8.2 2013 +Pentax Q10 DNG, PEF No 7.3 2012 +Pentax QS-1 DNG No 8.7 2013 +Pixii PIXII Camera (A1571) DNG No 14.1 2021 +Phase One H 20 TIF No 4.1 2007 +Phase One H 25 TIF No 4.1 2007 +Phase One IQ 140 IIQ No 6.5 2010 +Phase One IQ 160 IIQ No 6.5 2010 +Phase One IQ 150 IIQ No 9.4 2015 +Phase One IQ 180 IIQ No 6.5 2010 +Phase One IQ 250 IIQ No 8.4 2013 +Phase One IQ 260 IIQ No 8.3 2013 +Phase One IQ 280 IIQ No 8.3 2013 +Phase One IQ3 50MP IIQ No 9.1.1 2015 +Phase One IQ3 60MP IIQ No 9.1.1 2015 +Phase One IQ3 80MP IIQ No 9.1.1 2015 +Phase One IQ3 100MP IIQ No 9.9 2015 +Phase One IQ3 100MP Trichromatic (*see note) IIQ No 10.4 2017 +Phase One IQ4 150MP (**see note) IIQ No 12.2 2019 +Phase One IQ4 150MP Achromatic (** see note) IIQ No 13.2 2020 +Phase One P 20 TIF No 4.1 2007 +Phase One P 20+ TIF No 4.2 2007 +Phase One P 21 TIF No 4.1 2007 +Phase One P 21+ TIF No 4.2 2007 +Phase One P 25 TIF No 4.1 2007 +Phase One P 25+ TIF No 4.2 2007 +Phase One P 30 TIF No 4.1 2007 +Phase One P 30+ TIF No 4.2 2007 +Phase One P 40+ IIQ No 6.5 2010 +Phase One P 45 TIF No 4.1 2007 +Phase One P 45+ TIF No 4.2 2007 +Phase One P 65+ IIQ No 6.5 2010 +Ricoh GR DNG No 8.1 2013 +Ricoh GR II DNG No 9.1.1 2015 +Ricoh GR III DNG Yes 11.3 2018 +Ricoh GR IIIx DNG Yes 14.1 2021 +Ricoh GR III HDF DNG Yes 16.3 2023 +Ricoh GR IIIx HDF DNG Yes 16.3 2023 +Ricoh GR IV DNG Yes 17.5 2024 +Ricoh GR IV Monochrome DNG No 18.2 2025 +Ricoh GR Digital DNG No 2.3 2003 +Ricoh GR Digital II DNG No 2.3 2003 +Ricoh GR Digital III DNG No 5.5 2008 +Ricoh GR Digital IV DNG No 6.6 2010 +Ricoh GX100 DNG No 2.3 2003 +Ricoh GX200 DNG No 2.3 2003 +Ricoh GXR with GR LENS A12 28mm F2.5 DNG No 6.3 2010 +Ricoh GXR with GR LENS A12 50mm F2.5 MACRO DNG No 5.6 2008 +Ricoh GXR with GR LENS A16 24-85 F3.5-5.5 DNG No 6.7 2010 +Ricoh GXR with GR LENS S10 24-72mm F2.5-4.4 VC DNG No 5.6 2008 +Ricoh GXR with GR LENS Mount A12 DNG No 6.5 2010 +Ricoh THETA Z1 DNG No 11.3 2018 +Samsung EK-GN100 SRW No 7.4 2012 +Samsung EK-GN110 SRW No 7.4 2012 +Samsung EK-GN120 SRW No 7.4 2012 +Samsung EK-KN120 SRW No 7.4 2012 +Samsung EX2F SRW No 7.2 2012 +Samsung NX300 SRW No 7.4 2012 +Samsung NX500 SRW No 9.0 2015 +Samsung NX1000 SRW No 7.1 2012 +Samsung NX1100 SRW No 7.4 2012 +Samsung NX2000 SRW No 7.4 2012 +Samsung NX3000 SRW No 8.5 2013 +Samsung NX3300 SRW No 9.5 2015 +Samsung NX U SRW No 7.4 2012 +Samsung Pro 815 DNG No 2.3 2003 +Samsung TL350 (WB2000) SRW No 6.3 2010 +Samsung TL500 (EX1) SRW No 6.2 2010 +Samsung Galaxy Note 9 DNG No 11.1 2018 +Samsung Galaxy Note 20 Rear Main Camera DNG No 13.1 2020 +Samsung Galaxy Note 20 Ultra Rear Main Camera DNG No 13.1 2020 +Samsung Galaxy S20 Front Camera DNG No 12.2.1 2019 +Samsung Galaxy S20 Rear Main Camera DNG No 12.2.1 2019 +Samsung Galaxy S20+ Front Camera DNG No 12.2.1 2019 +Samsung Galaxy S20+ Rear Main Camera DNG No 12.2.1 2019 +Samsung Galaxy S20 Ultra Front Camera DNG No 12.2.1 2019 +Samsung Galaxy S20 Ultra Rear Main Camera DNG No 12.2.1 2019 +Samsung Galaxy S21 Rear Main Camera DNG No 13.2 2020 +Samsung Galaxy S21 Rear Wide Camera DNG No 13.2 2020 +Samsung Galaxy S21+ Rear Main Camera DNG No 13.2 2020 +Samsung Galaxy S21+ Rear Wide Camera DNG No 13.2 2020 +Samsung Galaxy S21 Ultra Rear Main Camera DNG No 13.2 2020 +Samsung Galaxy S21 Ultra Rear Wide Camera DNG No 13.2 2020 +Samsung Galaxy S21 Ultra Super Telephoto Camera DNG No 14.1 2021 +Samsung Galaxy S21 Ultra Telephoto Camera DNG No 14.1 2021 +Samsung Galaxy S6 DNG No 11.1 2018 +Samsung Galaxy S6 Edge DNG No 11.1 2018 +Samsung Galaxy S7 DNG No 9.8 2015 +Samsung Galaxy S7 Edge DNG No 9.8 2015 +Samsung Galaxy S8 DNG No 10.0 2017 +Samsung Galaxy S8+ DNG No 10.0 2017 +Samsung Galaxy S9 DNG No 10.4 2017 +Samsung Galaxy S9+ DNG No 10.4 2017 +Samsung Galaxy Z Fold3 Rear Main Camera DNG No 14.1 2021 +Samsung Galaxy Z Fold3 Rear Telephoto Camera DNG No 14.1 2021 +Samsung Galaxy Z Fold3 Rear Ultrawide Camera DNG No 14.1 2021 +Samsung Galaxy S22 Front Camera DNG No 14.2 2021 +Samsung Galaxy S22 Rear Telephoto Camera DNG Samsung Expert Raw 14.2 2021 +Samsung Galaxy S22 Rear Ultrawide Camera DNG Samsung Expert Raw 14.2 2021 +Samsung Galaxy S22 Rear Wide Camera DNG Samsung Expert Raw 14.2 2021 +Samsung Galaxy S22+ Front Camera DNG No 14.2 2021 +Samsung Galaxy S22+ Rear Telephoto Camera DNG Samsung Expert Raw 14.2 2021 +Samsung Galaxy S22+ Rear Ultrawide Camera DNG Samsung Expert Raw 14.2 2021 +Samsung Galaxy S22+ Rear Wide Camera DNG Samsung Expert Raw 14.2 2021 +Samsung Galaxy S22 Ultra Front Camera DNG No 14.2 2021 +Samsung Galaxy S22 Ultra Rear Super Telephoto Camera DNG Samsung Expert Raw 14.2 2021 +Samsung Galaxy S22 Ultra Rear Telephoto Camera DNG Samsung Expert Raw 14.2 2021 +Samsung Galaxy S22 Ultra Rear Ultrawide Camera DNG Samsung Expert Raw 14.2 2021 +Samsung Galaxy S22 Ultra Rear Wide Camera DNG Samsung Expert Raw 14.2 2021 +Samsung Galaxy S23 Front Camera DNG Samsung Expert Raw 15.2 2022 +Samsung Galaxy S23 Rear Telephoto Camera DNG Samsung Expert Raw 15.2 2022 +Samsung Galaxy S23 Rear Ultrawide Camera DNG Samsung Expert Raw 15.2 2022 +Samsung Galaxy S23 Rear Wide Camera DNG Samsung Expert Raw 15.2 2022 +Samsung Galaxy S23+ Front Camera DNG Samsung Expert Raw 15.2 2022 +Samsung Galaxy S23+ Rear Telephoto Camera DNG Samsung Expert Raw 15.2 2022 +Samsung Galaxy S23+ Rear Ultrawide Camera DNG Samsung Expert Raw 15.2 2022 +Samsung Galaxy S23+ Rear Wide Camera DNG Samsung Expert Raw 15.2 2022 +Samsung Galaxy S23 Ultra Front Camera DNG Samsung Expert Raw 15.2 2022 +Samsung Galaxy S23 Ultra Rear Super Telephoto Camera DNG Samsung Expert Raw 15.2 2022 +Samsung Galaxy S23 Ultra Rear Telephoto Camera DNG Samsung Expert Raw 15.2 2022 +Samsung Galaxy S23 Ultra Rear Ultrawide Camera DNG Samsung Expert Raw 15.2 2022 +Samsung Galaxy S23 Ultra Rear Wide Camera DNG Samsung Expert Raw 15.2 2022 +Samsung Galaxy S24 Front Camera DNG No 16.1.1 2023 +Samsung Galaxy S24 Rear Telephoto Camera DNG No 16.1.1 2023 +Samsung Galaxy S24 Rear Ultrawide Camera DNG No 16.1.1 2023 +Samsung Galaxy S24 Rear Wide Camera DNG No 16.1.1 2023 +Samsung Galaxy S24+ Front Camera DNG No 16.1.1 2023 +Samsung Galaxy S24+ Rear Telephoto Camera DNG No 16.1.1 2023 +Samsung Galaxy S24+ Rear Ultrawide Camera DNG No 16.1.1 2023 +Samsung Galaxy S24+ Rear Wide Camera DNG No 16.1.1 2023 +Samsung Galaxy S24 Ultra Front Camera DNG No 16.1.1 2023 +Samsung Galaxy S24 Ultra Rear Super Telephoto Camera DNG No 16.1.1 2023 +Samsung Galaxy S24 Ultra Rear Telephoto Camera DNG No 16.1.1 2023 +Samsung Galaxy S24 Ultra Rear Ultrawide Camera DNG No 16.1.1 2023 +Samsung Galaxy S24 Ultra Rear Wide Camera DNG No 16.1.1 2023 +Samsung Galaxy S25 Front Camera DNG No 17.2 2024 +Samsung Galaxy S25 Rear Telephoto Camera DNG No 17.2 2024 +Samsung Galaxy S25 Rear Ultrawide Camera DNG No 17.2 2024 +Samsung Galaxy S25 Rear Wide Camera DNG No 17.2 2024 +Samsung Galaxy S25+ Front Camera DNG No 17.2 2024 +Samsung Galaxy S25+ Rear Telephoto Camera DNG No 17.2 2024 +Samsung Galaxy S25+ Rear Ultrawide Camera DNG No 17.2 2024 +Samsung Galaxy S25+ Rear Wide Camera DNG No 17.2 2024 +Samsung Galaxy S25 Ultra Front Camera DNG No 17.2 2024 +Samsung Galaxy S25 Ultra Rear Super Telephoto Camera DNG No 17.2 2024 +Samsung Galaxy S25 Ultra Rear Telephoto Camera DNG No 17.2 2024 +Samsung Galaxy S25 Ultra Rear Ultrawide Camera DNG No 17.2 2024 +Samsung Galaxy S25 Ultra Rear Wide Camera DNG No 17.2 2024 +Samsung Galaxy Z Fold4 Rear Telephoto Camera DNG Samsung Expert Raw 14.5 2021 +Samsung Galaxy Z Fold4 Rear Ultrawide Camera DNG Samsung Expert Raw 14.5 2021 +Samsung Galaxy Z Fold4 Rear Wide Camera DNG Samsung Expert Raw 14.5 2021 +Samsung Galaxy Z Fold5 Front Camera DNG Samsung Expert Raw 15.5 2022 +Samsung Galaxy Z Fold5 Telephoto Camera DNG Samsung Expert Raw 15.5 2022 +Samsung Galaxy Z Fold5 Ultrawide Camera DNG Samsung Expert Raw 15.5 2022 +Samsung Galaxy Z Fold5 Wide Camera DNG Samsung Expert Raw 15.5 2022 +Samsung Galaxy Z Fold6 Front Camera DNG No 16.5 2023 +Samsung Galaxy Z Fold6 Rear Telephoto Camera DNG No 16.5 2023 +Samsung Galaxy Z Fold6 Rear Ultrawide Camera DNG No 16.5 2023 +Samsung Galaxy Z Fold6 Rear Wide Camera DNG No 16.5 2023 +Samsung Galaxy Z Fold Special Edition Front Camera DNG Samsung Expert Raw 17.1 2024 +Samsung Galaxy Z Fold Special Edition Rear Telephoto Camera DNG Samsung Expert Raw 17.1 2024 +Samsung Galaxy Z Fold Special Edition Rear Ultrawide Camera DNG Samsung Expert Raw 17.1 2024 +Samsung Galaxy Z Fold Special Edition Rear Wide Camera DNG Samsung Expert Raw 17.1 2024 +Samsung Galaxy Z Flip6 Rear Ultrawide Camera DNG No 16.5 2023 +Samsung Galaxy Z Flip6 Rear Wide Camera DNG No 16.5 2023 +Samsung Galaxy Z Flip7 FE Front Camera DNG Samsung Expert Raw 17.5 2024 +Samsung Galaxy Z Flip7 FE Rear Ultrawide Camera DNG Samsung Expert Raw 17.5 2024 +Samsung Galaxy Z Flip7 FE Wide Camera DNG Samsung Expert Raw 17.5 2024 +Samsung Galaxy Z Flip7 Front Camera DNG No 18.0 2025 +Samsung Galaxy Z Flip7 Rear Ultrawide Camera DNG No 18.0 2025 +Samsung Galaxy Z Flip7 Rear Wide Camera DNG No 18.0 2025 +Samsung Galaxy Z Fold7 Front Camera DNG Samsung Expert Raw 17.5 2024 +Samsung Galaxy Z Fold7 Rear Telephoto Camera DNG Samsung Expert Raw 17.5 2024 +Samsung Galaxy Z Fold7 Rear Ultrawide Camera DNG Samsung Expert Raw 17.5 2024 +Samsung Galaxy Z Fold7 Under-Display Camera DNG Samsung Expert Raw 17.5 2024 +Samsung Galaxy Z Fold7 Rear Wide Camera DNG Samsung Expert Raw 17.5 2024 +Samsung Galaxy Z Trifold Front Camera DNG Samsung Expert Raw 18.1.1 2025 +Samsung Galaxy Z Trifold Rear Telephoto Camera DNG Samsung Expert Raw 18.1.1 2025 +Samsung Galaxy Z Trifold Rear Ultrawide Camera DNG Samsung Expert Raw 18.1.1 2025 +Samsung Galaxy Z Trifold Rear Wide Camera DNG Samsung Expert Raw 18.1.1 2025 +Sigma BF DNG Yes 17.3 2024 +Sigma DP1 X3F No 4.6, 5.1 2007 +Sigma DP1s X3F No 5.6 2008 +Sigma DP2 X3F No 5.4 2008 +Sigma fp DNG Yes 12.1 2019 +Sigma fp L DNG Yes 13.2 2020 +Sigma SD9 X3F No 2.2 2003 +Sigma SD10 X3F No 2.2 2003 +Sigma SD14 X3F No 4.1 2007 +Skydio Skydio 2 DNG No 12.4 2019 +Sony Xperia 5 IV Telephoto Camera DNG No 15.0 2022 +Sony Xperia 5 IV Ultrawide Camera DNG No 15.0 2022 +Sony Xperia 5 IV Wide Camera DNG No 15.0 2022 +Sony Xperia 1 V Front Camera DNG No 15.4 2022 +Sony Xperia 1 V Ultrawide Camera DNG No 15.4 2022 +Sony Xperia 1 V Wide Camera DNG No 15.4 2022 +Sony Xperia 1 V Zoom Camera DNG No 15.4 2022 +Sony A1 II (ILCE-1M2) (** see note) ARW Yes 17.1 2024 +Sony A7 (ILCE-7) ARW Yes 8.3 2013 +Sony A7 II (ILCE-A7M2) ARW Yes 8.7.1 2013 +Sony A7 III (ILCE-7M3) ARW Yes 10.3 2017 +Sony A7 IV (ILCE-7M4) ARW Yes 14.1 2021 +Sony A7 V (ILCE-7M5) (** see note) ARW No 18.1.1 2025 +Sony A7C (ILCE-7C) ARW Yes 13.1 2020 +Sony A7C II (ILCE-7CM2) ARW Yes 16.0 2023 +Sony A7CR (ILCE-7CR) ARW Yes 16.0 2023 +Sony A7R (ILCE-7R) ARW Yes 8.3 2013 +Sony A7R II (ILCE-7RM2) ARW Yes 9.1.1 2015 +Sony A7R III (ILCE-7RM3) ARW Yes 10.1 2017 +Sony A7R IV (ILCE-7RM4) ARW Yes 11.4.1 2018 +Sony A7S (ILCE-7S) ARW Yes 8.5 2013 +Sony A7S II (ILCE-7SM2) ARW Yes 9.2 2015 +Sony A7s III (ILCE-7SM3) ARW Yes 13.0 2020 +Sony A9 (ILCE-9) ARW Yes 9.10.1 2015 +Sony A9 II (ILCE-9M2) ARW Yes 12.1 2019 +Sony A9 III (ILCE-9M3) ARW Yes 16.1 2023 +Sony A33 (SLT-A33) ARW No 6.2 2010 +Sony A35 (SLT-A35) ARW No 6.5 2010 +Sony A37 (SLT-A37) ARW No 7.1 2012 +Sony A55V (SLT-A55V) ARW No 6.2 2010 +Sony A57 (SLT-A57) ARW No 7.1 2012 +Sony A58 (SLT-A58) ARW No 7.4 2012 +Sony A65 (SLT-A65) ARW No 6.5 2010 +Sony A68 (ILCA-68) ARW Yes 9.4 2015 +Sony A77 (SLT-A77) ARW No 6.5 2010 +Sony A77 II (ILCA-77M2) ARW Yes 8.5 2013 +Sony A7R IIIA (ILCE-7RM3A) ARW Yes 13.3 2020 +Sony A7R IVA (ILCE-7RM4A) ARW Yes 13.3 2020 +Sony A7R V (ILCE-7RM5) ARW Yes 15.1 2022 +Sony A7R VI (ILCE-7RM6) (** see note) ARW No 18.3.2 2025 +Sony A99 (SLT-A99V) ARW Yes 7.2 2012 +Sony A99 II (ILCA-99M2) ARW Yes 9.8 2015 +Sony A100 (DSLR-A100) ARW No 3.5 2005 +Sony A200 (DSLR-A200) ARW No 4.4 2007 +Sony A230 (DSLR-A230) ARW No 5.4 2008 +Sony A290 (DSLR-A290) ARW No 6.2 2010 +Sony A300 (DSLR-A300) ARW No 4.4 2007 +Sony A330 (DSLR A330) ARW No 5.4 2008 +Sony A350 (DSLR-A350) ARW No 4.4 2007 +Sony A380 (DSLR-A380) ARW No 5.4 2008 +Sony A390 (DSLR-A390) ARW No 6.2 2010 +Sony A450 (DSLR-A450) ARW No 6.1 2010 +Sony A500 (DSLR-A500) ARW No 5.6 2008 +Sony A550 (DSLR-A550) ARW No 5.6 2008 +Sony A560 (DSLR-A560) ARW No 6.3 2010 +Sony A580 (DSLR-A580) ARW No 6.3 2010 +Sony A700 (DSLR-A700) ARW No 4.2 2007 +Sony A850 (DSLR-A850) ARW No 5.6 2008 +Sony A900 (DSLR-A900) ARW No 5.1 2008 +Sony A3000 (ILCE-3000) ARW No 8.2 2013 +Sony A3500 (ILCE-3500) ARW No 8.2 2013 +Sony A5000 (ILCE-5000) ARW Yes 8.4 2013 +Sony A5100 (ILCE-5100) ARW Yes 8.7 2013 +Sony A6000 (ILCE-6000) ARW Yes 8.4 2013 +Sony A6100 (ILCE-6100) ARW Yes 12.0 2019 +Sony A6300 (ILCE-6300) ARW Yes 9.5 2015 +Sony A6400 (ILCE-6400) ARW Yes 11.2.1 2018 +Sony A6500 (ILCE-6500) ARW Yes 9.8 2015 +Sony A6600 (ILCE-6600) ARW Yes 12.0 2019 +Sony A6700 (ILCE-6700) ARW Yes 15.5 2022 +Sony DSC-F828 SRF No 2.1 2003 +Sony DSC-HX95 ARW Yes 11.1 2018 +Sony DSC-HX99 ARW Yes 11.1 2018 +Sony DSC-V3 SRF No 3.0 2005 +Sony DSC-RX100M7A ARW Yes 17.3.1 2024 +Sony FX3 (ILME-FX3) ARW Yes 13.3 2020 +Sony FX30 (ILME-FX30) ARW Yes 15.1 2022 +Sony NEX-3 ARW Yes 6.2 2010 +Sony NEX-3N ARW Yes 7.4 2012 +Sony NEX-5 ARW Yes 6.2 2010 +Sony NEX-5N ARW Yes 6.5 2010 +Sony NEX-5R ARW Yes 7.2 2012 +Sony NEX-5T ARW Yes 8.2 2013 +Sony NEX-6 ARW Yes 7.2 2012 +Sony NEX-7 ARW Yes 6.6 2010 +Sony NEX-C3 ARW Yes 6.5 2010 +Sony NEX-F3 ARW Yes 7.1 2012 +Sony NEX-VG20 ARW Yes 7.1 2012 +Sony NEX-VG30 ARW Yes 7.3 2012 +Sony NEX-VG900 ARW Yes 7.3 2012 +Sony RX0 (DSC-RX0) ARW Yes 10.0 2017 +Sony RX0 II (DSC-RX0M2) ARW Yes 11.3 2018 +Sony RX1 (DSC-RX1) ARW Yes 7.3 2012 +Sony RX1R (DSC-RX1R) ARW Yes 8.1 2013 +Sony RX1R II (DSC-RX1RM2) ARW Yes 9.3 2015 +Sony RX10 (DSC-RX10) ARW Yes 8.3 2013 +Sony RX10 II (DSC-RX10M2) ARW Yes 9.1.1 2015 +Sony RX10 III (DSC-RX10M3) ARW Yes 9.5.1 2015 +Sony RX10 IV (DSC-RX10M4) ARW Yes 10.1 2017 +Sony RX10 V (DSC-RX10M5) (** see note) ARW No 18.4.1 2025 +Sony RX100 (DSC-RX100) ARW Yes 7.2 2012 +Sony RX100 II (DSC-RX100M2) ARW Yes 8.2 2013 +Sony RX100 III (DSC-RX100M3) ARW Yes 8.5 2013 +Sony RX100 IV (DSC-RX100M4) ARW Yes 9.1.1 2015 +Sony RX100 V (DSC-RX100M5) ARW Yes 9.8 2015 +Sony RX100 VA (DSC-RX100M5A) ARW Yes 10.5 2017 +Sony RX100 VI (DSC-RX100M6) ARW Yes 10.5 2017 +Sony RX100 VII (DSC-RX100M7) ARW Yes 11.4.1 2018 +Sony QX1 (ILCE-QX1) ARW Yes 8.7 2013 +Sony ZV-1 (DCZV1/B) ARW Yes 12.4 2019 +Sony Alpha 1 (ILCE-1)* ARW Yes 13.2 2020 +Sony Xperia 1 Wide-angle Camera DNG No 13.3 2020 +Sony Xperia 1 II Telephoto Camera DNG No 13.3 2020 +Sony Xperia 1 II Ultra wide-angle Camera DNG No 13.3 2020 +Sony Xperia 1 II Wide-angle Camera DNG No 13.3 2020 +Sony Xperia 1 IV Wide Camera DNG No 14.5 2021 +Sony Xperia 1 IV Zoom Camera DNG No 14.5 2021 +Sony Xperia 1 IV Ultrawide Camera DNG No 14.5 2021 +Sony Xperia 1 VI Front Camera DNG No 16.4 2023 +Sony Xperia 1 VI Ultrawide Camera DNG No 16.4 2023 +Sony Xperia 1 VI Wide Camera DNG No 16.4 2023 +Sony Xperia 1 VI Zoom Camera DNG No 16.4 2023 +Sony Xperia 1 VII Front Camera DNG No 17.4 2024 +Sony Xperia 1 VII Ultrawide Camera DNG No 17.4 2024 +Sony Xperia 1 VII Wide Camera DNG No 17.4 2024 +Sony Xperia 1 VII Zoom Camera DNG No 17.4 2024 +Sony Xperia 1 III Telephoto Camera DNG No 14.0 2021 +Sony Xperia 1 III Ultra wide-angle Camera DNG No 14.0 2021 +Sony Xperia 1 III Wide-angle Camera DNG No 14.0 2021 +Sony Xperia 5 III Super Telephoto Camera DNG No 14.0 2021 +Sony Xperia 5 III Telephoto Camera DNG No 14.0 2021 +Sony Xperia 5 III Ultra wide-angle Camera DNG No 14.0 2021 +Sony Xperia 5 III Wide-angle Camera DNG No 14.0 2021 +Sony Xperia 5 V Front Camera DNG No 16.4 2023 +Sony Xperia 5 V Ultrawide Camera DNG No 16.4 2023 +Sony Xperia 5 V Wide Camera DNG No 16.4 2023 +Sony Xperia Pro-I Rear Wide Camera DNG No 14.2 2021 +Sony ZV-E10 ARW Yes 14.0 2021 +Sony ZV-E1 ARW Yes 15.4 2022 +Sony ZV-E10M2 ARW +Sony ZV-1M2 ARW Yes 15.4 2022 +Sony ZV-1A ARW No 17.4 2024 +Sony ILX-LR1 ARW Yes 16.2.1 2023 +Sony ILCE - 1M2 (Final Support) ARW Yes 17.2 2024 +Sony ILCE - 6100A ARW Yes 17.2 2024 +Sony ILCE - 6400A ARW Yes 17.2 2024 +Sony FX2 (ILMEFX2) (** see note) ARW Yes 17.4.1 2024 +Sony FX3A (ILMEFX3A) ARW Yes 17.4.1 2024 +Sony RX1R III (DSC-RX1RM3) ARW Yes 17.4.2 2024 +Xiaomi Mi 11 Lite Front Camera DNG No 14.0 2021 +Xiaomi Mi 11 Lite Rear Ultrawide Camera DNG No 14.0 2021 +Xiaomi Mi 11 Lite Rear Wide Camera DNG No 14.0 2021 +Xiaomi Xiaomi 12S DNG No 14.4 2021 +Xiaomi Xiaomi 12S Pro DNG No 14.4 2021 +Xiaomi Xiaomi 12S Ultra DNG No 14.4 2021 +Xiaomi Xiaomi 13 Rear Telephoto Camera DNG No 15.1 2022 +Xiaomi Xiaomi 13 Rear Ultrawide Camera DNG No 15.1 2022 +Xiaomi Xiaomi 13 Rear Wide Camera DNG No 15.1 2022 +Xiaomi Xiaomi 13 Pro Rear Telephoto Camera DNG No 15.1 2022 +Xiaomi Xiaomi 13 Pro Rear Ultrawide Camera DNG No 15.1 2022 +Xiaomi Xiaomi 13 Pro Rear Wide Camera DNG No 15.1 2022 +Xiaomi Xiaomi 13 Ultra Rear Super Telephoto Camera DNG No 15.3 2022 +Xiaomi Xiaomi 13 Ultra Rear Telephoto Camera DNG No 15.3 2022 +Xiaomi Xiaomi 13 Ultra Rear Ultrawide Camera DNG No 15.3 2022 +Xiaomi Xiaomi 13 Ultra Rear Wide Camera DNG No 15.3 2022 +Xiaomi Xiaomi 13T Front Camera DNG No 15.5 2022 +Xiaomi Xiaomi 13T Rear Telephoto Camera DNG No 15.5 2022 +Xiaomi Xiaomi 13T Rear Ultrawide Camera DNG No 15.5 2022 +Xiaomi Xiaomi 13T Rear Wide Camera DNG No 15.5 2022 +Xiaomi Xiaomi 13T Pro Front Camera DNG No 15.5 2022 +Xiaomi Xiaomi 13T Pro Rear Telephoto Camera DNG No 15.5 2022 +Xiaomi Xiaomi 13T Pro Rear Ultrawide Camera DNG No 15.5 2022 +Xiaomi Xiaomi 13T Pro Rear Wide Camera DNG No 15.5 2022 +Xiaomi Xiaomi 14 Rear Telephoto Camera DNG No 16.0 2023 +Xiaomi Xiaomi 14 Rear Ultrawide Camera DNG No 16.0 2023 +Xiaomi Xiaomi 14 Rear Wide Camera DNG No 16.0 2023 +Xiaomi Xiaomi 14 Pro Rear Telephoto Camera DNG No 16.0 2023 +Xiaomi Xiaomi 14 Pro Rear Ultrawide Camera DNG No 16.0 2023 +Xiaomi Xiaomi 14 Pro Rear Wide Camera DNG No 16.0 2023 +Xiaomi Xiaomi 14 Ultra Rear Super Telephoto Camera DNG No 16.2 2023 +Xiaomi Xiaomi 14 Ultra Rear Telephoto Camera DNG No 16.2 2023 +Xiaomi Xiaomi 14 Ultra Rear Ultrawide Camera DNG No 16.2 2023 +Xiaomi Xiaomi 14 Ultra Rear Wide Camera DNG No 16.2 2023 +Xiaomi Xiaomi 14T Front Camera DNG No 16.5 2023 +Xiaomi Xiaomi 14T Telephoto Camera DNG No 16.5 2023 +Xiaomi Xiaomi 14T Ultrawide Camera DNG No 16.5 2023 +Xiaomi Xiaomi 14T Wide Camera DNG No 16.5 2023 +Xiaomi Xiaomi 14T Pro Front Camera DNG No 16.5 2023 +Xiaomi Xiaomi 14T Pro Telephoto Camera DNG No 16.5 2023 +Xiaomi Xiaomi 14T Pro Ultrawide Camera DNG No 16.5 2023 +Xiaomi Xiaomi 14T Pro Wide Camera DNG No 16.5 2023 +Xiaomi Xiaomi MIX Fold 3 Rear Telephoto Camera DNG No 15.5 2022 +Xiaomi Xiaomi MIX Fold 3 Rear Super Telephoto Camera DNG No 15.5 2022 +Xiaomi Xiaomi MIX Fold 3 Rear Ultrawide Camera DNG No 15.5 2022 +Xiaomi Xiaomi MIX Fold 3 Rear Wide Camera DNG No 15.5 2022 +Xiaomi Xiaomi Civi 4 Pro Rear Telephoto Camera DNG No 16.3 2023 +Xiaomi Xiaomi Civi 4 Pro Rear Ultrawide Camera DNG No 16.3 2023 +Xiaomi Xiaomi Civi 4 Pro Rear Wide Camera DNG No 16.3 2023 +Xiaomi Xiaomi 15 Rear Telephoto Camera DNG No 17.0 2024 +Xiaomi Xiaomi 15 Rear Ultrawide Camera DNG No 17.0 2024 +Xiaomi Xiaomi 15 Rear Wide Camera DNG No 17.0 2024 +Xiaomi Xiaomi 15 Pro Rear Telephoto Camera DNG No 17.0 2024 +Xiaomi Xiaomi 15 Pro Rear Ultrawide Camera DNG No 17.0 2024 +Xiaomi Xiaomi 15 Pro Rear Wide Camera DNG No 17.0 2024 +Xiaomi Xiaomi 17 Ultra Rear Telephoto Camera DNG No 18.1.1 2025 +Xiaomi Xiaomi 17 Ultra Rear Ultrawide Camera DNG No 18.1.1 2025 +Xiaomi Xiaomi 17 Ultra Rear Wide Camera DNG No 18.1.1 2025 +Yuneec CGO4 DNG No 9.5 2015 +Yuneec ION L1 Pro DNG No 11.3 2018 +Zeiss ZX1 DNG No 12.1 2019 diff --git a/docs/raw/coverage.py b/docs/raw/coverage.py new file mode 100644 index 000000000..c635c993a --- /dev/null +++ b/docs/raw/coverage.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Measure LightCraft's raw camera coverage against Adobe's supported-camera list. + + python3 docs/raw/coverage.py # print the summary, rewrite docs/raw/coverage.tsv + python3 docs/raw/coverage.py --check # exit 1 if coverage.tsv is stale + +Inputs (all in docs/raw/): + adobe-cameras.tsv Adobe's published list (maker, camera, extensions, ...). + evidence.tsv per-model evidence: verified decodes, known preview-only or refused bodies. + usage-weights.tsv estimated share of photographers per maker bucket and popular bodies. + +Every Adobe-listed model gets one class: + verified decoded from sensor data, and a real file from this body was checked (evidence.tsv) + unverified the decoder path for its format and coding should handle it; no sample checked + preview opens as the embedded JPEG (format or coding not decoded) + unsupported not imported, refused, or decodes wrongly + +The format rules below mirror the decoders in crates/raw/src (see docs/raw-parity.md). When a decoder +changes, update the rules and evidence, rerun, and commit coverage.tsv with the docs. +Standard library only; no third-party packages. +""" + +import csv +import os +import re +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) + +# Olympus ORF bodies that write uncompressed / packed data (crates/raw/src/vendor/orf.rs). Everything else +# writes compressed ORF, which is preview-only (the E-M5 II / PEN-F high-res mode alone is decoded). +ORF_DECODED = re.compile(r"^(E-1|E-10|E-20|EVOLT E-300|EVOLT E-330|EVOLT E-400|EVOLT E-500|E-300|E-330|E-500|Camedia C-\S+.*|Stylus XZ-2 iHS)$") +# Samsung SRW bodies with compressed raw data (crates/raw/src/vendor/srw.rs refuses 32770 / 32772 / 32773). +SRW_COMPRESSED = re.compile(r"(^|\W)(NX1|NX30|NX300|NX300M|NX500|NX2000|NX3000|NX3300|NX mini|EK-GN\d+|EK-KN\d+)(\W|$)") +# Fujifilm bodies before the X series whose RAF has no raw IFD (crates/raw/src/vendor/raf.rs). +RAF_OLD = re.compile(r"^(FinePix (E|F|S\d|S\d{3,4}|IS)|IS Pro|S5Pro|S3Pro|S2Pro)", re.I) + +# Probability that an unverified body of a format decodes correctly, used for the use-weighted share only. +CONFIDENCE = {"DNG": 0.95, "CR2": 0.95, "CR3": 0.6, "NEF": 0.9, "NRW": 0.9, "ARW": 0.9, "SR2": 0.8, "RAF": 0.95, + "RW2": 0.95, "RWL": 0.95, "RAW": 0.95, "PEF": 0.9, "ORF": 0.8, "SRW": 0.8} + + +def read_tsv(name): + with open(os.path.join(HERE, name), newline="", encoding="utf-8") as f: + lines = [line for line in f if not line.startswith("#")] + return list(csv.DictReader(lines, delimiter="\t")) + + +def exts(field): + return [e.strip().upper() for e in re.split(r"[,/+]", re.sub(r"\(.*?\)", "", field)) if e.strip()] + + +def classify(maker, camera, ext_field): + """(class, format, reason) from the format rules alone.""" + es = exts(ext_field) + for e in es: + if e == "DNG": + return "unverified", "DNG", "DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw)" + if e == "CR2": + return "unverified", "CR2", "CR2 lossless JPEG decoder" + if e == "CR3": + return "unverified", "CR3", "CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview" + if e in ("NEF", "NRW"): + return "unverified", e, "NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only" + if e == "ARW": + return "unverified", "ARW", "ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr)" + if e == "SR2": + return "unverified", "SR2", "SR2 decoder" + if e == "RAF": + if RAF_OLD.search(camera): + return "preview", "RAF", "pre-X FinePix RAF without a raw IFD" + return "unverified", "RAF", "RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans)" + if e in ("RW2", "RWL") or (e == "RAW" and maker in ("Panasonic", "Leica")): + return "unverified", e, "Panasonic decoder, every raw format" + if e == "PEF": + return "unverified", "PEF", "PEF decoder (uncompressed, Huffman, packed 12-bit)" + if e == "ORF": + if ORF_DECODED.match(camera): + return "unverified", "ORF", "uncompressed / packed ORF" + return "preview", "ORF", "compressed ORF is not decoded" + if e == "SRW": + if SRW_COMPRESSED.search(camera): + return "preview", "SRW", "compressed SRW is not decoded" + return "unverified", "SRW", "uncompressed / packed SRW" + e = es[0] if es else "" + if e in ("CRW", "3FR", "FFF", "IIQ", "MOS", "ERF", "KDC", "MRW", "X3F", "RAW", "ORI"): + return "preview", e, f"{e} is opened from its embedded JPEG only" + return "unsupported", e, f"{e or 'unknown'} is not imported" + + +def main(): + adobe = read_tsv("adobe-cameras.tsv") + evidence = {(r["maker"], r["camera"]): r for r in read_tsv("evidence.tsv")} + rows = [] + for a in adobe: + cls, fmt, reason = classify(a["maker"], a["camera"], a["extensions"]) + ev = evidence.get((a["maker"], a["camera"])) + if ev: + cls, reason = ev["status"], ev["source"] + rows.append({"maker": a["maker"], "camera": a["camera"], "format": fmt, "year_added": a["year_added"], "class": cls, "reason": reason}) + + out = os.path.join(HERE, "coverage.tsv") + header = ["maker", "camera", "format", "year_added", "class", "reason"] + text = "# Generated by docs/raw/coverage.py; do not edit.\n" + "\t".join(header) + "\n" + text += "".join("\t".join(r[k] for k in header) + "\n" for r in rows) + if "--check" in sys.argv: + with open(out, encoding="utf-8") as f: + if f.read() != text: + print("docs/raw/coverage.tsv is stale: run python3 docs/raw/coverage.py") + return 1 + else: + with open(out, "w", encoding="utf-8") as f: + f.write(text) + + classes = ["verified", "unverified", "preview", "unsupported"] + by_maker = {} + for r in rows: + by_maker.setdefault(r["maker"], {c: 0 for c in classes})[r["class"]] += 1 + print("| Maker | Adobe models | verified | unverified | preview | unsupported |") + print("|---|---:|---:|---:|---:|---:|") + for m, c in sorted(by_maker.items(), key=lambda kv: -sum(kv[1].values())): + print(f"| {m} | {sum(c.values())} | " + " | ".join(str(c[k]) for k in classes) + " |") + tot = {k: sum(c[k] for c in by_maker.values()) for k in classes} + n = sum(tot.values()) + print(f"| **All** | **{n}** | " + " | ".join(f"**{tot[k]}** ({100 * tot[k] / n:.0f}%)" for k in classes) + " |") + recent = [r for r in rows if r["year_added"] and int(r["year_added"]) >= 2017] + rc = {k: sum(1 for r in recent if r["class"] == k) for k in classes} + print(f"\nModels added since 2017 (ACR 10+): {len(recent)}: " + ", ".join(f"{k} {rc[k]}" for k in classes)) + + # Use-weighted share of photographers whose camera fully works (decodes from sensor data). + weights = read_tsv("usage-weights.tsv") + by_cam = {(r["maker"], r["camera"]): r for r in rows} + total = works = verified_share = 0.0 + print("\n| Bucket | Weight | Camera fully works | of which verified bodies |") + print("|---|---:|---:|---:|") + for b in [w for w in weights if w["kind"] == "maker"]: + makers = b["makers_or_camera"].split(",") + popular = [w["makers_or_camera"] for w in weights if w["kind"] == "body" and w["bucket"] == b["bucket"]] + members = [r for r in rows if r["maker"] in makers] + pop_rows = [r for m in makers for (mk, cam), r in by_cam.items() if mk == m and cam in popular] + missing = set(popular) - {r["camera"] for r in pop_rows} + if missing: + print(f"usage-weights.tsv: unknown cameras {sorted(missing)}", file=sys.stderr) + return 1 + rest = [r for r in members if r["camera"] not in popular] + + def score(r): + if r["class"] == "verified": + return 1.0 + if r["class"] == "unverified": + return CONFIDENCE.get(r["format"], 0.7) + return 0.0 + + def mean(rs, f): + return sum(f(r) for r in rs) / len(rs) if rs else 0.0 + + if pop_rows: + s = 0.8 * mean(pop_rows, score) + 0.2 * mean(rest, score) + v = 0.8 * mean(pop_rows, lambda r: r["class"] == "verified") + 0.2 * mean(rest, lambda r: r["class"] == "verified") + else: + s, v = mean(members, score), mean(members, lambda r: r["class"] == "verified") + w = float(b["weight"]) + total += w + works += w * s + verified_share += w * v + print(f"| {b['bucket']} | {w:g}% | {100 * s:.0f}% | {100 * v:.0f}% |") + print(f"| **Weighted** | {total:g}% | **{100 * works / total:.0f}%** | **{100 * verified_share / total:.0f}%** |") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/raw/coverage.tsv b/docs/raw/coverage.tsv new file mode 100644 index 000000000..8dc879069 --- /dev/null +++ b/docs/raw/coverage.tsv @@ -0,0 +1,1448 @@ +# Generated by docs/raw/coverage.py; do not edit. +maker camera format year_added class reason +Apple iPhone 17e Front Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 17e Rear Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 17 Front Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 17 Ultrawide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 17 Wide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 17 Pro Front Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 17 Pro Telephoto Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 17 Pro Ultrawide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 17 Pro Wide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 17 Pro Max Front Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 17 Pro Max Telephoto Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 17 Pro Max Ultrawide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 17 Pro Max Wide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone Air Front Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone Air Wide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 16e Front Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 16e Wide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 16 Front Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 16 Ultrawide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 16 Wide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 16 Plus Front Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 16 Plus Ultrawide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 16 Plus Wide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 16 Pro Front Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 16 Pro Telephoto Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 16 Pro Ultrawide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 16 Pro Wide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 16 Pro Max Front Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 16 Pro Max Telephoto Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 16 Pro Max Ultrawide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 16 Pro Max WIde Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 15 Plus Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 15 Pro Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 15 Pro Front Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 15 Pro Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 15 Pro Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 15 Pro Max Front Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 15 Pro Max Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 15 Pro Max Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 15 Pro Max Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 14 Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 14 Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 14 Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 14 Plus Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 14 Plus Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 14 Plus Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 14 Pro Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 14 Pro Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 14 Pro Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 14 Pro Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 14 Pro Max Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 14 Pro Max Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 14 Pro Max Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 14 Pro Max Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 13 Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 13 Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 13 Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 13 mini Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 13 mini Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 13 mini Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 13 Pro Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 13 Pro Rear Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 13 Pro Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 13 Pro Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 13 Pro Max Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 13 Pro Max Rear Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 13 Pro Max Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 13 Pro Max Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 12 Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 12 Mini Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 12 Ultrawide camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 12 Mini Ultrawide camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 12 Pro Front Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 12 Pro Rear Wide Camera DNG 2020 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Apple iPhone 12 Pro Rear Telephoto Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 12 Pro Rear Ultra Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 12 Pro Max Front Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 12 Pro Max Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 12 Pro Max Rear Telephoto Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 12 Pro Max Rear Ultra Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 11 DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 11 Pro DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 11 Pro Max DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPad Pro 9.7" (Wifi and Cellular) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPad Pro 11-inch (2nd generation) DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPad Pro 11-inch (3rd generation) DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPad Pro 11-inch (4th generation) Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPad Pro 11-inch (2018 model) DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPad Pro 11-inch M4 (2024 version) Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPad Air 11-inch M2 (2024 version) Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPad Pro 12.9-inch (4th generation) DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPad Pro 12.9-inch (5th generation) DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPad Pro 12.9-inch (6th generation) Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPad Pro 12.9-inch (2018 model) DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPad Pro 13-inch M4 (2024 version) Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPad mini (6th Generation) Rear Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 6s DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 6s Plus DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 7 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 7 Plus DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 8 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 8 Plus DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone SE (2020 version) DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone SE DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone X DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone XR DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone XS DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone XS Max DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPad Air (5th generation) Rear Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone SE (3rd generation) Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone SE (3rd generation) Rear Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPad (10th Generation) Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPad mini (A17 Pro) Rear Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Canon EOS-1D TIF 2003 unsupported TIF is not imported +Canon EOS-1D C CR2 2012 unverified CR2 lossless JPEG decoder +Canon EOS-1D Mark II CR2 2003 unverified CR2 lossless JPEG decoder +Canon EOS-1D Mark II N CR2 2005 unverified CR2 lossless JPEG decoder +Canon EOS-1D Mark III CR2 2007 unverified CR2 lossless JPEG decoder +Canon EOS-1D Mark IV CR2 2008 unverified CR2 lossless JPEG decoder +Canon EOS-1Ds TIF 2003 unsupported TIF is not imported +Canon EOS-1Ds Mark II CR2 2005 unverified CR2 lossless JPEG decoder +Canon EOS-1Ds Mark III CR2 2007 unverified CR2 lossless JPEG decoder +Canon EOS-1D X CR2 2012 unverified CR2 lossless JPEG decoder +Canon EOS-1D X Mark II CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS-1D X Mark III CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS 5D CR2 2005 unverified CR2 lossless JPEG decoder +Canon EOS 5D Mark II CR2 2008 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Canon EOS-5D Mark III CR2 2012 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Canon EOS-5D Mark IV (** see note) CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS 5DS CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS 5DS R CR2 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 6D CR2 2012 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 6D Mark II CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS 7D CR2 2008 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 7D Mark II CR2 2013 unverified CR2 lossless JPEG decoder +Canon EOS 10D CRW 2003 preview CRW is opened from its embedded JPEG only +Canon EOS 20D CR2 2003 unverified CR2 lossless JPEG decoder +Canon EOS 20Da CR2 2005 unverified CR2 lossless JPEG decoder +Canon EOS 30D CR2 2005 unverified CR2 lossless JPEG decoder +Canon EOS 40D CR2 2007 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 50D CR2 2008 unverified CR2 lossless JPEG decoder +Canon EOS 60D CR2 2010 unverified CR2 lossless JPEG decoder +Canon EOS 60Da CR2 2012 unverified CR2 lossless JPEG decoder +Canon EOS 70D CR2 2013 unverified CR2 lossless JPEG decoder +Canon EOS 77D (EOS 9000D) CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS 80D CR2 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 90D CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS 200D (EOS Kiss X9, EOS Rebel SL2) CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS 250D (EOS Rebel SL3, EOS 200D II, EOS Kiss X10) CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS D30 CRW 2003 preview CRW is opened from its embedded JPEG only +Canon EOS D60 CRW 2003 preview CRW is opened from its embedded JPEG only +Canon EOS 300D (EOS Digital Rebel, EOS Kiss Digital) CRW 2003 preview CRW is opened from its embedded JPEG only +Canon EOS 100D (EOS Digital Rebel SL1, EOS Kiss X7) CR2 2012 unverified CR2 lossless JPEG decoder +Canon EOS 500D (EOS Digital Rebel T1i, EOS Kiss X3 Digital) CR2 2008 unverified CR2 lossless JPEG decoder +Canon EOS 550D (EOS Digital Rebel T2i, EOS Kiss X4 Digital) CR2 2010 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 1100D (EOS Digital Rebel T3, EOS Kiss X50 Digital) CR2 2010 unverified CR2 lossless JPEG decoder +Canon EOS 600D (EOS Digital Rebel T3i, EOS Kiss X5 Digital) CR2 2010 unverified CR2 lossless JPEG decoder +Canon EOS 650D (EOS Digital Rebel T4i, EOS Kiss X4i) CR2 2012 unverified CR2 lossless JPEG decoder +Canon EOS 700D (EOS Digital Rebel T5i, EOS Kiss X7i) CR2 2012 unverified CR2 lossless JPEG decoder +Canon EOS 1200D (EOS Digital Rebel T5, EOS Kiss X70) CR2 2013 unverified CR2 lossless JPEG decoder +Canon EOS 1300D (EOS Digital Rebel T6, EOS Kiss X80) CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS 1500D (EOS Rebel T7, EOS Kiss X90, EOS 2000D) CR2 2017 unverified CR2 lossless JPEG decoder +Canon EOS 3000D (EOS Rebel T100, EOS 4000D) CR2 2017 unverified CR2 lossless JPEG decoder +Canon EOS 750D (EOS Digital Rebel T6i, EOS Kiss X8i) CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS 760D (EOS Digital Rebel T6s, EOS Kiss 8000D) CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS 800D (EOS Digital Rebel T7i, EOS Kiss X9i) CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS 850D (EOS Kiss X10i, EOS Rebel T8i) CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS 1000D (EOS Digital Rebel XS, EOS Kiss F) CR2 2008 unverified CR2 lossless JPEG decoder +Canon EOS 450D (EOS Digital Rebel XSi, EOS Kiss Digital X2) CR2 2007 unverified CR2 lossless JPEG decoder +Canon EOS 350D (EOS Digital Rebel XT, EOS Kiss Digital N) CR2 2005 unverified CR2 lossless JPEG decoder +Canon EOS 400D (EOS Digital Rebel XTi, EOS Kiss Digital X) CR2 2005 unverified CR2 lossless JPEG decoder +Canon EOS C50 CR3 2025 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS M CR2 2012 unverified CR2 lossless JPEG decoder +Canon EOS M2 CR2 2013 unverified CR2 lossless JPEG decoder +Canon EOS M200 CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS M3 CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS M5 CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS M50 Mark II CR3 2020 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS M6 CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS M6 Mark II CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS M10 CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS M50 (EOS Kiss M) CR3 2017 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) +Canon EOS M100 CR2 2017 unverified CR2 lossless JPEG decoder +Canon EOS R (** see note) CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R1 CR3 2023 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R3 CR3 2021 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R5 CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R5 C CR3 2021 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R5 Mark II CR3 2023 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R6 CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R6 V CR3 2025 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R6 Mark II CR3 2022 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R6 Mark III CR3 2025 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R7 CR3 2021 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R8 Mark II CR3 2025 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R10 CR3 2021 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS Ra CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS RP CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R8 CR3 2022 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) +Canon EOS R50 CR3 2022 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R50 V CR3 2024 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R100 CR3 2022 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) +Canon PowerShot 600 CRW 2003 preview CRW is opened from its embedded JPEG only +Canon PowerShot A5 CRW 2003 preview CRW is opened from its embedded JPEG only +Canon PowerShot A50 CRW 2003 preview CRW is opened from its embedded JPEG only +Canon PowerShot G1 CRW 2003 preview CRW is opened from its embedded JPEG only +Canon PowerShot G1 X CR2 2012 unverified CR2 lossless JPEG decoder +Canon PowerShot G1 X Mark II CR2 2013 unverified CR2 lossless JPEG decoder +Canon PowerShot G1 X Mark III CR2 2017 unverified CR2 lossless JPEG decoder +Canon PowerShot G2 CRW 2003 preview CRW is opened from its embedded JPEG only +Canon PowerShot G3 CRW 2003 preview CRW is opened from its embedded JPEG only +Canon PowerShot G3 X CR2 2015 unverified CR2 lossless JPEG decoder +Canon PowerShot G5 CRW 2003 preview CRW is opened from its embedded JPEG only +Canon PowerShot G5 X CR2 2015 unverified CR2 lossless JPEG decoder +Canon PowerShot G5 X Mark II CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon PowerShot G6 CRW 2003 preview CRW is opened from its embedded JPEG only +Canon PowerShot G7 X CR2 2013 unverified CR2 lossless JPEG decoder +Canon PowerShot G7 X Mark II CR2 2015 unverified CR2 lossless JPEG decoder +Canon PowerShot G7 X Mark III CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon PowerShot G9 CR2 2007 unverified CR2 lossless JPEG decoder +Canon PowerShot G9X CR2 2015 unverified CR2 lossless JPEG decoder +Canon PowerShot G9 X Mark II CR2 2015 unverified CR2 lossless JPEG decoder +Canon PowerShot G10 CR2 2008 unverified CR2 lossless JPEG decoder +Canon PowerShot G11 CR2 2008 unverified CR2 lossless JPEG decoder +Canon PowerShot G12 CR2 2010 unverified CR2 lossless JPEG decoder +Canon PowerShot G15 CR2 2012 unverified CR2 lossless JPEG decoder +Canon PowerShot G16 CR2 2013 unverified CR2 lossless JPEG decoder +Canon PowerShot Pro 1 CRW 2003 preview CRW is opened from its embedded JPEG only +Canon PowerShot Pro70 CRW 2003 preview CRW is opened from its embedded JPEG only +Canon PowerShot Pro90 IS CRW 2003 preview CRW is opened from its embedded JPEG only +Canon PowerShot S30 CRW 2003 preview CRW is opened from its embedded JPEG only +Canon PowerShot S40 CRW 2003 preview CRW is opened from its embedded JPEG only +Canon PowerShot S45 CRW 2003 preview CRW is opened from its embedded JPEG only +Canon PowerShot S50 CRW 2003 preview CRW is opened from its embedded JPEG only +Canon PowerShot S60 CRW 2003 preview CRW is opened from its embedded JPEG only +Canon PowerShot S90 CR2 2008 unverified CR2 lossless JPEG decoder +Canon PowerShot S95 CR2 2010 unverified CR2 lossless JPEG decoder +Canon PowerShot S70 CRW 2003 preview CRW is opened from its embedded JPEG only +Canon PowerShot S100 CR2 2010 unverified CR2 lossless JPEG decoder +Canon PowerShot S100V CR2 2010 unverified CR2 lossless JPEG decoder +Canon PowerShot S110 CR2 2012 unverified CR2 lossless JPEG decoder +Canon PowerShot S120 CR2 2013 unverified CR2 lossless JPEG decoder +Canon PowerShot SX1 IS CR2 2008 unverified CR2 lossless JPEG decoder +Canon PowerShot SX50 CR2 2012 unverified CR2 lossless JPEG decoder +Canon PowerShot SX60 HS CR2 2013 unverified CR2 lossless JPEG decoder +Canon PowerShot SX70 HS CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon PowerShot V1 CR3 2024 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Casio EXILIM EX-10 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-100 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-100F DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-100PRO DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-FC300S DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-FH100 DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-ZR700 DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-ZR710 DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-ZR750 DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-ZR800 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-ZR850 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-ZR1000 DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-ZR1100 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-ZR1200 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-ZR1300 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-ZR1500 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-ZR3000 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-ZR3100 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-ZR3200 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-ZR3500 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-ZR3600 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-ZR3700 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-ZR4000 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-ZR4100 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-ZR5000 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Casio EXILIM EX-ZR5100 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Contax N Digital RAW 2003 preview RAW is opened from its embedded JPEG only +DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI AIR 2S DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mini 2 DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Air 3 Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Air 3 Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mini 3 Pro DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mini 4 Pro DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 3 Classic (Hasselblad L2D-20c) DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 3 Cine Super Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 3 Cine (Hasselblad L2D-20c) DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 3 Pro Super Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 3 Pro Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 3 Pro (Hasselblad L2D-20c) DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 3 (Hasselblad L2D-20c) DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Osmo Pocket 3 D DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Air 3S Telephoto Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Air 3S Wide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Osmo Action 5 Pro DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 4 Pro (DJI FC9284) DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 4 Pro (DJI FC9287) DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 4 Pro (Hasselblad L3D-100c) DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DxO DxO ONE DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Epson R-D1 ERF 2003 preview ERF is opened from its embedded JPEG only +Epson R-D1s ERF 2005 preview ERF is opened from its embedded JPEG only +Epson R-D1x ERF 2008 preview ERF is opened from its embedded JPEG only +Fujifilm GFX 50R RAF 2017 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm GFX 50S RAF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm GFX 50S II RAF 2020 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm GFX 100 RAF 2018 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm GFX 100 II RAF 2022 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm GFX 100 II IR RAF 2024 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm GFX 100 IR RAF 2020 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm GFX 100S RAF 2020 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm GFX 100S II RAF 2023 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm GFX 100RF (Final Support) RAF 2024 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm FinePix E900 RAF 2005 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix F505 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix F550 EXR RAF 2010 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix F600 EXR RAF 2010 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix F605 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix F700 RAF 2003 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix F770 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix F775 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix F800 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix F900 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix HS10 RAF 2010 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm FinePix HS20 EXR RAF 2010 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm FinePix HS22 EXR RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm FinePix HS30 EXR RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm FinePix HS33 EXR RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm FinePix HS35 EXR RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm FinePix HS50 EXR RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm FinePix IS-1 RAF 2007 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix IS Pro RAF 2008 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix S1 RAF 2013 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix S100fs RAF 2007 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix S2 Pro RAF 2003 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix S20 Pro RAF 2003 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix S200 EXR RAF 2010 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix S205 EXR RAF 2013 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix S3 Pro RAF 2005 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix S5 Pro RAF 2007 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix S5000 Z RAF 2003 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix S5200 (FinePix S5600) RAF 2005 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix S6000fd (FinePix S6500fd) RAF 2005 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix S7000 Z RAF 2003 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix S9000 (FinePix S9500) RAF 2005 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix S9100 (FinePix S9600) RAF 2005 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix SL1000 RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm FinePix X10 RAF 2010 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm FinePix X100 RAF 2010 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm FinePix X-M1 RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm FinePix X-S1 RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X100F RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X100S RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X100T RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X100V** RAF 2019 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X100VI RAF 2023 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X20 RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X30 RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X70 RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-A1 RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-A2 RAF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-A3 RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-A5 RAF 2017 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-A7 RAF 2018 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-A10 RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-A20 RAF 2017 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-E1 RAF 2012 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-E2 RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-E2S RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-E3 RAF 2017 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-E4 RAF 2020 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-E5 RAF 2024 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-H1 RAF 2017 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-H2 IR RAF 2024 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-H2S RAF 2021 verified docs/raf-compression.md (supplied files, exact sensor arrays) +Fujifilm X-M5 RAF 2024 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-Pro1 RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-Pro2 RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-Pro3** RAF 2019 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-S10 RAF 2020 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-S20 RAF 2022 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-T1 RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-T1 IR RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-T2 RAF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-T3 RAF 2018 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-T4** RAF 2019 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-T5 RAF 2022 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-T10 RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-T20 RAF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-T30 RAF 2018 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-T30 II RAF 2021 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-T30 III RAF 2022 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-T50 RAF 2023 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-T100 RAF 2017 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-T200 RAF 2019 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm XF1 RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm XF10 RAF 2017 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm XQ1 RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm XQ2 RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-H2 RAF 2021 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Google Pixel DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel XL DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 2 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 2 XL DNG 2017 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Google Pixel 3 DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 3 Front Standard Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 3 Front Wide Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 3 XL DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 3 XL Front Standard Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 3 XL Front Wide Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 4 DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 4 XL DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 5 Front Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 5 Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 5 Rear Ultra Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 6 Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 6 Rear Main Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 6 Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 6 Pro Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 6 Pro Rear Main Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 6 Pro Rear Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 6 Pro Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 7 Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 7 Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 7 Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 7 Pro Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 7 Pro Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 7 Pro Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 7 Pro Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 4a Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 4a Rear Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 5a Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 5a Rear Main Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 5a Rear Ultra Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 6a Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 6a Rear Main Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 6a Rear Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 7a Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 7a Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 7a Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 8 Front Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 8 Ultrawide Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 8 Wide Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 8 Pro Front Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 8 Pro Telephoto Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 8 Pro Ultrawide Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 8 Pro Wide Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 8a Front Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 8a Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 8a Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 9 Front Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 9 Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 9 Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 9 Pro Front Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 9 Pro Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 9 Pro Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 9 Pro Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 9 Pro XL Front Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel Pixel 9 Pro XL Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel Pixel 9 Pro XL Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 9 Pro XL Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 9 Pro Fold Front Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 9 Pro Fold Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 9 Pro Fold Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 9 Pro Fold Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 9a Front Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 9a Ultrawide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 9a Wide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 10 Pro Front Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 10 Pro Telephoto Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 10 Pro Ultrawide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 10 Pro Wide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 10 Pro XL Front Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 10 Pro XL Telephoto Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 10 Pro XL Ultrawide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 10 Pro XL Wide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 10 Front Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 10 Telephoto Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 10 Ultrawide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 10 Wide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 10 Pro Fold Front Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 10 Pro Fold Telephoto Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 10 Pro Fold Ultrawide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 10 Pro Fold Wide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel Fold Inner Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel Fold Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel Fold Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel Fold Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +GoPro FUSION GPR 2017 unsupported GPR is not imported +GoPro HERO5 Black GPR 2015 unsupported GPR is not imported +GoPro HERO6 Black GPR 2017 unsupported GPR is not imported +GoPro HERO7 Black GPR 2018 unsupported GPR is not imported +GoPro HERO10 Black GPR 2021 unsupported GPR is not imported +GoPro HERO12 Black GPR 2024 unsupported GPR is not imported +GoPro HERO13 Black GPR 2024 unsupported GPR is not imported +GoPro MISSION 1 GPR 2025 unsupported GPR is not imported +GoPro MISSION 1 PRO GPR 2025 unsupported GPR is not imported +Hasselblad 503CWD 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad A6D-100c 3FR 2017 preview 3FR is opened from its embedded JPEG only +Hasselblad CF-22 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad CF-22MS 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad CF-39 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad CF-39MS 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad CFH-22 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad CFH-39 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad CFV 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad CFV-50c 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad H2D DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Hasselblad H2D-22 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad H2D-39 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad H3D-22 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad H3D-39 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad H3DII-22 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad H3DII-31 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad H3DII-39 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad H3DII-39MS 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad H3DII-50 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad H4D-40 3FR 2010 preview 3FR is opened from its embedded JPEG only +Hasselblad H4D-60 3FR 2010 preview 3FR is opened from its embedded JPEG only +Hasselblad H5D-40 3FR 2012 preview 3FR is opened from its embedded JPEG only +Hasselblad H5D-50 3FR 2012 preview 3FR is opened from its embedded JPEG only +Hasselblad H5D-50c 3FR 2013 preview 3FR is opened from its embedded JPEG only +Hasselblad H5D-60 3FR 2013 preview 3FR is opened from its embedded JPEG only +Hasselblad H6D-100c 3FR 2015 preview 3FR is opened from its embedded JPEG only +Hasselblad HV ARW 2013 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Hasselblad Lunar 3FR 2012 preview 3FR is opened from its embedded JPEG only +Hasselblad Stellar II 3FR 2015 preview 3FR is opened from its embedded JPEG only +Hasselblad X1D 3FR 2015 preview 3FR is opened from its embedded JPEG only +Hasselblad X2D II 100C (**) 3FR 2024 preview 3FR is opened from its embedded JPEG only +Hasselblad X1D II 50C 3FR 2018 preview 3FR is opened from its embedded JPEG only +Hasselblad X2D 100C 3FR 2021 preview 3FR is opened from its embedded JPEG only +Huawei Honor 20 Pro Rear Main Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Huawei Honor 20 Pro Rear Telephoto Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Huawei Honor 20 Pro Rear Wide Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Huawei Mate 10 Pro DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Huawei P30 Rear Main Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Huawei P30 Rear Telephoto Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Huawei P30 Rear Wide Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Huawei P30 Pro Rear Main Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Huawei P30 Pro Rear Telephoto Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Huawei P30 Pro Rear Wide Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Huawei P9 Lite DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Insta360 Ace Pro 2 DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Insta360 GO Ultra DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Kodak DCS 14n DCR 2003 unsupported DCR is not imported +Kodak DCS 720x DCR 2003 unsupported DCR is not imported +Kodak DCS 760 DCR 2003 unsupported DCR is not imported +Kodak DCS Pro 14nx DCR 2003 unsupported DCR is not imported +Kodak DCS Pro SLR/n DCR 2003 unsupported DCR is not imported +Kodak EasyShare P712 DCR 2005 unsupported DCR is not imported +Kodak EasyShare P850 KDC 2005 preview KDC is opened from its embedded JPEG only +Kodak EasyShare P880 KDC 2005 preview KDC is opened from its embedded JPEG only +Kodak EasyShare Z1015 IS KDC 2008 preview KDC is opened from its embedded JPEG only +Kodak EasyShare Z980 KDC 2008 preview KDC is opened from its embedded JPEG only +Kodak EasyShare Z981 RAW 2010 preview RAW is opened from its embedded JPEG only +Kodak EasyShare Z990 RAW 2010 preview RAW is opened from its embedded JPEG only +Konica Minolta Alpha-5 Digital (China) MRW 2005 preview MRW is opened from its embedded JPEG only +Konica Minolta Alpha Sweet Digital (Japan) MRW 2005 preview MRW is opened from its embedded JPEG only +Konica Minolta DiMAGE 5 MRW 2003 preview MRW is opened from its embedded JPEG only +Konica Minolta DiMAGE 7 MRW 2003 preview MRW is opened from its embedded JPEG only +Konica Minolta DiMAGE 7i MRW 2003 preview MRW is opened from its embedded JPEG only +Konica Minolta DiMAGE 7Hi MRW 2003 preview MRW is opened from its embedded JPEG only +Konica Minolta DiMAGE A1 MRW 2003 preview MRW is opened from its embedded JPEG only +Konica Minolta DiMAGE A2 MRW 2003 preview MRW is opened from its embedded JPEG only +Konica Minolta DiMAGE A200 MRW 2003 preview MRW is opened from its embedded JPEG only +Konica Minolta Maxxum 5D Dynax 5D MRW 2005 preview MRW is opened from its embedded JPEG only +Konica Minolta Maxxum 7D Dynax 7D MRW 2003 preview MRW is opened from its embedded JPEG only +Leaf AFi II 6 MOS 2008 preview MOS is opened from its embedded JPEG only +Leaf AFi II 7 MOS 2008 preview MOS is opened from its embedded JPEG only +Leaf Aptus 17 MOS 2007 preview MOS is opened from its embedded JPEG only +Leaf Aptus 22 MOS 2005 preview MOS is opened from its embedded JPEG only +Leaf Aptus 54s MOS 2007 preview MOS is opened from its embedded JPEG only +Leaf Aptus 65 MOS 2005 preview MOS is opened from its embedded JPEG only +Leaf Aptus 75 MOS 2005 preview MOS is opened from its embedded JPEG only +Leaf Aptus 75s MOS 2007 preview MOS is opened from its embedded JPEG only +Leaf Aptus-II 5 MOS 2008 preview MOS is opened from its embedded JPEG only +Leaf Aptus-II 6 MOS 2008 preview MOS is opened from its embedded JPEG only +Leaf Aptus-II 7 MOS 2008 preview MOS is opened from its embedded JPEG only +Leaf Aptus-II 8 MOS 2010 preview MOS is opened from its embedded JPEG only +Leaf Aptus-II 10R MOS 2010 preview MOS is opened from its embedded JPEG only +Leaf Aptus-II 12 MOS 2010 preview MOS is opened from its embedded JPEG only +Leaf Valeo 6 MOS 2003 preview MOS is opened from its embedded JPEG only +Leaf Valeo 11 MOS 2003 preview MOS is opened from its embedded JPEG only +Leaf Valeo 17 MOS 2005 preview MOS is opened from its embedded JPEG only +Leaf Valeo 22 MOS 2003 preview MOS is opened from its embedded JPEG only +Leaf Credo 40 IIQ 2012 preview IIQ is opened from its embedded JPEG only +Leaf Credo 50 IIQ 2013 preview IIQ is opened from its embedded JPEG only +Leaf Credo 60 IIQ 2012 preview IIQ is opened from its embedded JPEG only +Leaf Credo 80 IIQ 2012 preview IIQ is opened from its embedded JPEG only +Leica C (Typ 112) RWL 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica CL DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica C-Lux DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica D-Lux 2 RAW 2005 unverified Panasonic decoder, every raw format +Leica D-Lux 3 RAW 2005 verified PR #215: raw.pixls.us bulk check of every RW2 / RWL / RAW body +Leica D-Lux 4 RWL 2008 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica D-Lux 5 RWL 2010 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica D-Lux 6 RWL 2012 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica D-Lux 7 RWL 2018 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica D-Lux (Typ 109) RWL 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica D-Lux 8 DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica D-Lux 8 "100 Years of Leica" DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica Digilux 2 RAW 2003 verified PR #215: raw.pixls.us bulk check of every RW2 / RWL / RAW body +Leica Digilux 3 RAW 2005 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica Digital-Modul-R DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica Leitz Phone 3 DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M8 DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M9 DNG 2008 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M10 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M10-D DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M10-P DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M10-R DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M11-D DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M (Typ 262) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M-D (Typ 262) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M-E DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M-EV1 DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M-P DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M Monochrom DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M Monochrom (Typ 246) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M10 Monochrom DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M11 DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M11 MONOCHROM DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M11-P DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica Q (Typ 116) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica Q2 DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica Q2 Monochrom DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica Q3 DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica Q3 43 DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica Q3 Monochrom DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica S2 DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica S (Typ 007) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica S3 DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica SL (Typ 601) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica SL2 DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica SL2-S DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica SL3 DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica SL3-P DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica SL3-S DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica T (Typ 701) DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica TL DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica TL2 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica V-LUX 1 RAW 2005 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica V-LUX 2 RAW 2010 unverified Panasonic decoder, every raw format +Leica V-LUX 3 RAW 2010 unverified Panasonic decoder, every raw format +Leica V-LUX 4 RWL 2012 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica V-LUX 5 RWL 2018 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica V-LUX (Typ 114) RWL 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica X2 RWL 2012 unverified Panasonic decoder, every raw format +Leica X (Typ 113) DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica X-U (Typ 113) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +LG G7 ThinQ DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +LG G8 ThinQ Front Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +LG G8 ThinQ Rear Main Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +LG G8 ThinQ Rear Wide Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +LG V40 ThinQ Front Standard Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +LG V40 ThinQ Rear Main Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +LG V40 ThinQ Rear Wide Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +LG V50s ThinQ Front Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +LG V50s ThinQ Rear Main Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +LG V50s ThinQ Rear Wide Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +LG V60 ThinQ Front Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +LG V60 ThinQ Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +LG Velvet Front Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +LG Velvet Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +LG Velvet Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +LG Wing Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +LG Wing Front Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +LG Wing Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Light L16 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Mamiya Mamiya DM22 MOS 2008 preview MOS is opened from its embedded JPEG only +Mamiya Mamiya DM28 MOS 2008 preview MOS is opened from its embedded JPEG only +Mamiya Mamiya DM33 MOS 2008 preview MOS is opened from its embedded JPEG only +Mamiya Mamiya DM56 MOS 2008 preview MOS is opened from its embedded JPEG only +Mamiya Mamiya M18 TIF 2008 unsupported TIF is not imported +Mamiya Mamiya M22 TIF 2008 unsupported TIF is not imported +Mamiya Mamiya M31 TIF 2008 unsupported TIF is not imported +Mamiya Mamiya DM40 MOS 2010 preview MOS is opened from its embedded JPEG only +Mamiya ZD MEF 2005 unsupported MEF is not imported +Motorola Moto G Power (2021) Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Motorola Moto G Power (2021) Rear Macro Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Motorola Moto G Power (2021) Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Nikon 1 J1 NEF 2010 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon 1 J2 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon 1 J3 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon 1 J4 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon 1 J5 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon 1 S1 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon 1 S2 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon 1 V1 NEF 2010 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon 1 V2 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon 1 V3 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX 5000 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX 5400 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX 5700 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX 8400 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX 8700 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX 8800 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX A NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX A1000 NRW 2018 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX B700 NRW 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Nikon COOLPIX P330 NRW 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX P340 NRW 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX P1000 NRW 2018 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX P1100 NRW 2024 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX P6000 NRW 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX P7000 NRW 2010 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX P7100 NRW 2010 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX P7700 NRW 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX P7800 NRW 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX P950 NEF 2019 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D1 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D1H NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D1X NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D2H NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D2Hs NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D2X NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D2Xs NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D3 NEF 2007 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D3s NEF 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D3X NEF 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D4 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D4S NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D5 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D40 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D40x NEF 2007 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D50 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D60 NEF 2007 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D6 NEF 2019 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D70 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D70S NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D80 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D90 NEF 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D100 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D200 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D300 NEF 2007 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D300s NEF 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D500 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D600 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D610 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D700 NEF 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D750 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D780 NEF 2019 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D800 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D800E NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D810 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D810A NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D850 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D3000 NEF 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D3100 NEF 2010 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D3200 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D3300 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D3400 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D3500 NEF 2018 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D5000 NEF 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D5100 NEF 2010 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Nikon D5200 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D5300 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D5500 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D5600 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D7000 NEF 2010 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Nikon D7100 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D7200 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D7500 NEF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Nikon Df NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 30 NEF 2021 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 50 NEF 2019 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 50 II NEF 2024 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 5 NEF 2019 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 5 2 NEF 2024 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 5 2C NEF 2025 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 6 NEF 2018 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 6 II NEF 2020 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 6 III NEF 2023 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 7 NEF 2018 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 7 II NEF 2020 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 8 NEF 2022 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 9 (*See note) NEF 2021 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z fc NEF 2020 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z f NEF 2022 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Nikon ZR NEF 2024 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nokia Lumia 1020 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Nokia HMD Global Nokia 9 PureView Front Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Nokia HMD Global Nokia 9 PureView Rear Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +OM Digital Solutions OM System OM-1 ORF 2021 preview compressed ORF is not decoded +OM Digital Solutions OM System OM-5 ORF 2022 preview compressed ORF is not decoded +OM Digital Solutions OM System TG - 7 ORF 2023 preview compressed ORF is not decoded +OM Digital Solutions OM System OM-1 MARK II ORF 2023 preview compressed ORF is not decoded +OM Digital Solutions OM System OM-3 ORF 2024 preview compressed ORF is not decoded +OM Digital Solutions OM-5 Mark II ORF 2024 preview compressed ORF is not decoded +OM Digital Solutions OM-3 Astro ORF 2025 preview compressed ORF is not decoded +Olympus AIR A01 ORF 2015 preview compressed ORF is not decoded +Olympus Camedia C-5050 Zoom ORF 2003 unverified uncompressed / packed ORF +Olympus Camedia C-5060 Zoom ORF 2003 unverified uncompressed / packed ORF +Olympus Camedia C-7070 Wide Zoom ORF 2005 unverified uncompressed / packed ORF +Olympus Camedia C-8080 Wide Zoom ORF 2003 unverified uncompressed / packed ORF +Olympus E-1 ORF 2003 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Olympus E-3 ORF 2007 preview compressed ORF is not decoded +Olympus E-5 ORF 2010 preview compressed ORF is not decoded +Olympus E-10 ORF 2003 unverified uncompressed / packed ORF +Olympus E-20 ORF 2003 unverified uncompressed / packed ORF +Olympus E-30 ORF 2008 preview compressed ORF is not decoded +Olympus E-420 ORF 2008 preview compressed ORF is not decoded +Olympus E-450 ORF 2008 preview compressed ORF is not decoded +Olympus E-520 ORF 2008 preview compressed ORF is not decoded +Olympus E-600 ORF 2010 preview compressed ORF is not decoded +Olympus E-620 ORF 2008 preview compressed ORF is not decoded +Olympus EVOLT E-300 ORF 2005 unverified uncompressed / packed ORF +Olympus EVOLT E-330 ORF 2005 unverified uncompressed / packed ORF +Olympus EVOLT E-400 ORF 2005 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Olympus EVOLT E-410 ORF 2007 preview compressed ORF is not decoded +Olympus EVOLT E-500 ORF 2005 unverified uncompressed / packed ORF +Olympus EVOLT E-510 ORF 2007 preview compressed ORF is not decoded +Olympus OM-D E-M1 ORF 2013 preview compressed ORF is not decoded +Olympus OM-D E-M1X ORF 2018 preview compressed ORF is not decoded +Olympus OM-D E-M1 Mark II ORF 2015 preview compressed ORF is not decoded +Olympus OM-D E-M1 Mark III ORF 2019 preview compressed ORF is not decoded +Olympus OM-D E-M5 ORF 2012 preview compressed ORF is not decoded +Olympus OM-D E-M5 Mark II ORF 2015 preview compressed ORF is not decoded +Olympus OM-D E-M5 Mark III ORF 2019 preview compressed ORF is not decoded +Olympus OM-D E-M10 ORF 2013 preview compressed ORF is not decoded +Olympus OM-D E-M10 Mark II ORF 2015 preview compressed ORF is not decoded +Olympus OM-D E-M10 Mark III ORF 2017 preview compressed ORF is not decoded +Olympus OM-D E-M10 Mark III S ORF 2019 preview compressed ORF is not decoded +Olympus OM-D E-M10 Mark IV ORF 2019 preview compressed ORF is not decoded +Olympus PEN E-P1 ORF 2008 preview compressed ORF is not decoded +Olympus PEN E-P2 ORF 2008 preview compressed ORF is not decoded +Olympus PEN E-P3 ORF 2010 preview compressed ORF is not decoded +Olympus PEN E-P5 ORF 2013 preview compressed ORF is not decoded +Olympus PEN E-P7 ORF 2020 preview compressed ORF is not decoded +Olympus PEN E-PL1 ORF 2010 preview compressed ORF is not decoded +Olympus PEN E-PL1s ORF 2010 preview compressed ORF is not decoded +Olympus PEN E-PL10 ORF 2019 preview compressed ORF is not decoded +Olympus PEN E-PL2 ORF 2010 preview compressed ORF is not decoded +Olympus PEN E-PL3 ORF 2010 preview compressed ORF is not decoded +Olympus PEN E-PL6 ORF 2013 preview compressed ORF is not decoded +Olympus PEN E-PL5 ORF 2012 preview compressed ORF is not decoded +Olympus PEN E-PL7 ORF 2013 preview compressed ORF is not decoded +Olympus PEN E-PL8 ORF 2015 preview compressed ORF is not decoded +Olympus PEN E-PL9 ORF 2017 preview compressed ORF is not decoded +Olympus PEN E-PM1 ORF 2010 preview compressed ORF is not decoded +Olympus PEN E-PM2 ORF 2012 preview compressed ORF is not decoded +Olympus PEN-F ORF 2015 preview compressed ORF is not decoded +Olympus SP-310 ORF 2005 preview compressed ORF is not decoded +Olympus SP-320 ORF 2005 preview compressed ORF is not decoded +Olympus SP-350 ORF 2005 preview compressed ORF is not decoded +Olympus SP-500 UZ ORF 2005 preview compressed ORF is not decoded +Olympus SP-510 UZ ORF 2005 preview compressed ORF is not decoded +Olympus SP-550 UZ ORF 2007 preview compressed ORF is not decoded +Olympus SP-560 UZ ORF 2007 preview compressed ORF is not decoded +Olympus SP-565 UZ ORF 2008 preview compressed ORF is not decoded +Olympus SP-570 UZ ORF 2007 preview compressed ORF is not decoded +Olympus Stylus 1 ORF 2013 preview compressed ORF is not decoded +Olympus Stylus 1s ORF 2013 preview compressed ORF is not decoded +Olympus Stylus SH-2 ORF 2015 preview compressed ORF is not decoded +Olympus Stylus SH-3 ORF 2015 preview compressed ORF is not decoded +Olympus Stylus Tough TG-4 ORF 2015 preview compressed ORF is not decoded +Olympus Stylus Tough TG-5 ORF 2015 preview compressed ORF is not decoded +Olympus Stylus Tough TG-6 ORF 2018 preview compressed ORF is not decoded +Olympus Stylus XZ-2 iHS ORF 2012 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Olympus Stylus XZ-10 ORF 2012 preview compressed ORF is not decoded +Olympus XZ-1 ORF 2010 preview compressed ORF is not decoded +OnePlus 7T Front Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +OnePlus 7T Rear Main Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +OnePlus 8 Front Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +OnePlus 8 Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +OnePlus 8 Pro Front Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +OnePlus 8 Pro Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +OnePlus 12 Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +OnePlus 12 Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +OnePlus 12 Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X7 Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X7 Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X7 Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X7 Ultra Super Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X7 Ultra Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X7 Ultra Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X7 Ultra Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X8 Ultra Super Telephoto Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X8 Ultra Telephoto Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X8 Ultra Ultrawide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X8 Ultra Wide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X8 Telephoto Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X8 Ultrawide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X8 Wide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X8s Telephoto Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X8s Ultrawide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X8s Wide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X8s+ Telephoto Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X8s+ Ultrawide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X8s+ Wide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X8 Pro Super Telephoto Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X8 Pro Telephoto Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X8 Pro Ultrawide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X8 Pro Wide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X9 Telephoto Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X9 Ultrawide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X9 Wide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X9 Pro Telephoto Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X9 Pro Ultrawide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X9 Pro Wide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X9s Pro Telephoto Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X9s Pro Ultrawide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X9s Pro Wide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X9 Ultra Super Telephoto Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X9 Ultra Telephoto Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X9 Ultra Ultrawide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Oppo Find X9 Ultra Wide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Panasonic LUMIX AG-GH4 RW2 2013 unverified Panasonic decoder, every raw format +Panasonic LUMIX DC-FZ80 (DC-FZ82, DC-FZ85) RW2 2015 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-FZ80D (DC-FZ82D, DC-FZ85D) RW2 2023 unverified Panasonic decoder, every raw format +Panasonic LUMIX DC-FZ81 (DC-FZ83) RW2 2015 unverified Panasonic decoder, every raw format +Panasonic LUMIX DC-FZ1000M2 (DC-FZ10002) RW2 2018 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-G100 (DC-G110) RW2 2019 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-G9 RW2 2017 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-G99 (DC-G90, DC-G91, DC-G95) RW2 2018 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-GF9 (DC-GX850, DC-GX800) RW2 2015 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-GF10 (DC-GF90) RW2 2017 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-GH5 RW2 2015 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-GH5M2 RW2 2020 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-GH5s RW2 2017 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-GH6 RW2 2021 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-GH7 RW2 2023 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-GX880 RW2 2018 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-LX100 II RW2 2018 verified PR #215: raw.pixls.us bulk check of every RW2 / RWL / RAW body +Panasonic LUMIX DC-S1 RW2 2018 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-S1H RW2 2018 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-S1R RW2 2018 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-S5 RW2 2020 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-S9 RW2 2023 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-ZS70 (DC-TZ90, DC-TZ91, DC-TZ92, DC-TZ93) RW2 2015 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-ZS80 (DC-TZ95, DC-TZ96, DC-TZ97) RW2 2018 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-CM1 RW2 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-CM10 RW2 2015 unverified Panasonic decoder, every raw format +Panasonic LUMIX DMC-FZ1000 RW2 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FX150 RW2 2008 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ8 RAW 2007 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ18 RAW 2007 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ28 RW2 2008 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ30 RAW 2005 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ35 RW2 2008 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ38 RW2 2008 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ40 (DMC-FZ45) RW2 2010 verified PR #215: raw.pixls.us bulk check of every RW2 / RWL / RAW body +Panasonic LUMIX DMC-FZ50 RAW 2005 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ70 RW2 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ72 RW2 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ100 RW2 2010 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ150 RW2 2010 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ200 RW2 2012 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ2500 (DMC-FZ2000, DMC-FZH1) RW2 2015 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ300 RW2 2015 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ330 RW2 2015 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-G1 RW2 2008 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-G2 RW2 2010 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-G3 RW2 2010 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-G5 RW2 2012 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-G6 RW2 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-G7 RW2 2015 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-G8 (DMC-G80, DMC-G81, DMC-G85) RW2 2015 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-G10 RW2 2010 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GF1 RW2 2008 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GF2 RW2 2010 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GF3 RW2 2010 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GF5 RW2 2012 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GF6 RW2 2012 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GF7 RW2 2015 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GF8 RW2 2015 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GH1 RW2 2008 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GH2 RW2 2010 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GH3 RW2 2012 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GH4 RW2 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GM1 RW2 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GM1S RW2 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GM5 RW2 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GX1 RW2 2010 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GX7 RW2 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GX8 RW2 2015 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GX85 (DMC-GX80, DMC-GX7MK2) RW2 2015 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-GX9 (DC-GX7MK3) RW2 2017 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-L1 RAW 2005 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-L10 RAW 2007 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-LC1 RAW 2003 unverified Panasonic decoder, every raw format +Panasonic LUMIX DMC-LF1 RW2 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-LX1 RAW 2005 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-LX2 RAW 2005 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-LX3 RW2 2008 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-LX5 RW2 2010 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-LX7 RW2 2012 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-LX9 (DMC-LX10, DMC-LX15) RW2 2015 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-LX100 RW2 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-TZ82 RW2 2015 unverified Panasonic decoder, every raw format +Panasonic LUMIX DMC-ZS40 (DMC-TZ60, DMC-TZ61) RW2 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-ZS50 (DMC-TZ70, DMC-TZ71) RW2 2015 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-ZS60 (DMC-TZ80, DMC-TZ81, DMC-TZ85) RW2 2015 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-ZS100 (DMC-ZS110, DMC-TZ100, DMC-TZ101, DMC-TZ110, DMC-TX1) RW2 2015 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX ZS200 (DC-TX2, DC-TZ200, DC-TZ202, DC-TZ220, DC-ZS220) RW2 2017 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-G95D RW2 2021 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-G99D RW2 2021 unverified Panasonic decoder, every raw format +Panasonic LUMIX DC-TX2D (DC-TZ200D, DC-TZ202D, DC-TZ220D, DC-ZS200D, DC-ZS220D) RW2 2022 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-ZS80D (DC-TZ95D, DC-TZ96D) RW2 2022 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-S5M2 RW2 2022 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-S5M2X RW2 2022 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DCG9M2 RW2 2023 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-G100D RW2 2023 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-S5D RW2 2024 unverified Panasonic decoder, every raw format +Panasonic LUMIX DC - G97 (DC-G99M2) RW2 2024 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC - TZ99 (DC - ZS99) RW2 2024 unverified Panasonic decoder, every raw format +Panasonic LUMIX DC-S1RM2 (Final Support) RW2 2024 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-S1M2ES (Final Support) RW2 2024 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-S1M2 RW2 2024 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-ZS300 (DC-TZ300, DC-TX3) RW2 2025 unverified Panasonic decoder, every raw format +Panasonic LUMIX DC-L10 RW2 2025 unverified Panasonic decoder, every raw format +Parrot Anafi DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Parrot ANAFI Ai DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax 645D DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax 645Z DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax *ist D PEF 2003 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) +Pentax *ist DL PEF 2005 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) +Pentax *ist DL2 PEF 2005 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) +Pentax *ist DS PEF 2005 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) +Pentax *ist DS2 PEF 2005 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) +Pentax K-01 DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax K-1 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax K-1 II (* see note) DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax K-3 DNG 2013 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Pentax K-3 II († see note) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax K-5 DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax K-5 II DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax K-5 IIs DNG 2012 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Pentax K-7 PEF 2008 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) +Pentax K-3 Mark III DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax K-3 Mark III Monochrome DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax K-30 DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax K-50 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax K-70 PEF 2015 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) +Pentax K-500 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax KP DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax K-r PEF 2010 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) +Pentax K-S1 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax K-S2 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax K-x PEF 2008 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) +Pentax K10D DNG 2005 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Pentax K20D DNG 2007 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax K100D PEF 2005 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) +Pentax K100D Super PEF 2007 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) +Pentax K110D PEF 2005 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) +Pentax K200D DNG 2007 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax K2000 K-m PEF 2008 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) +Pentax KF PEF 2022 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) +Pentax MX-1 DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax Q DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax Q7 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax Q10 DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax QS-1 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pixii PIXII Camera (A1571) DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Phase One H 20 TIF 2007 unsupported TIF is not imported +Phase One H 25 TIF 2007 unsupported TIF is not imported +Phase One IQ 140 IIQ 2010 preview IIQ is opened from its embedded JPEG only +Phase One IQ 160 IIQ 2010 preview IIQ is opened from its embedded JPEG only +Phase One IQ 150 IIQ 2015 preview IIQ is opened from its embedded JPEG only +Phase One IQ 180 IIQ 2010 preview IIQ is opened from its embedded JPEG only +Phase One IQ 250 IIQ 2013 preview IIQ is opened from its embedded JPEG only +Phase One IQ 260 IIQ 2013 preview IIQ is opened from its embedded JPEG only +Phase One IQ 280 IIQ 2013 preview IIQ is opened from its embedded JPEG only +Phase One IQ3 50MP IIQ 2015 preview IIQ is opened from its embedded JPEG only +Phase One IQ3 60MP IIQ 2015 preview IIQ is opened from its embedded JPEG only +Phase One IQ3 80MP IIQ 2015 preview IIQ is opened from its embedded JPEG only +Phase One IQ3 100MP IIQ 2015 preview IIQ is opened from its embedded JPEG only +Phase One IQ3 100MP Trichromatic (*see note) IIQ 2017 preview IIQ is opened from its embedded JPEG only +Phase One IQ4 150MP (**see note) IIQ 2019 preview IIQ is opened from its embedded JPEG only +Phase One IQ4 150MP Achromatic (** see note) IIQ 2020 preview IIQ is opened from its embedded JPEG only +Phase One P 20 TIF 2007 unsupported TIF is not imported +Phase One P 20+ TIF 2007 unsupported TIF is not imported +Phase One P 21 TIF 2007 unsupported TIF is not imported +Phase One P 21+ TIF 2007 unsupported TIF is not imported +Phase One P 25 TIF 2007 unsupported TIF is not imported +Phase One P 25+ TIF 2007 unsupported TIF is not imported +Phase One P 30 TIF 2007 unsupported TIF is not imported +Phase One P 30+ TIF 2007 unsupported TIF is not imported +Phase One P 40+ IIQ 2010 preview IIQ is opened from its embedded JPEG only +Phase One P 45 TIF 2007 unsupported TIF is not imported +Phase One P 45+ TIF 2007 unsupported TIF is not imported +Phase One P 65+ IIQ 2010 preview IIQ is opened from its embedded JPEG only +Ricoh GR DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GR II DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GR III DNG 2018 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Ricoh GR IIIx DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GR III HDF DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GR IIIx HDF DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GR IV DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GR IV Monochrome DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GR Digital DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GR Digital II DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GR Digital III DNG 2008 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GR Digital IV DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GX100 DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GX200 DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GXR with GR LENS A12 28mm F2.5 DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GXR with GR LENS A12 50mm F2.5 MACRO DNG 2008 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GXR with GR LENS A16 24-85 F3.5-5.5 DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GXR with GR LENS S10 24-72mm F2.5-4.4 VC DNG 2008 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GXR with GR LENS Mount A12 DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh THETA Z1 DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung EK-GN100 SRW 2012 preview compressed SRW is not decoded +Samsung EK-GN110 SRW 2012 preview compressed SRW is not decoded +Samsung EK-GN120 SRW 2012 preview compressed SRW is not decoded +Samsung EK-KN120 SRW 2012 preview compressed SRW is not decoded +Samsung EX2F SRW 2012 unverified uncompressed / packed SRW +Samsung NX300 SRW 2012 preview compressed SRW is not decoded +Samsung NX500 SRW 2015 preview compressed SRW is not decoded +Samsung NX1000 SRW 2012 unverified uncompressed / packed SRW +Samsung NX1100 SRW 2012 unverified uncompressed / packed SRW +Samsung NX2000 SRW 2012 preview compressed SRW is not decoded +Samsung NX3000 SRW 2013 preview compressed SRW is not decoded +Samsung NX3300 SRW 2015 preview compressed SRW is not decoded +Samsung NX U SRW 2012 unverified uncompressed / packed SRW +Samsung Pro 815 DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung TL350 (WB2000) SRW 2010 unverified uncompressed / packed SRW +Samsung TL500 (EX1) SRW 2010 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Samsung Galaxy Note 9 DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Note 20 Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Note 20 Ultra Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S20 Front Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S20 Rear Main Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S20+ Front Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S20+ Rear Main Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S20 Ultra Front Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S20 Ultra Rear Main Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S21 Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S21 Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S21+ Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S21+ Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S21 Ultra Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S21 Ultra Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S21 Ultra Super Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S21 Ultra Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S6 DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S6 Edge DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S7 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S7 Edge DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S8 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S8+ DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S9 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S9+ DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold3 Rear Main Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold3 Rear Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold3 Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S22 Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S22 Rear Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S22 Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S22 Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S22+ Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S22+ Rear Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S22+ Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S22+ Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S22 Ultra Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S22 Ultra Rear Super Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S22 Ultra Rear Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S22 Ultra Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S22 Ultra Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S23 Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S23 Rear Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S23 Rear Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S23 Rear Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S23+ Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S23+ Rear Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S23+ Rear Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S23+ Rear Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S23 Ultra Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S23 Ultra Rear Super Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S23 Ultra Rear Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S23 Ultra Rear Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S23 Ultra Rear Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S24 Front Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S24 Rear Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S24 Rear Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S24 Rear Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S24+ Front Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S24+ Rear Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S24+ Rear Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S24+ Rear Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S24 Ultra Front Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S24 Ultra Rear Super Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S24 Ultra Rear Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S24 Ultra Rear Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S24 Ultra Rear Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S25 Front Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S25 Rear Telephoto Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S25 Rear Ultrawide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S25 Rear Wide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S25+ Front Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S25+ Rear Telephoto Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S25+ Rear Ultrawide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S25+ Rear Wide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S25 Ultra Front Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S25 Ultra Rear Super Telephoto Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S25 Ultra Rear Telephoto Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S25 Ultra Rear Ultrawide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S25 Ultra Rear Wide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold4 Rear Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold4 Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold4 Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold5 Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold5 Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold5 Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold5 Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold6 Front Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold6 Rear Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold6 Rear Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold6 Rear Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold Special Edition Front Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold Special Edition Rear Telephoto Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold Special Edition Rear Ultrawide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold Special Edition Rear Wide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Flip6 Rear Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Flip6 Rear Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Flip7 FE Front Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Flip7 FE Rear Ultrawide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Flip7 FE Wide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Flip7 Front Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Flip7 Rear Ultrawide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Flip7 Rear Wide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold7 Front Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold7 Rear Telephoto Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold7 Rear Ultrawide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold7 Under-Display Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Fold7 Rear Wide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Trifold Front Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Trifold Rear Telephoto Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Trifold Rear Ultrawide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy Z Trifold Rear Wide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sigma BF DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sigma DP1 X3F 2007 preview X3F is opened from its embedded JPEG only +Sigma DP1s X3F 2008 preview X3F is opened from its embedded JPEG only +Sigma DP2 X3F 2008 preview X3F is opened from its embedded JPEG only +Sigma fp DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sigma fp L DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sigma SD9 X3F 2003 preview X3F is opened from its embedded JPEG only +Sigma SD10 X3F 2003 preview X3F is opened from its embedded JPEG only +Sigma SD14 X3F 2007 preview X3F is opened from its embedded JPEG only +Skydio Skydio 2 DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 5 IV Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 5 IV Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 5 IV Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 1 V Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 1 V Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 1 V Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 1 V Zoom Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony A1 II (ILCE-1M2) (** see note) ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony A7 (ILCE-7) ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7 II (ILCE-A7M2) ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies +Sony A7 III (ILCE-7M3) ARW 2017 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7 IV (ILCE-7M4) ARW 2021 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7 V (ILCE-7M5) (** see note) ARW 2025 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony A7C (ILCE-7C) ARW 2020 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7C II (ILCE-7CM2) ARW 2023 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7CR (ILCE-7CR) ARW 2023 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7R (ILCE-7R) ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7R II (ILCE-7RM2) ARW 2015 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7R III (ILCE-7RM3) ARW 2017 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7R IV (ILCE-7RM4) ARW 2018 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7S (ILCE-7S) ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7S II (ILCE-7SM2) ARW 2015 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7s III (ILCE-7SM3) ARW 2020 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A9 (ILCE-9) ARW 2015 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A9 II (ILCE-9M2) ARW 2019 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A9 III (ILCE-9M3) ARW 2023 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A33 (SLT-A33) ARW 2010 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A35 (SLT-A35) ARW 2010 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A37 (SLT-A37) ARW 2012 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A55V (SLT-A55V) ARW 2010 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies +Sony A57 (SLT-A57) ARW 2012 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A58 (SLT-A58) ARW 2012 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A65 (SLT-A65) ARW 2010 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A68 (ILCA-68) ARW 2015 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A77 (SLT-A77) ARW 2010 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A77 II (ILCA-77M2) ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7R IIIA (ILCE-7RM3A) ARW 2020 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7R IVA (ILCE-7RM4A) ARW 2020 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7R V (ILCE-7RM5) ARW 2022 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7R VI (ILCE-7RM6) (** see note) ARW 2025 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony A99 (SLT-A99V) ARW 2012 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies +Sony A99 II (ILCA-99M2) ARW 2015 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A100 (DSLR-A100) ARW 2005 unsupported issue #702: regression, refused as not a readable photo +Sony A200 (DSLR-A200) ARW 2007 preview issue #535: preview-only on the raw.pixls.us sample +Sony A230 (DSLR-A230) ARW 2008 preview issue #535: preview-only on the raw.pixls.us sample +Sony A290 (DSLR-A290) ARW 2010 preview issue #535: preview-only on the raw.pixls.us sample +Sony A300 (DSLR-A300) ARW 2007 preview issue #535: preview-only on the raw.pixls.us sample +Sony A330 (DSLR A330) ARW 2008 preview issue #535: preview-only on the raw.pixls.us sample +Sony A350 (DSLR-A350) ARW 2007 preview issue #535: preview-only on the raw.pixls.us sample +Sony A380 (DSLR-A380) ARW 2008 preview issue #535: preview-only on the raw.pixls.us sample +Sony A390 (DSLR-A390) ARW 2010 preview issue #535: preview-only on the raw.pixls.us sample +Sony A450 (DSLR-A450) ARW 2010 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A500 (DSLR-A500) ARW 2008 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A550 (DSLR-A550) ARW 2008 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A560 (DSLR-A560) ARW 2010 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A580 (DSLR-A580) ARW 2010 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A700 (DSLR-A700) ARW 2007 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A850 (DSLR-A850) ARW 2008 preview issue #535: preview-only on the raw.pixls.us sample +Sony A900 (DSLR-A900) ARW 2008 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A3000 (ILCE-3000) ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A3500 (ILCE-3500) ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A5000 (ILCE-5000) ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A5100 (ILCE-5100) ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A6000 (ILCE-6000) ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A6100 (ILCE-6100) ARW 2019 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A6300 (ILCE-6300) ARW 2015 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A6400 (ILCE-6400) ARW 2018 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A6500 (ILCE-6500) ARW 2015 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A6600 (ILCE-6600) ARW 2019 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A6700 (ILCE-6700) ARW 2022 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony DSC-F828 SRF 2003 unsupported issue #702 (SRF refused); SRF not in the import list +Sony DSC-HX95 ARW 2018 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony DSC-HX99 ARW 2018 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony DSC-V3 SRF 2005 unsupported SRF not in the import list +Sony DSC-RX100M7A ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony FX3 (ILME-FX3) ARW 2020 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony FX30 (ILME-FX30) ARW 2022 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony NEX-3 ARW 2010 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony NEX-3N ARW 2012 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony NEX-5 ARW 2010 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony NEX-5N ARW 2010 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony NEX-5R ARW 2012 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony NEX-5T ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony NEX-6 ARW 2012 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony NEX-7 ARW 2010 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony NEX-C3 ARW 2010 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony NEX-F3 ARW 2012 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony NEX-VG20 ARW 2012 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony NEX-VG30 ARW 2012 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony NEX-VG900 ARW 2012 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony RX0 (DSC-RX0) ARW 2017 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX0 II (DSC-RX0M2) ARW 2018 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX1 (DSC-RX1) ARW 2012 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX1R (DSC-RX1R) ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX1R II (DSC-RX1RM2) ARW 2015 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX10 (DSC-RX10) ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX10 II (DSC-RX10M2) ARW 2015 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX10 III (DSC-RX10M3) ARW 2015 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX10 IV (DSC-RX10M4) ARW 2017 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX10 V (DSC-RX10M5) (** see note) ARW 2025 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony RX100 (DSC-RX100) ARW 2012 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX100 II (DSC-RX100M2) ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX100 III (DSC-RX100M3) ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX100 IV (DSC-RX100M4) ARW 2015 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX100 V (DSC-RX100M5) ARW 2015 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX100 VA (DSC-RX100M5A) ARW 2017 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX100 VI (DSC-RX100M6) ARW 2017 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX100 VII (DSC-RX100M7) ARW 2018 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony QX1 (ILCE-QX1) ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony ZV-1 (DCZV1/B) ARW 2019 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony Alpha 1 (ILCE-1)* ARW 2020 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony Xperia 1 Wide-angle Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 1 II Telephoto Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 1 II Ultra wide-angle Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 1 II Wide-angle Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 1 IV Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 1 IV Zoom Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 1 IV Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 1 VI Front Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 1 VI Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 1 VI Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 1 VI Zoom Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 1 VII Front Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 1 VII Ultrawide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 1 VII Wide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 1 VII Zoom Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 1 III Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 1 III Ultra wide-angle Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 1 III Wide-angle Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 5 III Super Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 5 III Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 5 III Ultra wide-angle Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 5 III Wide-angle Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 5 V Front Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 5 V Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia 5 V Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony Xperia Pro-I Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sony ZV-E10 ARW 2021 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony ZV-E1 ARW 2022 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony ZV-E10M2 ARW unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony ZV-1M2 ARW 2022 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony ZV-1A ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony ILX-LR1 ARW 2023 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony ILCE - 1M2 (Final Support) ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony ILCE - 6100A ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony ILCE - 6400A ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony FX2 (ILMEFX2) (** see note) ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony FX3A (ILMEFX3A) ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony RX1R III (DSC-RX1RM3) ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Xiaomi Mi 11 Lite Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Mi 11 Lite Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Mi 11 Lite Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 12S DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 12S Pro DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 12S Ultra DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 13 Rear Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 13 Rear Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 13 Rear Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 13 Pro Rear Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 13 Pro Rear Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 13 Pro Rear Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 13 Ultra Rear Super Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 13 Ultra Rear Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 13 Ultra Rear Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 13 Ultra Rear Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 13T Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 13T Rear Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 13T Rear Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 13T Rear Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 13T Pro Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 13T Pro Rear Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 13T Pro Rear Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 13T Pro Rear Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 14 Rear Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 14 Rear Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 14 Rear Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 14 Pro Rear Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 14 Pro Rear Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 14 Pro Rear Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 14 Ultra Rear Super Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 14 Ultra Rear Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 14 Ultra Rear Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 14 Ultra Rear Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 14T Front Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 14T Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 14T Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 14T Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 14T Pro Front Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 14T Pro Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 14T Pro Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 14T Pro Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi MIX Fold 3 Rear Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi MIX Fold 3 Rear Super Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi MIX Fold 3 Rear Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi MIX Fold 3 Rear Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi Civi 4 Pro Rear Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi Civi 4 Pro Rear Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi Civi 4 Pro Rear Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 15 Rear Telephoto Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 15 Rear Ultrawide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 15 Rear Wide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 15 Pro Rear Telephoto Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 15 Pro Rear Ultrawide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 15 Pro Rear Wide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 17 Ultra Rear Telephoto Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 17 Ultra Rear Ultrawide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Xiaomi Xiaomi 17 Ultra Rear Wide Camera DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Yuneec CGO4 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Yuneec ION L1 Pro DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Zeiss ZX1 DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) diff --git a/docs/raw/evidence.tsv b/docs/raw/evidence.tsv new file mode 100644 index 000000000..9b7460544 --- /dev/null +++ b/docs/raw/evidence.tsv @@ -0,0 +1,249 @@ +# Per-model evidence that overrides the format rules in coverage.py. status: verified | preview | unsupported. +# "verified" means a real file from this body decoded from sensor data in a corpus test or a recorded bulk check (source column). +maker camera status source +Apple iPhone 12 Pro Rear Wide Camera verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 40D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 550D (EOS Digital Rebel T2i, EOS Kiss X4 Digital) verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 5D Mark II verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 5DS R verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 6D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 7D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 80D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Canon EOS M50 (EOS Kiss M) verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) +Canon EOS R100 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) +Canon EOS R8 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) +Canon EOS-5D Mark III verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm GFX 100 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm GFX 100RF (Final Support) verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm GFX 100S verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm GFX 50S verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-A2 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-A5 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-E1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-E5 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-H2 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-H2S verified docs/raf-compression.md (supplied files, exact sensor arrays) +Fujifilm X-M5 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-T2 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-T20 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-T4** verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-T5 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-T50 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Google Pixel 2 XL verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Leica C (Typ 112) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica D-Lux (Typ 109) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica D-Lux 3 verified PR #215: raw.pixls.us bulk check of every RW2 / RWL / RAW body +Leica D-Lux 4 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica D-Lux 5 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica D-Lux 6 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica D-Lux 7 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica Digilux 2 verified PR #215: raw.pixls.us bulk check of every RW2 / RWL / RAW body +Leica Digilux 3 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica V-LUX (Typ 114) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica V-LUX 1 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica V-LUX 4 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica V-LUX 5 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Nikon COOLPIX B700 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Nikon D5100 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Nikon D7000 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Nikon D7500 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Nikon Z f verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Olympus E-1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Olympus EVOLT E-400 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Olympus Stylus XZ-2 iHS verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Panasonic LUMIX DC - G97 (DC-G99M2) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-FZ1000M2 (DC-FZ10002) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-FZ80 (DC-FZ82, DC-FZ85) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-G100 (DC-G110) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-G100D verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-G9 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-G95D verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-G99 (DC-G90, DC-G91, DC-G95) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-GF10 (DC-GF90) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-GF9 (DC-GX850, DC-GX800) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-GH5 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-GH5M2 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-GH5s verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-GH6 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-GH7 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-GX880 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-GX9 (DC-GX7MK3) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-LX100 II verified PR #215: raw.pixls.us bulk check of every RW2 / RWL / RAW body +Panasonic LUMIX DC-S1 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-S1H verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-S1M2 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-S1M2ES (Final Support) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-S1R verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-S1RM2 (Final Support) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-S5 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-S5M2 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-S5M2X verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-S9 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-TX2D (DC-TZ200D, DC-TZ202D, DC-TZ220D, DC-ZS200D, DC-ZS220D) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-ZS70 (DC-TZ90, DC-TZ91, DC-TZ92, DC-TZ93) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-ZS80 (DC-TZ95, DC-TZ96, DC-TZ97) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DC-ZS80D (DC-TZ95D, DC-TZ96D) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DCG9M2 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-CM1 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FX150 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ100 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ1000 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ150 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ18 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ200 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ2500 (DMC-FZ2000, DMC-FZH1) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ28 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ30 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ300 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ330 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ35 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ38 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ40 (DMC-FZ45) verified PR #215: raw.pixls.us bulk check of every RW2 / RWL / RAW body +Panasonic LUMIX DMC-FZ50 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ70 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ72 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-FZ8 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-G1 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-G10 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-G2 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-G3 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-G5 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-G6 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-G7 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-G8 (DMC-G80, DMC-G81, DMC-G85) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GF1 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GF2 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GF3 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GF5 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GF6 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GF7 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GF8 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GH1 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GH2 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GH3 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GH4 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GM1 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GM1S verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GM5 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GX1 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GX7 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GX8 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-GX85 (DMC-GX80, DMC-GX7MK2) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-L1 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-L10 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-LF1 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-LX1 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-LX100 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-LX2 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-LX3 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-LX5 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-LX7 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-LX9 (DMC-LX10, DMC-LX15) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-ZS100 (DMC-ZS110, DMC-TZ100, DMC-TZ101, DMC-TZ110, DMC-TX1) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-ZS40 (DMC-TZ60, DMC-TZ61) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-ZS50 (DMC-TZ70, DMC-TZ71) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX DMC-ZS60 (DMC-TZ80, DMC-TZ81, DMC-TZ85) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Panasonic LUMIX ZS200 (DC-TX2, DC-TZ200, DC-TZ202, DC-TZ220, DC-ZS220) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Pentax K-3 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Pentax K-5 IIs verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Pentax K10D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Ricoh GR III verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Samsung TL500 (EX1) verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Sony A100 (DSLR-A100) unsupported issue #702: regression, refused as not a readable photo +Sony A200 (DSLR-A200) preview issue #535: preview-only on the raw.pixls.us sample +Sony A230 (DSLR-A230) preview issue #535: preview-only on the raw.pixls.us sample +Sony A290 (DSLR-A290) preview issue #535: preview-only on the raw.pixls.us sample +Sony A300 (DSLR-A300) preview issue #535: preview-only on the raw.pixls.us sample +Sony A3000 (ILCE-3000) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A33 (SLT-A33) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A330 (DSLR A330) preview issue #535: preview-only on the raw.pixls.us sample +Sony A35 (SLT-A35) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A350 (DSLR-A350) preview issue #535: preview-only on the raw.pixls.us sample +Sony A3500 (ILCE-3500) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A37 (SLT-A37) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A380 (DSLR-A380) preview issue #535: preview-only on the raw.pixls.us sample +Sony A390 (DSLR-A390) preview issue #535: preview-only on the raw.pixls.us sample +Sony A450 (DSLR-A450) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A500 (DSLR-A500) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A5000 (ILCE-5000) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A5100 (ILCE-5100) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A550 (DSLR-A550) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A55V (SLT-A55V) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies +Sony A560 (DSLR-A560) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A57 (SLT-A57) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A58 (SLT-A58) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A580 (DSLR-A580) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A6000 (ILCE-6000) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A6100 (ILCE-6100) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A6300 (ILCE-6300) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A6400 (ILCE-6400) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A65 (SLT-A65) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A6500 (ILCE-6500) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A6600 (ILCE-6600) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A6700 (ILCE-6700) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A68 (ILCA-68) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7 (ILCE-7) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7 II (ILCE-A7M2) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies +Sony A7 III (ILCE-7M3) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7 IV (ILCE-7M4) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A700 (DSLR-A700) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A77 (SLT-A77) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A77 II (ILCA-77M2) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7C (ILCE-7C) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7C II (ILCE-7CM2) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7CR (ILCE-7CR) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7R (ILCE-7R) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7R II (ILCE-7RM2) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7R III (ILCE-7RM3) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7R IIIA (ILCE-7RM3A) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7R IV (ILCE-7RM4) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7R IVA (ILCE-7RM4A) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7R V (ILCE-7RM5) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7S (ILCE-7S) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7S II (ILCE-7SM2) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A7s III (ILCE-7SM3) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A850 (DSLR-A850) preview issue #535: preview-only on the raw.pixls.us sample +Sony A9 (ILCE-9) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A9 II (ILCE-9M2) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A9 III (ILCE-9M3) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A900 (DSLR-A900) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony A99 (SLT-A99V) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies +Sony A99 II (ILCA-99M2) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony Alpha 1 (ILCE-1)* verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony DSC-F828 unsupported issue #702 (SRF refused); SRF not in the import list +Sony DSC-HX95 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony DSC-HX99 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony DSC-V3 unsupported SRF not in the import list +Sony FX3 (ILME-FX3) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony FX30 (ILME-FX30) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony NEX-3 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony NEX-3N verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony NEX-5 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony NEX-5N verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony NEX-5R verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony NEX-5T verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony NEX-6 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony NEX-7 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony NEX-C3 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony NEX-F3 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony QX1 (ILCE-QX1) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX0 (DSC-RX0) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX0 II (DSC-RX0M2) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX1 (DSC-RX1) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX10 (DSC-RX10) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX10 II (DSC-RX10M2) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX10 III (DSC-RX10M3) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX10 IV (DSC-RX10M4) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX100 (DSC-RX100) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX100 II (DSC-RX100M2) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX100 III (DSC-RX100M3) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX100 IV (DSC-RX100M4) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX100 V (DSC-RX100M5) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX100 VA (DSC-RX100M5A) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX100 VI (DSC-RX100M6) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX100 VII (DSC-RX100M7) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX1R (DSC-RX1R) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony RX1R II (DSC-RX1RM2) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony ZV-1 (DCZV1/B) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony ZV-E1 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony ZV-E10 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) diff --git a/docs/raw/usage-weights.tsv b/docs/raw/usage-weights.tsv new file mode 100644 index 000000000..f3f57c5e0 --- /dev/null +++ b/docs/raw/usage-weights.tsv @@ -0,0 +1,114 @@ +# Estimated share of Lightroom raw photographers by camera maker, and the popular bodies of the last ~8 years inside each. +# Sources (2026-10-10): Nikkei 2025 mirrorless production via PhotoRumors (Canon 2.3 M, Sony 1.8 M, Nikon 0.86 M, Fujifilm 0.75 M +# units, i.e. ~36 / 29 / 14 / 12 % of CIPA's 6.31 M); BCN 2025 Japanese retail via PetaPixel (Sony 29.9 %, Canon 27.4 %, Nikon 15.1 %); +# camera makers named in this repo's user issues (325 issues: Sony 33, Nikon 31, Canon 26, phones 23, Fujifilm 9, Panasonic/Leica 8, +# OM/Olympus 6, Pentax/Ricoh 6, Samsung NX 5). Canon and Nikon get extra weight for their installed base of DSLRs. +# Rows "maker" give the maker bucket's weight (percent of photographers); rows "body" give a popular body inside a bucket. +# Inside a bucket, the listed bodies share 80 % equally and every other Adobe-listed model of the bucket's makers shares 20 %. +kind bucket makers_or_camera weight +maker Canon Canon 33 +maker Sony Sony 27 +maker Nikon Nikon 16 +maker Fujifilm Fujifilm 10 +maker Panasonic Panasonic 4 +maker OM / Olympus Olympus,OM Digital Solutions 3 +maker Phones Apple,Google,Samsung,Xiaomi,Huawei,Oppo,OnePlus,LG,Motorola,Nokia 3 +maker Pentax / Ricoh Pentax,Ricoh 1.5 +maker Leica Leica 1 +maker DJI DJI 1 +maker Medium format and others Hasselblad,Phase One,Leaf,Mamiya,Sigma,Kodak,Konica Minolta,Epson,Casio,GoPro,Insta360,Zeiss,Pixii,Light,Skydio,Parrot,Yuneec,Contax,DxO 0.5 +body Canon EOS R6 Mark II 1 +body Canon EOS R5 1 +body Canon EOS R6 1 +body Canon EOS R7 1 +body Canon EOS R8 1 +body Canon EOS R10 1 +body Canon EOS R50 1 +body Canon EOS RP 1 +body Canon EOS R (** see note) 1 +body Canon EOS R5 Mark II 1 +body Canon EOS R6 Mark III 1 +body Canon EOS R3 1 +body Canon EOS R100 1 +body Canon EOS 90D 1 +body Canon EOS M50 (EOS Kiss M) 1 +body Canon EOS M50 Mark II 1 +body Canon EOS M6 Mark II 1 +body Canon EOS-5D Mark IV (** see note) 1 +body Canon EOS 6D Mark II 1 +body Canon EOS 80D 1 +body Canon EOS 250D (EOS Rebel SL3, EOS 200D II, EOS Kiss X10) 1 +body Canon EOS 1500D (EOS Rebel T7, EOS Kiss X90, EOS 2000D) 1 +body Sony A7 IV (ILCE-7M4) 1 +body Sony A7 III (ILCE-7M3) 1 +body Sony A7R V (ILCE-7RM5) 1 +body Sony A7R IV (ILCE-7RM4) 1 +body Sony A7C (ILCE-7C) 1 +body Sony A7C II (ILCE-7CM2) 1 +body Sony A7CR (ILCE-7CR) 1 +body Sony A6400 (ILCE-6400) 1 +body Sony A6700 (ILCE-6700) 1 +body Sony A6600 (ILCE-6600) 1 +body Sony A6100 (ILCE-6100) 1 +body Sony Alpha 1 (ILCE-1)* 1 +body Sony A9 III (ILCE-9M3) 1 +body Sony ZV-E10 1 +body Sony A7s III (ILCE-7SM3) 1 +body Sony RX100 VII (DSC-RX100M7) 1 +body Nikon Z 6 II 1 +body Nikon Z 6 III 1 +body Nikon Z 8 1 +body Nikon Z 9 (*See note) 1 +body Nikon Z f 1 +body Nikon Z 5 1 +body Nikon Z 50 1 +body Nikon Z 50 II 1 +body Nikon Z fc 1 +body Nikon Z 7 II 1 +body Nikon D850 1 +body Nikon D750 1 +body Nikon D7500 1 +body Nikon D780 1 +body Fujifilm X-T5 1 +body Fujifilm X-T4** 1 +body Fujifilm X-S20 1 +body Fujifilm X-S10 1 +body Fujifilm X-H2S 1 +body Fujifilm X-H2 1 +body Fujifilm X100V** 1 +body Fujifilm X100VI 1 +body Fujifilm X-T30 II 1 +body Fujifilm X-E4 1 +body Fujifilm X-T50 1 +body Fujifilm GFX 100S 1 +body Fujifilm X-M5 1 +body Panasonic LUMIX DC-S5 1 +body Panasonic LUMIX DC-S5M2 1 +body Panasonic LUMIX DC-GH6 1 +body Panasonic LUMIX DC-GH5M2 1 +body Panasonic LUMIX DCG9M2 1 +body Panasonic LUMIX DC-G9 1 +body Panasonic LUMIX DC-S1 1 +body Panasonic LUMIX DC-GX9 (DC-GX7MK3) 1 +body Panasonic LUMIX DMC-G8 (DMC-G80, DMC-G81, DMC-G85) 1 +body Panasonic LUMIX DC-LX100 II 1 +body OM / Olympus OM-D E-M1 Mark III 1 +body OM / Olympus OM-D E-M1 Mark II 1 +body OM / Olympus OM-D E-M10 Mark IV 1 +body OM / Olympus OM-D E-M5 Mark III 1 +body OM / Olympus PEN E-P7 1 +body OM / Olympus Stylus Tough TG-6 1 +body OM / Olympus OM System OM-1 1 +body OM / Olympus OM System OM-1 MARK II 1 +body OM / Olympus OM System OM-5 1 +body Pentax / Ricoh K-1 II (* see note) 1 +body Pentax / Ricoh K-3 Mark III 1 +body Pentax / Ricoh KF 1 +body Pentax / Ricoh K-70 1 +body Pentax / Ricoh GR III 1 +body Pentax / Ricoh GR IIIx 1 +body Leica Q3 1 +body Leica Q2 1 +body Leica M11 1 +body Leica SL2 1 +body Leica M10 1 diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 000000000..f50d36886 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,113 @@ +# Roadmap: milestones, current focus and what's next + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; milestones moved here from ROADMAP.md, focus re-ranked after the 2026-10-10 re-measure) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 + +Forward-looking plan. The summary with the headline numbers is [ROADMAP.md](../ROADMAP.md); the +ranked shortfalls are [gaps.md](gaps.md); the numbers come from [target-app-parity.md](target-app-parity.md). +LightCraft has no `cargo xtask scorecard`; the measured checklist is +[parity-checklist.md](parity-checklist.md) (`cargo xtask parity`). + +## Current focus + +Toward **beta** (~340–600 Opus 5.5 agent hours; ranked, each a gap in [gaps.md](gaps.md)): + +1. **Camera colour of our own** (gap 1, 50–90 h): chart-based calibration for the most-used models, + building on the per-file JPEG fits and pooled profiles (`lightcraft-cli calibrate`); never Adobe + data. +2. **CR3 for every Canon body** (gap 2, 25–45 h): the remaining CRX variants, sRAW / mRAW, HEIF CR3; + clean-room from prose descriptions (decided 2026-10-05; compressed NEF is the template). +3. **Render-fidelity suite** (gap 3, 30–60 h): score our output against Lightroom's on the same CC0 + raws (references local in `plan/`), then tune; a retune ships as a new process version. +4. **Full-resolution zoom** (gap 4, 8–15 h): visible-region rendering so 100% shows original pixels. +5. **AI masks** (gap 5, 40–70 h): Subject / Sky / People on licensable weights; configure the SAM 3 + mirror. Needs the maintainer's model decision. +6. **Compressed ORF and HE NEF** (gaps 6–7, 28–55 h). +7. **Lens profiles of our own** (gap 8, 40–80 h). +8. **Tests in CI on macOS, Windows and Linux** (gap 9, 6–12 h). +9. **Per-model verification** (gap 10, 20–35 h). + +## Alpha gate + +The target's core workflows: what a typical Lightroom user does every day. Alpha requires each to +work end to end on the main platform (macOS) and the work to save and reopen. Checked on 2026-10-10 +against the code, tests, issues and [gaps.md](gaps.md). + +| Workflow | Works end to end? | Evidence | Hours to pass | +|---|---|---|---:| +| 1. Import a shoot (raw + JPEG) from a card or folder into the library | yes | Add / Copy / Move import with templates, duplicates, devices with DCIM, background import with cancel (`crates/engine/src/import.rs`, `tests_import.rs`, `tests_import_move.rs`); checklist A 83% | 0 | +| 2. Cull: rate, flag, label, compare / survey, filter, check focus | yes (partial: focus check) | ratings / flags / labels with advance, Compare, Survey ≤ 48, filter bar, smart albums (checklist B–D 88–92%); 100% zoom is capped by the preview size (2,560 px default, adjustable in Settings), gap 4 — degrades focus checks on large files but does not block culling | 0 (8–15 h to close gap 4, beta) | +| 3. Develop raws non-destructively (global sliders, crop, masks, heal) | yes for most cameras (partial: Canon CR3 outside the verified variants, compressed ORF, HE NEF open as embedded previews) | Sony, Nikon (except HE), Fujifilm, Panasonic, Pentax, CR2, DNG decode from sensor data (raw-parity.md); every Edit slider, crop / Upright, brush / gradient / range masks, heal / clone (checklist F, H, K); colour estimated per file rather than measured, gaps 1–3 | 0 (coverage and colour gaps are beta blockers 1–3, 6–7) | +| 4. Save, quit and reopen the work | yes | crash-safe journal + snapshots, torn-append and crash tests (`crates/catalog/src/journal.rs`, `tests_journal.rs`, `tests_torn_append.rs`); XMP sidecars read / write; failed saves reported | 0 | +| 5. Batch-apply a look: presets, copy / paste / sync settings | yes | presets (incl. `.xmp` / `.lrtemplate` import), copy / paste / sync, auto sync (checklist L 86%, N 100%) | 0 | +| 6. Export deliverables (JPEG / TIFF, size, sharpening, metadata, watermark, naming) | yes | one encoder for app, CLI and MCP (`crates/engine/src/export.rs`, `tests_export.rs`); checklist S 89% | 0 | + +**Result: passes.** Every core workflow runs end to end on macOS and the work survives quit and +reopen; the partial rows degrade fidelity for some cameras and large files but don't stop the +workflow, so LightCraft stays **alpha** (ready for real work ~61%, above the ~40% bar). + +## After beta + +Enhance (Raw Details, Super Resolution, X-Trans denoise), content-aware remove, HDR display, the +Classic output modules (Print, then Map, Book, Slideshow, publish services), tethering, video, +plug-in ecosystem, localization to `full` and new scripts (RTL, shaping). Estimates in +[gaps.md](gaps.md) entries 11–28; ~500–1,300 h after beta. + +## Milestones + +Original wall-clock estimates (made before work started, 4–6 parallel agents) are kept for +calibration: M0–M13 took ≈ 25 active hours against an estimate of ≈ 110–170 h. + +**Status legend:** ✅ done · 🚧 in progress · ⬜ not started + +| # | Milestone | Scope (summary) | Estimate (h) | Status | +|---|---|---|---|---| +| M0 | Skeleton + visual shell | workspace, xtask CI + layering, geom/color/raster, develop model, pipeline v0, catalog v0, engine commands, Lightroom-look UI (grid, loupe, filmstrip, Edit panel), control channel, MCP, web build | 3–5 | ✅ | +| M1 | Library core | import (JPEG/PNG/TIFF/WebP), EXIF/XMP, persistent catalog (op log + snapshots), albums, ratings/flags/labels, filter/search/sort, thumbnail cache, 100k-photo grid | 6–10 | ✅ (100k-photo grid scale test pending) | +| M2 | Pipeline v1 (quality) | WB temp/tint, profiles, local tone mapping (highlights/shadows), curves, HSL, point colour, colour grading, texture/clarity/dehaze, vignette, grain, B&W, auto tone/WB, histogram, before/after | 10–15 | ✅ (look tuning vs our references ongoing) | +| M3 | RAW I | TIFF/DNG (LJ92, deflate, tiles, opcodes), demosaic (AHD/PPG/bilinear), highlight recovery, DNG colour model, CR2, NEF, ARW, embedded previews | 10–15 | ✅ (DNG, CR2 incl. sRAW, ARW, NEF uncompressed / lossless / lossy / lossy after split, embedded previews) | +| M4 | Crop, geometry, optics | crop tool + overlays, straighten, Upright (auto/level/vertical/full/guided), manual transforms, CA, defringe, manual lens corrections | 6–10 | ✅ | +| M5 | Performance | source pyramids, wgpu compute pipeline (CPU oracle), draft/full renders, prefetch, budgets (16 ms slider updates on 24 MP) | 10–15 | 🚧 (stage cache, source pyramid, wgpu pipeline, prefetch, memory budget ✅; colour NR at half resolution, GPU histogram ⬜) | +| M6 | Masking | brush, linear/radial gradients, colour/luminance/depth range, add/subtract/intersect/invert, all local adjustments, masks panel | 8–12 | 🚧 (brush/linear/radial/colour/luminance range, add/subtract/intersect, masks panel ✅; depth range, AI masks ⬜) | +| M7 | Detail | sharpening + masking preview, luminance/colour NR, Denoise, Raw Details, Super Resolution | 6–10 | 🚧 (sharpening, luminance/colour NR ✅; AI Denoise 🟡 (Bayer, optional weights; policy/fidelity open); Raw Details, Super Resolution ⬜) | +| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) | +| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ | +| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs, HDR gain map JPEG / PQ AVIF ✅; JXL encode ⬜) | +| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF all, RW2 every format, PEF, ORF uncompressed and 12.8 bpp, SRW uncompressed, HEIC ✅; CR3 🟡 (3 bodies verified); **camera colour calibration** 🚧 (per-file JPEG fits, 4 bundled profiles, 52 spectral matrices; measured database missing); compressed ORF / SRW, HE NEF, IIQ, X3F ⬜) | +| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | 🚧 (faces: detection, recognition, People view ✅; Object / Describe masks via SAM 3 🟡; Subject / Sky / People masks, semantic search ⬜) | +| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ | +| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ | +| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; contact-sheet PDF 🟡; Map/Book/Slideshow/Print/Web ⬜) | +| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/Spanish/German/Russian/Ukrainian localisation 🟡; accessibility and further locales ⬜) | + +## Risks that coding hours alone don't retire + +- **AI features** (subject / sky / people masks, generative remove) need model weights with + licences we can ship; classical fallbacks first. No permissively licensed sky-segmentation or + raw-denoise model was found; we may need to train our own. +- **Camera colour and lens data** is a data problem: we never use Adobe's matrices, DCPs or LCPs. + DNG-embedded data first, then our own calibration; long-tail coverage grows over time and needs + people with cameras, lenses and charts. +- **Raw-format sources:** decoders are written from prose format descriptions (even ones published + alongside GPL code); decoder source is never read (decided 2026-10-05). Still open: + freedom-to-operate review for local Laplacian filters, PatchMatch and HEVC (HEIC). +- **Look parity** with Adobe's default rendering is tuned by eye until the fidelity suite exists. + Retuning changes existing edits, so it ships as a new process version + ([process-versions.md](process-versions.md)). + +## Design notes kept from the old ROADMAP + +- **Folder records:** library folders have records of their own in the catalog + (`Catalog.folder_records`, keyed by folder path; colour labels first). Photos still store full + paths, so moving a whole disk relinks each photo. If that becomes a problem (an "Update Folder + Location" for a renamed drive, journal growth on large moves), the next step is the Classic model: + photos point to a folder id (root + relative path). +- **Zoom:** native pinch zoom and two-finger pan work in Detail, Compare and Reference views; the + whole-image preview still has a configurable cap (2,560 px default), see gap 4. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Beta hours 340–600; full ready ~59% in the gate note | +| 2026-10-10 | minor | Added the Alpha gate table (core-workflow gate from the revised standard): passes, stays alpha | +| 2026-10-10 | major | Created: milestones moved from ROADMAP.md (statuses refreshed: M3 ✅, M11 / M12 / M15 detail), current focus re-ranked toward beta, risks and design notes kept | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md new file mode 100644 index 000000000..b88063dae --- /dev/null +++ b/docs/target-app-parity.md @@ -0,0 +1,304 @@ +# Target-app parity: LightCraft vs Adobe Lightroom + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Lightroom Classic 15.5 and the installed Lightroom 9.6; merged ROADMAP's parity estimate and honest assessment into this file) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) + +The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is +[parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas +have their own documents: [raw-parity.md](raw-parity.md), [file-format-parity.md](file-format-parity.md), +[ui-parity.md](ui-parity.md), [hardware-parity.md](hardware-parity.md), +[localization-parity.md](localization-parity.md). + +## Headline + +| Number | Value | Kind | +|---|---:|---| +| **Feature breadth**, checklist | **81.1%** of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%) | **measured**: recount of [parity-checklist.md](parity-checklist.md) rows (✅ 1, 🟡 ½, 🚫 left out), the same rule `cargo xtask parity` uses | +| **Feature breadth**, weighted by use | **~77%** | estimated: feature-area table below | +| **Ready for real work** (full target) | **~61%** (range 56–66%) | estimated: weighted sum over the dimensions with written weights, see [below](#ready-for-real-work-mainstream-practitioner-and-essentials-user) | +| **Mainstream practitioner** | **~49%** (range 44–54%) | estimated: weekly areas of a working photographer, depth 71.8% × 0.804 × colour 0.85 | +| **Essentials user** | **~64%** (range 59–69%) | estimated: core features at default settings, depth 77.4% × 0.830 | +| Remaining to **beta** | **~340–600 h** | estimated; ~60% parallelizes | +| Remaining to **full parity** | **~1,000–1,800 h** | estimated; ~70% parallelizes | +| **Stage** | **alpha** | see [Stage](#stage) | + +## The target and how it was measured + +- **Target:** Lightroom Classic 15.5 (August 2026; latest per Adobe's release notes) for the + library, modules and depth; Lightroom 9.6 (the cloud-library desktop app), installed on the + owner's Mac at `/Applications/Adobe Lightroom CC`, for the interface LightCraft's layout follows. + Both share the Camera Raw engine. +- **Installed bundle, inspected by listing only** (the clean-room rule forbids reading contents): + `Info.plist` document types (31 raw extensions, jpg, tif, heic, xmp, catalog bundles); 16 + `.lproj` localizations; `CameraProfiles` (1,500 Adobe Standard and 395 Camera Matching model + names); `LensProfiles` (3,635 files, 56 makers); `ModelZoo` (37 AI model names). The app was not + launched. +- **LightCraft side:** origin/main at `b70ae174` (2026-10-10): code counts (226 engine commands, 143 + UI commands, 122 + 10 develop controls, 2,561 tests, ≈ 202,000 Rust lines in 24 crates + 3 apps), + the checklist rows, raw corpus manifests, locale catalogs, CI workflows, docs. +- **Behaviour and output** were not compared against a running Lightroom in this pass; the + measured ΔE figures in [raw-parity.md](raw-parity.md) come from earlier work. + +**Presence is not parity.** ✅ rows are set by whoever lands a feature and have not been checked +systematically against Lightroom's behaviour; bugs keep turning up in ✅ areas (CR2 colour-filter +phase #85, duplicate Local entries #22, black GPU exports on an Intel iGPU #78). + +## By dimension + +| Dimension | Breadth | Ready for real work | Remaining | Doc | +|---|---:|---:|---:|---| +| Features (weighted areas below) | ~77% (checklist 81.1%, measured) | ~61% | 600–1,100 h | [Features](#features) | +| ↳ RAW cameras, colour and lens profiles (part of features) | 81% of Adobe's 1,446 models decode (16% verified), measured | ~56% | 255–480 h | [raw-parity.md](raw-parity.md) | +| UI/UX fidelity | ~85% | ~70% | 40–70 h | [ui-parity.md](ui-parity.md) | +| File formats (non-raw: images, sidecars, presets, catalogs) | ~85% | ~70% | 30–55 h | [file-format-parity.md](file-format-parity.md) | +| Hardware | ~50% | ~40% | 40–80 h (+ tethering and printing, counted under Classic modules) | [hardware-parity.md](hardware-parity.md) | +| Localization (12 key languages) | 7 of 12 present | ~52% (mean of the 12, measured) | 130–210 h for the target's set; +145–255 h for Hindi, Arabic, Indonesian, Vietnamese, Korean | [localization-parity.md](localization-parity.md) | +| Performance | ~80% | ~70% | 30–50 h | 24 MP GPU slider 3.9 ms, export 0.3 s (measured, [gpu-pipeline.md](gpu-pipeline.md)); 85k library opens in 1.7–4.7 s; 100k test pending | +| Stability | — | ~60% | 30–60 h | never-crash lints and guards workspace-wide, crash-tested journal, 2,561 tests; but CI runs tests only on FreeBSD, user reports keep finding bugs in ✅ areas | +| Platforms | macOS, Windows (x64 / x86 / ARM64), Linux (AppImage, deb, rpm, Flatpak, Nix), FreeBSD, web | ~60% | 25–45 h | macOS is where it is tested; Windows installer UI unverified on Windows (#79); web experimental (CPU only) | +| Ecosystem / plug-ins | ~25% | ~15% | 40–80 h + owner decision | no Lightroom SDK equivalent (export, publish, metadata plug-ins); beyond the target: JSON-lines control channel, MCP server with 28 tools + one per command, CLI | +| AI features | ~30% | ~20% | 120–220 h + model decisions | [AI](#ai) (overlaps the masking and Enhance rows; not added twice) | + +## Features + +Weights are a judgement of what Lightroom users spend their time on (library and develop dominate; +Classic output modules and video are used by few). Breadth and readiness per area are estimated +from the checklist sections and the deep-dive documents. + +| Area | Weight | Breadth | Ready | Remaining | Notes | +|---|---:|---:|---:|---:|---| +| Import and library (import, folders, albums, smart albums, keywords, metadata, search, faces) | 15% | 90% | 80% | 25–45 h | checklist A–E 83–92%; robust journal; 85k-photo libraries | +| RAW decoding and camera coverage | 15% | 81% | 76% | 120–220 h | [raw-parity.md](raw-parity.md), measured model by model: 84% of photographers' cameras decode (use-weighted), × 0.9 for per-body bugs; CR3 variants, compressed ORF, HE NEF | +| Colour, camera profiles, render fidelity | 10% | 65% | 35% | 80–150 h | 4 bundled profiles vs Adobe's 1,500; per-file fits median ΔE ~5 vs the camera JPEG, ~10% far off; no fidelity suite ([raw-parity.md](raw-parity.md#colour)) | +| Global develop adjustments (light, colour, curves, grading, effects, detail) | 15% | 93% | 70% | 30–60 h | checklist F 91.7%; character tuned by eye | +| Optics, lens profiles, crop and geometry | 6% | 80% | 50% | 40–80 h | no lens database; crop / Upright complete | +| Masking | 8% | 67% | 45% | 60–110 h | checklist K 67.4%; AI masks heuristic or SAM 3 without mirror | +| Remove, heal, Enhance (denoise, Raw Details, Super Resolution, Lens Blur) | 7% | 45% | 30% | 70–130 h | checklist I 75%, P 0% | +| Presets, profiles, versions, history, sync | 5% | 95% | 85% | 8–15 h | | +| Export | 6% | 89% | 75% | 15–30 h | checklist S 88.9% | +| Merge and HDR | 3% | 85% | 60% | 20–35 h | merge 100%, HDR 70% (no HDR display) | +| Views, compare, zoom, culling | 5% | 90% | 70% | 15–30 h | zoom detail P0 gap | +| Classic output modules (Map, Book, Slideshow, Print, Web, publish, tethering) | 4% | 15% | 10% | 90–150 h | Classic extras 48% counts many small keys; the modules themselves are mostly ⬜ | +| Video | 1% | 0% | 0% | 25–45 h | catalogued only | +| **Weighted** | 100% | **~77%** | **~61%** (60.6%) | **600–1,100 h** | | + +## AI + +Lightroom 9.6 ships 37 on-device models (names listed from its `ModelZoo`), covering subject, sky, +portrait, skin and object selection with refinement, depth estimation, denoise, dust detection, +reflections removal, distraction / people detection, culling scores (eyes open, blur, aesthetics) and +search embeddings; generative remove runs in Adobe's cloud. + +| Feature | LightCraft | Status | +|---|---|---| +| Subject / Sky / Background masks | classical heuristics; the file's own DNG semantic mattes | 🟡 | +| People (faces, body, hair, skin…) and Landscape masks | — | ⬜ | +| Object / Describe masks | SAM 3 in pure Rust (Metal / CPU); opt-in 3.4 GB download, no mirror configured | 🟡 | +| Depth range mask, Lens Blur | — | ⬜ | +| AI Denoise | Bayer only, pure-Rust CPU / GPU, user-installed weights | 🟡 | +| Raw Details, Super Resolution | — | ⬜ | +| Generative remove, distractions, reflections | — (generative is out of scope: cloud) | ⬜ | +| Dust detection | ✅ | ✅ | +| Faces / People | YuNet detection, SFace / AuraFace recognition (opt-in downloads), People view | ✅ (MVP, [faces.md](faces.md)) | +| Assisted culling | focus, bursts | 🟡 (no eyes-open / aesthetics scores) | +| Auto (tone), adaptive presets | Auto calibrated on Lightroom's Auto results (#628); no adaptive presets | 🟡 | +| Natural-language search | — | ⬜ | + +**Blocked on a model strategy** (maintainer decision): licensable weights or our own training; +pure-Rust inference is proven (faces, SAM 3, denoise). + +## Ready for real work, mainstream practitioner and essentials user + +Three readiness numbers (craftrules `standards/progress-docs.md`). The full number is a weighted +sum over the dimensions with written weights. Mainstream and essentials take the weighted depth of +the areas their user touches, then apply written multiplicative discounts for what still stops real +work. Each has its own hours to ~95%, calibrated as in [How hours are calibrated](#how-hours-are-calibrated) +(essentials ⊂ mainstream ⊂ full): + +| Audience | Ready | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | +|---|---:|---:|---| +| Full target (ready for real work) | **~61%** | **1,000–1,800 h** (~70% parallelizes) | raw colour, lens and camera data; AI models; Classic output modules; localization; plug-in ecosystem | +| Mainstream practitioner | **~49%** | **450–840 h** (~60% parallelizes) | camera colour and render fidelity; CR3 / ORF / HE NEF; lens database; subject / sky masks; Lightroom XMP exchange; stability | +| Essentials user | **~64%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send | + +### Full target (ready for real work): ~61% + +Weighted sum over the dimensions of [By dimension](#by-dimension) (AI is left out because it +overlaps the masking and Enhance feature areas). RAW decoding and camera colour are their own rows, +valued from the model-by-model measurement in [raw-parity.md](raw-parity.md): + +| Dimension | Weight | Ready | +|---|---:|---:| +| Features: the other 11 areas, weighted depth from [Features](#features) | 45% | 60.9% | +| RAW decoding and camera coverage | 9% | 76% (84% use-weighted × 0.9) | +| Camera colour and default rendering | 6% | 35% | +| UI/UX fidelity | 8% | 70% | +| File formats (non-raw) | 6% | 70% | +| Hardware | 3% | 40% | +| Localization | 3% | 52% | +| Performance | 6% | 70% | +| Stability | 8% | 60% | +| Platforms | 4% | 60% | +| Ecosystem / plug-ins | 2% | 15% | +| **Weighted sum** | 100% | **60.6% ≈ 61%** (range 56–66%) | + +Mainstream comes out lower than full: its scope is the raw-develop core, where LightCraft is weakest +(camera colour, CR3 coverage), and its discounts are multiplicative, while the full sum is lifted by +strong library, presets, UI and format dimensions. + +**Why the numbers moved with the RAW measurement:** decoding measured model by model against +Adobe's list is better than the earlier 50% estimate (84% of photographers' cameras decode, 76% after +a bug discount), colour is lower (35%, was 40%) and is now its own row. Net: full +2 points. +Mainstream now carries colour as a ×0.85 discount on top of the measured coverage (−2 points); +essentials replaces its guessed 45% raw look with 84% × 0.75 (+1 point). + +### Discounts (evidence: open issues from users, 2026-10-10) + +| Discount | Mainstream | Essentials | Evidence | +|---|---:|---:|---| +| Interaction fidelity / UI clarity | ×0.95 | ×0.95 | brush live preview gaps and feathering (#517, #518); crop artefacts and limits (#731, #742, #770, #771); Compare white-balance selector (#743–#745); low-res slider previews (#330); dialogs taller than the window (#781); mouse-wheel scroll (#714); 100% zoom capped (gap 4) | +| Stability on real machines | ×0.92 | ×0.92 | launch and quit crashes or silent startup failures (#250 macOS 12 Intel, #315 Windows Intel GPU, #431 Intel Mac, #260, #462, #783 Linux / Nouveau, #790 Windows installer); tests run in CI on FreeBSD only (gap 9) | +| File exchange with Lightroom users (mainstream) / opening files people send (essentials) | ×0.92 | ×0.95 | Adobe XMP sidecars change colours (#290), XMP CameraProfile and Upright ignored (#204, #205), catalog import fails (#599), same Light values render differently (#523, #209), no write-back to `.lrcat`; for casual users: CR3 beyond verified bodies, compressed ORF, HE NEF open as previews (#473, #485, #556), HEIC trouble (#693) | +| **Product** | **×0.804** | **×0.830** | | + +### Mainstream practitioner: ~49% + +A working photographer (wedding, portrait, events, landscape) shooting raw on one current camera, +weekly: import, cull, develop, a few masks, sync a look, export. Excluded: Classic output modules, +video, generative / cloud AI, plug-ins, tethering and other specialist hardware, languages other +than the user's own. + +| Area | Weight | Depth | +|---|---:|---:| +| Import and library | 18% | 80% | +| Cull: views, compare, zoom, ratings | 12% | 70% | +| Raw decoding for their camera | 27% | 84% (measured use-weighted share whose camera fully works, [raw-parity.md](raw-parity.md#by-use-estimated)) | +| Global develop adjustments | 15% | 70% | +| Crop, geometry, lens corrections | 6% | 50% | +| Masking (manual, subject / sky; no generative) | 8% | 45% | +| Heal / remove (no generative), denoise | 5% | 40% | +| Presets, copy / paste / sync | 5% | 85% | +| Export | 4% | 75% | +| **Weighted depth** | 100% | **71.8%** | + +Colour fidelity is a written discount, **×0.85**: most raws start near the camera's own look (median +ΔE ~5), about 10% far off (ΔE ~18), none calibrated to Adobe Standard, so a working photographer +corrects colour by hand (33 open colour issues). 71.8% × 0.804 × 0.85 = **~49%** (range 44–54%). + +### Essentials user: ~64% + +A casual photographer with default settings: import, look through, rate, fix a few photos with the +basic sliders, crop, apply a preset, undo, export JPEGs. + +| Feature | Weight | Depth | +|---|---:|---:| +| Import photos | 15% | 80% | +| Browse grid and detail view | 10% | 85% | +| Rate and flag | 5% | 90% | +| Basic sliders (exposure, contrast, highlights, shadows, white balance, vibrance / saturation) | 22% | 70% (slider strength differs from Lightroom, #196, #521) | +| Auto | 5% | 65% (#318) | +| Crop and straighten | 10% | 80% | +| Presets and profiles | 8% | 80% | +| Undo and history | 5% | 90% | +| Their camera's raws: decode × default look | 10% | 63% (84% whose camera fully works × colour factor 0.75) | +| Export JPEG | 10% | 85% | +| **Weighted depth** | 100% | **77.4%** | + +77.4% × 0.830 = **~64%** (range 59–69%). + +### User evidence (GitHub issues, excluding the four maintainers) + +- 324 issues from 159 users; 162 open. 8,719 stars, 2,698 forks (2026-10-10). +- Of the 162 open: **~92 core-path bugs** (33 colour / rendering, 16 decoding, ~36 UI / workflow, 7 + launch / stability), ~11 developer / test infrastructure, **~59 feature requests or niche asks** + (plug-ins, mobile, sync servers, tablets, MIDI, geocoding, themes). +- Praise in issue text (love / amazing / great work / awesome): ~14 issues; "thank you" in 27. + **No explicit "switched from Lightroom" report** was found ("switched" matches 6 issues, none a + switch report). +- Reading: strong interest, but most open user issues are core-path, and raw colour dominates them, + so the mainstream number is held down by default rendering rather than missing features. + +### Why the full number moved (55% → 46% → 59%) + +The first figure (~55%) was the feature depth "pulled down" by an unwritten judgement. A later pass +applied the mainstream discounts to it (~46%). The standard defines the full number as a weighted +sum over the dimensions, so it is now that sum with the weights written above: ~59%. Method aligned +with the standard, no new evidence. The stage stays **alpha**. + +## Stage + +**Alpha.** Core workflows exist end to end (import, cull, develop, mask, export, catalog migration), +but ready-for-real-work is ~61%, below the ~75% beta bar, and the main file formats have blocking +gaps: Canon CR3 decodes from sensor data on three verified bodies only, compressed ORF and HE NEF +open as previews, and colour on every non-DNG raw is estimated rather than measured. + +**Alpha gate:** all six core workflows (import, cull, develop, save and reopen, batch looks, export) +work end to end on macOS; see the table in [roadmap.md](roadmap.md#alpha-gate). The partial rows (CR3 +variants, compressed ORF, HE NEF as previews; capped 100% zoom) degrade but don't block a workflow. + +**To beta:** ~14 points of readiness, **~340–600 h**: the nine `B` gaps in [gaps.md](gaps.md) +(~230–430 h), plus burning down the ~92 open core-path user issues (40–80 h) and stability work +around them. Beta needs: CR3, ORF and +HE NEF decoding from sensor data; measured colour for the popular models and a fidelity suite with +scores; full-resolution zoom; Subject / Sky / People masks; a lens profile database for the +popular lenses; tests in CI on macOS, Windows and Linux. + +## How hours are calibrated + +Hours are Opus 5.5 agent wall-clock hours, one session working sequentially, including tests and +verification. Calibrated on this repository's history (git log and GitHub PR open-to-merge times): + +- **Project pace:** first commit 2026-09-30; by 2026-10-10, 1,066 non-merge commits and 325 merged + PRs, commits in 159 distinct clock hours, with 4–6 parallel agents plus community contributors. + On 2026-10-02 the ROADMAP recorded ≈ 105–145 agent-hours spent; M0–M13 took ≈ 25 active hours with + parallel agents against an up-front estimate of 110–170 h. +- **UI and library rows:** a lead agent closed ≈ 45 checklist rows in ≈ 5 h (2026-10-01) and ≈ 60 in + ≈ 12 h (2026-10-02): 6–12 minutes per small row. +- **Raw decoders:** RW2 in every format 6.5 h (#215), compressed RAF 8.6 h (#282), CR3 CRX 10 h + (#279), NEF lossy after split 4.7 h (#633), ORF 12.8 bpp 1.7 h (#651), HEIC 7 h (#609). +- **Large features:** AI RAW denoise 8.3 h (#384, 12.8k lines), SAM 3 masks 14.9 h (#162), + HDR editing and export 2.3 h (#646, on existing groundwork), contact-sheet PDF (#563), German UI + 13.8 h (#265). +- PR open time includes queueing and review, so these are upper bounds per feature; the ranges add + a long-tail factor of 1.3–1.5× for verification against real files. + +### Why the estimate went up + +The previous estimate (2026-10-02, kept below) was ≈ 270–505 h to full parity. It counted checklist +rows only. New evidence since: the target's own lists (≈ 1,000 dedicated camera models, 3,635 lens +profiles, 37 AI models, 16 languages) measured from the installed bundle; per-maker verification +counts; that CI tests only FreeBSD; and the standard's added dimensions (localization, stability, +platforms, ecosystem). The raw-codec package it estimated at 40–80 h took roughly that and closed +most of its list; what remains is the data-heavy part (colour, lenses, models) it did not count. + +## History of the numbers + +| Date | Checklist (all / P0 / P1 / P2) | Ready for real work | Remaining to full parity | Source | +|---|---|---|---|---| +| 2026-10-10 (RAW) | 81.1% (unchanged) | ~61% full · ~49% mainstream · ~64% essentials | unchanged | RAW measured model by model against Adobe's list; RAW decode and colour as explicit rows | +| 2026-10-10 (final) | 81.1% (unchanged) | ~59% full (weighted sum) · ~51% mainstream · ~63% essentials | full 1,000–1,800 h · mainstream 450–840 h · essentials 160–300 h | method aligned with the standard | +| 2026-10-10 (later) | 81.1% (unchanged) | ~46% full · ~51% mainstream · ~63% essentials | 1,000–1,800 h | full × discounts (superseded) | +| 2026-10-10 | 81.1% / 98.0% / 95.1% / 46.9% (517 rows) | ~55% | 1,000–1,800 h | this re-measure | +| 2026-10-08 | 79.7% / 98.2% / 95.6% / 41.6% (509 rows) | 60–70% | — | ROADMAP *Where we stand* | +| 2026-10-02 | 75.6% | — | 270–505 h (cloud parity without AI / Classic: 130–255 h) | ROADMAP *Parity estimate* | + +The 2026-10-08 assessment by dimension, kept for evidence: feature checklist 79.7%; RAW coverage +~55%; colour and image quality ~55–65%; AI and computational ~15–20%; workflow and library ~85% +(single machine); Classic modules ~30%; HDR and video ~25%; platform and robustness ~70%. By kind of +user: JPEG / DNG shooter ~85%; Nikon / Sony / Panasonic / older-Canon ~65%; Fujifilm ~65%; Canon +CR3 / Olympus compressed ~45%; Classic power user ~45%; relies on AI ~25%. Today's numbers per user: +JPEG / DNG ~75%; Sony, Panasonic, Fujifilm ~60%; Nikon ~55% (HE NEF); Canon CR3 ~40%; OM / Olympus +~30%; Classic power user ~40%; relies on AI ~20%. + +The 2026-10-02 effort table, kept for calibration: remaining P0/P1 UI 5–10 h; raw codecs 40–80 h; +lens database 15–30 h; video 20–40 h; AI 80–150 h; HDR 15–25 h; Classic modules 60–100 h; smaller +P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points | +| 2026-10-10 | minor | Full number restored to an additive weighted sum over the dimensions (~59%; method aligned with the standard, no new evidence); hours to ~95% for each of the three audiences; beta distance ~16 points | +| 2026-10-10 | minor | Added mainstream practitioner (~51%) and essentials user (~63%) numbers; full ready re-derived from written weights × discounts (55% → 46%); user-issue evidence; beta hours 340–600 | +| 2026-10-10 | minor | Stage re-checked against the core-workflow alpha gate: passes, stays alpha | +| 2026-10-10 | major | Created per the progress-docs standard: full re-measure (checklist recount, installed Lightroom 9.6 bundle listing, code counts, catalogs, CI), feature areas with weights, dimensions, AI, stage, calibration; merged ROADMAP's *Where we stand* and *Parity estimate* | diff --git a/docs/ui-parity.md b/docs/ui-parity.md new file mode 100644 index 000000000..4216f1091 --- /dev/null +++ b/docs/ui-parity.md @@ -0,0 +1,81 @@ +# UI parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; shortcut notes moved here from the checklist) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 + +How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier +keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in +[parity-checklist.md](parity-checklist.md) (sections Y and Z, Classic keys); this page is the +assessment and the notes that don't fit a row. Visual reference for agents: `plan/lightroom/` +(local, gitignored; `10-observed-ui.md` and screenshots). + +## Measured counts (2026-10-10) + +- 226 engine commands (`crates/engine/src/cmd/`, 30 modules) and 143 UI commands (`UI_COMMANDS` in + `crates/ui-egui/src/menus.rs`, 73 with a shortcut) plus 10 language commands; 23 secondary + shortcut bindings (`ALIASES`, `crates/ui-egui/src/shortcuts.rs`); a keymap editor. +- 122 develop controls plus 10 indexed templates (`crates/develop/src/controls.rs`). +- Checklist: menus 95.3% (86 in-scope rows), desktop shortcuts 94.4% (81 rows), Classic extras + 48.2% (82 rows), measured by recounting [parity-checklist.md](parity-checklist.md). +- 31 panel modules (`crates/ui-egui/src/panels/`), 26 context-menu sites. + +## Assessment by area + +| Area | Target | LightCraft | Ready | Main gaps | +|---|---|---|---:|---| +| Layout and views | Lightroom: grid / detail / edit; Classic: Library, Develop, Map, Book, Slideshow, Print, Web modules, filmstrip, panels | Lightroom-style single window: Photo Grid, Square Grid, Detail, Compare, Survey (≤ 48), Before / After, People, quick slideshow, second window, filmstrip | 75% | no Classic module switcher (by design) or output modules; dark theme only | +| Develop panels and sliders | Light, Color, Effects, Detail, Optics, Geometry, Lens Blur, Calibration | every global slider; scrub, double-click reset, Alt-drag previews where implemented | 80% | Lens Blur, Calibration panel depth; slider feel (acceleration, fine Shift-drag) unmeasured | +| Crop, Upright, transform handles | handles, aspect lock, angle, overlays (O / ⇧O), guided Upright | ✅ all of them; overlay cycle on ⇧O | 85% | crop opacity of cut-out area (15.5) | +| Masking tools | brush (A / B, flow, density, auto mask, pressure), linear / radial, range masks, AI masks, pins | brush, linear, radial, colour / luminance range, add / subtract / intersect / invert, masks panel, pins | 60% | brush live preview (#517 / #518), no pen pressure, no depth range, AI masks, Feather / Edge controls (15.5) | +| Remove / heal | content-aware remove, heal, clone, visualize spots | heal, clone, auto source, visualize spots, red / pet eye | 65% | content-aware (patch synthesis) remove, generative remove, detect distractions | +| Zoom and navigation | Fit / Fill / 1:1 / 2:1 … 11:1, Navigator, spacebar pan | Fit to 800%, pinch zoom, pan, Navigator-like | 60% | **original-pixel detail at 100% (preview capped at 2,560 px; P0 gap LR-VIEW-ZOOM)** | +| Library culling and metadata | ratings, flags, labels with keys, painter, keywording, metadata panel, filter bar | ratings / flags / labels and advance, keyword list and sets, keywording box, metadata fields, smart albums, filter bar | 85% | painter tool, quick collection, some Classic keys | +| Shortcuts | Lightroom desktop and Classic keymaps | Lightroom-desktop keymap with Classic additions, editable | 85% | deliberate differences below; no Classic keymap layer | +| Context menus | everywhere | 26 sites (grid, filmstrip, albums, folders, keywords, masks, presets…) | 70% | unaudited against Lightroom's menus item by item | +| Text fields | native editing behaviour | custom text field (LR-BEHAV-TEXTFIELD) | 70% | IME and selection edge cases | +| Accessibility | VoiceOver / Narrator, keyboard navigation | AccessKit: sliders, buttons, dropdowns, section headers announce | 30% | canvas tools pointer-only; not audited with a screen reader | +| Feel and responsiveness | GPU-interactive sliders (15.3) | ≤ 16 ms slider budget on GPU; background renders | 75% | colour NR at half resolution, 100k-photo grid test | +| **UI/UX overall** | | | **~70%** | estimated, weighted by use (develop and library 60%, tools 25%, rest 15%) | + +Remaining: **40–70 h** (zoom detail 8–15 h, brush and masking feel 8–12 h, accessibility audit 10–20 +h, Classic keymap layer 4–8 h, context-menu audit 4–8 h, text-field and IME polish 4–8 h). Output +modules and AI tools are counted with their features in [target-app-parity.md](target-app-parity.md). + +## Shortcuts + +Compared `plan/lightroom/06-shortcuts.md` (desktop part) with our bindings: command specs in `crates/engine/src/cmd/`, +`UI_COMMANDS` in `crates/ui-egui/src/menus.rs` and secondary bindings (`ALIASES`) in `crates/ui-egui/src/shortcuts.rs`. +`no_conflicting_bindings` (same file) fails when one key fires two actions. + +**Fixed (M16.1):** added Lightroom-desktop keys as secondary bindings for existing commands — ⌘D Select None, ⇧E +Export dialog, Space Toggle zoom, ⇧M Create Version, ⇧X Reject + advance, ⇧U Unflag + advance. `⇧Y` fired both +Before/After Split and the History panel; History lost the binding. `W` and `⇧⌘I` also fired the engine command +under the UI command that wraps it (a no-op error); the UI command now wins. + +**Deliberate differences (our key → Lightroom desktop key)** — each is a conflict with another binding we have: + +| Action | Ours | Lightroom desktop | Why | +|---|---|---|---| +| Pick flag | P | Z | Z = toggle zoom (Classic convention); P is the Classic pick key | +| Photos panel (left) | ⌘⇧L | P | P = pick | +| Expand/collapse edit sections | ⌘⌥1–5 | ⌘1–6 | ⌘0 = Zoom to Fit, ⌘1 = Zoom 100 %; Geometry lives in the Crop panel | +| Histogram | ⌘⇧H | ⌘0 | ⌘0 = Zoom to Fit | +| Square Grid | ⇧G | (none; ⇧G = Guided Upright) | Guided Upright is a button in the Crop panel | +| Export dialog | ⌘⇧E (+ ⇧E) | ⇧E | ⌘⇧E is "Edit in Photoshop" there; no external-editor command yet | +| Crop overlay cycle | ⇧O | O | O = mask overlay; ⇧O (mask colour / overlay orientation) unused otherwise | +| Create Version | ⌘⇧S (+ ⇧M) | ⇧M (Windows: Ctrl+⇧S) | — | +| Select None | ⌘⇧A (+ ⌘D) | ⌘D | — | + +**Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. Remaining Classic gaps: rating `[` / `]`, flag cycling and filter-bar keys. + +**Still missing / broken:** +- No command yet: F1 help (verify the rest of the old list: full screen, settings, stacks, + visualize spots and merges have commands now). +- `H` opens Remove; Lightroom also uses it (Classic) to hide pins — pins toggle from View → Show Mask Pins. +- ⌘M / ⌘H / ⌘Q / ⌘W rely on the platform window defaults (unverified). + + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created: measured command and shortcut counts, assessment by area, estimates; moved the shortcut conflicts section here from the checklist | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb1fd3165..f3fb3a143 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,6 +1,6 @@ # What's new in LightCraft -## October 2026 +## 2026-10-10 ### Activity stack - Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and @@ -11,6 +11,8 @@ - Quitting while a task that can be stopped is running asks first ("Quit Anyway" stops it). - Agents list and stop tasks with `activity.list` and `activity.cancel`; `ui.inspect` reports them as `activity`. +## 2026-10-09 + ### Smart album rules - The rule editor's field menu is grouped: Rating, Pick Flag, Color Label and Any Searchable Text at the top, then submenus for Source, File, Date, Keywords & People, Description, Camera Info, Location, Size, Develop and Assisted @@ -129,6 +131,8 @@ next to it while you rotate (issue #534). For an exact angle, type it in the new Angle field under Straighten and press Return (Esc keeps the old angle; a decimal comma works too), or click the Straighten value; hovering any slider's value now says it can be typed. +## 2026-10-08 + ### Albums tree - Album folders fold with the same disclosure triangle as Local, By Date, Folders and Keywords. - Right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… @@ -158,6 +162,60 @@ press the new keys (Esc cancels), × removes it, ↺ restores the original, Reset All undoes every change. A key that belonged to another command moves to the new one. Menus show the new keys; agents use `app.setShortcut`. +### Formats +- HEIC / HEIF photos (iPhone and Mac) open now: the optional `lightcraft-heif` crate (heic-rs, pure Rust) behind + codecs' `heif` feature — 8- and 10-bit, alpha, grid tiles, the container's rotation/mirror/crop, ICC, EXIF and XMP. + Off by default (HEVC patents are the distributor's call, same as PhotoCraft); official builds pass `--features heif`. +- HEIC colours now match libheif to within one code value (they were up to ~10 off): the HEVC stream's own + full-range/matrix signalling is honoured (iPhone photos are full range), chroma is upsampled like libheif, grids + take their tiles' ICC profile, and `imir` mirrors the way libheif writes it. Importing a HEIC reads its size from + the container instead of decoding it; small previews use the file's embedded thumbnail. The release packages + (macOS, Windows, Linux, FreeBSD, Nix) now really are built with HEIC support; a build without it reports + "HEIC/HEIF support isn't included in this build" for each `.heic`/`.heif` at import. + +### Editing +- The Tint slider works the right way round (issues #188, #321): left adds green, right adds magenta, as its track + shows and as in Lightroom, and Tint values in Lightroom XMP sidecars now render as they do there. A custom Tint + saved in an earlier version now shifts the other way; set it again (or re-run Auto / the white-balance picker). +- White balance on DNGs (and other raws with a colour matrix) re-develops the photo for the new white through the + camera's own colour matrices, as Lightroom does, instead of shifting the colours of the as-shot rendering: a grey + lit by the chosen white comes out grey and saturated colours move as the camera records them. The eyedropper and + Auto use the same model. Custom white balances on these photos render slightly differently than before. +- Crop (issue #295): a Lock toggle keeps the aspect ratio on every handle, Custom takes your own ratio (Apply), and + dragging a handle into the image edge stops there instead of pushing the crop out of shape. + +### Library and views +- Trackpads: pinch to zoom around the pointer and scroll with two fingers to pan the photo; panning keeps the photo + inside the view. A plain mouse wheel over a zoomed photo pans it too. +- A Folders section in the sidebar lists the folders your photos were imported from; choose one to see its photos. +- Select All and multi-selection show every selected photo in the grid and filmstrip, not only the active one + (issues #187, #298). Importing files that are in Recently Deleted asks whether to leave them there, restore them + (with their edits) or import them as new; the trash view's Photo menu has Empty Recently Deleted. +- The Import Photos review opens bigger and can be resized; its photo grid fills it (issue #337). Shift-click checks + or unchecks a range of photos (issue #338). +- When a folder holds several file types, the Import Photos review has a toggle per type (`ARW · 120`, `JPG · 120`): + import only the raws and leave the JPEGs beside them (issue #344). + +### Languages +- The interface is available in Spanish (issue #371), German and Russian (Edit ▸ Language), alongside English, + Chinese (Simplified and Traditional), Japanese and Brazilian Portuguese. + +### Editing +- Type an exact value into any slider (issue #322): click the number next to its name, type (`1.5`, `-20`, `5600`) + and press Return; Esc keeps the old value. + +### Editing +- The eye on the Light, Color and Detail section headers now hides their adjustments, as it already did for Effects, + Optics, Geometry and Calibration (issue #316). + +### Library +- Choosing a date under By Date or a keyword under Keywords shows those photos from All Photos, as their counts + promise, instead of filtering whatever album or folder was open, which often showed nothing (issue #341). +) +) + +## 2026-10-07 + ### RAW decoding - Olympus ORF raws get the starting look fitted to the camera's own JPEG too, instead of opening flat and grey (the E-1, E-400 and XZ-2 corpus files: all accepted), with white balance relative to the as-shot look as for the other @@ -214,72 +272,7 @@ existing LightCraft edits are preserved by default. Source settings/history are archived, unsupported fields are reported, and the original Lightroom database stays read-only. Rendering is approximate. -### Formats -- HEIC / HEIF photos (iPhone and Mac) open now: the optional `lightcraft-heif` crate (heic-rs, pure Rust) behind - codecs' `heif` feature — 8- and 10-bit, alpha, grid tiles, the container's rotation/mirror/crop, ICC, EXIF and XMP. - Off by default (HEVC patents are the distributor's call, same as PhotoCraft); official builds pass `--features heif`. -- HEIC colours now match libheif to within one code value (they were up to ~10 off): the HEVC stream's own - full-range/matrix signalling is honoured (iPhone photos are full range), chroma is upsampled like libheif, grids - take their tiles' ICC profile, and `imir` mirrors the way libheif writes it. Importing a HEIC reads its size from - the container instead of decoding it; small previews use the file's embedded thumbnail. The release packages - (macOS, Windows, Linux, FreeBSD, Nix) now really are built with HEIC support; a build without it reports - "HEIC/HEIF support isn't included in this build" for each `.heic`/`.heif` at import. - -### Presets and profiles -- Import presets from other editors: XMP presets, classic `.lrtemplate` files, "DNG presets" from mobile apps and `.zip` - bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along. -- Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders - with a counterpart here come along, the rest is listed. -- 23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners. -- Importing XMP presets no longer lists bookkeeping fields (`Cluster`, `SortName`, `SupportsAmount2`, the as-shot - white, empty Point Color slots…) as settings that couldn't be carried over. -- A preset whose lens-profile switch is off no longer turns off the lens corrections built into a DNG (iPhone ProRAW - and other files with embedded distortion / vignetting corrections), matching what the preset does elsewhere. -- XMP presets apply their red / green / blue curves only when they also have the master curve and all three channel - curves, as Lightroom does: a preset with just one channel curve (or no master curve) leaves the channels alone. -- Imported `.cube` LUT profiles appear in the Profile menu and the profile browser, grouped by their folder, and stay - favourites across restarts (issue #328). - -### Editing -- The Tint slider works the right way round (issues #188, #321): left adds green, right adds magenta, as its track - shows and as in Lightroom, and Tint values in Lightroom XMP sidecars now render as they do there. A custom Tint - saved in an earlier version now shifts the other way; set it again (or re-run Auto / the white-balance picker). -- White balance on DNGs (and other raws with a colour matrix) re-develops the photo for the new white through the - camera's own colour matrices, as Lightroom does, instead of shifting the colours of the as-shot rendering: a grey - lit by the chosen white comes out grey and saturated colours move as the camera records them. The eyedropper and - Auto use the same model. Custom white balances on these photos render slightly differently than before. -- Crop (issue #295): a Lock toggle keeps the aspect ratio on every handle, Custom takes your own ratio (Apply), and - dragging a handle into the image edge stops there instead of pushing the crop out of shape. - -### Library and views -- Trackpads: pinch to zoom around the pointer and scroll with two fingers to pan the photo; panning keeps the photo - inside the view. A plain mouse wheel over a zoomed photo pans it too. -- A Folders section in the sidebar lists the folders your photos were imported from; choose one to see its photos. -- Select All and multi-selection show every selected photo in the grid and filmstrip, not only the active one - (issues #187, #298). Importing files that are in Recently Deleted asks whether to leave them there, restore them - (with their edits) or import them as new; the trash view's Photo menu has Empty Recently Deleted. -- The Import Photos review opens bigger and can be resized; its photo grid fills it (issue #337). Shift-click checks - or unchecks a range of photos (issue #338). -- When a folder holds several file types, the Import Photos review has a toggle per type (`ARW · 120`, `JPG · 120`): - import only the raws and leave the JPEGs beside them (issue #344). - -### Languages -- The interface is available in Spanish (issue #371), German and Russian (Edit ▸ Language), alongside English, - Chinese (Simplified and Traditional), Japanese and Brazilian Portuguese. - -### Editing -- Type an exact value into any slider (issue #322): click the number next to its name, type (`1.5`, `-20`, `5600`) - and press Return; Esc keeps the old value. - -### Editing -- The eye on the Light, Color and Detail section headers now hides their adjustments, as it already did for Effects, - Optics, Geometry and Calibration (issue #316). - -### Library -- Choosing a date under By Date or a keyword under Keywords shows those photos from All Photos, as their counts - promise, instead of filtering whatever album or folder was open, which often showed nothing (issue #341). -) -) +## 2026-10-05 ### Reliability - The Windows installer asks where to install LightCraft (Program Files by default; upgrades keep the folder you @@ -377,6 +370,23 @@ library) are written atomically and saved as soon as you open another library, not only at quit. Quitting while changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel. +## 2026-10-02 + +### Presets and profiles +- Import presets from other editors: XMP presets, classic `.lrtemplate` files, "DNG presets" from mobile apps and `.zip` + bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along. +- Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders + with a counterpart here come along, the rest is listed. +- 23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners. +- Importing XMP presets no longer lists bookkeeping fields (`Cluster`, `SortName`, `SupportsAmount2`, the as-shot + white, empty Point Color slots…) as settings that couldn't be carried over. +- A preset whose lens-profile switch is off no longer turns off the lens corrections built into a DNG (iPhone ProRAW + and other files with embedded distortion / vignetting corrections), matching what the preset does elsewhere. +- XMP presets apply their red / green / blue curves only when they also have the master curve and all three channel + curves, as Lightroom does: a preset with just one channel curve (or no master curve) leaves the channels alone. +- Imported `.cube` LUT profiles appear in the Profile menu and the profile browser, grouped by their folder, and stay + favourites across restarts (issue #328). + ### Library - Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 59ce22b3f..6d96390e8 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -75,7 +75,7 @@ have none. So saving one photo never overwrites the other's metadata (`Session:: LightCraft reads face, pet, focus and barcode regions in the Metadata Working Group's region schema (`mwg-rs:Regions`, `http://www.metadataworkinggroup.com/schemas/regions/`), as Lightroom, digiKam, Picasa and others write them, from sidecars and from XMP embedded in the file. Named faces become people in the People view -(LR-LIB-PEOPLE in [`parity.md`](parity.md)), and every region is drawn as a box in the loupe. +(LR-LIB-PEOPLE in [`parity-checklist.md`](parity-checklist.md)), and every region is drawn as a box in the loupe. - **Read only.** LightCraft never writes `mwg-rs:Regions`; saving a sidecar keeps another application's regions byte for byte. Removing or resizing a box in the loupe changes the library only. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index b9aaa2256..cd226d6f2 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -27,7 +27,7 @@ commands: pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh) layers enforce the crate dependency layering (plan/architecture.md §3) parity [--write] - check docs/parity.md (every cmd:/ctl: id and path it cites exists) and print the + check docs/parity-checklist.md (every cmd:/ctl: id and path it cites exists) and print the Lightroom parity summary; --write refreshes the summary table in the document wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (+ the web app) web [--serve [port]] [--dev] diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index bad6a5b1d..04bffe06e 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -1,4 +1,4 @@ -//! `cargo xtask parity`: the Lightroom parity tracker (`docs/parity.md`). +//! `cargo xtask parity`: the Lightroom parity tracker (`docs/parity-checklist.md`). //! //! - **Reference check** (in `ci`): every `` `cmd:` `` must be a registered command (engine command specs, as //! listed by `lightcraft-cli commands --json`, or a UI command from `UI_COMMANDS`), every `` `ctl:` `` a develop @@ -10,7 +10,7 @@ use std::collections::{BTreeSet, HashSet}; use std::path::Path; -pub const DOC: &str = "docs/parity.md"; +pub const DOC: &str = "docs/parity-checklist.md"; const BEGIN: &str = ""; const END: &str = "";