diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 2bfa6a5ea..8d22f6d06 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -843,7 +843,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" => { @@ -1506,6 +1506,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, @@ -1575,6 +1578,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), @@ -1634,7 +1642,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}"))?, @@ -1664,6 +1672,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())?; @@ -1683,6 +1694,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 58d0d7a14..db33b82df 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -935,7 +935,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]| { @@ -948,6 +948,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>) { @@ -1209,3 +1210,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 b71e3d162..2779c50c1 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", "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"}, 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 379267c87..af7a7f44b 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() + }) } /// Software tag of the DNGs this writer makes. A bare "LightCraft" marks older files, whose CFAPattern is anchored at @@ -102,6 +163,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])); @@ -291,6 +360,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; @@ -342,6 +442,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 6dc383de8..918b07d1d 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 648e87250..d79045101 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -922,7 +922,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 d4bfb8fa9..df475a653 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-12 · **Change:** minor (Render to DNG as an export format) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (lossy DNG export; 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). @@ -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) | — | ✅ | — | 🟡 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` | @@ -63,7 +63,7 @@ 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 in the catalog) | | 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 | @@ -78,3 +78,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) | +| 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 4558f66a5..e289fa96d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -39,7 +39,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 23 | **Accessibility**: AccessKit tree audited by a test (every control named, sliders with range); left: canvas handles, spot and mask placement by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 4–8 | [ui-parity](ui-parity.md) | | 22 | **Pen pressure**, OS monitor profiles on Windows (Linux reads colord's, macOS the screen's), follow-window colour on Linux / Windows | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-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; 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) | +| 25 | **File writes**: AVIF import; Render to DNG as a catalog command (lossy DNG and the Render to DNG 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 and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; 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 | 13–27 | [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) | @@ -81,6 +81,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-12 | minor | Gap 25: Render to DNG lands as an export format; lossy DNG and AVIF import remain | | 2026-10-12 | minor | Gap 10 shrinks: the Hasselblad Lunar (a Sony NEX-7 ARW under its own Make) decodes and is verified, 236 models (#1275) | | 2026-10-12 | minor | Gap 14: HLG AVIF export (`hdrTransfer`) and a tested SDR/HDR display decision | +| 2026-10-12 | minor | Gap 25: lossy DNG export (DNG compression 34892) lands; AVIF import and Render to DNG as a catalog command 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 a9cd98ce0..781be46c3 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -400,7 +400,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-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 |