From dc6433179b6a62be41dffbbd65669e1003898320 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Sun, 25 Oct 2026 00:26:49 -0400 Subject: [PATCH] Export: note on LZW TIFF size, never larger ZIP at 16 bit (#2882) 16-bit LZW TIFFs come out larger than uncompressed ones and slower than ZIP. The export keeps LZW exactly as picked (some software reads only LZW), and says what it costs where it is chosen: - Export dialog: a note under Compression when LZW is selected, worded more strongly at 16 bit, a milder one at 8 bit and 32-bit float. - Export presets that write LZW TIFFs carry the same hint on hover, in the dialog's Preset list and the grid's Export with Preset menu. - tiffCompression docs: MCP schema and headless params, the user export guide and the parity checklist. Tests: the note shows for LZW only and under the Compression choice (headless), presets get the same hint, the dialog keeps LZW; 16-bit ZIP is never larger than uncompressed on a noisy synthetic render (noise 0 to 0.1) and on the demo photo's export. Fixes #2882 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM --- crates/engine/src/export.rs | 45 +++++++++ crates/engine/src/tests_export.rs | 23 +++++ crates/mcp/src/headless.rs | 2 +- crates/mcp/src/tools.rs | 2 +- crates/ui-egui/src/panels/dialogs.rs | 141 ++++++++++++++++++++++++++- crates/ui-egui/src/panels/grid.rs | 7 +- docs/parity-checklist.md | 2 +- docs/user/guides/export-photos.md | 11 ++- 8 files changed, 223 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index b1a5bce0f..571c45c76 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -3259,6 +3259,51 @@ mod tests { (v.len(), d) } + /// A 16-bit render of a photo-like scene: smooth colour gradients plus sensor-like noise of + /// `noise` (linear, about 0.002 to 0.03 for clean to high-ISO raws), the case where the low + /// byte of every 16-bit sample is mostly noise. + fn noisy_render(noise: f32) -> lightcraft_pipeline::Rendered { + let (w, h) = (512, 384); + // a hash of the position and channel: the same noise every run + let hash = |x: usize, y: usize, c: usize| { + let mut z = (x as u64) << 32 ^ (y as u64) << 2 ^ c as u64 ^ 0x2882; + z = (z ^ (z >> 33)).wrapping_mul(0xff51_afd7_ed55_8ccd); + z = (z ^ (z >> 33)).wrapping_mul(0xc4ce_b9fe_1a85_ec53); + (z ^ (z >> 33)) as f32 / u64::MAX as f32 - 0.5 + }; + let src = lightcraft_raster::Rgb32f::from_fn(w, h, |x, y| { + let (u, v) = (x as f32 / w as f32, y as f32 / h as f32); + let base = [0.05 + 0.4 * u, 0.1 + 0.3 * v, 0.2 + 0.2 * (u * v)]; + [0, 1, 2].map(|c| (base[c] + noise * hash(x, y, c)).max(0.0)) + }); + let req = lightcraft_pipeline::RenderRequest { depth: OutputDepth::U16, ..lightcraft_pipeline::RenderRequest::fit(w, h) }; + lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &req) + } + + /// Issue #2882: a 16-bit TIFF with ZIP (the default) is never larger than the same render + /// uncompressed, from smooth to noisy. (LZW can be: it is printed, not checked, and the export + /// dialog says so when it is picked.) + #[test] + fn sixteen_bit_zip_tiff_is_never_larger_than_uncompressed() { + assert_eq!(ExportOptions::default().tiff_compression, TiffCompression::Deflate, "ZIP is the default"); + for noise in [0.0, 0.002, 0.01, 0.03, 0.1] { + let r = noisy_render(noise); + assert!(matches!(r.deep.as_ref().unwrap().samples, DeepSamples::U16(_))); + let size = |tiff_compression| { + let o = ExportOptions { format: ExportFormat::Tiff, bit_depth: Some(16), tiff_compression, ..Default::default() }; + assert_eq!(o.effective_depth(), OutputDepth::U16); + encode_rendered(&r, &o, None).unwrap().len() + }; + let (none, zip, lzw) = (size(TiffCompression::None), size(TiffCompression::Deflate), size(TiffCompression::Lzw)); + eprintln!( + "noise {noise}: none {none} B, ZIP {zip} B ({:+.1} %), LZW {lzw} B ({:+.1} %)", + 100.0 * (zip as f64 / none as f64 - 1.0), + 100.0 * (lzw as f64 / none as f64 - 1.0) + ); + assert!(zip <= none, "noise {noise}: 16-bit ZIP {zip} B is larger than uncompressed {none} B"); + } + } + #[test] fn sixteen_bit_tiff_and_png_have_no_banding() { let tiff = |bit_depth| ExportOptions { format: ExportFormat::Tiff, bit_depth, ..Default::default() }; diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index d0f174245..2dc35626b 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -1203,3 +1203,26 @@ 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); } + +/// Issue #2882: the demo photo's 16-bit TIFF export (2000 px long edge) with ZIP (the default) is +/// never larger than the same export uncompressed. +#[test] +fn sixteen_bit_zip_tiff_export_is_never_larger_than_uncompressed() { + use crate::export::TiffCompression; + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + let size = |s: &mut Session, tiff_compression| { + let o = ExportOptions { + format: ExportFormat::Tiff, + bit_depth: Some(16), + tiff_compression, + ..ExportOptions::from_json(&json!({"longEdge": 2000})) + }; + export_photo(s, id, &o, 1).unwrap().bytes.len() + }; + let none = size(&mut s, TiffCompression::None); + let zip = size(&mut s, TiffCompression::Deflate); + let lzw = size(&mut s, TiffCompression::Lzw); + eprintln!("demo photo, 16-bit TIFF: none {none} B, ZIP {zip} B, LZW {lzw} B"); + assert!(zip <= none, "16-bit ZIP {zip} B is larger than uncompressed {none} B"); +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index d62a9a707..f4f32bb00 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -132,7 +132,7 @@ impl Backend for Headless { "engine.commands" => { let mut v: Vec = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null, - "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), fit?: dimensions|widthHeight (with width + height: dimensions fits either orientation, widthHeight limits the width to `width` and the height to `height`), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, destinationMode?: specificFolder|sameAsOriginal (sameAsOriginal = each photo into its original's folder; `dir` unused), subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", + "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), fit?: dimensions|widthHeight (with width + height: dimensions fits either orientation, widthHeight limits the width to `width` and the height to `height`), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, destinationMode?: specificFolder|sameAsOriginal (sameAsOriginal = each photo into its original's folder; `dir` unused), subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip (default zip; lzw is usually larger and slower than zip, at 16 bit often larger than none: only for software that needs LZW), dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", "enabled": self.session.active().is_some()})); Ok(Value::Array(v)) } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 8f782bb9d..b542b2b2f 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -238,7 +238,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "destinationMode": {"type": "string", "enum": ["specificFolder", "sameAsOriginal"], "description": "specificFolder (default) = everything into `dir`; sameAsOriginal = each photo into the folder of its original (+ `subfolder`); a photo with no folder on disk (demo, browser) or whose folder is gone gets an error"}, "subfolder": {"type": "string", "description": "Created inside the destination folder"}, "conflict": {"type": "string", "enum": ["unique", "overwrite", "skip"]}, - "tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"]}, + "tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"], "description": "zip (default); lzw is usually larger and slower than zip, and at 16 bit often larger than none: use it only when other software needs LZW"}, "dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed", "lossy"]}, "format": {"type": "string", "enum": ["jpeg", "png", "tiff", "psd", "webp", "avif", "jxl", "original", "dng", "rendereddng"], "description": "psd = a flat PSD (no layers); rendereddng = Render to DNG, a linear 16-bit float DNG of the rendered photo with the edit baked in; original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"}, "longEdge": {"type": "integer", "description": "Output long edge in px. When no size param is given: 3000; 0 = full size (cropped, native resolution)"}, diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index f14d7338b..dac47376c 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -798,7 +798,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.separator(); } builtin = b; - if ui.selectable_label(false, crate::i18n::builtin_label(&p.name, b)).clicked() { + let r = ui.selectable_label(false, crate::i18n::builtin_label(&p.name, b)); + let r = match preset_lzw_note(&p.params) { + Some(note) => r.on_hover_text(crate::i18n::tr(note)), + None => r, + }; + if r.clicked() { chosen = Some(p.name); } } @@ -1031,6 +1036,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if opts.format == F::Tiff { use lightcraft_engine::export::TiffCompression as Z; choices(ui, "Compression", "exportTiffCompression", &[(Z::None, "None"), (Z::Lzw, "LZW"), (Z::Deflate, "ZIP")], &mut opts.tiff_compression); + // issue #2882: LZW stays as picked, with a note on what it costs + if let Some(note) = tiff_lzw_note(opts) { + let r = ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)); + crate::widgets::register(ui.ctx(), "note:exportTiffLzw", r.rect); + } } if opts.format == F::Dng { use lightcraft_engine::export::DngCompression as Z; @@ -2118,6 +2128,29 @@ pub fn prompt(app: &mut LightcraftApp, title: &str, hint: &str, value: &str, com } /// The Export dialog's choices as `app.export` params (without the folder). +/// The 16-bit note: LZW's byte dictionary does worse on 16-bit samples than storing them, so +/// 16-bit LZW TIFFs came out 10-33 % larger than uncompressed ones (issue #2882). +pub(crate) const TIFF_LZW_NOTE_16: &str = + "At 16 bit, LZW is often larger than no compression, and larger and slower than ZIP. Use it only if other software needs LZW."; +/// The note at 8 bit and 32-bit float: one 8-bit file in #2882 was larger with LZW too. +pub(crate) const TIFF_LZW_NOTE: &str = + "LZW is usually larger and slower than ZIP, and can be larger than no compression. Use it only if other software needs LZW."; + +/// The note shown under Compression when a TIFF export uses LZW (none otherwise). The export +/// keeps LZW: some software reads only LZW TIFFs. +pub(crate) fn tiff_lzw_note(o: &lightcraft_engine::export::ExportOptions) -> Option<&'static str> { + use lightcraft_engine::export::{ExportFormat, OutputDepth, TiffCompression}; + if o.format != ExportFormat::Tiff || o.tiff_compression != TiffCompression::Lzw { + return None; + } + Some(if o.effective_depth() == OutputDepth::U16 { TIFF_LZW_NOTE_16 } else { TIFF_LZW_NOTE }) +} + +/// [`tiff_lzw_note`] for an export preset's params: the hint on presets that write LZW TIFFs. +pub(crate) fn preset_lzw_note(params: &serde_json::Value) -> Option<&'static str> { + tiff_lzw_note(&lightcraft_engine::export::ExportOptions::from_json(params)) +} + fn export_dialog_params( opts: &lightcraft_engine::export::ExportOptions, full_size: bool, @@ -2623,3 +2656,109 @@ mod cull_dialog_tests { assert_eq!(burst(&h), (1800.0, 1.0)); } } + +#[cfg(test)] +mod tiff_lzw_note_tests { + use std::time::Duration; + + use lightcraft_engine::export::{ExportFormat, ExportOptions, TiffCompression}; + use serde_json::json; + + use super::{TIFF_LZW_NOTE, TIFF_LZW_NOTE_16, export_dialog_params, preset_lzw_note, tiff_lzw_note}; + use crate::headless::Headless; + use crate::state::Dialog; + use crate::{LightcraftApp, Services}; + + const T: Duration = Duration::from_secs(20); + + fn tiff(compression: TiffCompression, bit_depth: Option) -> ExportOptions { + ExportOptions { format: ExportFormat::Tiff, tiff_compression: compression, bit_depth, ..Default::default() } + } + + // Issue #2882: the note shows for LZW only, worded more strongly at 16 bit (TIFF's default depth) + #[test] + fn lzw_gets_a_note_stronger_at_16_bit() { + assert_eq!(tiff_lzw_note(&tiff(TiffCompression::Lzw, None)), Some(TIFF_LZW_NOTE_16), "TIFF defaults to 16-bit"); + assert_eq!(tiff_lzw_note(&tiff(TiffCompression::Lzw, Some(16))), Some(TIFF_LZW_NOTE_16)); + assert_eq!(tiff_lzw_note(&tiff(TiffCompression::Lzw, Some(8))), Some(TIFF_LZW_NOTE)); + assert_eq!(tiff_lzw_note(&tiff(TiffCompression::Lzw, Some(32))), Some(TIFF_LZW_NOTE)); + for compression in [TiffCompression::None, TiffCompression::Deflate, TiffCompression::PackBits] { + for depth in [None, Some(8), Some(16), Some(32)] { + assert_eq!(tiff_lzw_note(&tiff(compression, depth)), None, "{compression:?} {depth:?}"); + } + } + // the setting only matters for TIFF: a JPEG preset that carries it gets no note + let jpeg = ExportOptions { format: ExportFormat::Jpeg, tiff_compression: TiffCompression::Lzw, ..Default::default() }; + assert_eq!(tiff_lzw_note(&jpeg), None); + assert!(TIFF_LZW_NOTE_16.contains("16 bit") && TIFF_LZW_NOTE.contains("other software needs LZW")); + } + + // Presets that write LZW TIFFs carry the same hint; the built-in ones don't write LZW + #[test] + fn presets_with_lzw_get_the_same_hint() { + assert_eq!(preset_lzw_note(&json!({"format": "tiff", "tiffCompression": "lzw"})), Some(TIFF_LZW_NOTE_16)); + assert_eq!(preset_lzw_note(&json!({"format": "tiff", "tiffCompression": "LZW", "bitDepth": 8})), Some(TIFF_LZW_NOTE)); + assert_eq!(preset_lzw_note(&json!({"format": "tiff", "tiffCompression": "zip"})), None); + assert_eq!(preset_lzw_note(&json!({"format": "tiff"})), None, "ZIP is the default"); + for p in lightcraft_engine::export::builtin_presets() { + assert_eq!(preset_lzw_note(&p.params), None, "{}", p.name); + } + } + + // The note never changes the choice: the dialog exports exactly the compression picked + #[test] + fn the_dialog_keeps_lzw_as_picked() { + for depth in [None, Some(8), Some(16), Some(32)] { + let o = tiff(TiffCompression::Lzw, depth); + let params = export_dialog_params(&o, true, &Default::default(), 0); + assert_eq!(ExportOptions::from_json(¶ms).tiff_compression, TiffCompression::Lzw, "{depth:?}: {params}"); + } + } + + fn set(h: &mut Headless, compression: TiffCompression, bit_depth: Option) { + if let Some(Dialog::Export { opts, .. }) = &mut h.app.ui.dialog { + opts.format = ExportFormat::Tiff; + opts.tiff_compression = compression; + opts.bit_depth = bit_depth; + } else { + panic!("no Export dialog"); + } + h.settle(T); + } + + fn note_rect(h: &Headless) -> Option { + h.app.widgets.iter().find(|(w, _)| w == "note:exportTiffLzw").map(|(_, r)| *r) + } + + fn widget(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) + } + + // Scenario: in the Export dialog, picking LZW for a TIFF shows the note under Compression; ZIP + // and None show none, and the choice stays LZW + #[test] + fn export_dialog_shows_the_lzw_note_under_compression() { + let services = Services { png: None, write_shared: Some(std::sync::Arc::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1600.0, 1000.0], 1.0); + h.settle(T); + let r = h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(T); + for compression in [TiffCompression::Deflate, TiffCompression::None] { + set(&mut h, compression, None); + assert!(note_rect(&h).is_none(), "{compression:?} has no note"); + } + for depth in [None, Some(8), Some(32)] { + set(&mut h, TiffCompression::Lzw, depth); + let note = note_rect(&h).unwrap_or_else(|| panic!("LZW at {depth:?} shows the note")); + // under the Compression choice, inside the dialog + assert!(note.top() >= widget(&h, "button:exportTiffCompression-1").bottom() - 1.0, "{note:?}"); + assert!(widget(&h, "dialog:window").contains_rect(note), "{note:?}"); + let Some(Dialog::Export { opts, .. }) = &h.app.ui.dialog else { panic!("dialog closed") }; + assert_eq!(opts.tiff_compression, TiffCompression::Lzw, "the note leaves the choice alone"); + } + set(&mut h, TiffCompression::Deflate, None); + assert!(note_rect(&h).is_none(), "back to ZIP: the note goes"); + } +} diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 883f47f8b..a904f328c 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -1413,7 +1413,12 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } crate::widgets::menu_button(ui, crate::i18n::tr("Export with Preset"), |ui| { for (p, _) in app.session.all_export_presets() { - if ui.button(&p.name).clicked() { + let r = ui.button(&p.name); + let r = match crate::panels::dialogs::preset_lzw_note(&p.params) { + Some(note) => r.on_hover_text(crate::i18n::tr(note)), + None => r, + }; + if r.clicked() { match app.run("app.export", json!({"preset": p.name, "background": true})) { Ok(r) if r.get("background").is_some() => {} Ok(r) => { diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index d059494ce..c02e9c686 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -394,7 +394,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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/panels/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 | -| LR-EXP-COMPRESSION | TIFF compression | P1 | ✅ | `cmd:app.export` (`tiffCompression`: none / lzw / zip) | | +| LR-EXP-COMPRESSION | TIFF compression | P1 | ✅ | `cmd:app.export` (`tiffCompression`: none / lzw / zip) | ZIP is the default. LZW is usually larger and slower than ZIP, and at 16 bit often larger than none (#2882); the export dialog notes this when LZW is picked | | LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC); lossy JPEG XL declares sRGB / P3 / Rec. 2020 (Adobe RGB and ProPhoto written as Rec. 2020), lossless JPEG XL embeds the profile | | LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ or HLG AVIF, PQ PNG, PQ JPEG XL, float TIFF; no HDR presets yet | | LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | | diff --git a/docs/user/guides/export-photos.md b/docs/user/guides/export-photos.md index 71bca3241..985263644 100644 --- a/docs/user/guides/export-photos.md +++ b/docs/user/guides/export-photos.md @@ -16,11 +16,12 @@ Select the photos, then choose **File** > **Export…**. Pick a format, a size a 5. Under the size options, tick **Full size**, or clear it and choose **Resize to** one of **Long Edge**, **Short Edge**, **Width**, **Height**, **Width × Height**, **Megapixels** or **Percentage**, then enter the value. 6. Set **Color space** (**sRGB**, **P3**, **Adobe RGB**, **ProPhoto** or **Rec.2020**) if you need something other than the default. Lossy **JPEG XL** writes **Adobe RGB** and **ProPhoto** as **Rec.2020**. Tick **Lossless** to keep them. 7. Optional: set **Sharpen** for **Screen**, **Matte** or **Glossy** output, set **Metadata** to **All**, **No camera**, **Copyright** or **None**, and tick **Remove location info** or **Watermark**. -8. Next to **Folder**, click **Choose…** and pick where the files go. **Subfolder** adds a folder inside it. -9. Set **File name** to a template such as `{name}-{seq}`. Click **Tags** to see the tags you can insert. -10. Set **If file exists** to **Add number**, **Overwrite** or **Skip**. -11. On macOS, tick **Add to Apple Photos** to add the exported files to Photos as well. Type an **Album** name to put them in that album. The first time, macOS asks whether LightCraft may control Photos. -12. Click **OK**. The export shows its progress in the activity stack at the top left. +8. For **TIFF**, set **Compression** to **ZIP** (the default), **LZW** or **None**. **LZW** is usually larger and slower than **ZIP**, and at 16 bit it can be larger than **None**. Use it only if other software needs LZW. +9. Next to **Folder**, click **Choose…** and pick where the files go. **Subfolder** adds a folder inside it. +10. Set **File name** to a template such as `{name}-{seq}`. Click **Tags** to see the tags you can insert. +11. Set **If file exists** to **Add number**, **Overwrite** or **Skip**. +12. On macOS, tick **Add to Apple Photos** to add the exported files to Photos as well. Type an **Album** name to put them in that album. The first time, macOS asks whether LightCraft may control Photos. +13. Click **OK**. The export shows its progress in the activity stack at the top left. If you did not choose a folder, the window stays open and shows an error, so your choices are not lost.