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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM
This commit is contained in:
radialmonster
2026-10-25 00:26:49 -04:00
co-authored by Claude Opus 5.5
parent 38f8d25087
commit dc6433179b
8 changed files with 223 additions and 10 deletions
+45
View File
@@ -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() };
+23
View File
@@ -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");
}
+1 -1
View File
@@ -132,7 +132,7 @@ impl Backend for Headless {
"engine.commands" => {
let mut v: Vec<Value> = 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))
}
+1 -1
View File
@@ -238,7 +238,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"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)"},
+140 -1
View File
@@ -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<u8>) -> 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(&params).tiff_compression, TiffCompression::Lzw, "{depth:?}: {params}");
}
}
fn set(h: &mut Headless, compression: TiffCompression, bit_depth: Option<u8>) {
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<egui::Rect> {
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");
}
}
+6 -1
View File
@@ -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) => {
+1 -1
View File
@@ -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`) | |
+6 -5
View File
@@ -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.