# Conflicts:
#	docs/gaps.md
This commit is contained in:
Brandon Thomas
2026-10-14 21:57:58 -04:00
11 changed files with 255 additions and 12 deletions
+16 -2
View File
@@ -843,7 +843,7 @@ impl ExportOptions {
"jxlEffort" => one_of(k, v, &["fast", "normal"])?,
"dngCompression" => {
serde_json::from_value::<DngCompression>(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<crate::merge::ByteReader>,
packet: String,
dng: Option<DngCompression>,
/// Lossy DNG: JPEG quality and the long edge to scale to (`None`: full size).
quality: u8,
lossy_edge: Option<u32>,
/// 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
+29 -1
View File
@@ -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);
}
+1 -1
View File
@@ -238,7 +238,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"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"},
+1 -1
View File
@@ -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.
+102
View File
@@ -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<u32>,
},
}
/// 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<u16> {
(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<u32>) -> Result<RawImage> {
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<u16> = 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<Vec<u8>> {
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<Vec<u8>> {
}
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<Vec<u8>> = (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::<Result<_>>()?;
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<Vec<u8>> {
.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 {
+95
View File
@@ -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<u16> = (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<u32>) -> Vec<u8> {
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<f32> = (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)));
}
+3 -1
View File
@@ -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]);
+1 -1
View File
@@ -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| {
+4 -3
View File
@@ -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 |
+2 -1
View File
@@ -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 |
+1 -1
View File
@@ -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 |