collective

Browse / github.com/storytold/lightcraft / patch

LR-EXP-TYPE: transparent sources keep their alpha in PNG, TIFF and WebP exports (#2976)

Export: transparent PNG sources lose their alpha (flattened onto white) even when exporting PNG/TIFF/WebP **Version:** LightCraft 0.14.0 (`lightcraft-cli`, Windows 11) Exporting a photo that has transparency (a PNG with an alpha channel) always flattens it onto white. Exporting to a format that can hold alpha (PNG, TIFF, WebP) drops the transparency, so logos, cut-outs and composites can't round-trip through LightCraft. ### Steps 1. Make a 300×200 RGBA PNG: fully transparent, with an opaque 150×100 rectangle in the middle. 2. Run `lightcraft-cli run --library ./lib --script steps.jsonl` with: ```json {"command":"library.import","params":{"paths":["src/t.png"]}} {"command":"library.selectAll","

Status
open
Score
0.00
Adopters
0 other operators
Author
Peaperfish (imported from pull request #3116, open)
Base
662df66f1c08bdb1b1f1d0c1c638c4cef936d0b0
Size
+759 −41 in 25 files
Review
pending
Shared
2026-10-28
Id
d9e6fafd90b451f65ab85bf0

How it went

No other operator has reported using it yet.

Diff

raw · apps/lightcraft-cli/tests/it/cli.rs apps/lightcraft-web/src/wire.rs apps/lightcraft-web/src/workers.rs crates/engine/src/export.rs crates/engine/src/export/encoding.rs crates/engine/src/export/options.rs crates/engine/src/export/prepare.rs crates/engine/src/export/tests.rs crates/engine/src/export/watermark.rs crates/engine/src/files/load.rs crates/engine/src/media.rs crates/engine/src/media/quick.rs crates/engine/src/media/render.rs crates/engine/src/media/session.rs crates/engine/src/media/source.rs crates/engine/src/slide_title.rs crates/engine/src/smart.rs crates/engine/src/tests/export.rs crates/gpu/src/render.rs crates/pipeline/src/geometry.rs crates/pipeline/src/lib.rs crates/pipeline/src/tests_geometry.rs crates/ui-egui/src/render.rs docs/parity-checklist.md docs/whats-new.md

diff --git a/apps/lightcraft-cli/tests/it/cli.rs b/apps/lightcraft-cli/tests/it/cli.rs--- a/apps/lightcraft-cli/tests/it/cli.rs+++ b/apps/lightcraft-cli/tests/it/cli.rs@@ -502,6 +502,49 @@ fn snapshot_starts_without_a_gpu() {     } } +/// Issue #2976: a transparent PNG exported as PNG kept its pixels but lost its alpha (flattened+/// onto white); JPEG still composites onto white.+#[test]+fn transparent_png_export_keeps_its_alpha() {+    let input = tmp("alpha-in.png");+    // 300 × 200, transparent, with an opaque 150 × 100 rectangle in the middle+    let img =+        lightcraft_raster::Rgba8::from_fn(+            300,+            200,+            |x, y| if (75..225).contains(&x) && (50..150).contains(&y) { [200, 40, 30, 255] } else { [0, 0, 0, 0] },+        );+    let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &Default::default()).unwrap();+    std::fs::write(&input, png).unwrap();+    let (png_out, jpg_out) = (tmp("alpha-out.png"), tmp("alpha-out.jpg"));+    for out in [&png_out, &jpg_out] {+        let _ = std::fs::remove_file(out);+        let (ok, lines, err) = run_cli(&["--import", input.to_str().unwrap(), "app.export", &format!("path={}", out.display())], None);+        assert!(ok && lines.iter().all(|l| l["ok"] == true), "{err} {lines:?}");+    }++    let d = lightcraft_codecs::decode(&std::fs::read(&png_out).unwrap(), Default::default()).unwrap();+    assert!(d.has_alpha, "the PNG keeps an alpha channel");+    assert_eq!((d.width, d.height), (300, 200));+    let px = d.to_srgb8();+    for (x, y) in [(2, 2), (297, 2), (2, 197), (297, 197), (40, 100)] {+        assert_eq!(px.get(x, y)[3], 0, "transparent at ({x}, {y})");+    }+    let centre = px.get(150, 100);+    assert_eq!(centre[3], 255, "opaque in the rectangle");+    assert!(centre[0].abs_diff(200) <= 2 && centre[1].abs_diff(40) <= 2 && centre[2].abs_diff(30) <= 2, "{centre:?}");+    // the rectangle's edge stays where it was, without a white fringe+    assert_eq!(px.get(74, 100)[3], 0);+    let edge = px.get(75, 100);+    assert_eq!(edge[3], 255);+    assert!(edge[1] < 60, "no white fringe: {edge:?}");++    let j = lightcraft_codecs::decode(&std::fs::read(&jpg_out).unwrap(), Default::default()).unwrap();+    assert!(!j.has_alpha);+    let corner = j.to_srgb8().get(2, 2);+    assert!(corner.iter().take(3).all(|&c| c >= 250), "JPEG composites onto white: {corner:?}");+}+ fn run_cli(args: &[&str], stdin: Option<&str>) -> (bool, Vec<Value>, String) {     let mut child = Command::new(binary()).arg("run").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();     if let Some(text) = stdin {diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs--- a/apps/lightcraft-web/src/wire.rs+++ b/apps/lightcraft-web/src/wire.rs@@ -123,6 +123,7 @@ impl WireJob {             proof: None,             window: self.window.map(|[x, y, w, h]| lightcraft_engine::pipeline::PixelWindow { x, y, w, h }),             display: None,+            alpha: false,         }     } diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs--- a/apps/lightcraft-web/src/workers.rs+++ b/apps/lightcraft-web/src/workers.rs@@ -97,7 +97,7 @@ async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option<Bac             match b.read(&key).await.ok().flatten().and_then(|bytes| lightcraft_preview::decode_jpeg(&bytes)) {                 Some(image) => {                     let histogram = Histogram::of_srgb8(&image);-                    return Ok((Rendered { image, histogram, deep: None }, 0));+                    return Ok((Rendered { image, histogram, deep: None, alpha: None }, 0));                 }                 None => cache_miss = true,             }@@ -325,7 +325,7 @@ impl Workers {             let rgba = Uint8Array::new(&get(&data, "rgba")).to_vec();             let hist = Uint32Array::new(&get(&data, "hist")).to_vec();             match (Rgba8::from_bytes(wd, ht, &rgba), Histogram::from_words(&hist)) {-                (Some(image), Some(histogram)) => Ok(Rendered { image, histogram, deep: None }),+                (Some(image), Some(histogram)) => Ok(Rendered { image, histogram, deep: None, alpha: None }),                 _ => Err("render worker: bad image".to_string()),             }         } else {@@ -441,7 +441,7 @@ impl RenderOffload for Workers {                 photo: job.photo,                 level: job.level,                 key: job.key,-                rendered: Ok(Rendered { image, histogram, deep: None }),+                rendered: Ok(Rendered { image, histogram, deep: None, alpha: None }),                 gpu_fallback: None,                 loaded: None,                 quick: None,diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs--- a/crates/engine/src/export.rs+++ b/crates/engine/src/export.rs@@ -8,7 +8,7 @@ use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, JxlColour, J pub use lightcraft_codecs::{JxlEffort, TiffCompression}; use lightcraft_meta::{DateTime, Gps, Metadata}; pub use lightcraft_pipeline::{DeepImage, DeepSamples, OutputDepth, OutputSpace};-use lightcraft_raster::Rgba8;+use lightcraft_raster::{Plane, Rgba8}; use serde::{Deserialize, Serialize};  #[cfg(test)]@@ -44,11 +44,11 @@ pub use prepare::{     AfterExport, Destination, DngCompression, Exported, PreparedExport, export_batch, export_photo, output_size, prepare_batch, prepare_export, }; #[cfg(test)]-use prepare::{render_weight, too_big_for_memory};-#[cfg(test)]-use watermark::watermark_coverage;+use prepare::{can_carry_alpha, render_weight, too_big_for_memory}; pub(crate) use watermark::watermark_logo; pub use watermark::{Anchor, Watermark, draw_watermark, draw_watermark_deep, draw_watermark_in, draw_watermark_preview, read_graphic};+#[cfg(test)]+use watermark::{draw_watermark_deep_over, watermark_coverage};  #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")]diff --git a/crates/engine/src/export/encoding.rs b/crates/engine/src/export/encoding.rs--- a/crates/engine/src/export/encoding.rs+++ b/crates/engine/src/export/encoding.rs@@ -1,5 +1,5 @@ use super::options::{ExportFormat, MetadataPolicy, SharpenAmount, SharpenFor};-use super::watermark::{Watermark, draw_watermark, draw_watermark_deep};+use super::watermark::{Watermark, draw_watermark, draw_watermark_deep_over}; use super::*;  /// Unsharp mask on an 8-bit image (separable box-blur approximation of a small Gaussian).@@ -156,11 +156,16 @@ fn finish_8bit(img: &Rgba8, o: &ExportOptions) -> Rgba8 {  /// Output sharpening and the watermark on a high-bit-depth render. fn finish_deep(img: &DeepImage, o: &ExportOptions) -> DeepImage {+    finish_deep_over(img, None, o)+}++/// [`finish_deep`] of an image with straight alpha `alpha`, which the watermark raises.+fn finish_deep_over(img: &DeepImage, alpha: Option<&mut Plane>, o: &ExportOptions) -> DeepImage {     let mut img = img.clone();     output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount);     if let Some(wm) = &o.watermark {         let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() };-        draw_watermark_deep(&mut img, &wm);+        draw_watermark_deep_over(&mut img, alpha, &wm);     }     img }@@ -219,17 +224,50 @@ pub fn srgb8_in(space: OutputSpace, c: [u8; 3]) -> [u8; 3] {  /// Encode a render according to `o`: its high-bit-depth samples when it has them (16-bit PNG/TIFF, /// 10-bit AVIF, 32-bit float linear TIFF), else its 8-bit image.+///+/// A render with alpha ([`lightcraft_pipeline::Rendered::alpha`]) keeps it in the formats that+/// can ([`ExportFormat::keeps_alpha`]) when it is not opaque everywhere (issue #2976). pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {+    let alpha = r.alpha.as_ref().filter(|_| o.format.keeps_alpha());     match &r.deep {-        Some(d) if o.effective_depth() != OutputDepth::U8 => encode_deep(d, o, meta),-        _ => encode_with_metadata(&r.image, o, meta),+        Some(d) if o.effective_depth() != OutputDepth::U8 => encode_deep_with_alpha(d, alpha, o, meta),+        _ => match alpha {+            Some(a) => encode_with_metadata(&with_alpha(&r.image, a)?, o, meta),+            None => encode_with_metadata(&r.image, o, meta),+        },+    }+}++/// `img` with straight alpha `a` (its size) in its fourth channel.+fn with_alpha(img: &Rgba8, a: &Plane) -> Result<Rgba8, String> {+    if (a.width, a.height) != (img.width, img.height) || a.data.len() != img.data.len() {+        return Err("export: the transparency does not match the rendered image".into());+    }+    let mut out = img.clone();+    for (p, &av) in out.data.iter_mut().zip(&a.data) {+        p[3] = if av.is_finite() { (av.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 255 };     }+    Ok(out) }  /// Encode a high-bit-depth image (see [`encode_rendered`]). pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {+    encode_deep_with_alpha(img, None, o, meta)+}++/// [`encode_deep`] keeping straight alpha `alpha` (the image's size) in PNG and TIFF when it is+/// not opaque everywhere.+fn encode_deep_with_alpha(img: &DeepImage, alpha: Option<&Plane>, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {     let meta_src = meta;-    let img = finish_deep(img, o);+    let mut alpha = match alpha {+        Some(a) if (a.width, a.height) != (img.width, img.height) => return Err("export: the transparency does not match the rendered image".into()),+        a => a.cloned(),+    };+    let img = finish_deep_over(img, alpha.as_mut(), o);+    let alpha = alpha.filter(|a| match img.samples {+        DeepSamples::U16(_) => a.data.iter().any(|&v| (v.clamp(0.0, 1.0) * 65535.0).round() < 65535.0),+        DeepSamples::F32(_) => a.data.iter().any(|&v| v < 1.0),+    });     let profile = match img.samples {         DeepSamples::F32(_) => icc::write_matrix_trc(&img.space.rgb_space(), &lightcraft_codecs::Trc::Linear),         DeepSamples::U16(_) => icc::write_named(named_space(img.space)),@@ -238,9 +276,27 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>)     let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));     let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) };     let (w, h) = (img.width as u32, img.height as u32);-    let e = match &img.samples {-        DeepSamples::U16(v) => EncodeImage::new(w, h, 3, Samples::U16(v)),-        DeepSamples::F32(v) => EncodeImage::new(w, h, 3, Samples::F32(v)),+    // RGB plus the alpha as a fourth sample+    fn rgba<T: Copy>(v: &[T], a: &Plane, alpha: impl Fn(f32) -> T) -> Vec<T> {+        v.as_chunks::<3>()+            .0+            .iter()+            .zip(&a.data)+            .flat_map(|(p, &av)| [p[0], p[1], p[2], alpha(if av.is_finite() { av.clamp(0.0, 1.0) } else { 1.0 })])+            .collect()+    }+    let (rgba16, rgba32);+    let e = match (&img.samples, alpha.as_ref().filter(|_| matches!(o.format, ExportFormat::Png | ExportFormat::Tiff))) {+        (DeepSamples::U16(v), Some(a)) => {+            rgba16 = rgba(v, a, |av| (av * 65535.0).round() as u16);+            EncodeImage::new(w, h, 4, Samples::U16(&rgba16))+        }+        (DeepSamples::F32(v), Some(a)) => {+            rgba32 = rgba(v, a, |av| av);+            EncodeImage::new(w, h, 4, Samples::F32(&rgba32))+        }+        (DeepSamples::U16(v), None) => EncodeImage::new(w, h, 3, Samples::U16(v)),+        (DeepSamples::F32(v), None) => EncodeImage::new(w, h, 3, Samples::F32(v)),     };     let r = match (o.format, &img.samples) {         (ExportFormat::Png, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => lightcraft_codecs::encode_png_pq(w, h, v, &meta),diff --git a/crates/engine/src/export/options.rs b/crates/engine/src/export/options.rs--- a/crates/engine/src/export/options.rs+++ b/crates/engine/src/export/options.rs@@ -56,6 +56,12 @@ impl ExportFormat {     pub fn is_rendered(self) -> bool {         !matches!(self, Self::Original | Self::Dng)     }++    /// Formats whose exports keep a transparent source's alpha (straight, unassociated alpha, as+    /// #1670 writes it); the others composite it onto white (issue #2976).+    pub fn keeps_alpha(self) -> bool {+        matches!(self, Self::Png | Self::Tiff | Self::Webp)+    } }  /// How the output size is chosen (see [`Resize`]).diff --git a/crates/engine/src/export/prepare.rs b/crates/engine/src/export/prepare.rs--- a/crates/engine/src/export/prepare.rs+++ b/crates/engine/src/export/prepare.rs@@ -123,8 +123,14 @@ fn prepare_guarded(         let label = p.file_name.clone();         let meta = export_metadata(p, &session.catalog, o);         let gain_map = o.hdr_output() && o.format == ExportFormat::Jpeg;-        let job = session.export_job(id, w, h, o.effective_space(), o.effective_depth())?;+        // (HDR files are written without alpha: their transparent areas stay on white)+        let alpha = o.format.keeps_alpha() && !o.hdr_output();+        let alpha_file = can_carry_alpha(&p.format);+        let job = session.export_job_with_alpha(id, w, h, o.effective_space(), o.effective_depth(), alpha)?;         let weight = render_weight(full_px, long, job.level, w.saturating_mul(h));+        // a file that can carry transparency, exported to a format that keeps it: its alpha planes too+        // (whether it has any is only known once it is decoded)+        let weight = if alpha && alpha_file { weight.saturating_add(alpha_weight(full_px, long, job.level, w.saturating_mul(h))) } else { weight };         if let Some(why) = too_big_for_memory((w, h), native_px, weight, crate::memory::total_ram()) {             return Err(format!("{label}: {why}"));         }@@ -191,6 +197,21 @@ pub(super) fn too_big_for_memory(out: (usize, usize), native_px: usize, weight: /// `level` into `out_px` output pixels: the decoded source (linear RGB f32, 12 B/px) plus the /// pipeline's planes and the encoded output (~36 B per output pixel). Measured: a 24 MP export /// peaks at ~1.2 GB, one at 2048 px from the 2560 px preview at ~150 MB.+/// What keeping a transparent source's alpha adds to [`render_weight`] (issue #2976): the source's+/// alpha plane and its oriented or prefiltered copy (8 bytes a source pixel), and at the output the+/// colour and alpha resampled side by side, the alpha plane and the RGBA image encoded (24 bytes+/// a pixel).+fn alpha_weight(full_px: usize, long: usize, level: crate::media::SourceLevel, out_px: usize) -> usize {+    let src_px = render_weight(full_px, long, level, 0) / 12;+    src_px.saturating_mul(8).saturating_add(out_px.saturating_mul(24))+}++/// Whether a photo of catalog format `format` (its upper-case extension, `Photo::format`) is a+/// kind of file whose decoder can give it transparency.+pub(super) fn can_carry_alpha(format: &str) -> bool {+    matches!(format.to_ascii_uppercase().as_str(), "PNG" | "TIF" | "TIFF" | "WEBP" | "GIF" | "BMP" | "JXL" | "HEIC" | "HEIF" | "HIF")+}+ pub(super) fn render_weight(full_px: usize, long: usize, level: crate::media::SourceLevel, out_px: usize) -> usize {     let edge = level.max_edge().min(long.max(1));     let scale = edge as f64 / long.max(1) as f64;diff --git a/crates/engine/src/export/tests.rs b/crates/engine/src/export/tests.rs--- a/crates/engine/src/export/tests.rs+++ b/crates/engine/src/export/tests.rs@@ -47,6 +47,29 @@ fn eight_bit_exports_preserve_real_alpha() {     } } +/// Issue #2976: on a transparent area of an export that keeps alpha, the watermark composites+/// over it and raises the alpha, so it shows; opaque pixels blend as before.+#[test]+fn watermark_shows_over_transparency() {+    let wm = Watermark { text: "LC".into(), size: 0.3, opacity: 1.0, shadow: false, color: [255, 255, 255], ..Default::default() };+    let mut clear = Rgba8::from_fn(200, 100, |_, _| [0, 0, 0, 0]);+    draw_watermark(&mut clear, &wm);+    let marked: Vec<&[u8; 4]> = clear.data.iter().filter(|p| p[3] > 0).collect();+    assert!(!marked.is_empty(), "the mark raises the alpha");+    assert!(marked.iter().all(|p| p[0] == 255 && p[1] == 255 && p[2] == 255), "in its own colour, not faded towards black");+    assert!(marked.iter().any(|p| p[3] == 255));+    let mut opaque = Rgba8::from_fn(200, 100, |_, _| [0, 0, 0, 255]);+    draw_watermark(&mut opaque, &wm);+    assert!(opaque.data.iter().all(|p| p[3] == 255));+    let lit = |i: &Rgba8| i.data.iter().filter(|p| p[0] > 128).count();+    assert!(lit(&opaque) > 0);+    // straight-alpha deep images: the plane is raised the same way+    let mut deep = DeepImage { width: 200, height: 100, space: OutputSpace::Srgb, samples: DeepSamples::U16(vec![0; 200 * 100 * 3]) };+    let mut alpha = Plane::new(200, 100);+    draw_watermark_deep_over(&mut deep, Some(&mut alpha), &wm);+    assert!(alpha.data.iter().any(|&a| a > 0.99) && alpha.data.contains(&0.0));+}+ #[test] fn formats_have_magic() {     let img = test_image();@@ -1419,6 +1442,32 @@ fn side_by_side_batches_report_failures_in_order() {     assert_eq!(written.len(), 1, "only the photo before the failure"); } +/// Issue #2976: a file that can carry transparency, exported to a format that keeps it, has its+/// alpha planes counted; any other export weighs what it did.+#[test]+fn alpha_planes_are_counted_only_where_transparency_can_be_kept() {+    let dir = crate::tests_xmp::temp_dir("export-alpha-weight");+    let img = Rgba8::from_fn(120, 80, |x, y| [(x * 2) as u8, (y * 3) as u8, 90, 255]);+    let png = dir.join("logo.png");+    std::fs::write(&png, encode_image(&img, &ExportOptions { format: ExportFormat::Png, ..Default::default() }).unwrap()).unwrap();+    let jpg = dir.join("photo.jpg");+    std::fs::write(&jpg, encode_image(&img, &ExportOptions::default()).unwrap()).unwrap();+    let mut s = crate::Session::new().with_fs();+    s.execute("library.import", &json!({"paths": [png.to_string_lossy(), jpg.to_string_lossy()]})).unwrap();+    let id_of = |s: &crate::Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id;+    let (logo, photo) = (id_of(&s, "logo.png"), id_of(&s, "photo.jpg"));+    let weight = |s: &mut crate::Session, id, format| prepare_export(s, id, &ExportOptions { format, ..Default::default() }, 1).unwrap().weight;+    // the same output size in each format: the difference is the alpha planes alone+    let (logo_png, logo_tiff, logo_jpeg) =+        (weight(&mut s, logo, ExportFormat::Png), weight(&mut s, logo, ExportFormat::Tiff), weight(&mut s, logo, ExportFormat::Jpeg));+    assert!(logo_png > logo_jpeg, "a PNG exported as PNG counts its alpha: {logo_png} vs {logo_jpeg}");+    assert_eq!(logo_png, logo_tiff);+    let (photo_png, photo_jpeg) = (weight(&mut s, photo, ExportFormat::Png), weight(&mut s, photo, ExportFormat::Jpeg));+    assert_eq!(photo_png, photo_jpeg, "a JPEG source weighs the same in any format");+    assert!(can_carry_alpha("png") && can_carry_alpha("TIF") && !can_carry_alpha("JPG") && !can_carry_alpha("NEF"));+    let _ = std::fs::remove_dir_all(&dir);+}+ #[test] fn export_weights_follow_the_source_and_output_sizes() {     use crate::media::SourceLevel;diff --git a/crates/engine/src/export/watermark.rs b/crates/engine/src/export/watermark.rs--- a/crates/engine/src/export/watermark.rs+++ b/crates/engine/src/export/watermark.rs@@ -111,9 +111,23 @@ fn blend_watermark(img: &mut Rgba8, wm: &Watermark, floor_scale: f32) {     let width = img.width;     watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, floor_scale, |x, y, k, col| {         let p = &mut img.data[y * width + x];+        if p[3] == 255 {+            for c in 0..3 {+                p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8;+            }+            return;+        }+        // over a transparent pixel (an export that keeps transparency): the mark composites over+        // it and raises its alpha, so it shows where the photo is transparent+        let a = p[3] as f32 / 255.0;+        let out = k + a * (1.0 - k);+        if out <= 0.0 {+            return;+        }         for c in 0..3 {-            p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8;+            p[c] = ((col[c] as f32 * k + p[c] as f32 * a * (1.0 - k)) / out).round().clamp(0.0, 255.0) as u8;         }+        p[3] = (out * 255.0).round().clamp(0.0, 255.0) as u8;     }); } @@ -137,20 +151,48 @@ pub fn draw_watermark_in(img: &mut Rgba8, wm: &Watermark, x0: usize, y0: usize, /// Draw `wm` onto a high-bit-depth image (`wm.color` is in the image's space, 8-bit encoded; float /// images are linear and blend in linear light). pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) {+    draw_watermark_deep_over(img, None, wm);+}++/// [`draw_watermark_deep`] onto an image with straight alpha `alpha` (its size): the mark+/// composites over transparent pixels and raises their alpha, as [`draw_watermark`] does.+pub(super) fn draw_watermark_deep_over(img: &mut DeepImage, mut alpha: Option<&mut Plane>, wm: &Watermark) {     let (width, trc) = (img.width, img.space.trc());+    // over a transparent pixel `i`: the weights of the mark and of the pixel, its alpha raised+    // (`None`: an opaque pixel, blended as without alpha)+    let mut over = move |i: usize, k: f32| -> Option<(f32, f32)> {+        let a = alpha.as_deref_mut().and_then(|a| a.data.get_mut(i))?;+        let before = if a.is_finite() { a.clamp(0.0, 1.0) } else { 1.0 };+        if before >= 1.0 {+            return None;+        }+        let out = k + before * (1.0 - k);+        *a = out;+        Some(if out <= 0.0 { (0.0, 1.0) } else { (k / out, before * (1.0 - k) / out) })+    };     match &mut img.samples {         DeepSamples::U16(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| {+            let w = over(y * width + x, k);             let i = (y * width + x) * 3;             for c in 0..3 {                 let p = v[i + c] as f32;-                v[i + c] = (p + (col[c] as f32 * 257.0 - p) * k).round().clamp(0.0, 65535.0) as u16;+                let t = col[c] as f32 * 257.0;+                let blended = match w {+                    Some((wk, wp)) => p * wp + t * wk,+                    None => p + (t - p) * k,+                };+                v[i + c] = blended.round().clamp(0.0, 65535.0) as u16;             }         }),         DeepSamples::F32(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| {+            let w = over(y * width + x, k);             let i = (y * width + x) * 3;             for c in 0..3 {                 let target = trc.decode(col[c] as f32 / 255.0);-                v[i + c] += (target - v[i + c]) * k;+                v[i + c] = match w {+                    Some((wk, wp)) => v[i + c] * wp + target * wk,+                    None => v[i + c] + (target - v[i + c]) * k,+                };             }         }),     }diff --git a/crates/engine/src/files/load.rs b/crates/engine/src/files/load.rs--- a/crates/engine/src/files/load.rs+++ b/crates/engine/src/files/load.rs@@ -202,6 +202,7 @@ fn load_bytes_now(                 local_tone,                 // (the catalog knows the original's size: `media::source_info`)                 native_long: None,+                alpha: None,             },             used_reduced,         ));@@ -209,8 +210,15 @@ fn load_bytes_now(     let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?;     drop(bytes);     let img = d.to_working();+    let orientation = Orientation::from_exif(d.orientation);+    // the transparency, fitted and oriented with the pixels, for exports that keep it (issue #2976);+    // a channel that is opaque everywhere is not kept+    let alpha = d.alpha.filter(|a| (a.width, a.height) == (img.width, img.height) && a.data.iter().any(|&v| v < 1.0)).map(|a| {+        let a = if a.width.max(a.height) > max_edge { fit(&a, max_edge, max_edge, Filter::Mitchell) } else { a };+        Arc::new(a.into_oriented(orientation))+    });     let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img };-    Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default(), false))+    Ok((img.into_oriented(orientation), None, SourceInfo { alpha, ..SourceInfo::default() }, false)) }  /// Which white-balance scale a raw is edited on, decided from the file alone, the way the catalog decides it fromdiff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs--- a/crates/engine/src/media.rs+++ b/crates/engine/src/media.rs@@ -23,7 +23,7 @@ use lightcraft_catalog::{MediaKind, Op, Photo, PhotoId, Source}; use lightcraft_develop::DevelopSettings; use lightcraft_pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache}; use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache};-use lightcraft_raster::{Histogram, Rgb32f, Rgba8};+use lightcraft_raster::{Histogram, Plane, Rgb32f, Rgba8}; use serde::{Deserialize, Serialize};  mod cache;diff --git a/crates/engine/src/media/quick.rs b/crates/engine/src/media/quick.rs--- a/crates/engine/src/media/quick.rs+++ b/crates/engine/src/media/quick.rs@@ -65,7 +65,7 @@ impl QuickJob {         };         let rendered = |image: Rgba8| {             let histogram = Histogram::of_srgb8(&image);-            Ok(Rendered { image, histogram, deep: None })+            Ok(Rendered { image, histogram, deep: None, alpha: None })         };         for (cache, k) in &self.cached {             if let Some(img) = cache.get(*k) {diff --git a/crates/engine/src/media/render.rs b/crates/engine/src/media/render.rs--- a/crates/engine/src/media/render.rs+++ b/crates/engine/src/media/render.rs@@ -225,7 +225,7 @@ impl RenderJob {                 photo: self.photo,                 level: self.level,                 key: self.key,-                rendered: Ok(Rendered { image, histogram, deep: None }),+                rendered: Ok(Rendered { image, histogram, deep: None, alpha: None }),                 gpu_fallback: None,                 loaded: None,                 quick: None,@@ -245,7 +245,7 @@ impl RenderJob {                 photo: self.photo,                 level: self.level,                 key: self.key,-                rendered: Ok(Rendered { image, histogram, deep: None }),+                rendered: Ok(Rendered { image, histogram, deep: None, alpha: None }),                 gpu_fallback: None,                 loaded: None,                 quick: None,@@ -264,11 +264,26 @@ impl RenderJob {             Ok(source) => {                 // the plain picture, or (Denoise amount above 0 and a denoised picture cached) the mix the amount asks for                 let src = &source.image_for(self.settings.denoise_amount());+                // (the result if the transparency can't follow the render, made before `self.info` moves)+                let alpha_failed = self.request.alpha.then(|| self.failed("the transparency did not follow the render".into()));                 let info = source.info_or(self.info);+                // an export that keeps transparency, of a source that has some: the render resamples+                // the pixels with their alpha and develops the colour without the white they were+                // flattened onto (issue #2976); every other render is the one it always was+                let request = RenderRequest { alpha: self.request.alpha && info.alpha.is_some(), ..self.request };                 // Thumbnails (many small jobs side by side) stay on the CPU; views and exports use                 // the GPU when there is one.                 let gpu = self.cache.is_none();-                let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu);+                let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &request, self.stages.as_deref(), gpu);+                // a plane that doesn't fit the pixels or the render fails the export: written without+                // its alpha, the file would be wrong at every soft edge+                let fits = |a: &Plane| (a.width, a.height) == (rendered.image.width, rendered.image.height);+                if request.alpha+                    && !rendered.alpha.as_ref().is_some_and(fits)+                    && let Some(failed) = alpha_failed+                {+                    return failed;+                }                 // for a display profile: the image goes to the display's values, the histogram                 // and the cached view preview stay sRGB                 let mut srgb = None;@@ -355,8 +370,10 @@ fn develop_image(     stages: Option<&StageCache>,     gpu: bool, ) -> (Rendered, Option<String>) {-    // LUT profiles have no GPU stage: they render on the CPU+    // LUT profiles have no GPU stage: they render on the CPU, and so does a render that keeps+    // transparency (the GPU stages don't sample the alpha yet: issue #2976)     let fallback = if gpu+        && !req.alpha         && !lightcraft_pipeline::lut::is_lut_profile(&s.profile.id)         && !s.masks.iter().any(|m| m.visible && m.refine > 0.0)         && req.proof.is_none()diff --git a/crates/engine/src/media/session.rs b/crates/engine/src/media/session.rs--- a/crates/engine/src/media/session.rs+++ b/crates/engine/src/media/session.rs@@ -428,6 +428,21 @@ impl crate::Session {         max_h: usize,         space: lightcraft_pipeline::OutputSpace,         depth: lightcraft_pipeline::OutputDepth,+    ) -> Result<RenderJob, String> {+        self.export_job_with_alpha(id, max_w, max_h, space, depth, false)+    }++    /// [`Self::export_job`]; with `alpha`, for a file that keeps transparency (PNG, TIFF, WebP): the+    /// render is of the source's colour without the white its transparent areas are flattened onto,+    /// with [`Rendered::alpha`] (issue #2976). A source without transparency renders as without.+    pub fn export_job_with_alpha(+        &mut self,+        id: PhotoId,+        max_w: usize,+        max_h: usize,+        space: lightcraft_pipeline::OutputSpace,+        depth: lightcraft_pipeline::OutputDepth,+        alpha: bool,     ) -> Result<RenderJob, String> {         let mut job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?;         // An export renders the full image: never a thumbnail's source developed from a smaller copy of the raw@@ -452,7 +467,11 @@ impl crate::Session {         }         job.request.space = space;         job.request.depth = depth;+        job.request.alpha = alpha;         job.key ^= (space as u64 + 1).wrapping_mul(0xa076_1d64_78bd_642f) ^ (depth as u64 + 1).wrapping_mul(0xe703_7ed1_a0b4_28db);+        if alpha {+            job.key ^= 0x8ebc_6af0_9c88_c6e3;+        }         Ok(job)     } diff --git a/crates/engine/src/media/source.rs b/crates/engine/src/media/source.rs--- a/crates/engine/src/media/source.rs+++ b/crates/engine/src/media/source.rs@@ -169,10 +169,11 @@ impl DecodedSource {         mixed     } -    /// Cache cost: the picture, its segmentation mattes, its denoised twin and room for the lazy mix.+    /// Cache cost: the picture, its segmentation mattes and transparency, its denoised twin and room for the lazy mix.     pub fn bytes(&self) -> usize {         let mattes = self.info.as_ref().and_then(|i| i.mattes.as_ref()).map_or(0, |m| m.bytes());         let mattes = mattes + self.info.as_ref().and_then(|i| i.local_tone.as_ref()).map_or(0, |t| t.map.gains.len() * 4);+        let mattes = mattes + self.info.as_ref().and_then(|i| i.alpha.as_ref()).map_or(0, |a| a.data.len() * 4);         self.image.data.len() * 12 + 64 + std::mem::size_of::<SourceInfo>() + mattes + self.denoised.as_ref().map_or(0, |t| t.bytes())     } @@ -234,7 +235,15 @@ impl SourceRef {                 // Lanczos, not a box average: a box at 2-3:1 halves the contrast of detail near                 // the output's resolution limit and aliases what is finer                 let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Lanczos3);-                return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() });+                // the transparency is fitted with the pixels (the same size and filter)+                let info = from.info.clone().map(|i| SourceInfo {+                    alpha: i.alpha.as_ref().map(|a| {+                        let a = lightcraft_raster::resample::resize(a, image.width, image.height, lightcraft_raster::resample::Filter::Lanczos3);+                        Arc::new(a)+                    }),+                    ..i+                });+                return Ok(DecodedSource { image: Arc::new(image), denoised: None, info, ..(**from).clone() });             }             SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?,             SourceRef::File { path, max_edge, loader, fallback, denoise, reduced } => {diff --git a/crates/engine/src/slide_title.rs b/crates/engine/src/slide_title.rs--- a/crates/engine/src/slide_title.rs+++ b/crates/engine/src/slide_title.rs@@ -358,7 +358,7 @@ impl TitleCompose {             photo: lightcraft_catalog::PhotoId(0),             level: crate::media::SourceLevel::Preview,             key: self.key(),-            rendered: Ok(lightcraft_pipeline::Rendered { image, histogram, deep: None }),+            rendered: Ok(lightcraft_pipeline::Rendered { image, histogram, deep: None, alpha: None }),             loaded: None,             quick: None,             display: None,diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs--- a/crates/engine/src/smart.rs+++ b/crates/engine/src/smart.rs@@ -247,6 +247,8 @@ pub fn decode(bytes: &[u8]) -> Result<(Rgb32f, Option<CameraTone>, Option<Source         mattes: (!mattes.is_empty()).then(|| Arc::new(mattes)),         local_tone: f.local_tone.map(Arc::new),         native_long: None,+        // (a smart preview keeps no transparency: an offline original exports onto white)+        alpha: None,     });     Ok((img, tone, info)) }diff --git a/crates/engine/src/tests/export.rs b/crates/engine/src/tests/export.rs--- a/crates/engine/src/tests/export.rs+++ b/crates/engine/src/tests/export.rs@@ -1203,3 +1203,223 @@ fn same_folder_as_original_refuses_photos_without_a_folder() {     assert!(e.contains("folder is missing") && e.contains(&gone), "{e}");     let _ = std::fs::remove_dir_all(&dir); }++/// A library with one 300 × 200 PNG: transparent, with an opaque red 150 × 100 rectangle in the middle.+fn library_with_transparent_png(tag: &str) -> (Session, lightcraft_catalog::PhotoId, std::path::PathBuf) {+    let dir = temp_dir(tag);+    let src = dir.join("logo.png");+    let img =+        lightcraft_raster::Rgba8::from_fn(+            300,+            200,+            |x, y| if (75..225).contains(&x) && (50..150).contains(&y) { [200, 40, 30, 255] } else { [0, 0, 0, 0] },+        );+    let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &Default::default()).unwrap();+    std::fs::write(&src, png).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;+    (s, id, dir)+}++/// Issue #2976: exports of a transparent source keep its alpha in PNG and TIFF at 8 and 16 bits, in+/// WebP at 8 bits and in 32-bit float TIFF, with the colour of the opaque part as it was (no white+/// mixed in); TIFF stores it unassociated (`ExtraSamples` = 2, as #1670 writes it).+#[test]+fn transparent_sources_keep_their_alpha_in_png_tiff_and_webp() {+    let (mut s, id, dir) = library_with_transparent_png("export-alpha");+    for (format, depth) in [("png", 8), ("png", 16), ("tiff", 8), ("tiff", 16), ("tiff", 32), ("webp", 8)] {+        let o = ExportOptions::from_json(&json!({"format": format, "bitDepth": depth}));+        let e = export_photo(&mut s, id, &o, 1).unwrap();+        let d = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap();+        assert!(d.has_alpha, "{format} {depth}-bit keeps alpha");+        assert_eq!((d.width, d.height), (300, 200), "{format} {depth}-bit");+        let a = d.alpha.as_ref().unwrap();+        for (x, y) in [(2, 2), (297, 197), (74, 100), (150, 49)] {+            assert!(a.get(x, y) < 1e-3, "{format} {depth}-bit: transparent at ({x}, {y})");+        }+        for (x, y) in [(75, 100), (150, 100), (224, 149)] {+            assert!(a.get(x, y) > 0.999, "{format} {depth}-bit: opaque at ({x}, {y})");+        }+        let edge = d.to_srgb8().get(75, 100);+        assert!(edge[0].abs_diff(200) <= 2 && edge[1].abs_diff(40) <= 2 && edge[2].abs_diff(30) <= 2, "{format} {depth}-bit edge {edge:?}");+        if format == "tiff" {+            // ExtraSamples (338), SHORT, count 1, value 2: unassociated alpha+            assert!(e.bytes.windows(10).any(|w| w == [0x52, 0x01, 3, 0, 1, 0, 0, 0, 2, 0]), "{depth}-bit TIFF alpha is unassociated");+        }+    }+    let _ = std::fs::remove_dir_all(&dir);+}++/// JPEG (and the other formats without alpha in this export) composites a transparent source onto+/// white as before; an opaque PNG source still exports as RGB (#1670).+#[test]+fn transparency_stays_on_white_where_it_is_not_kept() {+    let (mut s, id, dir) = library_with_transparent_png("export-alpha-white");+    // (AVIF is left out: it composites onto white too, but this build can't decode one to check)+    for format in ["jpeg", "psd", "jxl"] {+        let e = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": format})), 1).unwrap();+        let d = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap();+        assert!(!d.has_alpha, "{format}");+        let corner = d.to_srgb8().get(2, 2);+        assert!(corner.iter().take(3).all(|&c| c >= 250), "{format} corner on white: {corner:?}");+    }+    let src = dir.join("opaque.png");+    let img = lightcraft_raster::Rgba8::from_fn(64, 48, |x, y| [(x * 3) as u8, (y * 5) as u8, 90, 255]);+    std::fs::write(&src, lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &Default::default()).unwrap()).unwrap();+    s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();+    let opaque = s.catalog.photos().map(|p| p.id).find(|&p| p != id).unwrap();+    let e = export_photo(&mut s, opaque, &ExportOptions::from_json(&json!({"format": "png"})), 1).unwrap();+    assert_eq!(e.bytes[25], 2, "PNG IHDR declares RGB");+    let _ = std::fs::remove_dir_all(&dir);+}++/// The alpha follows the pixels through rotation, crop and resize.+#[test]+fn exported_alpha_follows_rotate_crop_and_resize() {+    let (mut s, id, dir) = library_with_transparent_png("export-alpha-geometry");+    // turned right: 200 × 300 with the rectangle at x 50..150, y 75..225; its left half: 100 × 300;+    // fitted to 150 px: 50 × 150, the rectangle at x 25..50, y 37.5..112.5+    s.execute("photo.rotateRight", &json!({})).unwrap();+    s.execute("crop.set", &json!({"rect": [0.0, 0.0, 0.5, 1.0]})).unwrap();+    let e = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "png", "longEdge": 150})), 1).unwrap();+    let d = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap();+    assert_eq!((d.width, d.height), (50, 150));+    let px = d.to_srgb8();+    for (x, y) in [(30, 75), (45, 40), (45, 110)] {+        let p = px.get(x, y);+        assert_eq!(p[3], 255, "opaque at ({x}, {y})");+        assert!(p[0] > 150 && p[1] < 80, "red at ({x}, {y}): {p:?}");+    }+    for (x, y) in [(10, 75), (30, 20), (30, 130)] {+        assert_eq!(px.get(x, y)[3], 0, "transparent at ({x}, {y})");+    }+    let _ = std::fs::remove_dir_all(&dir);+}++/// Issue #2976: a source without transparency (no alpha channel, or one opaque everywhere) exports+/// exactly as before, byte for byte, through the PNG/TIFF/WebP path too: the grey a Transform brings+/// in around the photo stays grey and no alpha is written.+#[test]+fn opaque_sources_export_exactly_as_before() {+    let dir = temp_dir("export-alpha-opaque");+    let mut s = Session::new().with_fs();+    let img = lightcraft_raster::Rgba8::from_fn(96, 64, |x, y| [(x * 2) as u8, (y * 3) as u8, 90, 255]);+    let rgba = dir.join("rgba.png");+    std::fs::write(&rgba, lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &Default::default()).unwrap()).unwrap();+    let rgb = dir.join("rgb.png");+    let flat: Vec<u8> = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();+    let rgb_png =+        lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::new(96, 64, 3, lightcraft_codecs::Samples::U8(&flat)), &Default::default());+    std::fs::write(&rgb, rgb_png.unwrap()).unwrap();+    s.execute("library.import", &json!({"paths": [rgba.to_string_lossy(), rgb.to_string_lossy()]})).unwrap();+    let ids: Vec<_> = s.catalog.photos().map(|p| p.id).collect();+    assert_eq!(ids.len(), 2);+    for id in ids {+        s.execute("develop.set", &json!({"ids": [id.0], "control": "geometry.vertical", "value": -60})).unwrap();+        for (format, depth) in [("png", 8), ("png", 16), ("tiff", 8), ("webp", 8)] {+            let o = ExportOptions::from_json(&json!({"format": format, "bitDepth": depth}));+            let e = export_photo(&mut s, id, &o, 1).unwrap();+            // the same render without the transparency path, as before #2976+            let (w, h) = crate::export::output_size(s.catalog.photo(id).unwrap(), &o);+            let meta = crate::export::export_metadata(s.catalog.photo(id).unwrap(), &s.catalog, &o);+            let job = s.export_job(id, w, h, o.effective_space(), o.effective_depth()).unwrap();+            let before = crate::export::encode_rendered(&job.run().rendered.unwrap(), &o, meta.as_ref()).unwrap();+            assert!(e.bytes == before, "{format} {depth}-bit: identical to the render without transparency");+            let d = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap();+            assert!(!d.has_alpha, "{format} {depth}-bit: no alpha");+            if (format, depth) == ("png", 8) {+                // the keystone brings in the light grey outside the photo at the bottom corners+                let corner = d.to_srgb8().get(1, d.height as usize - 2);+                assert!(corner[0] == corner[1] && corner[1] == corner[2] && corner[0] > 150, "grey corner: {corner:?}");+            }+        }+    }+    let _ = std::fs::remove_dir_all(&dir);+}++/// Issue #2976, end to end: a logo with soft, semi-transparent edges keeps them through a crop, a+/// 10° straighten and an exposure change, with the colour of its opaque part (no white fringe) and+/// transparent corners, in PNG, TIFF and WebP.+#[test]+fn soft_edges_keep_their_colour_through_crop_straighten_and_exposure() {+    let dir = temp_dir("export-alpha-soft");+    let src = dir.join("soft.png");+    // a blue disc of radius 90 with a 24 px soft edge+    let img = lightcraft_raster::Rgba8::from_fn(400, 300, |x, y| {+        let d = ((x as f32 + 0.5 - 200.0).powi(2) + (y as f32 + 0.5 - 150.0).powi(2)).sqrt();+        let a = ((90.0 - d) / 24.0 + 0.5).clamp(0.0, 1.0);+        if a > 0.0 { [30, 120, 200, (a * 255.0).round() as u8] } else { [0, 0, 0, 0] }+    });+    std::fs::write(&src, lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &Default::default()).unwrap()).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;+    s.execute("crop.set", &json!({"rect": [0.1, 0.1, 0.9, 0.9]})).unwrap();+    s.execute("crop.set", &json!({"angle": 10.0})).unwrap();+    s.execute("develop.set", &json!({"ids": [id.0], "control": "light.exposure", "value": 0.7})).unwrap();+    for format in ["png", "tiff", "webp"] {+        let e = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": format})), 1).unwrap();+        let d = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap();+        assert!(d.has_alpha, "{format}");+        let px = d.to_srgb8();+        let (w, h) = (px.width, px.height);+        assert_eq!(px.get(1, 1)[3], 0, "{format}: transparent corner");+        let centre = px.get(w / 2, h / 2);+        assert_eq!(centre[3], 255, "{format}: opaque centre");+        assert!(centre[2] > 200, "{format}: the exposure brightened the disc: {centre:?}");+        // every soft-edge pixel has the colour of the opaque part+        let soft: Vec<[u8; 4]> = px.data.iter().copied().filter(|p| (24..=232).contains(&p[3])).collect();+        assert!(soft.len() > 500, "{format}: the soft edge survives ({} pixels)", soft.len());+        let tolerance = 3;+        for p in &soft {+            assert!((0..3).all(|c| p[c].abs_diff(centre[c]) <= tolerance), "{format}: soft-edge colour {p:?} vs the disc's {centre:?}");+        }+    }+    let _ = std::fs::remove_dir_all(&dir);+}++/// Review of #2976: a hard edge (the issue's opaque red rectangle on transparency) resampled by a+/// downscale or a straighten keeps its colour wherever its alpha is partial: no white mixed in.+#[test]+fn hard_edges_stay_red_when_downscaled_or_straightened() {+    let (mut s, id, dir) = library_with_transparent_png("export-alpha-hard-edge");+    let straightened = |s: &mut Session| s.execute("crop.set", &json!({"angle": 5.0})).map(|_| ());+    let cases: [(&str, serde_json::Value, bool); 2] =+        [("longEdge 150", json!({"format": "png", "longEdge": 150}), false), ("5° straighten", json!({"format": "png"}), true)];+    for (name, options, straighten) in cases {+        if straighten {+            straightened(&mut s).unwrap();+        }+        let e = export_photo(&mut s, id, &ExportOptions::from_json(&options), 1).unwrap();+        let d = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap();+        assert!(d.has_alpha, "{name}");+        let px = d.to_srgb8();+        let partial: Vec<[u8; 4]> = px.data.iter().copied().filter(|p| p[3] > 0 && p[3] < 255).collect();+        assert!(partial.len() > 20, "{name}: the edge is resampled ({} partial pixels)", partial.len());+        for p in &partial {+            assert!(p[0].abs_diff(200) <= 4 && p[1].abs_diff(40) <= 4 && p[2].abs_diff(30) <= 4, "{name}: edge pixel {p:?} is not red (200, 40, 30)");+        }+    }+    let _ = std::fs::remove_dir_all(&dir);+}++/// Review of #2976: a transparency plane that does not fit the source's pixels fails the export+/// with an error; the file is never written as flattened RGB in its place.+#[test]+fn a_transparency_that_does_not_fit_fails_the_export() {+    use lightcraft_pipeline::{OutputDepth, OutputSpace, SourceInfo};+    use std::sync::Arc;+    let (mut s, id, dir) = library_with_transparent_png("export-alpha-mismatch");+    let image = Arc::new(lightcraft_raster::Rgb32f::from_fn(300, 200, |_, _| [1.0; 3]));+    let info = SourceInfo { alpha: Some(Arc::new(lightcraft_raster::Plane::new(10, 10))), ..Default::default() };+    let mut job = s.export_job_with_alpha(id, 300, 200, OutputSpace::Srgb, OutputDepth::U8, true).unwrap();+    job.source = crate::media::SourceRef::Loaded(Box::new(crate::media::DecodedSource::new(image.clone(), Some(info.clone()))));+    let err = job.run().rendered.err().expect("the export fails");+    assert!(err.contains("transparency"), "{err}");+    // the same source exported without transparency (JPEG) renders as before+    let mut job = s.export_job_with_alpha(id, 300, 200, OutputSpace::Srgb, OutputDepth::U8, false).unwrap();+    job.source = crate::media::SourceRef::Loaded(Box::new(crate::media::DecodedSource::new(image, Some(info))));+    assert!(job.run().rendered.is_ok());+    let _ = std::fs::remove_dir_all(&dir);+}diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs--- a/crates/gpu/src/render.rs+++ b/crates/gpu/src/render.rs@@ -780,7 +780,7 @@ pub fn render(     if let Some(k) = plan.keep {         image = image.crop(k.x, k.y, k.w, k.h);     }-    Some(Rendered { image, histogram, deep: None })+    Some(Rendered { image, histogram, deep: None, alpha: None }) }  /// Pixels whose alpha is not 255 (cheap: no early exit, so it vectorizes).diff --git a/crates/pipeline/src/geometry.rs b/crates/pipeline/src/geometry.rs--- a/crates/pipeline/src/geometry.rs+++ b/crates/pipeline/src/geometry.rs@@ -8,7 +8,7 @@ use lightcraft_develop::{DevelopSettings, EmbeddedLens}; use lightcraft_geom::{Affine, CropGeometry, Homography, Orientation, Point, Rect}; use lightcraft_raster::resample::{Filter, resize};-use lightcraft_raster::{Rgb32f, par_rows};+use lightcraft_raster::{Plane, Rgb32f, par_rows};  use crate::optics::{Warp, reorient_lens}; @@ -275,6 +275,86 @@ impl Frame {         }     } +    /// [`Self::sample`] of a source flattened onto white (`lightcraft_codecs::to_working`) together+    /// with its straight alpha `alpha` (the source's size), for an output that keeps transparency+    /// (issue #2976). Colour and alpha are resampled with the same weights, and each output pixel+    /// is unflattened after that, so the colour comes out alpha-weighted: no white from transparent+    /// pixels is mixed into edges. Lens corrections: red and blue are unflattened with the alpha at+    /// their own (chromatic aberration) positions, the output alpha follows green, and the vignette+    /// gain applies to the unflattened colour. What the mapping brings in from outside the source+    /// has alpha 0. Returns the straight colour and its alpha.+    pub fn sample_with_alpha(&self, src: &Rgb32f, alpha: &Plane, w: usize, h: usize) -> (Rgb32f, Plane) {+        let orient = |i: &Rgb32f| if self.orient == Orientation::Normal { None } else { Some(i.oriented(self.orient)) };+        let (oriented, oriented_a) = (orient(src), if self.orient == Orientation::Normal { None } else { Some(alpha.oriented(self.orient)) });+        let (o, oa) = (oriented.as_ref().unwrap_or(src), oriented_a.as_ref().unwrap_or(alpha));+        let plan = self.sample_plan(src.width, src.height, w, h);+        let (base, base_a) = match plan.prefilter {+            Some((nw, nh)) => {+                (std::borrow::Cow::Owned(resize(o, nw, nh, Filter::Mitchell)), std::borrow::Cow::Owned(resize(oa, nw, nh, Filter::Mitchell)))+            }+            None => (std::borrow::Cow::Borrowed(o), std::borrow::Cow::Borrowed(oa)),+        };+        // colour and alpha side by side, so one pass fills both+        let mut both: Vec<[f32; 4]> = vec![[0.0; 4]; w.saturating_mul(h)];+        let affine = |both: &mut Vec<[f32; 4]>, xf: Affine| {+            par_rows(both, w, |y, row| {+                for (x, px) in row.iter_mut().enumerate() {+                    let p = xf.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));+                    let a = alpha_at(&base_a, p.x as f32, p.y as f32);+                    let v = base.sample_bilinear(p.x as f32, p.y as f32).map(|f| unflatten(f, a));+                    *px = [v[0], v[1], v[2], a];+                }+            });+        };+        match (plan.mode, self.warp.as_ref()) {+            (SampleMode::Copy, _) => {+                for ((px, v), &a) in both.iter_mut().zip(&base.data).zip(&base_a.data) {+                    let v = v.map(|f| unflatten(f, a));+                    *px = [v[0], v[1], v[2], a];+                }+            }+            (SampleMode::Warp(o2t), Some(wp)) => {+                let (per_channel, gain) = (wp.per_channel(), wp.has_gain());+                let (sx, sy) = (plan.sx, plan.sy);+                par_rows(&mut both, w, |y, row| {+                    for (x, px) in row.iter_mut().enumerate() {+                        let Some((c, g)) = wp.frame(&o2t, x, y) else {+                            *px = [BLANK[0], BLANK[1], BLANK[2], 0.0];+                            continue;+                        };+                        // (inside the frame: the warp has decided the point shows the image)+                        let (gx, gy) = ((g.x * sx) as f32, (g.y * sy) as f32);+                        let a = bilinear(&base_a, gx, gy);+                        let mut v = base.sample_bilinear(gx, gy).map(|f| unflatten(f, a));+                        if per_channel {+                            for ch in [0usize, 2] {+                                let q = wp.corrected_to_source(c, ch);+                                let (qx, qy) = ((q.x * sx) as f32, (q.y * sy) as f32);+                                v[ch] = unflatten(base.sample_bilinear(qx, qy)[ch], bilinear(&base_a, qx, qy));+                            }+                        }+                        if gain {+                            let k = wp.gain(g);+                            v = v.map(|c| c * k);+                        }+                        *px = [v[0], v[1], v[2], a];+                    }+                });+            }+            // `sample_plan` plans a warp only when there is one; without it the same mapping is affine+            (SampleMode::Warp(o2t), None) => affine(&mut both, Affine::scale(plan.sx, plan.sy) * o2t),+            (SampleMode::Affine(xf), _) => affine(&mut both, xf),+        }+        let mut img = Rgb32f::new(w, h);+        let mut a = Plane::new(w, h);+        for ((p, o), px) in img.data.iter_mut().zip(a.data.iter_mut()).zip(&both) {+            *p = [px[0], px[1], px[2]];+            // (the Mitchell prefilter rings a little past 0 and 1)+            *o = if px[3].is_finite() { px[3].clamp(0.0, 1.0) } else { 0.0 };+        }+        (img, a)+    }+     /// The crop rectangle as a quad in normalized oriented coordinates (for overlays).     pub fn crop_quad_norm(&self) -> [Point; 4] {         let r = self.crop.rect_px(self.ow, self.oh);@@ -348,6 +428,44 @@ fn sample_warped(base: &Rgb32f, sx: f64, sy: f64, wp: &Warp, o2t: Affine, w: usi     out } +/// Undo the flatten onto white (`f = c·a + (1 − a)` in linear light) of a value resampled with+/// its alpha `a` (both with the same weights, so the result is the alpha-weighted colour). Pixels+/// with almost no alpha keep the white: their colour is not recoverable.+#[inline]+fn unflatten(f: f32, a: f32) -> f32 {+    const VISIBLE: f32 = 1.0 / 1024.0;+    if a.is_finite() && a >= VISIBLE { (f - (1.0 - a)) / a } else { f }+}++/// Bilinear alpha at pixel coordinates `(x, y)` of `a`; 0 off the image, which (as for a warp,+/// `Warp::edges`) has a half-pixel margin, where its edge pixels extend.+fn alpha_at(a: &Plane, x: f32, y: f32) -> f32 {+    const MARGIN: f32 = 0.5;+    let (w, h) = (a.width as f32, a.height as f32);+    if !(x >= -MARGIN && y >= -MARGIN && x <= w + MARGIN && y <= h + MARGIN) {+        return 0.0;+    }+    bilinear(a, x, y)+}++/// Bilinear sample of `a` at pixel coordinates `(x, y)`, with the weights of+/// `Rgb32f::sample_bilinear` at the same point (the edge pixel past the edge); 0 for an empty+/// plane or a coordinate that is not finite.+fn bilinear(a: &Plane, x: f32, y: f32) -> f32 {+    if a.data.is_empty() || !x.is_finite() || !y.is_finite() {+        return 0.0;+    }+    let fx = x.clamp(-1.0, a.width as f32 + 1.0) - 0.5;+    let fy = y.clamp(-1.0, a.height as f32 + 1.0) - 0.5;+    let (x0, y0) = (fx.floor(), fy.floor());+    let (tx, ty) = (fx - x0, fy - y0);+    let (x0, y0) = (x0 as isize, y0 as isize);+    let (p, q, r, t) = (a.get_clamped(x0, y0), a.get_clamped(x0 + 1, y0), a.get_clamped(x0, y0 + 1), a.get_clamped(x0 + 1, y0 + 1));+    let top = p + (q - p) * tx;+    let bot = r + (t - r) * tx;+    top + (bot - top) * ty+}+ /// Whole-image rectangle in normalized coordinates. pub const FULL: Rect = Rect::UNIT; diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs--- a/crates/pipeline/src/lib.rs+++ b/crates/pipeline/src/lib.rs@@ -99,6 +99,12 @@ pub struct SourceInfo {     /// Noise reduction sizes its radii in the original's pixels ([`Plan::detail_scale`]), so a     /// preview looks like the full-size render scaled down (issue #960).     pub native_long: Option<usize>,+    /// The source's straight alpha (0..1), EXIF-oriented and the size of the source buffer, when it+    /// has transparency. The pixels handed to the renderer are flattened onto white in linear light+    /// (`lightcraft_codecs::to_working`, issue #1212); a render that keeps transparency+    /// ([`RenderRequest::alpha`]) resamples them together with this plane and recovers their colour+    /// after (`geometry::Frame::sample_with_alpha`, issue #2976).+    pub alpha: Option<Arc<Plane>>, }  impl Default for SourceInfo {@@ -115,6 +121,7 @@ impl Default for SourceInfo {             mattes: None,             local_tone: None,             native_long: None,+            alpha: None,         }     } }@@ -149,6 +156,10 @@ pub struct RenderRequest {     /// Previews for a monitor with a display profile: render 8-bit into its primaries instead of     /// `space` (see [`DisplaySpace`]; ignored by deep renders).     pub display: Option<DisplaySpace>,+    /// The output keeps the source's transparency (exports to PNG, TIFF and WebP of a source with+    /// [`SourceInfo::alpha`]): the colour is developed without the white it is flattened onto, and+    /// [`Rendered::alpha`] carries the alpha through the same geometry (issue #2976).+    pub alpha: bool, }  /// A window of the (virtual) full output, in its pixels: what a zoomed view needs, rendered@@ -186,6 +197,7 @@ impl RenderRequest {             proof: None,             window: None,             display: None,+            alpha: false,         }     } }@@ -196,6 +208,9 @@ pub struct Rendered {     pub histogram: Histogram,     /// The high-bit-depth result when [`RenderRequest::depth`] asks for one.     pub deep: Option<DeepImage>,+    /// Straight alpha (0..1) of the output, the size of `image`, when the request keeps the+    /// source's transparency and the source has some ([`RenderRequest::alpha`]).+    pub alpha: Option<Plane>, }  /// Everything the per-pixel stage needs, precomputed at output resolution.@@ -559,14 +574,22 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende     let Plan { ref frame, w, h, px_per_long, detail_scale, geo, lin_key, .. } = plan;     let s = &*plan.settings; +    // an output that keeps transparency: the flattened pixels and their alpha are resampled+    // together and unflattened after (`Frame::sample_with_alpha`, issue #2976)+    let alpha_src = info.alpha.as_deref().filter(|a| req.alpha && a.width == src_img.width && a.height == src_img.height);     let shared = match (&src, cache) {-        (Src::Shared(a), Some(c)) => Some((*a, c)),+        // (the straight colour is this render's own: no stage of an earlier render applies to it)+        (Src::Shared(a), Some(c)) if alpha_src.is_none() => Some((*a, c)),         _ => None,     };     let cached = shared.and_then(|(a, c)| c.get(a, geo));-    let sampled = match &cached {-        Some(e) => e.sampled.clone(),-        None => Arc::new(frame.sample(src_img, w, h)),+    let (sampled, alpha) = match (&cached, alpha_src) {+        (Some(e), _) => (e.sampled.clone(), None),+        (None, Some(a)) => {+            let (img, alpha) = frame.sample_with_alpha(src_img, a, w, h);+            (Arc::new(img), Some(alpha))+        }+        (None, None) => (Arc::new(frame.sample(src_img, w, h)), None),     };     lap("sample", &mut t); @@ -603,7 +626,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende         let image = deep.to_rgba8();         let histogram = Histogram::of_srgb8(&image);         lap("finish (deep)", &mut t);-        return Rendered { image, histogram, deep: Some(deep) };+        return Rendered { image, histogram, deep: Some(deep), alpha };     }     let image = finish::finish(&prep, s, frame, info, req.space, req.display.as_ref(), req.proof);     lap("finish", &mut t);@@ -621,6 +644,10 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende     let masks = overlay_alphas_all(req.overlay, &plan, &prep);     visualize::apply(&mut image, req.overlay, &plan, mask.as_ref(), masks.as_deref());     let mut image = if plan.keep.is_some() { cut(&image) } else { image };+    let alpha = match (alpha, plan.keep) {+        (Some(a), Some(k)) => Some(a.crop(k.x, k.y, k.w, k.h)),+        (a, _) => a,+    };     if req.overlay == Overlay::HdrRange && edit.hdr.enabled {         // the HDR rendition of the same request says how far above SDR white each pixel goes         let hreq = RenderRequest { depth: OutputDepth::F32Hdr, overlay: Overlay::None, proof: None, ..*req };@@ -629,7 +656,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende         }         lap("visualize hdr", &mut t);     }-    Rendered { image, histogram, deep: None }+    Rendered { image, histogram, deep: None, alpha } }  /// The colour a colour-range mask would sample at normalized image point `p` (OkLab of thediff --git a/crates/pipeline/src/tests_geometry.rs b/crates/pipeline/src/tests_geometry.rs--- a/crates/pipeline/src/tests_geometry.rs+++ b/crates/pipeline/src/tests_geometry.rs@@ -524,3 +524,72 @@ fn auto_straighten_leaves_steep_lines_alone() {     let deg = level(&tilted_scene(8.0, 30.0)).expect("8° is");     assert!((deg + 8.0).abs() < 0.5, "{deg}"); }++/// Issue #2976: an alpha plane resamples exactly as the pixels do (here: a grey copy of the same+/// plane), for plain, cropped / straightened / flipped / resized and warped frames, and the colour+/// that comes back from a source flattened onto white is its own colour wherever there is any+/// alpha: the flatten is undone after resampling, so no white is mixed into edges. Areas the warp+/// brings in from outside the source are transparent.+#[test]+fn alpha_follows_the_pixels_through_every_frame() {+    use crate::geometry::Frame;+    use lightcraft_geom::Orientation;+    use lightcraft_geom::Rect;+    use lightcraft_raster::Plane;+    let (w, h) = (240, 160);+    let colour = [0.6f32, 0.2, 0.1];+    let soft = Plane::from_fn(w, h, |x, y| grid_value(Point::new(x as f64 + 0.5, y as f64 + 0.5), 24.0));+    // a hard-edged rectangle: the case a resample mixes white into when it unflattens too early+    let hard = Plane::from_fn(w, h, |x, y| if (60..180).contains(&x) && (40..120).contains(&y) { 1.0 } else { 0.0 });+    let mut cropped = DevelopSettings { orientation: Orientation::Rotate90, ..Default::default() };+    cropped.crop.geometry.rect = Rect::new(0.1, 0.2, 0.9, 0.7);+    cropped.crop.geometry.angle = 3.0;+    cropped.crop.flip_h = true;+    let mut lens = DevelopSettings::default();+    lens.optics.distortion = 40.0;+    lens.geometry.vertical = -30.0;+    for (plane_name, plane) in [("soft", &soft), ("hard", &hard)] {+        let grey = Rgb32f::from_fn(w, h, |x, y| [plane.get(x, y); 3]);+        let flat = Rgb32f::from_fn(w, h, |x, y| {+            let a = plane.get(x, y);+            colour.map(|c| c * a + (1.0 - a))+        });+        for (frame_name, s, out) in+            [("plain", DevelopSettings::default(), (w, h)), ("cropped", cropped.clone(), (90, 70)), ("warped", lens.clone(), (180, 120))]+        {+            let name = format!("{plane_name} {frame_name}");+            let f = Frame::new(w, h, &s, true);+            let (ow, oh) = out;+            let (img, a) = f.sample_with_alpha(&flat, plane, ow, oh);+            let g = f.sample(&grey, ow, oh);+            assert_eq!((a.width, a.height, img.width, img.height), (ow, oh, ow, oh), "{name}");+            let mut outside = 0;+            for (i, ((&av, gp), cp)) in a.data.iter().zip(&g.data).zip(&img.data).enumerate() {+                if gp[1] == crate::geometry::BLANK[1] && gp[0] == crate::geometry::BLANK[0] {+                    assert_eq!(av, 0.0, "{name}: outside the source at {i}");+                    outside += 1;+                    continue;+                }+                assert!((av - gp[1].clamp(0.0, 1.0)).abs() < 1e-4, "{name} at {i}: alpha {av} vs pixel {}", gp[1]);+                if av > 0.01 {+                    assert!(+                        cp.iter().zip(colour).all(|(c, want)| (c - want).abs() < 2e-3),+                        "{name} at {i} (alpha {av}): colour {cp:?}, not {colour:?}"+                    );+                }+            }+            if frame_name == "warped" {+                assert!(outside > 0, "the warp brings in area from outside the source");+            }+        }+    }+    // a plane that is opaque everywhere gives back the plain resample (to float rounding: the+    // prefilter brings 1.0 back as 0.99999994; opaque sources never take this path)+    let opaque = Plane::from_fn(w, h, |_, _| 1.0);+    let src = Rgb32f::from_fn(w, h, |x, y| [x as f32 / w as f32, y as f32 / h as f32, 0.3]);+    let f = Frame::new(w, h, &cropped, true);+    let (img, a) = f.sample_with_alpha(&src, &opaque, 90, 70);+    let plain = f.sample(&src, 90, 70);+    assert!(img.data.iter().zip(&plain.data).all(|(p, q)| p.iter().zip(q).all(|(a, b)| (a - b).abs() < 1e-5)));+    assert!(a.data.iter().all(|&v| (v - 1.0).abs() < 1e-5));+}diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs--- a/crates/ui-egui/src/render.rs+++ b/crates/ui-egui/src/render.rs@@ -1347,7 +1347,7 @@ mod color_image_tests {             display: None,         };         let histogram = Histogram::of_srgb8(&image);-        let ok = Renderer::run_for(|| result(Ok(lightcraft_pipeline::Rendered { image: image.clone(), histogram, deep: None })), &None);+        let ok = Renderer::run_for(|| result(Ok(lightcraft_pipeline::Rendered { image: image.clone(), histogram, deep: None, alpha: None })), &None);         assert_eq!(ok.color.map(|c| c.pixels.clone()), Some(color_image(&image).pixels));         let failed = Renderer::run_for(|| result(Err("unreadable".into())), &None);         assert!(failed.color.is_none());diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md--- a/docs/parity-checklist.md+++ b/docs/parity-checklist.md@@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (LR-EXP-TYPE: transparent sources keep their alpha in PNG, TIFF and WebP exports, #2976; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6  The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good@@ -80,7 +80,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate (local adjustment templates become mask effects, LR-MASK-EFFECTS), photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); a preset that names a profile (`crs:Look`) applies the imported profile of that name, imported before or after it, and Lightroom's basic profiles map to ours (`crates/engine/src/profile_refs.rs`, issue #1001); Adobe profile formats deliberately unsupported (so `.xmp` creative profiles' colour tables aren't read) | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back, each on its own from the row's list when several are hidden, or all at once with Restore All (issue #2114); a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |-| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared primaries and transfer curve (linear, sRGB, BT.709 or a power; PQ / HLG stay preview-only) without clipping or gamut mapping, checked on one real file with 108 lossy tiles and on 2 Lightroom Classic 15.1 lossy LinearRaw DNGs (ILCE-7CM2) whose tiles declare Rec. 2100 primaries with the sRGB curve, which were preview-only before, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`; neither carries a JPEG, so their uncompressed 8-bit RGB preview (1217 × 913 in the H4D-40 FFF, 218 × 218 in the CFV 3FR) is the embedded preview, handed on as a baseline TIFF, `crates/raw/src/vendor/hasselblad.rs`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Minolta / Konica Minolta MRW (the `\0MRM` block container: `PRD` sensor size, 12-bit packed or 16-bit-word storage and the RGGB / GBRG cell, `WBG` gains in cell order, the `TTW` TIFF for Exif and orientation, black 0 and white from the saturation plateau; `crates/raw/src/vendor/mrw.rs`, DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D checked on CC0 files against their previews), Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/it/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); folder discovery and file pickers include Sony SR2 and SRF, with the same extension list used by CLI/MCP: DSC-R1 SR2 decodes from sensor data; DSC-F828 SRF stays preview-only; transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant Nikon 12-bit small RAW (D810 / D4S): packed Y0/Y1/Cb/Cr rows decode to linear RGB; header probes retain geometry without decoding pixels, and truncated strips are refused. Chroma gains are approximate. |+| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared primaries and transfer curve (linear, sRGB, BT.709 or a power; PQ / HLG stay preview-only) without clipping or gamut mapping, checked on one real file with 108 lossy tiles and on 2 Lightroom Classic 15.1 lossy LinearRaw DNGs (ILCE-7CM2) whose tiles declare Rec. 2100 primaries with the sRGB curve, which were preview-only before, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`; neither carries a JPEG, so their uncompressed 8-bit RGB preview (1217 × 913 in the H4D-40 FFF, 218 × 218 in the CFV 3FR) is the embedded preview, handed on as a baseline TIFF, `crates/raw/src/vendor/hasselblad.rs`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Minolta / Konica Minolta MRW (the `\0MRM` block container: `PRD` sensor size, 12-bit packed or 16-bit-word storage and the RGGB / GBRG cell, `WBG` gains in cell order, the `TTW` TIFF for Exif and orientation, black 0 and white from the saturation plateau; `crates/raw/src/vendor/mrw.rs`, DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D checked on CC0 files against their previews), Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/it/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); folder discovery and file pickers include Sony SR2 and SRF, with the same extension list used by CLI/MCP: DSC-R1 SR2 decodes from sensor data; DSC-F828 SRF stays preview-only; transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews and contact sheets show transparent areas on white instead of black, issue #1212, and the alpha is kept beside the image for the exports that write it (LR-EXP-TYPE, #2976); median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant Nikon 12-bit small RAW (D810 / D4S): packed Y0/Y1/Cb/Cr rows decode to linear RGB; header probes retain geometry without decoding pixels, and truncated strips are refused. Chroma gains are approximate. | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/it/corpus.rs`, `crates/raw/tests/it/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Public raw.pixls.us coverage sweep (#1327): 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned (`crates/raw/tests/it/coverage_sweep_corpus.rs`, `docs/raw/coverage-sweep.tsv`; `docs/raw/coverage.py`: 515 → 545 verified rows). Render/reference luminance correlation is 0.6801 to 0.9998; eleven candidates excluded for identity, provenance or missing/mismatched reference images. Nikon D6 / Z 6 / Z 7 uncompressed and Z 8 HE variants remain preview-only. Own-matrix DNG tone differs from the camera JPEG (#1277); sensor verification is not colour calibration. | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`), `crates/raw/src/dng.rs` (`decode_reduced`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs; a DNG that keeps reduced-resolution copies of its LinearRaw image (Lightroom HDR/panorama merges) develops a thumbnail or preview from the smallest copy that covers it (512 px of a 23836 × 7078 pano: 38 s → 1.1 s) | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic |@@ -390,7 +390,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) |-| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests/export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP; a raw without colour matrices of its own, as a NEF, gets the colour it opens with as `ColorMatrix1` / `ForwardMatrix1` / `AsShotNeutral`, also in Convert / Copy as DNG, so other raw converters open it, issue #1745), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build. Opaque 8-bit PNG/TIFF/WebP exports omit the redundant alpha channel; real transparency is retained (#1670). |+| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests/export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP; a raw without colour matrices of its own, as a NEF, gets the colour it opens with as `ColorMatrix1` / `ForwardMatrix1` / `AsShotNeutral`, also in Convert / Copy as DNG, so other raw converters open it, issue #1745), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build. A transparent source keeps its alpha, unassociated (`ExtraSamples` = 2 in TIFF), in PNG and TIFF at 8 and 16 bits, in WebP at 8 bits (its only depth), and in 32-bit float TIFF: the flattened pixels and their alpha are resampled together and unflattened after (`Frame::sample_with_alpha` in `crates/pipeline/src/geometry.rs`), so hard and soft edges keep their colour through orientation, crop, straighten, flips, lens and Transform corrections and resizing; areas a warp brings in from outside the photo are transparent, and a watermark over transparent pixels raises their alpha (#2976, `export_job_with_alpha` in `crates/engine/src/media/session.rs`; tested by `transparent_png_export_keeps_its_alpha` in `apps/lightcraft-cli/tests/it/cli.rs` and `transparent_sources_keep_their_alpha_in_png_tiff_and_webp`, `hard_edges_stay_red_when_downscaled_or_straightened` and `exported_alpha_follows_rotate_crop_and_resize` in `crates/engine/src/tests/export.rs`). Transparent exports render on the CPU (the GPU stages don't sample alpha yet); opaque photos still use the GPU. Neighbourhood stages (noise reduction, sharpening, Texture / Clarity / Dehaze) still see the white under transparent pixels, and the colour is developed straight where previews and JPEG develop it on white. JPEG, PSD, AVIF, JPEG XL and HDR exports, and an offline original exported from its smart preview, composite it onto white. Opaque PNG/TIFF/WebP exports omit the redundant alpha channel (#1670). | | LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `fit`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/dialogs.rs` | full size = the cropped native size (no longer upscaled); both of Lightroom's boxes: Dimensions (W × H fits either orientation) and Width & Height (`fit: widthHeight`, each edge bound to its own number, #1620); ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) | | LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`), `crates/codecs/src/jpeg_par.rs` | 4:4:4 chroma (4:2:0 when a file-size limit is set); Huffman tables made for each image from its own symbol counts (T.81 Annex K.2) instead of the example tables: the same pixels, 12–14 % smaller files at quality 80–100 on a 24 MP photo-like image, still interleaved baseline (issue #1373); ordinary and HDR gain map JPEGs search quality up to the requested maximum to fit `limitKb`, including gain map and metadata, or return an error. Tested by `size_limit_respected` and `hdr_jpeg_size_limit_respected`. | | LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU |@@ -799,6 +799,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | LR-EXP-TYPE: a transparent source keeps its alpha (unassociated) in PNG, TIFF and WebP exports, resampled with the pixels through crop, rotation, lens / Transform corrections and resizing; JPEG and the other formats still composite onto white; transparent exports render on the CPU (#2976). LR-IMP-FORMATS note updated. Status counts unchanged | | 2026-10-25 | patch | LR-BEHAV-TITLEBAR: the top bar's title-bar gestures come from craft-window's `title_bar`: a double-click follows the system setting, and a press on a button, the search field or a panel floating over the bar never moves the window; status counts unchanged | | 2026-10-24 | minor | KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 (`keyword.nextSet`, `keyword.previousSet`) go to the next / previous keyword set; status counts unchanged (KEYC-METADATA stays 🟡) | | 2026-10-24 | minor | KEYC-METADATA: ⌘K / Ctrl+K (`panel.addKeywords`, Add Keywords) shows the Keywords panel, opens Keywording and puts the cursor in the Add keyword box; status counts unchanged (KEYC-METADATA stays 🟡) |diff --git a/docs/whats-new.md b/docs/whats-new.md--- a/docs/whats-new.md+++ b/docs/whats-new.md@@ -1,5 +1,15 @@ # What's new in LightCraft +## 2026-10-27++### Transparent images stay transparent in PNG, TIFF and WebP exports+- Exporting a PNG logo or cut-out with transparency as PNG, TIFF or WebP keeps its transparency, instead of+  flattening it onto white (issue #2976). Edits apply to the colour only; crop, rotation, lens and Transform+  corrections and resizing move the transparency with the picture, and hard and soft edges keep their colour. All+  three store it as straight (unassociated) alpha. JPEG, and the other formats without transparency in this export,+  still composite onto white, and the grid and loupe still show transparent areas on white. Transparent exports+  render on the CPU for now; other photos still use the GPU.+ ## 2026-10-24  ### Relative-date filters and smart albums follow the clock