From a1d7c488f817e23841e6e0330e2509a6988591dc Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Mon, 12 Oct 2026 04:33:12 +1100 Subject: [PATCH] LR-EXP-RENDERDNG: Render to DNG export format (linear float DNG of the rendered photo) New export format `rendereddng` (export dialog: Format DNG, "DNG of: Rendered photo"; `app.export`; MCP export tool): the rendered photo, with the edit, crop, size, output sharpening and watermark baked in, written by the new `lightcraft_raw::write_rendered_dng` as a LinearRaw DNG of 16-bit float Deflate tiles in the export colour space. ColorMatrix1 (XYZ -> space), ForwardMatrix1 (space -> XYZ D50), a neutral AsShotNeutral and an identity ProfileToneCurve make raw converters show it as rendered. HDR edits keep highlights above white. Bad input (wrong sample count, zero or huge sizes, NaN/inf, out of half-float range) gives an error or is clamped, never a panic. Also fixes the Rust 1.99 clippy lint chunks_exact_to_as_chunks in pipeline/spots.rs that fails `cargo xtask ci` on main. Sources: Adobe DNG Specification 1.4+ (LinearRaw, ColorMatrix/ForwardMatrix, ProfileToneCurve); own design. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/export.rs | 34 +++++++++++++++- crates/mcp/src/tools.rs | 2 +- crates/pipeline/src/spots.rs | 4 +- crates/raw/src/dngwrite.rs | 61 ++++++++++++++++++++++++++++ crates/raw/src/lib.rs | 2 +- crates/raw/tests/rendered_dng.rs | 50 +++++++++++++++++++++++ crates/ui-egui/src/panels/dialogs.rs | 10 ++++- docs/file-format-parity.md | 5 ++- docs/gaps.md | 5 ++- docs/parity-checklist.md | 1 + 10 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 crates/raw/tests/rendered_dng.rs diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index f356d6e4..2985949e 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -28,6 +28,8 @@ pub enum ExportFormat { Original, /// A DNG of the raw data (raw photos only), the edits embedded as XMP. Dng, + /// Render to DNG: a linear DNG (16-bit float) of the rendered photo, the edit baked in. + RenderedDng, } impl ExportFormat { @@ -42,6 +44,7 @@ impl ExportFormat { "jxl" | "jpegxl" | "jpeg-xl" | "jpeg_xl" => Self::Jxl, "original" => Self::Original, "dng" => Self::Dng, + "rendereddng" | "renderdng" | "render-dng" | "lineardng" => Self::RenderedDng, _ => return None, }) } @@ -56,7 +59,7 @@ impl ExportFormat { Self::Jxl => "jxl", // the original keeps its own extension (see `ExportOptions::file_name_for`) Self::Original => "", - Self::Dng => "dng", + Self::Dng | Self::RenderedDng => "dng", } } @@ -1133,6 +1136,8 @@ impl ExportOptions { (ExportFormat::Tiff, Some(32)) if self.hdr => OutputDepth::F32Hdr, (ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear, (ExportFormat::Tiff, _) => OutputDepth::U16, + (ExportFormat::RenderedDng, _) if self.hdr => OutputDepth::F32Hdr, + (ExportFormat::RenderedDng, _) => OutputDepth::F32Linear, (ExportFormat::Psd, Some(8)) => OutputDepth::U8, (ExportFormat::Psd, _) => OutputDepth::U16, (ExportFormat::Avif, _) if self.hdr => OutputDepth::F32Hdr, @@ -1151,6 +1156,7 @@ impl ExportOptions { ExportFormat::Png => &[(8, "8-bit"), (16, "16-bit")], ExportFormat::Tiff => &[(16, "16-bit"), (8, "8-bit"), (32, "32-bit float")], ExportFormat::Psd => &[(16, "16-bit"), (8, "8-bit")], + ExportFormat::RenderedDng => &[(16, "16-bit float")], ExportFormat::Avif => &[(8, "8-bit"), (10, "10-bit")], ExportFormat::Jxl => &[(8, "8-bit"), (16, "16-bit")], } @@ -1341,6 +1347,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta), ExportFormat::Jxl => lightcraft_codecs::encode_jxl(&e, &o.jxl(space, false), &meta), f @ (ExportFormat::Original | ExportFormat::Dng) => return Err(format!("{f:?} export does not encode pixels")), + ExportFormat::RenderedDng => return Err("Render to DNG needs a float render".into()), }; r.map_err(|e| e.to_string()) } @@ -1431,6 +1438,7 @@ pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, met /// Encode a high-bit-depth image (see [`encode_rendered`]). pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { + let meta_src = meta; let img = finish_deep(img, o); let profile = match img.samples { DeepSamples::F32(_) => icc::write_matrix_trc(&img.space.rgb_space(), &lightcraft_codecs::Trc::Linear), @@ -1449,6 +1457,10 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) (ExportFormat::Png, _) => encode::encode_png(&e, &meta), (ExportFormat::Tiff, _) => encode::encode_tiff(&e, o.tiff_compression, &meta), (ExportFormat::Psd, _) => encode::encode_psd(&e, &meta), + (ExportFormat::RenderedDng, DeepSamples::F32(v)) => { + let m = meta_src.cloned().unwrap_or_default(); + return lightcraft_raw::write_rendered_dng(img.width, img.height, v, &img.space.rgb_space(), &m, xmp.clone()).map_err(|e| e.to_string()); + } (ExportFormat::Avif, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => { lightcraft_codecs::encode_avif_pq(w, h, v, o.quality, 8, &meta) } @@ -3109,6 +3121,26 @@ mod tests { } // Issue #496: photos rendered side by side give the same files, names, order and progress as + // Render to DNG: the rendered photo as a linear float DNG that reads back as the same picture + #[test] + fn rendered_dng_matches_the_rendered_tiff() { + let mut s = crate::Session::with_demo(); + let id = s.visible()[0]; + let dng = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "rendereddng", "width": 64})), 1).unwrap(); + assert!(dng.file_name.ends_with(".dng"), "{}", dng.file_name); + let png = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "png", "width": 64})), 1).unwrap(); + let raw = lightcraft_raw::decode(&dng.bytes).unwrap(); + assert_eq!((raw.width, raw.height, raw.cpp), (dng.width, dng.height, 3)); + assert!(raw.color.profile.tone_curve.is_some(), "identity tone curve keeps converters from adding one"); + let back = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); + let reference = lightcraft_codecs::decode_thumbnail(&png.bytes, 4096).unwrap().image; + assert_eq!((reference.width, reference.height), (back.width, back.height)); + // mean brightness: the PNG holds 8-bit sRGB, the DNG linear sRGB + let mean_dng: f32 = back.data.iter().map(|p| lightcraft_color::transfer::linear_to_srgb(p[1].min(1.0))).sum::() / back.data.len() as f32; + let mean_tif: f32 = reference.data.iter().map(|p| p[1] as f32 / 255.0).sum::() / reference.data.len() as f32; + assert!((mean_dng - mean_tif).abs() < 0.02, "{mean_dng} vs {mean_tif}"); + } + // one at a time, and a cancel stops at the same photo #[test] fn side_by_side_batches_match_one_at_a_time() { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 93b0567d..556e0d1a 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -239,7 +239,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "conflict": {"type": "string", "enum": ["unique", "overwrite", "skip"]}, "tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"]}, "dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed"]}, - "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"}, + "format": {"type": "string", "enum": ["jpeg", "png", "tiff", "psd", "webp", "avif", "jxl", "original", "dng", "rendereddng"], "description": "psd = a flat PSD (no layers); rendereddng = Render to DNG, a linear 16-bit float DNG of the rendered photo with the edit baked in; 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"}, "width": {"type": "integer", "description": "Output width in px (with height: fit inside width × height, either orientation)"}, diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3..f8b3fbbd 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/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index 2f04f016..ba71825f 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -362,3 +362,64 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { } Ok(TiffWriter::new(opts.order, false).write(&[ifd])?) } + +/// Exif `LightSource` code of a white point the rendered DNG writer knows (D65 = 21, D50 = 23). +fn illuminant_of(white: lightcraft_color::Xy) -> u16 { + if (white.x - lightcraft_color::D50.x).abs() < 1e-3 && (white.y - lightcraft_color::D50.y).abs() < 1e-3 { 23 } else { 21 } +} + +/// Render to DNG: a linear DNG (`LinearRaw`, 16-bit float Deflate tiles) of an already rendered, +/// oriented image. `rgb` is `width × height` interleaved linear RGB in `space` (white = 1; values +/// above 1 are kept up to the half-float maximum, negative and non-finite ones become 0). The colour tags describe `space` +/// exactly — `ColorMatrix1` = XYZ → space, `ForwardMatrix1` = space → XYZ D50, a neutral +/// `AsShotNeutral` — and an identity `ProfileToneCurve` keeps raw converters from adding their +/// default tone curve on top of the baked-in edit. +pub fn write_rendered_dng( + width: usize, + height: usize, + rgb: &[f32], + space: &lightcraft_color::RgbSpace, + metadata: &lightcraft_meta::Metadata, + xmp: Option, +) -> Result> { + let n = width.checked_mul(height).and_then(|p| p.checked_mul(3)).ok_or(RawError::Limit("image too large"))?; + if width == 0 || height == 0 || width > u32::MAX as usize || height > u32::MAX as usize { + return Err(RawError::Corrupt(format!("cannot write a {width}×{height} DNG"))); + } + let src = rgb.get(..n).ok_or_else(|| RawError::Corrupt(format!("{} samples for a {width}×{height} RGB image", rgb.len())))?; + let data: Vec = src.iter().map(|&v| if v.is_finite() { v.clamp(0.0, 65504.0) } else { 0.0 }).collect(); + let full = crate::Rect::new(0, 0, width, height); + let forward = lightcraft_color::bradford(space.white, lightcraft_color::D50).mul(&space.to_xyz()); + let color = crate::ColorData { + illuminant: [illuminant_of(space.white), 0], + color_matrix: [Some(space.from_xyz()), None], + forward_matrix: [Some(forward), None], + as_shot_neutral: Some([1.0; 3]), + profile: crate::profile::ProfileLook { + tone_curve: Some(crate::profile::ToneCurve { points: vec![[0.0, 0.0], [1.0, 1.0]] }), + ..Default::default() + }, + ..Default::default() + }; + let raw = RawImage { + format: crate::RawFormat::Dng, + width, + height, + cpp: 3, + data: RawData::F32(data), + cfa: None, + bits: 16, + black: crate::BlackLevel::uniform(0.0), + white: vec![1.0], + active_area: full, + crop: full, + orientation: lightcraft_geom::Orientation::Normal, + color, + wb_multipliers: None, + linearized: false, + opcodes: crate::OpcodeLists::default(), + metadata: metadata.clone(), + }; + let order = ByteOrder::Little; + write_dng(&raw, &DngWriteOptions { compression: DngCompression::Deflate { tile: 256, half: true }, order, xmp }) +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 8a09a9ed..50596d3a 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -48,7 +48,7 @@ mod unpack; mod vendor; pub use demosaic::{Method, demosaic}; -pub use dngwrite::{DngCompression, DngWriteOptions, write_dng}; +pub use dngwrite::{DngCompression, DngWriteOptions, write_dng, write_rendered_dng}; pub use lightcraft_color::Mat3; pub use lightcraft_geom::Orientation; pub use lightcraft_meta::Metadata; diff --git a/crates/raw/tests/rendered_dng.rs b/crates/raw/tests/rendered_dng.rs new file mode 100644 index 00000000..fad006fe --- /dev/null +++ b/crates/raw/tests/rendered_dng.rs @@ -0,0 +1,50 @@ +//! Render to DNG: a linear DNG of a rendered image reads back as the same pixels and colours. + +use lightcraft_color::{Mat3, PROPHOTO, REC2020, SRGB}; +use lightcraft_raw::{Method, color, write_rendered_dng}; + +fn image(w: usize, h: usize) -> Vec { + (0..w * h).flat_map(|i| [(i % w) as f32 / w as f32, (i / w) as f32 / h as f32, 0.25 + (i % 7) as f32 * 0.25]).collect() +} + +fn close(a: &Mat3, b: &Mat3, tol: f64) -> bool { + a.0.iter().flatten().zip(b.0.iter().flatten()).all(|(x, y)| (x - y).abs() < tol) +} + +#[test] +fn rendered_dng_round_trips_pixels_and_colour() { + let (w, h) = (300usize, 70usize); + let rgb = image(w, h); + for space in [SRGB, PROPHOTO] { + let meta = lightcraft_meta::Metadata { make: Some("Maker".into()), model: Some("Model".into()), ..Default::default() }; + let bytes = write_rendered_dng(w, h, &rgb, &space, &meta, None).unwrap(); + let raw = lightcraft_raw::decode(&bytes).unwrap(); + assert_eq!((raw.width, raw.height, raw.cpp, raw.cfa.is_none()), (w, h, 3, true)); + assert_eq!(raw.metadata.model.as_deref(), Some("Model")); + let dev = raw.develop(Method::Ahd).unwrap(); + for (p, q) in rgb.as_chunks::<3>().0.iter().zip(&dev.data) { + for k in 0..3 { + // above-white samples (1.25, 1.5, 1.75) are clipped by white level 1 on develop + let want = p[k].min(1.0); + assert!((q[k].min(1.0) - want).abs() <= want * 1e-3 + 1e-4, "{space:?} {p:?} {q:?}"); + } + } + // the camera transform is the space → Rec. 2020 matrix under a neutral white + let t = color::camera_transform(&raw, color::as_shot_white_xy(&raw)); + assert!(!t.matrix_is_fallback); + assert!(t.wb.iter().all(|v| (v - 1.0).abs() < 1e-3), "{:?}", t.wb); + assert!(close(&t.matrix, &space.to_space(&REC2020), 2e-3), "{}: {:?} vs {:?}", space.name, t.matrix, space.to_space(&REC2020)); + } +} + +#[test] +fn rendered_dng_rejects_bad_input_without_panicking() { + let m = lightcraft_meta::Metadata::default(); + assert!(write_rendered_dng(0, 4, &[], &SRGB, &m, None).is_err()); + assert!(write_rendered_dng(4, 4, &[0.5; 10], &SRGB, &m, None).is_err()); + assert!(write_rendered_dng(usize::MAX, 3, &[0.5; 10], &SRGB, &m, None).is_err()); + let odd = [f32::NAN, f32::INFINITY, -2.0, 70000.0, 0.5, 1.0]; + let raw = lightcraft_raw::decode(&write_rendered_dng(2, 1, &odd, &SRGB, &m, None).unwrap()).unwrap(); + let dev = raw.develop(Method::Bilinear).unwrap(); + assert!(dev.data.iter().flatten().all(|v| v.is_finite() && *v >= 0.0)); +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca..03048231 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -678,13 +678,21 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }); ui.add_space(4.0); let before = opts.format; + // "Rendered DNG" is a DNG option (below), not a format of its own in this list + let mut shown = if opts.format == F::RenderedDng { F::Dng } else { opts.format }; choices( ui, "Format", "exportFormat", &[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Psd, "PSD"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Jxl, "JPEG XL"), (F::Dng, "DNG"), (F::Original, "Original files")], - &mut opts.format, + &mut shown, ); + if shown != opts.format && !(shown == F::Dng && opts.format == F::RenderedDng) { + opts.format = shown; + } + if matches!(opts.format, F::Dng | F::RenderedDng) { + choices(ui, "DNG of", "exportDngKind", &[(F::Dng, "Raw data"), (F::RenderedDng, "Rendered photo")], &mut opts.format); + } if !opts.format.is_rendered() { let note = if opts.format == F::Dng { "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply." diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 79c66b69..d4bfb8fa 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 (Render to DNG as an export format) · **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). @@ -25,7 +25,7 @@ import and export formats. Ours come from `crates/codecs/src/sniff.rs`, `crates/ | 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 | +| Render to DNG (15.5) | — | ✅ | — | 🟡 export format "Rendered DNG" (linear float DNG, edit baked in); not yet added to the catalog | `crates/raw/src/dngwrite.rs` (`write_rendered_dng`) | | 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, PQ JPEG XL, float TIFF; no HLG | | @@ -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 | Render to DNG: "Rendered DNG" export format (linear 16-bit float DNG of the rendered photo) | diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f5622..a799b85c 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: Render to DNG export format; 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**: lossy DNG; AVIF import; Render to DNG as a catalog command (the export format landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–34 | [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: Render to DNG lands as an export format; lossy 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 77ae26e4..1b0c6f81 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -399,6 +399,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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-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-RENDERDNG | Render to DNG (Classic 15.5) | P2 | 🟡 | `cmd:app.export` (`format: rendereddng`), `crates/raw/src/dngwrite.rs` (`write_rendered_dng`), `crates/raw/tests/rendered_dng.rs` | export format `rendereddng` (dialog: Format DNG, "DNG of: Rendered photo"): the rendered photo (edit, crop, size, sharpening, watermark baked in) as a LinearRaw DNG of 16-bit float Deflate tiles in the export colour space, with `ColorMatrix1` / `ForwardMatrix1` for that space, a neutral `AsShotNeutral` and an identity `ProfileToneCurve` so raw converters show it as rendered; HDR edits keep highlights above white. Not a menu command on selected photos (Lightroom adds the DNG to the catalog next to the original); export only | | 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 |