mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
Merge PR #1047
# Conflicts: # crates/pipeline/src/spots.rs # docs/gaps.md
This commit is contained in:
commit
d3c024e4df
9 files changed
+161
-7
No files matched your search
@@ -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<Vec<u8>, 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)
|
||||
}
|
||||
@@ -3152,6 +3164,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::<f32>() / back.data.len() as f32;
|
||||
let mean_tif: f32 = reference.data.iter().map(|p| p[1] as f32 / 255.0).sum::<f32>() / 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() {
|
||||
|
||||
@@ -239,7 +239,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
||||
"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)"},
|
||||
|
||||
@@ -364,3 +364,64 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
|
||||
}
|
||||
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<String>,
|
||||
) -> Result<Vec<u8>> {
|
||||
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<f32> = 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 })
|
||||
}
|
||||
@@ -49,7 +49,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;
|
||||
|
||||
@@ -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<f32> {
|
||||
(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));
|
||||
}
|
||||
@@ -679,13 +679,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."
|
||||
|
||||
@@ -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) |
|
||||
+2
-1
@@ -41,7 +41,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
|
||||
| 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) |
|
||||
| 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 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) |
|
||||
@@ -79,6 +79,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
|
||||
| 2026-10-12 | minor | Gap 23 (accessibility): the AccessKit tree audit and its fixes landed; what is left is canvas placement by keyboard and a real screen-reader session; 10–20 h → 4–8 h; gap not closed |
|
||||
| 2026-10-12 | minor | Gap 22: Linux previews use the display profile from colord |
|
||||
| 2026-10-12 | minor | Gap 27: the CPU ↔ GPU equivalence suite passes on an AMD Radeon 8060S (Vulkan, Linux), the first non-Apple GPU verified; 15–30 h → 13–27 h; gap not closed |
|
||||
| 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 |
|
||||
|
||||
@@ -410,6 +410,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 |
|
||||
|
||||
|
||||
Reference in new issue
Block a user