From faa5478966090b8b040f83dd1cb8d43b04049229 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:46:54 -0400 Subject: [PATCH] LR-EXP-DIALOG + MENU-FILE-EXPORTPRESETS: export presets, destination policy, TIFF compression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Built-in export presets (JPEG Small 2048 px, JPEG Large full size, Original + Settings, DNG) and user presets saved with the library (export.presets / savePreset / deletePreset); app.export {preset, …overrides} everywhere (desktop, MCP, lightcraft-cli run). - File → Export with Preset submenu (built-ins, user presets, Custom…); dialog: preset picker, Save preset, Choose… folder picker, subfolder, if-file-exists (add number / overwrite / skip), start number, TIFF compression (none / LZW / ZIP); {date} naming token. - One export_batch for the desktop app and headless MCP (paths, conflicts, sidecars); ExportOptions::to_json is the exact inverse of from_json. - ⌘E = Export with Previous (alias); parity: Show in Finder row was stale. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/export.rs | 100 +++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/export.rs | 185 ++++++++++++++++++++++++++- crates/engine/src/lib.rs | 3 + crates/engine/src/library.rs | 4 + crates/engine/src/tests_export.rs | 46 +++++++ crates/engine/src/tests_prefs.rs | 5 + crates/mcp/src/headless.rs | 26 +--- crates/ui-egui/src/control.rs | 26 ++-- crates/ui-egui/src/menubar.rs | 53 ++++++++ crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/dialogs.rs | 115 ++++++++++++++--- crates/ui-egui/src/shortcuts.rs | 1 + crates/ui-egui/src/state.rs | 3 + docs/parity.md | 24 ++-- 15 files changed, 520 insertions(+), 75 deletions(-) create mode 100644 crates/engine/src/cmd/export.rs diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs new file mode 100644 index 000000000..75173c84e --- /dev/null +++ b/crates/engine/src/cmd/export.rs @@ -0,0 +1,100 @@ +//! Export presets: built-in ones ([`crate::export::builtin_presets`]) and the user's, saved with the +//! library. `app.export {preset}` (desktop app, MCP, `lightcraft-cli run`) starts from a preset's +//! params; the call's own params override them (see [`Session::export_params`]). + +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd}; +use crate::export::{ExportPreset, builtin_presets}; +use crate::{Result, Session}; + +impl Session { + /// Built-in presets followed by the user's. + pub fn all_export_presets(&self) -> Vec<(ExportPreset, bool)> { + builtin_presets().into_iter().map(|p| (p, true)).chain(self.export_presets.iter().cloned().map(|p| (p, false))).collect() + } + + /// `app.export` params with a named `preset` expanded: the preset's params, overridden by the + /// call's own (`preset` itself removed). Unknown preset names are an error. + pub fn export_params(&self, p: &Value) -> std::result::Result { + let Some(name) = p.get("preset").and_then(Value::as_str) else { return Ok(p.clone()) }; + let (preset, _) = self + .all_export_presets() + .into_iter() + .find(|(x, _)| x.name.eq_ignore_ascii_case(name.trim())) + .ok_or_else(|| format!("unknown export preset `{name}` (see export.presets)"))?; + let mut out = preset.params; + if let (Some(o), Some(own)) = (out.as_object_mut(), p.as_object()) { + for (k, v) in own.iter().filter(|(k, _)| *k != "preset") { + o.insert(k.clone(), v.clone()); + } + } + Ok(out) + } +} + +fn list(s: &Session) -> Value { + Value::Array(s.all_export_presets().into_iter().map(|(p, builtin)| json!({"name": p.name, "builtin": builtin, "params": p.params})).collect()) +} + +/// Params that describe a destination or a selection, not the export settings. +const NOT_SETTINGS: &[&str] = &["ids", "path", "dir", "preset"]; + +fn save(s: &mut Session, p: &Value) -> Result { + const ID: &str = "export.savePreset"; + let name = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(ID, "missing `name`"))?; + if builtin_presets().iter().any(|b| b.name.eq_ignore_ascii_case(name)) { + return Err(bad(ID, format!("`{name}` is a built-in preset; choose another name"))); + } + let mut params = match p.get("params") { + Some(v @ Value::Object(_)) => v.clone(), + Some(_) => return Err(bad(ID, "`params` must be an object of app.export params")), + None => s.last_export.clone().ok_or_else(|| bad(ID, "no `params` and no previous export to save"))?, + }; + if let Some(o) = params.as_object_mut() { + o.retain(|k, _| !NOT_SETTINGS.contains(&k.as_str())); + } + let preset = ExportPreset { name: name.to_string(), params }; + match s.export_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) { + Some(x) => *x = preset, + None => s.export_presets.push(preset), + } + s.save_prefs()?; + Ok(list(s)) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + const ID: &str = "export.deletePreset"; + let name = p.get("name").and_then(Value::as_str).ok_or_else(|| bad(ID, "missing `name`"))?; + let before = s.export_presets.len(); + s.export_presets.retain(|x| !x.name.eq_ignore_ascii_case(name.trim())); + if s.export_presets.len() == before { + return Err(bad(ID, format!("no user export preset `{name}`"))); + } + s.save_prefs()?; + Ok(list(s)) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "export.presets", + "Export Presets", + [], + None, + "{} → [{name, builtin, params}] (use with app.export {preset: name, …overrides})", + always, + |s, _| Ok(list(s)) + ), + cmd!( + "export.savePreset", + "Save Export Preset", + [], + None, + "{name, params?: app.export params (default: the last export's)} — adds or replaces a user preset, saved with the library → presets", + always, + save + ), + cmd!("export.deletePreset", "Delete Export Preset", [], None, "{name} — removes a user preset → presets", always, delete), + ] +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b3afec644..1c82dd4ff 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -7,6 +7,7 @@ mod color; mod develop; mod edit; +mod export; mod keywords; mod library; mod manage; @@ -117,6 +118,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(xmp::specs()); v.extend(preset_files::specs()); v.extend(prefs::specs()); + v.extend(export::specs()); v }) } diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 7b9b1bab7..fadb64cd2 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -4,7 +4,8 @@ //! published primaries and curves, optional output sharpening and a JPEG file-size limit. Pure (bytes in, bytes out) so the desktop //! app, CLI, MCP and the web build share it; writing the file is the caller's job. -use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, Samples, TiffCompression, encode, icc}; +pub use lightcraft_codecs::TiffCompression; +use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, Samples, encode, icc}; use lightcraft_meta::{DateTime, Gps, Metadata}; pub use lightcraft_pipeline::{DeepImage, DeepSamples, OutputDepth, OutputSpace}; use lightcraft_raster::Rgba8; @@ -130,6 +131,36 @@ impl Resize { } } +/// What happens when an output file already exists. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Conflict { + /// Add `-2`, `-3`, … to the name. + #[default] + Unique, + Overwrite, + /// Leave the existing file and skip the photo. + Skip, +} + +/// A named set of `app.export` params. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ExportPreset { + pub name: String, + pub params: serde_json::Value, +} + +/// The built-in export presets (File → Export with Preset). +pub fn builtin_presets() -> Vec { + let p = |name: &str, params: serde_json::Value| ExportPreset { name: name.into(), params }; + vec![ + p("JPEG (Small)", serde_json::json!({"format": "jpeg", "quality": 85, "longEdge": 2048, "colorSpace": "srgb"})), + p("JPEG (Large)", serde_json::json!({"format": "jpeg", "quality": 92, "longEdge": 0, "colorSpace": "srgb"})), + p("Original + Settings", serde_json::json!({"format": "original"})), + p("DNG", serde_json::json!({"format": "dng"})), + ] +} + /// Output sharpening target (applied after resizing, in display-encoded values). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -313,8 +344,17 @@ pub struct ExportOptions { pub limit_kb: Option, pub sharpen: SharpenFor, pub sharpen_amount: SharpenAmount, - /// File name template: `{name}` (original stem), `{seq}` (1-based, zero-padded to 3), `{ext}` is appended. + /// File name template: `{name}` (original stem), `{seq}` (zero-padded to 3, counting from + /// `start_number`), `{date}` (capture date, YYYYMMDD); the extension is appended. pub naming: String, + /// First `{seq}` value. + pub start_number: u32, + /// Created inside the destination folder (empty = none). + pub subfolder: String, + /// When a file of the same name exists in the destination folder. + pub conflict: Conflict, + /// TIFF compression. + pub tiff_compression: TiffCompression, pub metadata: MetadataPolicy, /// Strip GPS / location even when the policy would include it. pub remove_location: bool, @@ -338,6 +378,10 @@ impl Default for ExportOptions { sharpen: SharpenFor::None, sharpen_amount: SharpenAmount::Standard, naming: "{name}".into(), + start_number: 1, + subfolder: String::new(), + conflict: Conflict::Unique, + tiff_compression: TiffCompression::Deflate, metadata: MetadataPolicy::All, remove_location: false, watermark: None, @@ -370,6 +414,15 @@ impl ExportOptions { sharpen: enm(p, "sharpen").unwrap_or(d.sharpen), sharpen_amount: enm(p, "sharpenAmount").unwrap_or(d.sharpen_amount), naming: s("naming").map_or(d.naming, str::to_string), + start_number: u("startNumber").map_or(d.start_number, |v| v.min(999_999) as u32), + subfolder: s("subfolder").map_or(d.subfolder, |v| v.trim().replace(['\\', ':', '\0'], "_")), + conflict: enm(p, "conflict").unwrap_or(d.conflict), + tiff_compression: match s("tiffCompression").map(str::to_ascii_lowercase).as_deref() { + Some("none") => TiffCompression::None, + Some("lzw") => TiffCompression::Lzw, + Some("zip" | "deflate") => TiffCompression::Deflate, + _ => d.tiff_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") { @@ -412,6 +465,23 @@ impl ExportOptions { }) } + /// These options as `app.export` params ([`ExportOptions::from_json`] reads them back + /// unchanged; full size is written as `longEdge: 0`). + pub fn to_json(&self) -> serde_json::Value { + let mut v = serde_json::to_value(self).unwrap_or_default(); + if let Some(o) = v.as_object_mut() { + o.remove("resize"); + if let Some(sz) = Self::resize_json(self.resize.as_ref()).as_object() { + o.extend(sz.clone()); + } + if self.resize.is_none() { + o.insert("longEdge".into(), 0.into()); + } + o.retain(|_, v| !v.is_null()); + } + v + } + /// Whether `p` names an output size (any of the size params, even 0 = full size). pub fn has_size_param(p: &serde_json::Value) -> bool { ["resize", "longEdge", "shortEdge", "width", "height", "megapixels", "percent"].iter().any(|k| p.get(k).is_some()) @@ -456,8 +526,15 @@ impl ExportOptions { /// Output file name for photo `stem` at 1-based position `seq` in a batch. pub fn file_name(&self, stem: &str, seq: usize) -> String { + self.file_name_dated(stem, seq, None) + } + + /// [`ExportOptions::file_name`] with the capture time (ISO 8601) for `{date}`. + pub fn file_name_dated(&self, stem: &str, seq: usize, captured: Option<&str>) -> String { let base = if self.naming.trim().is_empty() { "{name}" } else { self.naming.as_str() }; - let name = base.replace("{name}", stem).replace("{seq}", &format!("{seq:03}")); + let n = seq + self.start_number.max(1) as usize - 1; + let date: String = captured.map(|c| c.chars().take(10).filter(char::is_ascii_digit).collect()).unwrap_or_default(); + let name = base.replace("{name}", stem).replace("{seq}", &format!("{n:03}")).replace("{date}", &date); let name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect(); format!("{name}.{}", self.format.extension()) } @@ -465,8 +542,13 @@ impl ExportOptions { /// Output file name for photo file `file_name` (the original's extension is kept for /// [`ExportFormat::Original`]). pub fn file_name_for(&self, file_name: &str, seq: usize) -> String { + self.file_name_for_dated(file_name, seq, None) + } + + /// [`ExportOptions::file_name_for`] with the capture time for `{date}`. + pub fn file_name_for_dated(&self, file_name: &str, seq: usize, captured: Option<&str>) -> String { let (stem, ext) = file_name.rsplit_once('.').unwrap_or((file_name, "")); - let name = self.file_name(stem, seq); + let name = self.file_name_dated(stem, seq, captured); if self.format == ExportFormat::Original { let base = name.trim_end_matches('.'); if ext.is_empty() { base.to_string() } else { format!("{base}.{ext}") } @@ -603,7 +685,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada } } ExportFormat::Png => encode::encode_png(&e, &meta), - ExportFormat::Tiff => encode::encode_tiff(&e, TiffCompression::Deflate, &meta), + ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta), ExportFormat::Webp => encode::encode_webp_lossless(&e, &meta), ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta), f @ (ExportFormat::Original | ExportFormat::Dng) => return Err(format!("{f:?} export does not encode pixels")), @@ -667,7 +749,7 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) }; let r = match o.format { ExportFormat::Png => encode::encode_png(&e, &meta), - ExportFormat::Tiff => encode::encode_tiff(&e, TiffCompression::Deflate, &meta), + ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta), ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta), f => return Err(format!("{f:?} export is 8-bit only")), }; @@ -739,7 +821,7 @@ pub fn output_size(p: &lightcraft_catalog::Photo, o: &ExportOptions) -> (usize, /// [`ExportFormat::Original`] / [`ExportFormat::Dng`]). pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoId, o: &ExportOptions, seq: usize) -> Result { let p = session.catalog.photo(id).ok_or("no such photo")?; - let file_name = o.file_name_for(&p.file_name, seq); + let file_name = o.file_name_for_dated(&p.file_name, seq, p.captured.as_deref()); if !o.format.is_rendered() { return export_file(session, id, o, file_name); } @@ -778,6 +860,67 @@ fn export_file(session: &mut crate::Session, id: lightcraft_catalog::PhotoId, o: } } +/// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy +/// applied), or one exact file path (single photo; overwritten). +pub struct Destination<'a> { + pub dir: &'a str, + pub exact: Option<&'a str>, +} + +/// Export `ids` in order: render/encode each ([`export_photo`]), pick its path, and hand the +/// bytes (and sidecars) to `write`. `exists` tells whether a path is taken (conflict policy). +/// Returns one JSON object per photo: `{path, width, height, bytes, sidecars}` or +/// `{skipped: path}`. +pub fn export_batch( + session: &mut crate::Session, + ids: &[lightcraft_catalog::PhotoId], + o: &ExportOptions, + to: &Destination, + write: &mut dyn FnMut(&str, &[u8]) -> Result<(), String>, + exists: &dyn Fn(&str) -> bool, +) -> Result, String> { + use serde_json::json; + let join = |a: &str, b: &str| if a.is_empty() { b.to_string() } else { format!("{}/{b}", a.trim_end_matches('/')) }; + let dir = if o.subfolder.is_empty() { to.dir.to_string() } else { join(to.dir, &o.subfolder) }; + let mut taken = std::collections::HashSet::new(); + let mut out = Vec::new(); + for (i, id) in ids.iter().enumerate() { + let e = export_photo(session, *id, o, i + 1)?; + let path = match to.exact.filter(|_| ids.len() == 1) { + Some(p) => p.to_string(), + None => { + let mut path = join(&dir, &e.file_name); + let busy = |p: &str, taken: &std::collections::HashSet| taken.contains(p) || exists(p); + if busy(&path, &taken) { + match o.conflict { + Conflict::Overwrite => {} + Conflict::Skip => { + out.push(json!({"skipped": path})); + continue; + } + Conflict::Unique => { + let (stem, ext) = e.file_name.rsplit_once('.').map_or((e.file_name.as_str(), None), |(a, b)| (a, Some(b))); + let name = |n: usize| join(&dir, &ext.map_or(format!("{stem}-{n}"), |x| format!("{stem}-{n}.{x}"))); + path = (2..).map(name).find(|p| !busy(p, &taken)).expect("a free name"); + } + } + } + path + } + }; + write(&path, &e.bytes)?; + let mut sidecars = Vec::new(); + for (ext, bytes) in &e.sidecars { + let sc = std::path::Path::new(&path).with_extension(ext).to_string_lossy().to_string(); + write(&sc, bytes)?; + sidecars.push(sc); + } + taken.insert(path.clone()); + out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars})); + } + Ok(out) +} + #[cfg(test)] mod tests { use super::*; @@ -882,6 +1025,34 @@ mod tests { assert_eq!(ExportOptions { format: ExportFormat::Dng, ..Default::default() }.file_name_for("a.cr2", 1), "a.dng"); } + #[test] + fn params_round_trip() { + let o = ExportOptions { + format: ExportFormat::Tiff, + quality: 70, + resize: Some(Resize { mode: ResizeMode::Megapixels, value: 12.5, height: 0, dont_enlarge: false }), + ppi: 300, + limit_kb: Some(500), + sharpen: SharpenFor::Glossy, + naming: "{date}-{name}-{seq}".into(), + start_number: 42, + subfolder: "Client".into(), + conflict: Conflict::Skip, + tiff_compression: TiffCompression::Lzw, + metadata: MetadataPolicy::Copyright, + remove_location: true, + watermark: Some(Watermark { text: "© LC".into(), ..Default::default() }), + color_space: OutputSpace::ProPhoto, + bit_depth: Some(16), + ..Default::default() + }; + assert_eq!(ExportOptions::from_json(&o.to_json()), o); + let full = ExportOptions::default(); + assert_eq!(ExportOptions::from_json(&full.to_json()), full); + assert!(ExportOptions::has_size_param(&full.to_json()), "full size is explicit"); + assert_eq!(o.file_name_dated("IMG", 2, Some("2026-09-30T10:00:00")), "20260930-IMG-043.tif"); + } + #[test] fn resize_modes() { let size = |p: serde_json::Value, w: f64, h: f64| ExportOptions::from_json(&p).resize.map(|r| r.apply(w, h)); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9c894ad2f..69d149869 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -105,6 +105,8 @@ pub struct Session { pub xmp: sidecar::XmpPrefs, /// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json. pub last_export: Option, + /// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json. + pub export_presets: Vec, /// File probes from the last import review (`library.importPreview`), reused by the import. pub import_probes: std::collections::HashMap, /// Develop defaults applied on import (persisted in prefs.json). @@ -147,6 +149,7 @@ impl Session { library: None, xmp: sidecar::XmpPrefs::default(), last_export: None, + export_presets: Vec::new(), import_probes: Default::default(), import_defaults: import::ImportDefaults::default(), cache_mb: 0, diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 3ec3b58ea..e5d73da0c 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -105,6 +105,8 @@ struct PrefsFile { /// Parameters of the last export (for Export with Previous). #[serde(alias = "last_export")] last_export: Option, + /// The user's export presets. + export_presets: Vec, /// Develop defaults for imported photos. import: crate::import::ImportDefaults, /// Thumbnail disk cache budget (MB, 0 = default). @@ -170,6 +172,7 @@ impl Session { let prefs = read_json::(files.as_mut(), "prefs.json").unwrap_or_default(); self.xmp = prefs.xmp; self.last_export = prefs.last_export; + self.export_presets = prefs.export_presets; self.import_defaults = prefs.import; self.cache_mb = prefs.cache_mb; // view state @@ -274,6 +277,7 @@ impl Session { let v = serde_json::to_vec_pretty(&PrefsFile { xmp: self.xmp, last_export: self.last_export.clone(), + export_presets: self.export_presets.clone(), import: self.import_defaults.clone(), cache_mb: self.cache_mb, }) diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 0af2e6df9..923a3ad8f 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -97,3 +97,49 @@ fn dng_export_rejects_non_raw_photos() { assert!(err.contains("needs a raw photo"), "{err}"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn presets_conflicts_and_subfolders() { + use crate::export::{Destination, export_batch}; + let mut s = Session::with_demo(); + let ids: Vec<_> = s.visible().iter().copied().take(2).collect(); + // built-ins and user presets; the call's own params override the preset's + let names: Vec = + s.execute("export.presets", &json!({})).unwrap().as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect(); + assert!(names.contains(&"JPEG (Small)".to_string()) && names.contains(&"Original + Settings".to_string()), "{names:?}"); + let p = s.export_params(&json!({"preset": "jpeg (small)", "quality": 50})).unwrap(); + assert_eq!((p["longEdge"].as_u64(), p["quality"].as_u64(), p.get("preset")), (Some(2048), Some(50), None)); + assert!(s.export_params(&json!({"preset": "nope"})).unwrap_err().contains("unknown export preset")); + assert!(s.execute("export.savePreset", &json!({"name": "JPEG (Large)", "params": {}})).is_err(), "built-in names are reserved"); + s.execute("export.savePreset", &json!({"name": "Web", "params": {"format": "png", "width": 64, "ids": [1], "dir": "/x"}})).unwrap(); + let web = s.export_params(&json!({"preset": "Web"})).unwrap(); + assert_eq!(web, json!({"format": "png", "width": 64}), "targets are not part of a preset"); + s.execute("export.savePreset", &json!({"name": "web", "params": {"format": "webp"}})).unwrap(); + assert_eq!(s.export_presets.len(), 1, "same name (any case) replaces"); + s.execute("export.deletePreset", &json!({"name": "WEB"})).unwrap(); + assert!(s.export_presets.is_empty()); + + // a fake file system: what exists, what was written + let existing = ["out/Sub/LC01347.png".to_string()]; + let to = Destination { dir: "out", exact: None }; + let batch = |s: &mut Session, ids: &[_], o: &ExportOptions| { + let mut written: Vec = Vec::new(); + let mut write = |p: &str, _: &[u8]| { + written.push(p.to_string()); + Ok(()) + }; + let files = export_batch(s, ids, o, &to, &mut write, &|p| existing.contains(&p.to_string())).unwrap(); + (files, written) + }; + let o = ExportOptions::from_json(&json!({"format": "png", "width": 32, "subfolder": "Sub", "naming": "LC01347"})); + // both photos get the same name from the template: the first is renamed past the existing + // file, the second past the first + let (files, written) = batch(&mut s, &ids, &o); + assert_eq!(written, ["out/Sub/LC01347-2.png", "out/Sub/LC01347-3.png"]); + assert_eq!(files[0]["width"], 32); + let (files, written) = batch(&mut s, &ids, &ExportOptions { conflict: crate::export::Conflict::Skip, ..o.clone() }); + assert_eq!(files.iter().filter(|f| f["skipped"] == "out/Sub/LC01347.png").count(), 2, "{files:?}"); + assert!(written.is_empty()); + let (_, written) = batch(&mut s, &ids[..1], &ExportOptions { conflict: crate::export::Conflict::Overwrite, ..o }); + assert_eq!(written, ["out/Sub/LC01347.png"]); +} diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 2c6802b26..d4e3ec53c 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -124,6 +124,11 @@ fn preferences_persist_with_the_library() { let r = s.execute("library.preferences", &json!({})).unwrap(); assert_eq!(r["cacheMb"], 512); assert_eq!(r["persistent"], true); + // export presets are library preferences too + s.execute("export.savePreset", &json!({"name": "Proof", "params": {"format": "jpeg", "percent": 25}})).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!(s.export_params(&json!({"preset": "Proof"})).unwrap(), json!({"format": "jpeg", "percent": 25})); // older prefs.json files (snake_case `last_export`, no import section) still load std::fs::write(lib.join("prefs.json"), r#"{"xmp": {"autoWrite": false}, "last_export": {"format": "png"}}"#).unwrap(); let mut s = Session::new().with_fs(); diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index ed602ff66..643753146 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -57,7 +57,8 @@ impl Headless { /// (see `lightcraft_engine::export::ExportOptions::from_json`, plus `ids`, `dir`, `path`). /// With `path` and no `format`, the format follows the path's extension. fn export(&mut self, p: &Value) -> Result { - use lightcraft_engine::export::{ExportFormat, ExportOptions, Resize, export_photo}; + use lightcraft_engine::export::{Destination, ExportFormat, ExportOptions, Resize, export_batch}; + let p = &self.session.export_params(p)?; let mut opts = ExportOptions::from_json(p); if !ExportOptions::has_size_param(p) { opts.resize = Some(Resize::long_edge(3000)); @@ -72,26 +73,13 @@ impl Headless { None => vec![self.photo_or_active(p)?], }; let dir = p.get("dir").and_then(Value::as_str).unwrap_or(""); - let mut files = Vec::new(); - for (i, id) in ids.iter().enumerate() { - let e = export_photo(&mut self.session, *id, &opts, i + 1)?; - let path = match exact.filter(|_| ids.len() == 1) { - Some(path) => path.to_string(), - None if dir.is_empty() => e.file_name.clone(), - None => format!("{dir}/{}", e.file_name), - }; - if let Some(parent) = Path::new(&path).parent().filter(|d| !d.as_os_str().is_empty()) { + let write = &mut |path: &str, bytes: &[u8]| { + if let Some(parent) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) { std::fs::create_dir_all(parent).map_err(|err| err.to_string())?; } - std::fs::write(&path, &e.bytes).map_err(|err| format!("{path}: {err}"))?; - let mut sidecars = Vec::new(); - for (ext, bytes) in &e.sidecars { - let sc = Path::new(&path).with_extension(ext).to_string_lossy().to_string(); - std::fs::write(&sc, bytes).map_err(|err| format!("{sc}: {err}"))?; - sidecars.push(sc); - } - files.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars})); - } + std::fs::write(path, bytes).map_err(|err| format!("{path}: {err}")) + }; + let files = export_batch(&mut self.session, &ids, &opts, &Destination { dir, exact }, write, &|path| Path::new(path).exists())?; // Single-photo exports also report path/width/height at the top level (back-compat). let mut out = files.first().cloned().unwrap_or_else(|| json!({})); out["files"] = json!(files); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 57a5dac61..68858d6f8 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -317,7 +317,8 @@ pub fn default_export_dir() -> String { /// [`lightcraft_engine::export::ExportOptions::from_json`], plus `dir` (output folder) or `path` /// (exact output file, single photo), `ids` (default: the selection, else the active photo). pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result { - use lightcraft_engine::export::{ExportOptions, export_photo}; + use lightcraft_engine::export::{Destination, ExportOptions, export_batch}; + let p = &app.session.export_params(p)?; let mut opts = ExportOptions::from_json(p); if let (Some(path), None) = (p.get("path").and_then(Value::as_str), p.get("format")) { let ext = path.rsplit_once('.').map_or("", |(_, e)| e); @@ -338,27 +339,18 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result Option> { v.extend(groups.into_iter().map(|(label, g, k)| item("library.sort", json!({"group": k}), label, None, true, Some(cur.group == g)))); v } + "Export with Preset" => { + let sel = !app.session.selection.ids.is_empty() || has; + let mut v: Vec = Vec::new(); + let mut builtin = true; + for (p, b) in app.session.all_export_presets() { + if builtin && !b { + v.push(MenuNode::Separator); + } + builtin = b; + v.push(item("app.export", json!({"preset": p.name}), p.name, None, sel, None)); + } + v.push(MenuNode::Separator); + v.push(item("dialog.export", Value::Null, "Custom…", None, sel, None)); + v + } "Add to Album" => { let sel = !app.session.selection.ids.is_empty() || has; let mut albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.name.clone(), a.id.0)).collect(); @@ -619,6 +635,43 @@ mod tests { assert_eq!(*checked, Some(app.ui.filmstrip)); } + #[test] + fn export_with_preset_submenu() { + let mut app = app(); + let bar = menu_bar(&app); + let file = &bar.iter().find(|(t, _)| t == "File").unwrap().1; + let Some(MenuNode::Submenu { children, .. }) = + file.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Export with Preset")) + else { + panic!("no Export with Preset submenu") + }; + let presets: Vec<_> = children + .iter() + .filter_map(|n| match n { + MenuNode::Item { id, params, .. } if id == "app.export" => params["preset"].as_str().map(str::to_string), + _ => None, + }) + .collect(); + assert_eq!(presets.first().map(String::as_str), Some("JPEG (Small)")); + assert!(find(children, "dialog.export").is_some(), "Custom… opens the dialog"); + // a user preset appears after the built-ins; choosing it exports with its settings + app.session.execute("export.savePreset", &json!({"name": "Tiny PNG", "params": {"format": "png", "width": 40}})).unwrap(); + let written = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(String, Vec)>::new())); + let w = written.clone(); + app.services.write = Some(Box::new(move |p: &str, b: &[u8]| { + w.lock().unwrap().push((p.to_string(), b.to_vec())); + Ok(()) + })); + let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": "/nonexistent-lc-test"})).unwrap(); + assert_eq!(r["files"][0]["width"], 40, "{r}"); + let w = written.lock().unwrap(); + assert!(w[0].0.starts_with("/nonexistent-lc-test/") && w[0].0.ends_with(".png"), "{}", w[0].0); + assert!(w[0].1.starts_with(b"\x89PNG")); + // remembered (expanded) for Export with Previous, folder included + let last = app.session.last_export.clone().unwrap(); + assert_eq!((last["format"].as_str(), last["width"].as_u64(), last.get("preset")), (Some("png"), Some(40), None)); + } + #[test] fn shortcut_text_per_platform() { assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⌘⇧Z"); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e57e48326..d884f985d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -525,7 +525,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::merge::open(app, "merge.hdr"), diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index e39646c66..a1bf07ae8 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -233,10 +233,36 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { group_checklist(ui, "presetInclude", groups); } Dialog::CopySettings { groups } => group_checklist(ui, "copyGroup", groups), - Dialog::Export { opts, full_size, resize, limit_kb, dir } => { + Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } => { use lightcraft_engine::export::{Anchor as P, ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; let n = app.session.selection.ids.len().max(1); ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(t.text_dim)); + // Preset: load a built-in or saved set of options into the dialog + field(ui, "Preset", |ui| { + let mut chosen = None; + let c = egui::ComboBox::from_id_salt("exportPreset").width(220.0).selected_text("Choose…").show_ui(ui, |ui| { + let mut builtin = true; + for (p, b) in app.session.all_export_presets() { + if builtin && !b { + ui.separator(); + } + builtin = b; + if ui.selectable_label(false, &p.name).clicked() { + chosen = Some(p.name); + } + } + }); + crate::widgets::register(ui.ctx(), "combo:exportPreset", c.response.rect); + if let Some(name) = chosen + && let Ok(params) = app.session.export_params(&json!({"preset": name})) + { + let o = lightcraft_engine::export::ExportOptions::from_json(¶ms); + *full_size = o.resize.is_none(); + *resize = o.resize.unwrap_or_default(); + *limit_kb = o.limit_kb.unwrap_or(0); + *opts = o; + } + }); ui.add_space(4.0); let before = opts.format; choices( @@ -355,10 +381,58 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } ui.add_space(4.0); + 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); + } field(ui, "File name", |ui| { - ui.add(egui::TextEdit::singleline(&mut opts.naming).hint_text("{name}-{seq}").desired_width(f32::INFINITY)) + ui.add(egui::TextEdit::singleline(&mut opts.naming).hint_text("{name}-{seq} · {date}").desired_width(f32::INFINITY)) + }); + if opts.naming.contains("{seq}") { + let mut v = opts.start_number as f64; + if num(ui, &START_NUMBER, &mut v) { + opts.start_number = v as u32; + } + } + field(ui, "Folder", |ui| { + let can_pick = app.services.pick_folder.is_some(); + let w = ui.available_width() - if can_pick { 76.0 } else { 0.0 }; + ui.add(egui::TextEdit::singleline(dir).desired_width(w)); + if can_pick + && crate::widgets::text_button(ui, "exportChooseFolder", "Choose…", false).clicked() + && let Some(pick) = app.services.pick_folder.as_mut() + && let Some(d) = pick() + { + *dir = d; + } + }); + field(ui, "Subfolder", |ui| { + ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text("none").desired_width(f32::INFINITY)) + }); + use lightcraft_engine::export::Conflict as K; + choices( + ui, + "If file exists", + "exportConflict", + &[(K::Unique, "Add number"), (K::Overwrite, "Overwrite"), (K::Skip, "Skip")], + &mut opts.conflict, + ); + ui.add_space(4.0); + field(ui, "Save preset", |ui| { + let w = ui.available_width() - 60.0; + ui.add(egui::TextEdit::singleline(preset_name).hint_text("Preset name").desired_width(w)); + let named = !preset_name.trim().is_empty(); + if crate::widgets::text_button(ui, "exportSavePreset", "Save", false).clicked() && named { + let params = export_dialog_params(opts, *full_size, resize, *limit_kb); + match app.run("export.savePreset", json!({"name": preset_name.trim(), "params": params})) { + Ok(_) => { + app.toast(ui.ctx(), format!("Saved export preset “{}”", preset_name.trim())); + preset_name.clear(); + } + Err(e) => app.toast(ui.ctx(), e), + } + } }); - field(ui, "Folder", |ui| ui.add(egui::TextEdit::singleline(dir).desired_width(f32::INFINITY))); } Dialog::Merge { opts } => crate::merge::body(app, ui, opts), Dialog::Import { opts } => crate::import::body(app, ui, opts), @@ -515,22 +589,9 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("develop.copy", json!({"groups": groups})), - Dialog::Export { opts, full_size, resize, limit_kb, dir } => { - let mut p = json!({ - "format": opts.format, "quality": opts.quality, "limitKb": limit_kb, - "sharpen": opts.sharpen, "sharpenAmount": opts.sharpen_amount, "naming": opts.naming, "dir": dir, - "metadata": opts.metadata, "removeLocation": opts.remove_location, "watermark": opts.watermark, - "colorSpace": opts.color_space, "bitDepth": opts.bit_depth, "ppi": opts.ppi, - }); - // full size is an explicit `longEdge: 0` (an absent size means "the default size" to agents) - let size = if *full_size { - json!({"longEdge": 0, "dontEnlarge": resize.dont_enlarge}) - } else { - lightcraft_engine::export::ExportOptions::resize_json(Some(resize)) - }; - if let (Some(o), Some(sz)) = (p.as_object_mut(), size.as_object()) { - o.extend(sz.clone()); - } + Dialog::Export { opts, full_size, resize, limit_kb, dir, .. } => { + let mut p = export_dialog_params(opts, *full_size, resize, *limit_kb); + p["dir"] = json!(dir); app.run("app.export", p) } Dialog::Merge { opts } => crate::merge::start_final(app, opts), @@ -540,6 +601,21 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result serde_json::Value { + let o = lightcraft_engine::export::ExportOptions { + resize: (!full_size).then_some(*resize), + limit_kb: (limit_kb > 0).then_some(limit_kb), + ..opts.clone() + }; + o.to_json() +} + // ------------------------------------------------------------------------------------------ dialog widgets /// Two columns of settings-group checkboxes (Copy Settings, Create Preset) plus All / None. @@ -587,6 +663,7 @@ const SIZE_W: ControlSpec = spec("export.sizeW", "Width (px)", 16.0, 20_000.0, 2 const SIZE_H: ControlSpec = spec("export.sizeH", "Height (px)", 16.0, 20_000.0, 2048.0, 16.0); const SIZE_MP: ControlSpec = ControlSpec { decimals: 1, ..spec("export.sizeMp", "Megapixels", 0.1, 100.0, 12.0, 0.1) }; const SIZE_PCT: ControlSpec = spec("export.sizePercent", "Percent", 1.0, 400.0, 50.0, 1.0); +const START_NUMBER: ControlSpec = spec("export.startNumber", "Start number", 1.0, 9999.0, 1.0, 1.0); const PPI: ControlSpec = spec("export.ppi", "Resolution (ppi)", 1.0, 1200.0, 240.0, 1.0); /// Image Sizing: full size, or a resize mode and its value(s), don't enlarge, ppi. diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index be33a30cb..75273428a 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -11,6 +11,7 @@ use crate::LightcraftApp; pub const ALIASES: &[(&str, &str, &str)] = &[ ("Cmd+D", "library.selectNone", "{}"), ("Shift+E", "dialog.export", "{}"), + ("Cmd+E", "app.exportPrevious", "{}"), ("Space", "view.zoomToggle", "{}"), ("Shift+M", "version.create", "{}"), ("Shift+X", "photo.flag", r#"{"flag": "reject", "advance": true}"#), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6922caef6..c5a2a6922 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -334,6 +334,9 @@ pub enum Dialog { opts: lightcraft_engine::export::ExportOptions, full_size: bool, resize: lightcraft_engine::export::Resize, + /// Name typed for "Save as Preset". + #[serde(default)] + preset_name: String, limit_kb: u32, dir: String, }, diff --git a/docs/parity.md b/docs/parity.md index 3dad234a8..d089ac559 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -22,7 +22,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done | |---|---:|---:|---:|---:|---:|---:| | A. Import (IMP) | 5 | 3 | 5 | 1 | 2/4 (50%) | 2/4 (50%) | -| B. Library management (LIB) | 16 | 2 | 7 | 2 | 8/9 (89%) | 7/9 (78%) | +| B. Library management (LIB) | 17 | 2 | 6 | 2 | 9/9 (100%) | 7/9 (78%) | | C. Views & navigation (VIEW) | 13 | 1 | 3 | 0 | 8/9 (89%) | 4/4 (100%) | | D. Search & filter (FILT) | 7 | 2 | 4 | 0 | 4/4 (100%) | 3/4 (75%) | | E. Metadata (META) | 2 | 2 | 2 | 0 | 2/2 (100%) | 0/2 (0%) | @@ -39,16 +39,16 @@ 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) | 9 | 4 | 5 | 0 | 5/7 (71%) | 4/7 (57%) | +| S. Export (EXP) | 13 | 1 | 4 | 0 | 7/7 (100%) | 6/7 (86%) | | T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 4 | 1 | 3 | 3 | 1/1 (100%) | 3/4 (75%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 10 | 3 | 4 | 1 | 7/8 (88%) | 3/5 (60%) | -| Y. Menus | 53 | 17 | 15 | 8 | 38/47 (81%) | 13/23 (57%) | -| Z. Keyboard shortcuts (desktop) | 53 | 14 | 13 | 1 | 44/52 (85%) | 9/23 (39%) | +| Y. Menus | 54 | 17 | 14 | 8 | 39/47 (83%) | 13/23 (57%) | +| Z. Keyboard shortcuts (desktop) | 54 | 13 | 13 | 1 | 45/52 (87%) | 9/23 (39%) | | Lightroom Classic extras | 4 | 20 | 55 | 9 | — | 3/21 (14%) | -| **Total** | 253 | 85 | 162 | 37 | 173/198 (87%) | 70/144 (49%) | +| **Total** | 260 | 81 | 159 | 37 | 178/198 (90%) | 72/144 (50%) | ## Top gaps @@ -137,7 +137,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ⬜ | | | | LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` | template tokens {name} {seq:N} {date:%Y%m%d} {camera} {title}; preview; renames files on disk with their XMP sidecars, never overwriting (-1, -2… suffixes), rolls back on failure; undo/redo move the files; virtual copies follow | | LR-LIB-CAPTURETIME | Edit capture time | P1 | ✅ | `cmd:photo.setCaptureTime`, `cmd:dialog.captureTime`, `crates/catalog/src/dates.rs` | set (the other selected photos shift by the same amount, or `each`), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time” | -| LR-LIB-SHOWFINDER | Reveal original in file manager | P0 | ⬜ | | | +| LR-LIB-SHOWFINDER | Reveal original in file manager | P0 | ✅ | `cmd:app.showInFinder` | ⌘R (see MENU-FILE-SHOWFINDER) | | LR-LIB-COVER | Album cover | P2 | ✅ | `cmd:album.setCover` | | | LR-LIB-CULL | Assisted culling | P2 | ⬜ | | | | LR-LIB-ACTIVITY | Comments & likes | OOS | 🚫 | | | @@ -405,19 +405,19 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-EXP-DIALOG | Export dialog | P0 | 🟡 | `cmd:dialog.export`, `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export works; no built-in or saved export presets | +| 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/panels/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 | | 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, WebP, AVIF, DNG (raw photos: lossless re-encode with the edits in the embedded XMP), Original (+ XMP sidecar). No JXL encoder; non-raw → DNG not supported | | LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `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); W × H fits either orientation; ppi written to JFIF / pHYs / TIFF tags | | LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | | | 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 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 | ⬜ | | always Deflate | +| LR-EXP-COMPRESSION | TIFF compression | P1 | ✅ | `cmd:app.export` (`tiffCompression`: none / lzw / zip) | | | 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) | | LR-EXP-HDR | HDR output | P2 | ⬜ | | | | LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | | | LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | | LR-EXP-WATERMARK | Watermark | P1 | 🟡 | `crates/engine/src/export.rs` (`Watermark`) | text only; no graphic watermark | -| LR-EXP-NAMING | File naming | P1 | 🟡 | `cmd:app.export` (`naming`: `{name}`, `{seq}`) | no date tokens, custom start number | -| LR-EXP-LOCATION | Destination folder | P0 | 🟡 | `crates/ui-egui/src/panels/dialogs.rs` (folder field) | no folder picker, subfolder or name-conflict policy | +| 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-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`) | file copied byte for byte, sidecar named after the output | @@ -512,7 +512,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-FILE-IMPORTPROFILES | Import Profiles & Presets… | P1 | 🟡 | `cmd:file.importPresets` | presets only | | MENU-FILE-EXPORT | Export… | P0 | ✅ | `cmd:dialog.export` | | | MENU-FILE-EXPORTPREV | Export with Previous | P0 | ✅ | `cmd:app.exportPrevious` | ⌥⇧⌘E | -| MENU-FILE-EXPORTPRESETS | Export preset submenu | P0 | ⬜ | | | +| MENU-FILE-EXPORTPRESETS | Export preset submenu | P0 | ✅ | `cmd:app.export` (`preset`), `crates/ui-egui/src/menubar.rs` (Export with Preset) | built-ins, then user presets, then Custom… (the dialog); exports to the last folder | | MENU-FILE-SHARE | Share / get link / invite | OOS | 🚫 | | | | MENU-FILE-PHOTOSHOP | Edit in external editor | P2 | ⬜ | | | | MENU-FILE-SHOWFINDER | Show in Finder | P0 | ✅ | `cmd:app.showInFinder` | ⌘R; Explorer on Windows, the folder on Linux; disabled for demo scenes and on the web | @@ -626,7 +626,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-SHOWORIGINAL | Show original — \ | P0 | ✅ | `cmd:view.showOriginal` | | | KEY-TOGGLEZOOM | Toggle zoom — Space | P0 | ✅ | `cmd:view.zoomToggle` | Space is a secondary binding (primary Z) | | KEY-MASKCOLOR | Cycle mask colour — ⇧O | P1 | ⬜ | | ⇧O cycles crop overlays | -| KEY-EXPORTPREV | Export with previous — ⌘E | P0 | 🟡 | `cmd:app.exportPrevious` | bound to ⌥⇧⌘E (Classic); ⌘E not bound | +| KEY-EXPORTPREV | Export with previous — ⌘E | P0 | ✅ | `cmd:app.exportPrevious` | ⌘E (alias) and ⌥⇧⌘E (Classic) | | KEY-EXPORTDIALOG | Export dialog — ⇧E | P0 | ✅ | `cmd:dialog.export` | secondary binding (primary ⌘⇧E) | | KEY-ENTERFULLSCREEN | Window full screen — ⇧⌘F | P1 | ✅ | `cmd:view.enterFullScreen` | | | KEY-STACK | Group / ungroup stack — ⌘G / ⇧⌘G | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup` | also S expand/collapse, ⇧S top of stack |