mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
LR-EXP-DIALOG + MENU-FILE-EXPORTPRESETS: export presets, destination policy, TIFF compression
- 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
80dfb437c1
commit
faa5478966
@@ -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<Value, String> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
||||
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),
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
+178
-7
@@ -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<ExportPreset> {
|
||||
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<u32>,
|
||||
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<Exported, String> {
|
||||
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<Vec<serde_json::Value>, 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<String>| 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));
|
||||
|
||||
@@ -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<serde_json::Value>,
|
||||
/// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json.
|
||||
pub export_presets: Vec<export::ExportPreset>,
|
||||
/// File probes from the last import review (`library.importPreview`), reused by the import.
|
||||
pub import_probes: std::collections::HashMap<String, media::ProbeInfo>,
|
||||
/// 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,
|
||||
|
||||
@@ -105,6 +105,8 @@ struct PrefsFile {
|
||||
/// Parameters of the last export (for Export with Previous).
|
||||
#[serde(alias = "last_export")]
|
||||
last_export: Option<serde_json::Value>,
|
||||
/// The user's export presets.
|
||||
export_presets: Vec<crate::export::ExportPreset>,
|
||||
/// 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::<PrefsFile>(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,
|
||||
})
|
||||
|
||||
@@ -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<String> =
|
||||
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<String> = 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"]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Value, String> {
|
||||
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);
|
||||
|
||||
@@ -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<Value, String> {
|
||||
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<Value, String
|
||||
if ids.is_empty() {
|
||||
return Err("no photo selected".into());
|
||||
}
|
||||
let dir = p.get("dir").and_then(Value::as_str).map(str::to_string).filter(|d| !d.is_empty()).unwrap_or_else(default_export_dir);
|
||||
let exact = p.get("path").and_then(Value::as_str).filter(|_| ids.len() == 1).map(str::to_string);
|
||||
let mut out = Vec::new();
|
||||
for (i, id) in ids.into_iter().enumerate() {
|
||||
let e = export_photo(&mut app.session, id, &opts, i + 1)?;
|
||||
let path = exact.clone().unwrap_or_else(|| if dir.is_empty() { e.file_name.clone() } else { format!("{dir}/{}", e.file_name) });
|
||||
let w = app.services.write.as_mut().ok_or("no writer")?;
|
||||
w(&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();
|
||||
w(&sc, bytes)?;
|
||||
sidecars.push(sc);
|
||||
}
|
||||
out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars}));
|
||||
}
|
||||
// no folder given (e.g. File → Export with Preset): the last export's, else the default
|
||||
let last_dir = app.session.last_export.as_ref().and_then(|l| l.get("dir")).and_then(Value::as_str).map(str::to_string);
|
||||
let dir = p.get("dir").and_then(Value::as_str).map(str::to_string).or(last_dir).filter(|d| !d.is_empty()).unwrap_or_else(default_export_dir);
|
||||
let to = Destination { dir: &dir, exact: p.get("path").and_then(Value::as_str) };
|
||||
let w = app.services.write.as_mut().ok_or("no writer")?;
|
||||
let out = export_batch(&mut app.session, &ids, &opts, &to, &mut |path, bytes| w(path, bytes), &|path| std::path::Path::new(path).exists())?;
|
||||
// remember for Export with Previous (and to prefill the dialog)
|
||||
let mut last = p.clone();
|
||||
if let Some(o) = last.as_object_mut() {
|
||||
o.remove("ids");
|
||||
o.remove("path");
|
||||
o.insert("dir".into(), json!(dir));
|
||||
}
|
||||
app.session.last_export = Some(last);
|
||||
let _ = app.session.save_prefs();
|
||||
|
||||
@@ -65,6 +65,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
||||
"---",
|
||||
"dialog.export",
|
||||
"app.exportPrevious",
|
||||
"@Export with Preset",
|
||||
"---",
|
||||
"library.toggleAutoWriteXmp",
|
||||
"---",
|
||||
@@ -335,6 +336,21 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
|
||||
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<MenuNode> = 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<u8>)>::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");
|
||||
|
||||
@@ -525,7 +525,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
// no previous export: 2048 px long edge; a previous full-size export: full size
|
||||
let full_size = opts.resize.is_none() && lightcraft_engine::export::ExportOptions::has_size_param(&prev);
|
||||
let resize = opts.resize.unwrap_or_default();
|
||||
app.ui.dialog = Some(Dialog::Export { opts, full_size, resize, limit_kb: u("limitKb", 0) as u32, dir });
|
||||
app.ui.dialog = Some(Dialog::Export { opts, full_size, resize, preset_name: String::new(), limit_kb: u("limitKb", 0) as u32, dir });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"dialog.mergeHdr" => crate::merge::open(app, "merge.hdr"),
|
||||
|
||||
@@ -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<serde_jso
|
||||
}),
|
||||
),
|
||||
Dialog::CopySettings { groups } => 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_jso
|
||||
}
|
||||
}
|
||||
|
||||
/// The Export dialog's choices as `app.export` params (without the folder).
|
||||
fn export_dialog_params(
|
||||
opts: &lightcraft_engine::export::ExportOptions,
|
||||
full_size: bool,
|
||||
resize: &lightcraft_engine::export::Resize,
|
||||
limit_kb: u32,
|
||||
) -> 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.
|
||||
|
||||
@@ -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}"#),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
+12
-12
@@ -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%) |
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## 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 |
|
||||
|
||||
Reference in New Issue
Block a user