From cc3eb1c846a1136ec52c854b2aa86dd20750dd34 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Mon, 12 Oct 2026 02:40:10 +1100 Subject: [PATCH 1/2] LR-EXP-DNGOPT: lossy DNG export (DNG 1.4 compression 34892) DNG export gains a "Lossy" compression: the raw data is demosaiced to linear camera RGB (OpcodeList3 and the default crop applied), scaled to the resize long edge when one is set, and stored as 8-bit baseline JPEG tiles at the export quality. The codes follow a 1/2.2 power curve that a LinearizationTable maps back to linear 16-bit values; colour matrices, white balance and the profile look are kept. Available in the export dialog, `app.export` (`dngCompression: "lossy"`) and the MCP export tool. Also fixes a new clippy lint (chunks_exact_to_as_chunks) in pipeline/spots.rs that fails `cargo xtask ci` on Rust 1.99. Sources: Adobe DNG Specification 1.4+ (Compression 34892, LinearizationTable); own design for the code curve. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/export.rs | 18 ++++- crates/engine/src/tests_xmp.rs | 30 +++++++- crates/mcp/src/tools.rs | 2 +- crates/pipeline/src/spots.rs | 4 +- crates/raw/Cargo.toml | 2 +- crates/raw/src/dngwrite.rs | 102 +++++++++++++++++++++++++++ crates/raw/tests/lossy_dng.rs | 95 +++++++++++++++++++++++++ crates/raw/tests/robust.rs | 4 +- crates/ui-egui/src/panels/dialogs.rs | 2 +- docs/file-format-parity.md | 9 +-- docs/gaps.md | 5 +- docs/parity-checklist.md | 2 +- 12 files changed, 259 insertions(+), 16 deletions(-) create mode 100644 crates/raw/tests/lossy_dng.rs diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index f356d6e4d..033de22fb 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -928,7 +928,7 @@ impl ExportOptions { "jxlEffort" => one_of(k, v, &["fast", "normal"])?, "dngCompression" => { serde_json::from_value::(v.clone()) - .map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed")))?; + .map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed|lossy")))?; } "metadata" => one_of(k, v, &["all", "allExceptCamera", "copyright", "none"])?, "colorSpace" => { @@ -1579,6 +1579,9 @@ enum Work { read: Option, packet: String, dng: Option, + /// Lossy DNG: JPEG quality and the long edge to scale to (`None`: full size). + quality: u8, + lossy_edge: Option, /// The original must not carry a location ([`removes_location`]). remove_location: bool, label: String, @@ -1642,6 +1645,11 @@ fn prepare_guarded( read: session.media.file_bytes.clone(), packet: crate::sidecar::sidecar_packet_with(p, &session.catalog, removes_location(p, &session.catalog, o)), dng, + quality: o.quality, + lossy_edge: o.resize.is_some().then(|| { + let (w, h) = output_size(p, o); + u32::try_from(w.max(h)).unwrap_or(u32::MAX) + }), remove_location: removes_location(p, &session.catalog, o), label: p.file_name.clone(), size: (p.width as usize, p.height as usize), @@ -1681,7 +1689,7 @@ impl PreparedExport { }; Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new(), gpu_fallback }) } - Work::File { path, read, packet, dng, label, size, remove_location } => { + Work::File { path, read, packet, dng, quality, lossy_edge, label, size, remove_location } => { let bytes = match &read { Some(r) => r(&path)?, None => std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?, @@ -1711,6 +1719,9 @@ impl PreparedExport { DngCompression::Lossless => lightcraft_raw::DngCompression::Lj92 { tile: 256 }, DngCompression::Deflate => lightcraft_raw::DngCompression::Deflate { tile: 256, half: false }, DngCompression::Uncompressed => lightcraft_raw::DngCompression::Uncompressed, + DngCompression::Lossy => { + lightcraft_raw::DngCompression::LossyJpeg { tile: 256, quality: quality.clamp(1, 100), long_edge: lossy_edge } + } }; let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), compression, ..Default::default() }) .map_err(|e| e.to_string())?; @@ -1730,6 +1741,9 @@ pub enum DngCompression { /// Deflate (zip) tiles with prediction. Deflate, Uncompressed, + /// Lossy DNG: demosaiced linear data as JPEG tiles at the export quality, scaled to the resize + /// setting when there is one (Lightroom's "Use Lossy Compression"). + Lossy, } /// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index c768078ce..7fcfebb73 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -850,7 +850,7 @@ fn dng_export_compression_choices() { s.execute("library.import", &json!({"paths": [corpus.to_string_lossy()]})).unwrap(); let id = s.active().unwrap(); let mut sizes = Vec::new(); - for c in ["lossless", "deflate", "uncompressed"] { + for c in ["lossless", "deflate", "uncompressed", "lossy"] { let o = crate::export::ExportOptions::from_json(&json!({"format": "dng", "dngCompression": c})); let mut out = Vec::new(); let mut write = |_: &str, b: &[u8]| { @@ -863,6 +863,7 @@ fn dng_export_compression_choices() { sizes.push((c, out.len())); } assert!(sizes[2].1 > sizes[0].1 && sizes[2].1 > sizes[1].1, "uncompressed is the biggest: {sizes:?}"); + assert!(sizes[3].1 < sizes[0].1 / 2, "lossy is much smaller than lossless: {sizes:?}"); } fn write_jpeg(path: &Path, capture: Option<&str>) { @@ -1124,3 +1125,30 @@ fn files_sharing_a_stem_get_their_own_sidecars() { let _ = std::fs::remove_dir_all(&src); let _ = std::fs::remove_dir_all(&lib); } + +/// Lossy DNG export: demosaiced JPEG tiles at the resize long edge, smaller than lossless, colour tags kept. +#[test] +fn lossy_dng_export_scales_and_shrinks() { + let src = temp_dir("lossydng"); + std::fs::write(src.join("synth.dng"), synthetic_dng_with(None, Default::default())).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let id = s.catalog.photos().next().unwrap().id; + let mut export = |p: serde_json::Value| { + let o = crate::export::ExportOptions::from_json(&p); + let mut out = Vec::new(); + let mut write = |_: &str, b: &[u8]| { + out = b.to_vec(); + Ok(()) + }; + crate::export::export_batch(&mut s, &[id], &o, &crate::export::Destination { dir: "x".into(), exact: None }, &mut write, &|_| false).unwrap(); + out + }; + let lossless = export(json!({"format": "dng"})); + let lossy = export(json!({"format": "dng", "dngCompression": "lossy", "quality": 80, "longEdge": 32})); + let raw = lightcraft_raw::decode(&lossy).unwrap(); + assert_eq!((raw.cpp, raw.cfa.is_none(), raw.width.max(raw.height)), (3, true, 32)); + assert_eq!(raw.color.as_shot_neutral, Some([0.5, 1.0, 0.7])); + assert!(lossy.len() < lossless.len(), "{} vs {}", lossy.len(), lossless.len()); + let _ = std::fs::remove_dir_all(&src); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 93b0567d8..553416def 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -238,7 +238,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "subfolder": {"type": "string"}, "conflict": {"type": "string", "enum": ["unique", "overwrite", "skip"]}, "tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"]}, - "dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed"]}, + "dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed", "lossy"]}, "format": {"type": "string", "enum": ["jpeg", "png", "tiff", "psd", "webp", "avif", "jxl", "original", "dng"], "description": "psd = a flat PSD (no layers); original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"}, "longEdge": {"type": "integer", "description": "Output long edge in px. When no size param is given: 3000; 0 = full size (cropped, native resolution)"}, "shortEdge": {"type": "integer", "description": "Output short edge in px"}, diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..f8b3fbbdf 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } diff --git a/crates/raw/Cargo.toml b/crates/raw/Cargo.toml index 2ca9ef246..5b1706bde 100644 --- a/crates/raw/Cargo.toml +++ b/crates/raw/Cargo.toml @@ -20,6 +20,7 @@ rayon = { workspace = true } miniz_oxide = { workspace = true } zune-jpeg = { workspace = true } zune-core = { workspace = true } +jpeg-encoder = { workspace = true } jxl-oxide = { workspace = true, optional = true } [features] @@ -28,7 +29,6 @@ default = ["jxl"] jxl = ["dep:jxl-oxide"] [dev-dependencies] -jpeg-encoder = { workspace = true } proptest = { workspace = true } zune-jpegxl = { workspace = true } # Pinned CC0 corpus identities; not a product dependency. diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index 2f04f016b..88ad740fb 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -23,6 +23,67 @@ pub enum DngCompression { tile: u32, half: bool, }, + /// Lossy DNG (DNG 1.4 compression 34892): the raw data demosaiced to linear camera RGB (with `OpcodeList3` + /// and the default crop applied), scaled down to at most `long_edge` pixels on the long side when given, + /// then stored as 8-bit baseline JPEG tiles of `tile × tile` pixels at JPEG `quality` (1–100). The 8-bit + /// codes follow a 1/2.2 power curve that a `LinearizationTable` maps back to linear 16-bit values, so + /// shadows keep their precision. The colour tags (matrices, white balance, profile look) are kept. + LossyJpeg { + tile: u32, + quality: u8, + long_edge: Option, + }, +} + +/// Gamma of the 8-bit codes in a lossy DNG (own choice: perceptually close to even steps, like sRGB). +const LOSSY_GAMMA: f64 = 2.2; + +/// The 256-entry `LinearizationTable` of a lossy DNG: code → linear 16-bit value. +fn lossy_linearization() -> Vec { + (0..256u32).map(|i| ((i as f64 / 255.0).powf(LOSSY_GAMMA) * 65535.0).round() as u16).collect() +} + +/// The image a lossy DNG stores: `raw` developed to linear camera RGB (white = 1), scaled to fit +/// `long_edge`, as 8-bit codes (in `u16` samples) on the [`LOSSY_GAMMA`] curve, with the levels, +/// geometry and opcode lists that this data needs (black 0, white 65535 after linearization). +fn lossy_source(raw: &RawImage, long_edge: Option) -> Result { + let mut rgb = raw.develop(crate::Method::Ahd)?; + if let Some(edge) = long_edge.map(|e| e.max(1) as usize) { + let long = rgb.width.max(rgb.height); + if long > edge { + let s = edge as f64 / long as f64; + let w = ((rgb.width as f64 * s).round() as usize).max(1); + let h = ((rgb.height as f64 * s).round() as usize).max(1); + rgb = lightcraft_raster::resample::resize(&rgb, w, h, lightcraft_raster::resample::Filter::Lanczos3); + } + } + let (w, h) = (rgb.width, rgb.height); + if w == 0 || h == 0 { + return Err(RawError::Corrupt("empty image".into())); + } + let inv = 1.0 / LOSSY_GAMMA as f32; + let data: Vec = rgb + .data + .par_iter() + .flat_map_iter(|px| px.map(|v| if v.is_finite() { (v.clamp(0.0, 1.0).powf(inv) * 255.0).round() as u16 } else { 0 })) + .collect(); + let full = crate::Rect::new(0, 0, w, h); + Ok(RawImage { + width: w, + height: h, + cpp: 3, + data: RawData::U16(data), + cfa: None, + bits: 8, + black: crate::BlackLevel::default(), + white: vec![65535.0], + active_area: full, + crop: full, + // list 1 and 2 work on the undemosaiced data and list 3 is applied by `develop` + opcodes: crate::OpcodeLists::default(), + linearized: true, + ..raw.clone() + }) } /// IEEE 754 binary32 → binary16 (round to nearest even; overflow → ±inf, NaN kept). @@ -98,6 +159,14 @@ fn rat_vec(v: &[f64]) -> Value { /// Serialise `raw` as a DNG. pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { raw.validate()?; + let lossy; + let raw = match opts.compression { + DngCompression::LossyJpeg { long_edge, .. } => { + lossy = lossy_source(raw, long_edge)?; + &lossy + } + _ => raw, + }; let (w, h, cpp) = (raw.width, raw.height, raw.cpp); let mut ifd = IfdBuilder::new(); ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); @@ -276,6 +345,37 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { } ifd.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![bytes] }); } + (RawData::U16(v), DngCompression::LossyJpeg { tile, quality, .. }) => { + // `lossy_source` made 3-sample 8-bit codes + let tile = (tile.clamp(16, 4096) & !15) as usize; + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8; cpp])); + ifd.set(t::COMPRESSION, Value::Short(vec![compression::LOSSY_JPEG])); + ifd.set(t::LINEARIZATION_TABLE, Value::Short(lossy_linearization())); + let quality = quality.clamp(1, 100); + let (ta, td) = (w.div_ceil(tile), h.div_ceil(tile)); + let tiles: Vec> = (0..ta * td) + .into_par_iter() + .map(|i| { + let (tx, ty) = (i % ta * tile, i / ta * tile); + let mut buf = Vec::with_capacity(tile * tile * 3); + for y in 0..tile { + let sy = (ty + y).min(h - 1); + for x in 0..tile { + let sx = (tx + x).min(w - 1); + for s in 0..3 { + buf.push(v.get((sy * w + sx) * cpp + s.min(cpp - 1)).map_or(0, |&c| c.min(255) as u8)); + } + } + } + let mut out = Vec::new(); + jpeg_encoder::Encoder::new(&mut out, quality) + .encode(&buf, tile as u16, tile as u16, jpeg_encoder::ColorType::Rgb) + .map(|()| out) + .map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}"))) + }) + .collect::>()?; + ifd.set_image(ImageData::Tiles { tile_width: tile as u32, tile_height: tile as u32, tiles }); + } (RawData::U16(v), _) => { let maxv = v.iter().copied().max().unwrap_or(0); let bits = (16 - maxv.leading_zeros()).max(raw.bits.min(16)).max(2) as u8; @@ -327,6 +427,8 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { .collect(); ifd.set_image(ImageData::Tiles { tile_width: tile as u32, tile_height: tile as u32, tiles }); } + // handled above (the data is converted first) + DngCompression::LossyJpeg { .. } => return Err(RawError::Unsupported("lossy DNG of this data".into())), DngCompression::Lj92 { tile } => { let tile = (tile.max(2) & !1) as usize; if tile > 65534 { diff --git a/crates/raw/tests/lossy_dng.rs b/crates/raw/tests/lossy_dng.rs new file mode 100644 index 000000000..dd34b5158 --- /dev/null +++ b/crates/raw/tests/lossy_dng.rs @@ -0,0 +1,95 @@ +//! Lossy DNG (compression 34892) round trip: write, decode, develop, compare with the source. + +use lightcraft_raw::{ + BlackLevel, Cfa, ColorData, DngCompression, DngWriteOptions, Mat3, Method, OpcodeLists, Orientation, RawData, RawFormat, RawImage, Rect, +}; + +/// A smooth Bayer mosaic (12-bit, black 64) of `w × h` with a colour matrix and a white balance. +fn mosaic(w: usize, h: usize) -> RawImage { + let data: Vec = (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let c = (x % 2) + (y % 2); + (64.0 + 3000.0 * (x as f32 / w as f32) * (0.5 + c as f32 * 0.25) + 500.0 * (y as f32 / h as f32)) as u16 + }) + .collect(); + let color = ColorData { + illuminant: [21, 0], + color_matrix: [Some(Mat3([[0.7, -0.1, -0.05], [-0.4, 1.2, 0.2], [-0.1, 0.2, 0.6]])), None], + as_shot_neutral: Some([0.5, 1.0, 0.7]), + ..Default::default() + }; + RawImage { + format: RawFormat::Dng, + width: w, + height: h, + cpp: 1, + data: RawData::U16(data), + cfa: Some(Cfa::bayer("RGGB").unwrap()), + bits: 12, + black: BlackLevel::uniform(64.0), + white: vec![4095.0], + active_area: Rect::new(0, 0, w, h), + crop: Rect::new(2, 2, w - 4, h - 4), + orientation: Orientation::Rotate90, + color, + wb_multipliers: None, + linearized: false, + opcodes: OpcodeLists::default(), + metadata: Default::default(), + } +} + +fn lossy(raw: &RawImage, long_edge: Option) -> Vec { + let opts = DngWriteOptions { compression: DngCompression::LossyJpeg { tile: 32, quality: 95, long_edge }, ..Default::default() }; + lightcraft_raw::write_dng(raw, &opts).unwrap() +} + +#[test] +fn lossy_dng_round_trips_close_to_the_source() { + let raw = mosaic(80, 56); + let bytes = lossy(&raw, None); + let back = lightcraft_raw::decode(&bytes).unwrap(); + assert_eq!((back.width, back.height, back.cpp, back.cfa.is_none()), (76, 52, 3, true), "demosaiced, default crop applied"); + assert!(back.linearized); + assert_eq!(back.orientation, raw.orientation); + assert_eq!(back.color.color_matrix, raw.color.color_matrix); + assert_eq!(back.color.as_shot_neutral, raw.color.as_shot_neutral); + let a = raw.develop(Method::Ahd).unwrap(); + let b = back.develop(Method::Ahd).unwrap(); + assert_eq!((a.width, a.height), (b.width, b.height)); + let (mut sum, mut worst) = (0f64, 0f32); + for (p, q) in a.data.iter().zip(&b.data) { + for k in 0..3 { + let d = (p[k].clamp(0.0, 1.0) - q[k]).abs(); + sum += d as f64; + worst = worst.max(d); + } + } + let mean = sum / (a.data.len() * 3) as f64; + assert!(mean < 0.01 && worst < 0.08, "mean {mean} worst {worst}"); +} + +#[test] +fn lossy_dng_scales_to_the_long_edge_and_is_smaller() { + let raw = mosaic(160, 96); + let small = lossy(&raw, Some(78)); + let back = lightcraft_raw::decode(&small).unwrap(); + assert_eq!(back.width.max(back.height), 78); + assert_eq!(back.width, 78); + assert!(back.height == 46 || back.height == 47, "{}", back.height); + let full = lightcraft_raw::write_dng(&raw, &DngWriteOptions { compression: DngCompression::Uncompressed, ..Default::default() }).unwrap(); + assert!(small.len() < full.len() / 2, "{} vs {}", small.len(), full.len()); +} + +#[test] +fn lossy_dng_of_linear_data_and_extreme_values() { + // a 3-sample linear raw with values above white, negative and non-finite samples: no panic, clipped + let (w, h) = (20usize, 12usize); + let data: Vec = (0..w * h * 3).map(|i| [2.0, -1.0, f32::NAN, 0.5, f32::INFINITY][i % 5]).collect(); + let raw = RawImage { cpp: 3, cfa: None, data: RawData::F32(data), white: vec![1.0], black: BlackLevel::uniform(0.0), ..mosaic(w, h) }; + let back = lightcraft_raw::decode(&lossy(&raw, Some(4))).unwrap(); + assert_eq!(back.width.max(back.height), 4); + let rgb = back.develop(Method::Bilinear).unwrap(); + assert!(rgb.data.iter().flatten().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); +} diff --git a/crates/raw/tests/robust.rs b/crates/raw/tests/robust.rs index 7bd5a5e54..fa51ab5fb 100644 --- a/crates/raw/tests/robust.rs +++ b/crates/raw/tests/robust.rs @@ -75,7 +75,9 @@ proptest! { #[test] fn truncation_at_every_length() { - for comp in [DngCompression::Uncompressed, DngCompression::Lj92 { tile: 16 }] { + for comp in + [DngCompression::Uncompressed, DngCompression::Lj92 { tile: 16 }, DngCompression::LossyJpeg { tile: 16, quality: 80, long_edge: None }] + { let d = sample(comp); for n in (0..d.len()).step_by(7) { exercise(&d[..n]); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca9..32cf784fe 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -879,7 +879,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } if opts.format == F::Dng { use lightcraft_engine::export::DngCompression as Z; - choices(ui, "Compression", "exportDngCompression", &[(Z::Lossless, "Lossless"), (Z::Deflate, "ZIP"), (Z::Uncompressed, "None")], &mut opts.dng_compression); + choices(ui, "Compression", "exportDngCompression", &[(Z::Lossless, "Lossless"), (Z::Deflate, "ZIP"), (Z::Uncompressed, "None"), (Z::Lossy, "Lossy")], &mut opts.dng_compression); } let naming_id = egui::Id::new("export-naming"); let tags_open = field(ui, "File name", |ui| { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 79c66b69c..140591148 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (lossy DNG export) · **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). @@ -24,7 +24,7 @@ import and export formats. Ours come from `crates/codecs/src/sniff.rs`, `crates/ | JPEG XL | ✅ | ✅ | ✅ (`jxl-oxide`) incl. DNG 1.7 JXL tiles | ✅ lossy / lossless, 8/16-bit, PQ HDR (`jixel`, native only; ⬜ web) | `crates/engine/src/tests_export.rs` | | 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` | +| DNG (from raw) | ✅ | ✅ incl. lossy, embedded original | ✅ | ✅ uncompressed, LJ92, Deflate or lossy (JPEG tiles, optional long edge); ⬜ 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` | @@ -63,13 +63,13 @@ import and export formats. Ours come from `crates/codecs/src/sniff.rs`, `crates/ | Part | Weight | Ready for real work | Kind | |---|---:|---:|---| -| Standard image import / export | 35% | ~85% | estimated (table above; missing: AVIF read, PSD write, lossy DNG) | +| Standard image import / export | 35% | ~85% | estimated (table above; missing: AVIF read, Render to 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: **26–47 h** (AVIF decode 8–15 h if a permissive decoder is written or found; PSD write 4–8 h; lossy DNG and Render to DNG 4–8 h; HLG 3–5 h; catalog import depth 8–12 h). +Remaining: **21–36 h** (AVIF decode 8–15 h if a permissive decoder is written or found; Render to DNG 2–4 h; HLG 3–5 h; catalog import depth 8–12 h). ## Revision history @@ -77,3 +77,4 @@ Remaining: **26–47 h** (AVIF decode 8–15 h if a permissive decoder is writte |---|---|---| | 2026-10-10 | major | Created from the code and the installed Lightroom's document types | | 2026-10-10 | minor | JPEG XL export (#247): JXL write ✅ (native only), PQ JPEG XL HDR | +| 2026-10-12 | minor | Lossy DNG export (JPEG tiles, `dngCompression: "lossy"`); PSD write had already landed (#953), so it leaves the remaining hours | diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..37b69ba6a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 25: lossy DNG export; earlier: CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **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 @@ -39,7 +39,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 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**: lossy DNG, Render to DNG; AVIF import (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 21–37 | [file-format-parity](file-format-parity.md) | +| 25 | **File writes**: Render to DNG; AVIF import (lossy DNG export landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–33 | [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; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | 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) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 25: lossy DNG export (DNG compression 34892) lands; Render to DNG and AVIF import remain | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | | 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..c139c74e4 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -397,7 +397,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes | | LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet | -| LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP or none; no embedded JPEG preview size, no lossy DNG output | +| LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP, none, or lossy (demosaiced linear camera RGB as 8-bit JPEG tiles at the export quality, a `LinearizationTable` restoring linear values, scaled to the resize long edge when set; `crates/raw/tests/lossy_dng.rs`); no embedded JPEG preview size | | LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | | LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | `cmd:app.export` (`addToPhotos`, `photosAlbum`), `cmd:export.addToPhotos`, `cmd:export.photosImports`, `crates/engine/src/apple_photos.rs`, `crates/ui-egui/src/panels/dialogs.rs`, `packaging/macos/entitlements.plist`, `docs/apple-photos.md` | macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by `/usr/bin/osascript`; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and `lightcraft-cli` all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in `docs/apple-photos.md`). Other platforms' photo libraries are not covered | | LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. No "Edit a copy / Edit original" choice for non-raw files yet | From 971ad25b74e2198602acf7adb0c8ae80517d3655 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Wed, 14 Oct 2026 22:22:15 +1100 Subject: [PATCH 2/2] docs: leave upstream's remaining-hours figure unchanged (the estimate was ours) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/file-format-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2d073ad11..df475a653 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -69,7 +69,7 @@ import and export formats. Ours come from `crates/codecs/src/sniff.rs`, `crates/ | 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: **20–34 h** (AVIF decode 8–15 h if a permissive decoder is written or found; Render to DNG in the catalog 1–2 h; HLG 3–5 h; catalog import depth 8–12 h). +Remaining: **26–47 h** (AVIF decode 8–15 h if a permissive decoder is written or found; 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