mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
LR-EXP-DNGOPT: DNG export compression (lossless / ZIP / none)
ExportOptions.dng_compression (`dngCompression`: lossless (default) | deflate | uncompressed), in the export dialog when the format is DNG. Corpus-gated test: all three decode, uncompressed is the biggest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b3249bfb57
commit
1aaafb6218
@@ -445,6 +445,8 @@ pub struct ExportOptions {
|
||||
pub conflict: Conflict,
|
||||
/// TIFF compression.
|
||||
pub tiff_compression: TiffCompression,
|
||||
/// DNG export: how the raw data is stored.
|
||||
pub dng_compression: DngCompression,
|
||||
pub metadata: MetadataPolicy,
|
||||
/// Strip GPS / location even when the policy would include it.
|
||||
pub remove_location: bool,
|
||||
@@ -472,6 +474,7 @@ impl Default for ExportOptions {
|
||||
subfolder: String::new(),
|
||||
conflict: Conflict::Unique,
|
||||
tiff_compression: TiffCompression::Deflate,
|
||||
dng_compression: DngCompression::Lossless,
|
||||
metadata: MetadataPolicy::All,
|
||||
remove_location: false,
|
||||
watermark: None,
|
||||
@@ -513,6 +516,7 @@ impl ExportOptions {
|
||||
Some("zip" | "deflate") => TiffCompression::Deflate,
|
||||
_ => d.tiff_compression,
|
||||
},
|
||||
dng_compression: enm(p, "dngCompression").unwrap_or(d.dng_compression),
|
||||
metadata: enm(p, "metadata").unwrap_or(d.metadata),
|
||||
remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location),
|
||||
watermark: match p.get("watermark") {
|
||||
@@ -944,7 +948,7 @@ enum Work {
|
||||
path: String,
|
||||
read: Option<crate::merge::ByteReader>,
|
||||
packet: String,
|
||||
dng: bool,
|
||||
dng: Option<DngCompression>,
|
||||
label: String,
|
||||
size: (usize, usize),
|
||||
},
|
||||
@@ -972,7 +976,7 @@ pub fn prepare_export(
|
||||
path: path.clone(),
|
||||
read: session.media.file_bytes.clone(),
|
||||
packet: crate::sidecar::sidecar_packet(p, &session.catalog),
|
||||
dng: o.format == ExportFormat::Dng,
|
||||
dng: (o.format == ExportFormat::Dng).then_some(o.dng_compression),
|
||||
label: p.file_name.clone(),
|
||||
size: (p.width as usize, p.height as usize),
|
||||
}
|
||||
@@ -995,15 +999,20 @@ impl PreparedExport {
|
||||
Some(r) => r(&path)?,
|
||||
None => std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?,
|
||||
};
|
||||
if !dng {
|
||||
let Some(dng) = dng else {
|
||||
return Ok(Exported { file_name, bytes, width: size.0, height: size.1, sidecars: vec![("xmp", packet.into_bytes())] });
|
||||
}
|
||||
};
|
||||
if lightcraft_raw::probe(&bytes).is_none() {
|
||||
return Err(format!("{label}: DNG export needs a raw photo"));
|
||||
}
|
||||
let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{label}: {e}"))?;
|
||||
drop(bytes);
|
||||
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), ..Default::default() })
|
||||
let compression = match dng {
|
||||
DngCompression::Lossless => lightcraft_raw::DngCompression::Lj92 { tile: 256 },
|
||||
DngCompression::Deflate => lightcraft_raw::DngCompression::Deflate { tile: 256, half: false },
|
||||
DngCompression::Uncompressed => lightcraft_raw::DngCompression::Uncompressed,
|
||||
};
|
||||
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), compression, ..Default::default() })
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Exported { file_name, bytes: dng, width: raw.width, height: raw.height, sidecars: Vec::new() })
|
||||
}
|
||||
@@ -1011,6 +1020,18 @@ impl PreparedExport {
|
||||
}
|
||||
}
|
||||
|
||||
/// How an exported DNG stores its raw data.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DngCompression {
|
||||
/// Lossless JPEG (the usual DNG compression).
|
||||
#[default]
|
||||
Lossless,
|
||||
/// Deflate (zip) tiles with prediction.
|
||||
Deflate,
|
||||
Uncompressed,
|
||||
}
|
||||
|
||||
/// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy
|
||||
/// applied), or one exact file path (single photo; overwritten).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
|
||||
@@ -415,3 +415,33 @@ fn gps_typed_in_is_saved_to_xmp() {
|
||||
s.execute("photo.setMeta", &json!({"gps": null})).unwrap();
|
||||
assert!(s.catalog.photo(id).unwrap().meta.gps.is_none());
|
||||
}
|
||||
|
||||
/// DNG export compression: lossless, zip and none all decode; none is the biggest.
|
||||
#[test]
|
||||
fn dng_export_compression_choices() {
|
||||
let corpus = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw/nef-nikon-d5100-uncompressed.nef");
|
||||
if !corpus.exists() {
|
||||
eprintln!("skip: {} absent", corpus.display());
|
||||
return;
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [corpus.to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
let mut sizes = Vec::new();
|
||||
for c in ["lossless", "deflate", "uncompressed"] {
|
||||
let o = crate::export::ExportOptions::from_json(&json!({"format": "dng", "dngCompression": c}));
|
||||
let mut out = Vec::new();
|
||||
let mut write = |_: &str, b: &[u8]| {
|
||||
out = b.to_vec();
|
||||
Ok(())
|
||||
};
|
||||
crate::export::export_batch(&mut s, &[id], &o, &crate::export::Destination { dir: "x".into(), exact: None }, &mut write, &|_| false).unwrap();
|
||||
let raw = lightcraft_raw::decode(&out).unwrap_or_else(|e| panic!("{c}: {e}"));
|
||||
assert!(raw.width > 1000, "{c}");
|
||||
sizes.push((c, out.len()));
|
||||
}
|
||||
assert!(sizes[2].1 > sizes[0].1 && sizes[2].1 > sizes[1].1, "uncompressed is the biggest: {sizes:?}");
|
||||
}
|
||||
|
||||
@@ -559,6 +559,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
use lightcraft_engine::export::TiffCompression as Z;
|
||||
choices(ui, "Compression", "exportTiffCompression", &[(Z::None, "None"), (Z::Lzw, "LZW"), (Z::Deflate, "ZIP")], &mut opts.tiff_compression);
|
||||
}
|
||||
if opts.format == F::Dng {
|
||||
use lightcraft_engine::export::DngCompression as Z;
|
||||
choices(ui, "Compression", "exportDngCompression", &[(Z::Lossless, "Lossless"), (Z::Deflate, "ZIP"), (Z::Uncompressed, "None")], &mut opts.dng_compression);
|
||||
}
|
||||
field(ui, "File name", |ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut opts.naming).hint_text("{name}-{seq} · {date}").desired_width(f32::INFINITY))
|
||||
});
|
||||
|
||||
+4
-4
@@ -39,7 +39,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
| P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — |
|
||||
| Q. HDR (HDR) | 0 | 0 | 5 | 0 | — | — |
|
||||
| R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) |
|
||||
| S. Export (EXP) | 15 | 0 | 3 | 0 | 7/7 (100%) | 7/7 (100%) |
|
||||
| S. Export (EXP) | 15 | 1 | 2 | 0 | 7/7 (100%) | 7/7 (100%) |
|
||||
| T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — |
|
||||
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
|
||||
| V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) |
|
||||
@@ -48,9 +48,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
| Y. Menus | 79 | 2 | 4 | 8 | 46/47 (98%) | 22/23 (96%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) |
|
||||
| Lightroom Classic extras | 22 | 10 | 47 | 9 | — | 18/21 (86%) |
|
||||
| **Total** | 370 | 25 | 105 | 37 | 193/198 (97%) | 132/144 (92%) |
|
||||
| **Total** | 370 | 26 | 104 | 37 | 193/198 (97%) | 132/144 (92%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **76.5%** of 500 in-scope rows — P0 98.7% of 198 · P1 94.4% of 144 · P2 32.3% of 158.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **76.6%** of 500 in-scope rows — P0 98.7% of 198 · P1 94.4% of 144 · P2 32.6% of 158.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
@@ -420,7 +420,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`: `{name}`, `{seq}`, `{date}`; `startNumber`) | free-form template rather than a list of named schemes |
|
||||
| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker) | folder picker on the desktop; existing files get `-2`, `-3`… by default |
|
||||
| LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet |
|
||||
| LR-EXP-DNGOPT | DNG options | P2 | ⬜ | | |
|
||||
| LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP or none; no embedded JPEG preview size, no lossy DNG output |
|
||||
| LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`) | file copied byte for byte, sidecar named after the output |
|
||||
| LR-EXP-PHOTOS | Export to the system photo library | P2 | ⬜ | | |
|
||||
| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInExternal`, `crates/engine/src/cmd/convert.rs` | a 16-bit TIFF `-Edit` copy with the edits (Adobe RGB / ProPhoto / P3 / sRGB) next to the original, added stacked on top of it and opened in the editor set in Settings ▸ General (or the system default) |
|
||||
|
||||
Reference in New Issue
Block a user