mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-19 02:10:25 -05:00
Merge PR #996
# Conflicts: # crates/ui-egui/locales/de.json # crates/ui-egui/locales/es.json # crates/ui-egui/locales/fr.json # crates/ui-egui/locales/ja.json # crates/ui-egui/locales/pt-br.json # crates/ui-egui/locales/ru.json # crates/ui-egui/locales/uk.json # crates/ui-egui/locales/zh-hans.json # crates/ui-egui/locales/zh-hant.json # docs/parity-checklist.md
This commit is contained in:
commit
ff66d188e8
20 files changed
+307
-48
No files matched your search
+115
-18
@@ -162,6 +162,29 @@ pub enum Conflict {
|
||||
Skip,
|
||||
}
|
||||
|
||||
/// Where a batch's files go (Lightroom Classic's Export To).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DestinationMode {
|
||||
/// One folder for the whole batch ([`Destination::dir`]).
|
||||
#[default]
|
||||
SpecificFolder,
|
||||
/// Each photo's own folder: the folder its original is in. A photo without one on disk (a
|
||||
/// demo photo, a photo in the browser's storage) or whose folder is gone gets an error.
|
||||
SameAsOriginal,
|
||||
}
|
||||
|
||||
impl DestinationMode {
|
||||
/// `specificFolder` / `sameAsOriginal` (any case).
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"specificfolder" => Some(Self::SpecificFolder),
|
||||
"sameasoriginal" => Some(Self::SameAsOriginal),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named set of `app.export` params.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ExportPreset {
|
||||
@@ -555,6 +578,8 @@ pub struct ExportOptions {
|
||||
pub naming: String,
|
||||
/// First `{seq}` value.
|
||||
pub start_number: u32,
|
||||
/// A specific folder for the whole batch, or each photo's own folder.
|
||||
pub destination_mode: DestinationMode,
|
||||
/// Created inside the destination folder (empty = none).
|
||||
pub subfolder: String,
|
||||
/// When a file of the same name exists in the destination folder.
|
||||
@@ -607,6 +632,7 @@ impl Default for ExportOptions {
|
||||
sharpen_amount: SharpenAmount::Standard,
|
||||
naming: "{name}".into(),
|
||||
start_number: 1,
|
||||
destination_mode: DestinationMode::SpecificFolder,
|
||||
subfolder: String::new(),
|
||||
conflict: Conflict::Unique,
|
||||
tiff_compression: TiffCompression::Deflate,
|
||||
@@ -649,6 +675,7 @@ pub const OPTION_PARAMS: &[&str] = &[
|
||||
"sharpenAmount",
|
||||
"naming",
|
||||
"startNumber",
|
||||
"destinationMode",
|
||||
"subfolder",
|
||||
"conflict",
|
||||
"tiffCompression",
|
||||
@@ -839,6 +866,7 @@ impl ExportOptions {
|
||||
"sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?,
|
||||
"hdrTransfer" => one_of(k, v, &["pq", "hlg"])?,
|
||||
"conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?,
|
||||
"destinationMode" => one_of(k, v, &["specificFolder", "sameAsOriginal"])?,
|
||||
"tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?,
|
||||
"jxlEffort" => one_of(k, v, &["fast", "normal"])?,
|
||||
"dngCompression" => {
|
||||
@@ -951,6 +979,7 @@ impl ExportOptions {
|
||||
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),
|
||||
destination_mode: s("destinationMode").and_then(DestinationMode::parse).unwrap_or(d.destination_mode),
|
||||
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() {
|
||||
@@ -1481,6 +1510,9 @@ pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoI
|
||||
pub struct PreparedExport {
|
||||
pub photo: lightcraft_catalog::PhotoId,
|
||||
pub file_name: String,
|
||||
/// The folder of the photo's original on disk ([`DestinationMode::SameAsOriginal`]); `None`
|
||||
/// for a demo photo or one in the browser's storage.
|
||||
pub source_dir: Option<String>,
|
||||
work: Work,
|
||||
/// The library's originals, which [`run_batch`] never writes over (shared by a batch).
|
||||
guard: std::sync::Arc<crate::originals::OriginalGuard>,
|
||||
@@ -1543,6 +1575,7 @@ fn prepare_guarded(
|
||||
) -> Result<PreparedExport, String> {
|
||||
let p = session.catalog.photo(id).ok_or("no such photo")?;
|
||||
let file_name = o.file_name_for(p, seq);
|
||||
let source_dir = original_folder(&p.source);
|
||||
// HDR output only for photos edited in HDR; the rest of the batch exports as usual
|
||||
let sdr_opts;
|
||||
let o = if o.hdr && !p.develop.hdr.enabled {
|
||||
@@ -1589,7 +1622,18 @@ fn prepare_guarded(
|
||||
};
|
||||
(work, weight)
|
||||
};
|
||||
Ok(PreparedExport { photo: id, file_name, work, guard, weight })
|
||||
Ok(PreparedExport { photo: id, file_name, source_dir, work, guard, weight })
|
||||
}
|
||||
|
||||
/// The folder a photo's original is in, for [`DestinationMode::SameAsOriginal`]. None for a demo
|
||||
/// photo, and on the web, where an original lives in the browser's storage, not in a folder.
|
||||
fn original_folder(source: &lightcraft_catalog::Source) -> Option<String> {
|
||||
let lightcraft_catalog::Source::File { path } = source else { return None };
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return None;
|
||||
}
|
||||
let dir = std::path::Path::new(path).parent()?.to_string_lossy().to_string();
|
||||
(!dir.is_empty()).then_some(dir)
|
||||
}
|
||||
|
||||
/// Why an export of `out` pixels (`w`, `h`), from a photo of `native_px` pixels at its own size,
|
||||
@@ -1700,8 +1744,9 @@ pub enum DngCompression {
|
||||
}
|
||||
|
||||
/// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy
|
||||
/// applied to each file together with its sidecars), or one exact file path (single photo; an
|
||||
/// ordinary file already there is replaced). Either way a catalogued original (or its sidecar)
|
||||
/// applied to each file together with its sidecars; with [`DestinationMode::SameAsOriginal`] each
|
||||
/// photo's own folder instead of `dir`), or one exact file path (single photo; an ordinary file
|
||||
/// already there is replaced). Either way a catalogued original (or its sidecar)
|
||||
/// is never written over: see [`crate::originals`].
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Destination {
|
||||
@@ -1898,12 +1943,12 @@ fn run_batch_with(
|
||||
if !progress(i, &item.file_name) {
|
||||
break;
|
||||
}
|
||||
let (photo, name, guard) = (item.photo, item.file_name.clone(), item.guard.clone());
|
||||
placer.place(photo, name, &guard, run_gated(item))?;
|
||||
let (photo, name, guard, source_dir) = (item.photo, item.file_name.clone(), item.guard.clone(), item.source_dir.clone());
|
||||
placer.place(photo, name, source_dir.as_deref(), &guard, run_gated(item))?;
|
||||
}
|
||||
return Ok(std::mem::take(&mut placer.out));
|
||||
}
|
||||
let heads: Vec<_> = items.iter().map(|i| (i.photo, i.file_name.clone(), i.guard.clone())).collect();
|
||||
let heads: Vec<_> = items.iter().map(|i| (i.photo, i.file_name.clone(), i.guard.clone(), i.source_dir.clone())).collect();
|
||||
let queue = Queue {
|
||||
lane: std::sync::Mutex::new(Lane { items: items.into_iter().map(Some).collect(), next: 0, placed: 0, stop: false, done: Default::default() }),
|
||||
cv: std::sync::Condvar::new(),
|
||||
@@ -1918,12 +1963,12 @@ fn run_batch_with(
|
||||
}
|
||||
}
|
||||
let placed = || {
|
||||
for (i, (photo, name, guard)) in heads.into_iter().enumerate() {
|
||||
for (i, (photo, name, guard, source_dir)) in heads.into_iter().enumerate() {
|
||||
if !progress(i, &name) {
|
||||
break;
|
||||
}
|
||||
let e = queue.result(i);
|
||||
placer.place(photo, name, &guard, e)?;
|
||||
placer.place(photo, name, source_dir.as_deref(), &guard, e)?;
|
||||
}
|
||||
Ok::<_, String>(())
|
||||
};
|
||||
@@ -2034,12 +2079,11 @@ impl Queue {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a batch's files go: each photo's path (subfolder, conflict policy, sidecars, never an
|
||||
/// original) and its write, in batch order.
|
||||
/// Where a batch's files go: each photo's path (destination folder, subfolder, conflict policy,
|
||||
/// sidecars, never an original) and its write, in batch order.
|
||||
struct Placer<'a> {
|
||||
o: &'a ExportOptions,
|
||||
to: &'a Destination,
|
||||
dir: String,
|
||||
single: bool,
|
||||
taken: std::collections::HashSet<String>,
|
||||
out: Vec<serde_json::Value>,
|
||||
@@ -2060,29 +2104,53 @@ impl<'a> Placer<'a> {
|
||||
exists: &'a dyn Fn(&str) -> bool,
|
||||
stop_on_error: bool,
|
||||
) -> Placer<'a> {
|
||||
let dir = if o.subfolder.is_empty() { to.dir.clone() } else { join_path(&to.dir, &o.subfolder) };
|
||||
Placer { o, to, dir, single: false, taken: Default::default(), out: Vec::new(), write, exists, stop_on_error }
|
||||
Placer { o, to, single: false, taken: Default::default(), out: Vec::new(), write, exists, stop_on_error }
|
||||
}
|
||||
|
||||
/// Place photo `photo`'s export result (file `name`): write it and record the outcome. `Err`
|
||||
/// only when it failed and `stop_on_error` is set.
|
||||
/// The folder photo `name` (its original in `source_dir`) is written to: the batch's folder or
|
||||
/// the photo's own ([`DestinationMode`]), plus the subfolder. An error for a photo with no
|
||||
/// folder of its own, or whose folder is gone: never a file written somewhere else instead.
|
||||
fn folder_for(&self, name: &str, source_dir: Option<&str>) -> Result<String, String> {
|
||||
let base = match self.o.destination_mode {
|
||||
DestinationMode::SpecificFolder => self.to.dir.clone(),
|
||||
DestinationMode::SameAsOriginal => {
|
||||
let Some(d) = source_dir else {
|
||||
return Err(format!(
|
||||
"{name}: the photo has no folder on disk (a demo photo or one in the browser's storage), so it can't be exported to the same folder as its original; choose a specific folder"
|
||||
));
|
||||
};
|
||||
if !(self.exists)(d) {
|
||||
return Err(format!("{name}: the original's folder is missing ({d}); reconnect it or choose a specific folder"));
|
||||
}
|
||||
d.to_string()
|
||||
}
|
||||
};
|
||||
Ok(if self.o.subfolder.is_empty() { base } else { join_path(&base, &self.o.subfolder) })
|
||||
}
|
||||
|
||||
/// Place photo `photo`'s export result (file `name`, its original in `source_dir`): write it
|
||||
/// and record the outcome. `Err` only when it failed and `stop_on_error` is set.
|
||||
fn place(
|
||||
&mut self,
|
||||
photo: lightcraft_catalog::PhotoId,
|
||||
name: String,
|
||||
source_dir: Option<&str>,
|
||||
guard: &crate::originals::OriginalGuard,
|
||||
e: Result<Exported, String>,
|
||||
) -> Result<(), String> {
|
||||
use serde_json::json;
|
||||
let (o, dir, exists) = (self.o, &self.dir, self.exists);
|
||||
let e = match e {
|
||||
Ok(e) => e,
|
||||
// an exact file path needs no folder
|
||||
let dir = if self.to.exact.is_some() && self.single { Ok(String::new()) } else { self.folder_for(&name, source_dir) };
|
||||
let (o, exists) = (self.o, self.exists);
|
||||
let (dir, e) = match dir.and_then(|dir| e.map(|e| (dir, e))) {
|
||||
Ok(v) => v,
|
||||
Err(err) if self.stop_on_error => return Err(err),
|
||||
Err(err) => {
|
||||
self.out.push(json!({"photo": photo.0, "file": name, "error": err}));
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let dir = dir.as_str();
|
||||
// the exported file and its sidecars
|
||||
let group = |main: &str| std::iter::once(main.to_string()).chain(e.sidecars.iter().map(|(x, _)| sidecar_path(main, x))).collect::<Vec<_>>();
|
||||
let path = match self.to.exact.as_deref().filter(|_| self.single) {
|
||||
@@ -3077,6 +3145,35 @@ mod tests {
|
||||
ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap();
|
||||
}
|
||||
|
||||
/// Issue #956: `destinationMode` picks a specific folder (the default) or each photo's own
|
||||
/// folder; it is checked like the other params and kept in presets and Export with Previous.
|
||||
#[test]
|
||||
fn destination_mode_param() {
|
||||
use serde_json::json;
|
||||
assert_eq!(ExportOptions::default().destination_mode, DestinationMode::SpecificFolder);
|
||||
for (v, want) in [
|
||||
("sameAsOriginal", DestinationMode::SameAsOriginal),
|
||||
("SameAsOriginal", DestinationMode::SameAsOriginal),
|
||||
("specificFolder", DestinationMode::SpecificFolder),
|
||||
] {
|
||||
assert_eq!(ExportOptions::from_params(&json!({"destinationMode": v})).unwrap().destination_mode, want, "{v}");
|
||||
}
|
||||
let e = ExportOptions::from_params(&json!({"destinationMode": "nextToIt"})).unwrap_err().to_string();
|
||||
assert!(e.contains("`destinationMode` must be one of specificFolder|sameAsOriginal"), "{e}");
|
||||
assert!(ExportOptions::from_params(&json!({"destinationMode": 1})).is_err());
|
||||
assert!(
|
||||
ExportOptions::from_params(&json!({"destinationMod": "sameAsOriginal"}))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("did you mean `destinationMode`")
|
||||
);
|
||||
// saved (a preset, `lastExport`) and read back
|
||||
let o = ExportOptions { destination_mode: DestinationMode::SameAsOriginal, subfolder: "Exports".into(), ..Default::default() };
|
||||
let j = o.to_json();
|
||||
assert_eq!(j["destinationMode"], "sameAsOriginal");
|
||||
assert_eq!(ExportOptions::from_params(&ExportOptions::known_keys_only(&j)).unwrap(), o);
|
||||
}
|
||||
|
||||
/// Issue #183: the `watermark` object's keys, kinds and ranges are checked (and documented in
|
||||
/// the `app.export` param string), instead of unknown keys passing and sizes clamping silently.
|
||||
#[test]
|
||||
|
||||
@@ -874,3 +874,94 @@ mod apple_photos {
|
||||
assert_eq!(s.commands().into_iter().find(|c| c.id == "export.addToPhotos").unwrap().enabled, cfg!(target_os = "macos"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #956: "Same folder as original photo" puts each photo's export next to its own original
|
||||
/// (plus the subfolder), so one batch from two folders lands in two folders. The conflict policy
|
||||
/// and the original guard work per folder as they do for a specific folder.
|
||||
#[test]
|
||||
fn same_folder_as_original_exports_next_to_each_photo() {
|
||||
use crate::export::{Conflict, Destination, DestinationMode};
|
||||
let dir = temp_dir("export-same-folder");
|
||||
let (a, b) = (dir.join("A"), dir.join("B"));
|
||||
// two different photos (the same bytes would be imported once)
|
||||
let jpeg = |blue: u8| {
|
||||
let img = lightcraft_raster::Rgba8::from_fn(48, 32, |x, y| [(x * 5) as u8, (y * 7) as u8, blue, 255]);
|
||||
crate::export::encode_image(&img, &ExportOptions::default()).unwrap()
|
||||
};
|
||||
let originals = [(&a, jpeg(120)), (&b, jpeg(30))];
|
||||
for (d, bytes) in &originals {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
std::fs::write(d.join("IMG_1.jpg"), bytes).unwrap();
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
let paths = [a.join("IMG_1.jpg").to_string_lossy().to_string(), b.join("IMG_1.jpg").to_string_lossy().to_string()];
|
||||
s.execute("library.import", &json!({"paths": paths})).unwrap();
|
||||
let ids: Vec<_> = s.catalog.photos().map(|p| p.id).collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
// the folder of a specific-folder export is not used
|
||||
let to = Destination { dir: dir.join("unused").to_string_lossy().to_string(), exact: None };
|
||||
let o = ExportOptions { destination_mode: DestinationMode::SameAsOriginal, subfolder: "Exports".into(), ..Default::default() };
|
||||
let exists = |p: &str| std::path::Path::new(p).exists();
|
||||
let files = crate::export::export_batch(&mut s, &ids, &o, &to, &mut crate::export::write_file, &exists).unwrap();
|
||||
let written: Vec<&str> = files.iter().map(|f| f["path"].as_str().unwrap()).collect();
|
||||
let mut want = vec![a.join("Exports/IMG_1.jpg").to_string_lossy().to_string(), b.join("Exports/IMG_1.jpg").to_string_lossy().to_string()];
|
||||
let mut got: Vec<String> = written.iter().map(|p| p.to_string()).collect();
|
||||
want.sort();
|
||||
got.sort();
|
||||
assert_eq!(got, want);
|
||||
assert!(!dir.join("unused").exists(), "nothing written to the specific folder");
|
||||
// again: each folder's own collision gets the next free name
|
||||
let files = crate::export::export_batch(&mut s, &ids, &o, &to, &mut crate::export::write_file, &exists).unwrap();
|
||||
assert!(files.iter().all(|f| f["path"].as_str().unwrap().ends_with("Exports/IMG_1-2.jpg")), "{files:?}");
|
||||
// no subfolder: next to the original, which is never written over
|
||||
let beside = ExportOptions { subfolder: String::new(), conflict: Conflict::Overwrite, ..o.clone() };
|
||||
let err = crate::export::export_batch(&mut s, &ids, &beside, &to, &mut crate::export::write_file, &exists).unwrap_err();
|
||||
assert!(err.contains("original of IMG_1.jpg"), "{err}");
|
||||
let unique = ExportOptions { conflict: Conflict::Unique, ..beside };
|
||||
let files = crate::export::export_batch(&mut s, &ids, &unique, &to, &mut crate::export::write_file, &exists).unwrap();
|
||||
assert!(files.iter().all(|f| f["path"].as_str().unwrap().ends_with("IMG_1-2.jpg")), "{files:?}");
|
||||
for (d, bytes) in &originals {
|
||||
assert_eq!(&std::fs::read(d.join("IMG_1.jpg")).unwrap(), bytes, "the original is untouched");
|
||||
assert!(d.join("IMG_1-2.jpg").exists());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #956: a photo with no folder on disk (a demo photo) or whose folder is gone gets its own
|
||||
/// error in "Same folder as original photo" mode instead of a file written somewhere else.
|
||||
#[test]
|
||||
fn same_folder_as_original_refuses_photos_without_a_folder() {
|
||||
use crate::export::{Destination, DestinationMode};
|
||||
let o = ExportOptions { destination_mode: DestinationMode::SameAsOriginal, subfolder: "Exports".into(), ..Default::default() };
|
||||
let to = Destination { dir: "out".into(), exact: None };
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
|
||||
let mut written = Vec::new();
|
||||
let mut write = |p: &str, _: &[u8]| -> Result<(), String> {
|
||||
written.push(p.to_string());
|
||||
Ok(())
|
||||
};
|
||||
let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err();
|
||||
assert!(err.contains("no folder on disk"), "{err}");
|
||||
// without stopping: one error per photo, nothing written
|
||||
let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap();
|
||||
let files = crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap();
|
||||
assert_eq!(files.len(), 2);
|
||||
for (f, id) in files.iter().zip(&ids) {
|
||||
assert_eq!(f["photo"], id.0, "{f}");
|
||||
assert!(f["error"].as_str().unwrap().contains("no folder on disk"), "{f}");
|
||||
}
|
||||
drop(write);
|
||||
assert!(written.is_empty(), "{written:?}");
|
||||
|
||||
// a photo on disk whose folder is gone (moved, an unplugged drive)
|
||||
let (mut s, id, dir, _src, _) = library_with_jpeg("export-same-folder-gone");
|
||||
let items = crate::export::prepare_batch(&mut s, &[id], &o).unwrap();
|
||||
let gone = dir.to_string_lossy().to_string();
|
||||
let exists = |p: &str| p != gone && !p.starts_with(&format!("{gone}/"));
|
||||
let mut write = |p: &str, _: &[u8]| -> Result<(), String> { Err(format!("wrote {p}")) };
|
||||
let files = crate::export::run_batch(items, &o, &to, &mut write, &exists, false, &mut |_, _| true).unwrap();
|
||||
let e = files[0]["error"].as_str().unwrap();
|
||||
assert!(e.contains("folder is missing") && e.contains(&gone), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -135,7 +135,7 @@ impl Backend for Headless {
|
||||
"engine.commands" => {
|
||||
let mut v: Vec<Value> = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
|
||||
v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null,
|
||||
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default",
|
||||
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, destinationMode?: specificFolder|sameAsOriginal (sameAsOriginal = each photo into its original's folder; `dir` unused), subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default",
|
||||
"enabled": self.session.active().is_some()}));
|
||||
Ok(Value::Array(v))
|
||||
}
|
||||
|
||||
@@ -235,7 +235,8 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
||||
"preset": {"type": "string", "description": "Export preset id or name"},
|
||||
"background": {"type": "boolean", "description": "Desktop background export (headless exports remain blocking)"},
|
||||
"resize": {"type": "object", "description": "Full Resize options; alternatively use the size helpers below"},
|
||||
"subfolder": {"type": "string"},
|
||||
"destinationMode": {"type": "string", "enum": ["specificFolder", "sameAsOriginal"], "description": "specificFolder (default) = everything into `dir`; sameAsOriginal = each photo into the folder of its original (+ `subfolder`); a photo with no folder on disk (demo, browser) or whose folder is gone gets an error"},
|
||||
"subfolder": {"type": "string", "description": "Created inside the destination folder"},
|
||||
"conflict": {"type": "string", "enum": ["unique", "overwrite", "skip"]},
|
||||
"tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"]},
|
||||
"dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed", "lossy"]},
|
||||
@@ -748,6 +749,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
||||
"preset",
|
||||
"background",
|
||||
"resize",
|
||||
"destinationMode",
|
||||
"subfolder",
|
||||
"conflict",
|
||||
"tiffCompression",
|
||||
|
||||
@@ -2220,5 +2220,9 @@
|
||||
"Checking raw files": "RAW-Dateien werden geprüft",
|
||||
"1 raw can now be developed from its raw data": "1 RAW-Datei kann jetzt aus ihren Rohdaten entwickelt werden",
|
||||
"This raw is now developed from its raw data": "Diese RAW-Datei wird jetzt aus ihren Rohdaten entwickelt",
|
||||
"This raw variant still can't be decoded": "Diese RAW-Variante kann noch nicht dekodiert werden"
|
||||
"This raw variant still can't be decoded": "Diese RAW-Variante kann noch nicht dekodiert werden",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "Keine Objektivdaten in dieser Datei eingebettet (nur DNG-Objektiv-Opcodes).",
|
||||
"Export to": "Exportieren nach",
|
||||
"Specific folder": "Bestimmter Ordner",
|
||||
"Same folder as original photo": "Im selben Ordner wie das Originalfoto"
|
||||
}
|
||||
@@ -2097,5 +2097,9 @@
|
||||
"Checking raw files": "Comprobando archivos RAW",
|
||||
"1 raw can now be developed from its raw data": "1 RAW ya se puede revelar a partir de sus datos RAW",
|
||||
"This raw is now developed from its raw data": "Este RAW ya se revela a partir de sus datos RAW",
|
||||
"This raw variant still can't be decoded": "Esta variante RAW aún no se puede decodificar"
|
||||
"This raw variant still can't be decoded": "Esta variante RAW aún no se puede decodificar",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "Este archivo no incluye datos de objetivo (solo opcodes de objetivo DNG).",
|
||||
"Export to": "Exportar a",
|
||||
"Specific folder": "Carpeta específica",
|
||||
"Same folder as original photo": "Misma carpeta que la foto original"
|
||||
}
|
||||
@@ -2059,5 +2059,9 @@
|
||||
"Checking raw files": "Vérification des fichiers RAW",
|
||||
"1 raw can now be developed from its raw data": "1 fichier RAW peut désormais être développé à partir de ses données RAW",
|
||||
"This raw is now developed from its raw data": "Ce fichier RAW est désormais développé à partir de ses données RAW",
|
||||
"This raw variant still can't be decoded": "Cette variante RAW ne peut toujours pas être décodée"
|
||||
"This raw variant still can't be decoded": "Cette variante RAW ne peut toujours pas être décodée",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "Aucune donnée d'objectif intégrée à ce fichier (opcodes d'objectif DNG uniquement).",
|
||||
"Export to": "Exporter vers",
|
||||
"Specific folder": "Dossier spécifique",
|
||||
"Same folder as original photo": "Même dossier que la photo d’origine"
|
||||
}
|
||||
@@ -2063,5 +2063,9 @@
|
||||
"Checking raw files": "RAWファイルを確認中",
|
||||
"1 raw can now be developed from its raw data": "1枚のRAWをRAWデータから現像できるようになりました",
|
||||
"This raw is now developed from its raw data": "このRAWはRAWデータから現像されるようになりました",
|
||||
"This raw variant still can't be decoded": "このRAWの形式はまだデコードできません"
|
||||
"This raw variant still can't be decoded": "このRAWの形式はまだデコードできません",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "このファイルにレンズ情報は含まれていません(DNGのレンズ情報のみ対応)",
|
||||
"Export to": "書き出し先",
|
||||
"Specific folder": "指定フォルダー",
|
||||
"Same folder as original photo": "元の写真と同じフォルダー"
|
||||
}
|
||||
@@ -2100,5 +2100,9 @@
|
||||
"Checking raw files": "Verificando arquivos RAW",
|
||||
"1 raw can now be developed from its raw data": "1 RAW agora pode ser revelado a partir dos dados RAW",
|
||||
"This raw is now developed from its raw data": "Este RAW agora é revelado a partir dos dados RAW",
|
||||
"This raw variant still can't be decoded": "Esta variante RAW ainda não pode ser decodificada"
|
||||
"This raw variant still can't be decoded": "Esta variante RAW ainda não pode ser decodificada",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "Nenhum dado de lente incorporado neste arquivo (somente opcodes de lente DNG).",
|
||||
"Export to": "Exportar para",
|
||||
"Specific folder": "Pasta específica",
|
||||
"Same folder as original photo": "Mesma pasta da foto original"
|
||||
}
|
||||
@@ -2253,5 +2253,9 @@
|
||||
"Checking raw files": "Проверка RAW-файлов",
|
||||
"1 raw can now be developed from its raw data": "1 RAW-файл теперь можно проявлять из RAW-данных",
|
||||
"This raw is now developed from its raw data": "Этот RAW-файл теперь проявляется из RAW-данных",
|
||||
"This raw variant still can't be decoded": "Этот вариант RAW пока не удаётся декодировать"
|
||||
"This raw variant still can't be decoded": "Этот вариант RAW пока не удаётся декодировать",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "В этом файле нет данных об объективе (только опкоды объектива DNG).",
|
||||
"Export to": "Экспорт в",
|
||||
"Specific folder": "Указанная папка",
|
||||
"Same folder as original photo": "Та же папка, что и у оригинала"
|
||||
}
|
||||
@@ -2253,5 +2253,9 @@
|
||||
"Checking raw files": "Перевірка RAW-файлів",
|
||||
"1 raw can now be developed from its raw data": "1 RAW-файл тепер можна проявляти з RAW-даних",
|
||||
"This raw is now developed from its raw data": "Цей RAW-файл тепер проявляється з RAW-даних",
|
||||
"This raw variant still can't be decoded": "Цей варіант RAW поки що не вдається декодувати"
|
||||
"This raw variant still can't be decoded": "Цей варіант RAW поки що не вдається декодувати",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "У цьому файлі немає даних об’єктива (лише інструкції об’єктива DNG).",
|
||||
"Export to": "Експорт до",
|
||||
"Specific folder": "Вказана папка",
|
||||
"Same folder as original photo": "Та сама папка, що й оригінал"
|
||||
}
|
||||
@@ -2099,5 +2099,9 @@
|
||||
"Checking raw files": "正在检查 RAW 文件",
|
||||
"1 raw can now be developed from its raw data": "1 个 RAW 现在可以从 RAW 数据冲印",
|
||||
"This raw is now developed from its raw data": "此 RAW 现在从 RAW 数据冲印",
|
||||
"This raw variant still can't be decoded": "这种 RAW 变体仍无法解码"
|
||||
"This raw variant still can't be decoded": "这种 RAW 变体仍无法解码",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "此文件中未嵌入镜头数据 (仅支持 DNG 镜头操作码)。",
|
||||
"Export to": "导出到",
|
||||
"Specific folder": "指定文件夹",
|
||||
"Same folder as original photo": "与原始照片相同的文件夹"
|
||||
}
|
||||
@@ -2061,5 +2061,9 @@
|
||||
"Checking raw files": "正在檢查 RAW 檔案",
|
||||
"1 raw can now be developed from its raw data": "1 個 RAW 現在可以從 RAW 資料沖印",
|
||||
"This raw is now developed from its raw data": "此 RAW 現在從 RAW 資料沖印",
|
||||
"This raw variant still can't be decoded": "這種 RAW 變體仍無法解碼"
|
||||
"This raw variant still can't be decoded": "這種 RAW 變體仍無法解碼",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "此檔案未內嵌鏡頭資料(僅支援 DNG 鏡頭操作碼)。",
|
||||
"Export to": "輸出至",
|
||||
"Specific folder": "指定資料夾",
|
||||
"Same folder as original photo": "與原始照片相同的資料夾"
|
||||
}
|
||||
@@ -504,7 +504,8 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String
|
||||
// An exact output file needs no folder. A folder given but left blank (the Export dialog's Folder
|
||||
// field cleared) is an error, like Lightroom refusing to export to an unspecified folder, rather
|
||||
// than a silent write into the working directory. No folder at all (e.g. File → Export with
|
||||
// Preset): the last export's, else the default.
|
||||
// Preset): the last export's, else the default. "Same folder as original photo" needs none:
|
||||
// each photo goes to its own folder.
|
||||
let exact = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).map(str::to_string);
|
||||
let dir = match p.get("dir").and_then(Value::as_str) {
|
||||
Some(d) => d.trim().to_string(),
|
||||
@@ -514,7 +515,8 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String
|
||||
last_dir.map(str::to_string).unwrap_or_else(default_export_dir)
|
||||
}
|
||||
};
|
||||
if dir.is_empty() && exact.is_none() {
|
||||
let own_folders = opts.destination_mode == lightcraft_engine::export::DestinationMode::SameAsOriginal;
|
||||
if dir.is_empty() && exact.is_none() && !own_folders {
|
||||
return Err(NO_EXPORT_FOLDER.into());
|
||||
}
|
||||
let to = Destination { dir: dir.clone(), exact };
|
||||
|
||||
@@ -1188,6 +1188,12 @@ mod tests {
|
||||
let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": " "}));
|
||||
assert_eq!(r.unwrap_err(), crate::control::NO_EXPORT_FOLDER);
|
||||
assert_eq!(written.lock().unwrap().len(), n, "nothing written without a folder");
|
||||
// "Same folder as original photo" needs no folder; a demo photo has none of its own, so it
|
||||
// gets its own error rather than a file written elsewhere (issue #956)
|
||||
let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": " ", "destinationMode": "sameAsOriginal"}));
|
||||
let e = r.unwrap_err();
|
||||
assert!(e.contains("no folder on disk"), "{e}");
|
||||
assert_eq!(written.lock().unwrap().len(), n, "nothing written for a photo without a folder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -941,18 +941,32 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
opts.start_number = v as u32;
|
||||
}
|
||||
}
|
||||
field(ui, "Folder", |ui| {
|
||||
trailing_button_row(ui, |ui| {
|
||||
if app.services.pick_folder.is_some()
|
||||
&& crate::widgets::text_button(ui, "exportChooseFolder", crate::i18n::tr("Choose…"), false).clicked()
|
||||
&& let Some(pick) = app.services.pick_folder.as_mut()
|
||||
&& let Some(d) = pick()
|
||||
{
|
||||
*dir = d;
|
||||
}
|
||||
crate::widgets::named(ui.add(egui::TextEdit::singleline(dir).desired_width(ui.available_width())), crate::i18n::tr("Folder"));
|
||||
// like Lightroom Classic's Export To: one folder, or each photo's own folder (not on
|
||||
// the web, where originals live in the browser's storage, not in folders)
|
||||
use lightcraft_engine::export::DestinationMode as D;
|
||||
if !cfg!(target_arch = "wasm32") || opts.destination_mode != D::SpecificFolder {
|
||||
choices(
|
||||
ui,
|
||||
"Export to",
|
||||
"exportDestinationMode",
|
||||
&[(D::SpecificFolder, "Specific folder"), (D::SameAsOriginal, "Same folder as original photo")],
|
||||
&mut opts.destination_mode,
|
||||
);
|
||||
}
|
||||
if opts.destination_mode == D::SpecificFolder {
|
||||
field(ui, "Folder", |ui| {
|
||||
trailing_button_row(ui, |ui| {
|
||||
if app.services.pick_folder.is_some()
|
||||
&& crate::widgets::text_button(ui, "exportChooseFolder", crate::i18n::tr("Choose…"), false).clicked()
|
||||
&& let Some(pick) = app.services.pick_folder.as_mut()
|
||||
&& let Some(d) = pick()
|
||||
{
|
||||
*dir = d;
|
||||
}
|
||||
crate::widgets::named(ui.add(egui::TextEdit::singleline(dir).desired_width(ui.available_width())), crate::i18n::tr("Folder"));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
field(ui, "Subfolder", |ui| {
|
||||
crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), crate::i18n::tr("Subfolder"))
|
||||
});
|
||||
|
||||
@@ -93,10 +93,10 @@ fn export_dialog_is_unchanged_in_a_tall_window() {
|
||||
let h = export_dialog([1600.0, 1000.0]);
|
||||
assert_fits(&h, "Export at 1600×1000");
|
||||
let dialog = rect(&h, "dialog:window");
|
||||
// (846 tall as before #781: the whole dialog, nothing scrolled away; 615 wide since the
|
||||
// format row gained JPEG XL, #780. On macOS the dialog has one more row, "Add to Apple Photos"
|
||||
// (#236), so it is 876 tall there)
|
||||
let height = if cfg!(target_os = "macos") { 876.0 } else { 846.0 };
|
||||
// (846 tall as before #781: the whole dialog, nothing scrolled away, plus the Export to row
|
||||
// since #956; 615 wide since the format row gained JPEG XL, #780. On macOS the dialog has one
|
||||
// more row, "Add to Apple Photos" (#236), so it is 30 taller there)
|
||||
let height = if cfg!(target_os = "macos") { 908.0 } else { 878.0 };
|
||||
assert!((dialog.height() - height).abs() <= 1.0, "{dialog:?}");
|
||||
assert!((dialog.width() - 615.2).abs() <= 1.0, "{dialog:?}");
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ filmstrip is visible.
|
||||
| `ui.resize` | `{width, height}` | Resize the window |
|
||||
| `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) |
|
||||
| `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill; `loupe:renderFailed` is the pill saying a render failed behind a stand-in; Compare and Reference panes have their own (`compare:rendering:<pane>`, `compare:renderFailed:<pane>`) |
|
||||
| `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` |
|
||||
| `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background`; `destinationMode: "sameAsOriginal"` writes each photo into its original's folder (+ `subfolder`) instead of `dir` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` |
|
||||
| `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) |
|
||||
| `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error |
|
||||
| `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` |
|
||||
|
||||
@@ -398,7 +398,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | |
|
||||
| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` |
|
||||
| LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; 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), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) |
|
||||
| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `destinationMode`: specificFolder / sameAsOriginal, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; Export to: Specific folder or Same folder as original photo (each photo into its original's folder + the subfolder; a demo/browser photo or a missing folder is that photo's error, issue #956); existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) |
|
||||
| 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 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP, none, or lossy (demosaiced linear camera RGB as 8-bit JPEG tiles at the export quality, a `LinearizationTable` restoring linear values, scaled to the resize long edge when set; `crates/raw/tests/lossy_dng.rs`); no embedded JPEG preview size |
|
||||
| LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy |
|
||||
@@ -820,6 +820,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
| 2026-10-11 | minor | Model setup folder action, opener error and retry (LR-MASK-OBJECTS). |
|
||||
| 2026-10-12 | minor | LR-VIEW-ZOOM: slider-drag drafts are timed with the frame that shows them, drop to 0.6 mid-drag when far over budget, stay 0.6 under a zoom window; workers convert texture pixels (issue #330); status unchanged |
|
||||
| 2026-10-11 | minor | LR-VIEW-ZOOM: a slider drag keeps the loupe's full resolution where the GPU draws the view and keeps up (0.6 drafts on the CPU, with soft proofing or the HDR range, or after over-budget drags; issue #330); status unchanged |
|
||||
| 2026-10-11 | minor | LR-EXP-LOCATION: Export to "Same folder as original photo" (`destinationMode`), issue #956; status unchanged (✅) |
|
||||
| 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references |
|
||||
| 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) |
|
||||
| 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |
|
||||
|
||||
@@ -239,6 +239,16 @@
|
||||
- Lossy JPEG XL files in Display P3 or Rec. 2020 now open in their real colours; saturated colours used to come in
|
||||
noticeably duller.
|
||||
|
||||
### Export next to the originals
|
||||
- The Export dialog has Export to: Specific folder (as before) or Same folder as original photo, like Lightroom
|
||||
Classic (issue #956). With the second, each photo is exported into the folder its original is in, plus the
|
||||
Subfolder if you give one (say `Exports`), so a batch from several folders lands next to each set of originals.
|
||||
"If file exists" works per folder as usual, and an original is never written over.
|
||||
- A photo that has no folder on disk (a demo photo, or one in the web version's browser storage) or whose folder
|
||||
is missing (moved, drive unplugged) is not exported; it gets its own error naming it.
|
||||
- The choice is saved in export presets and Export with Previous. Agents pass
|
||||
`destinationMode: "sameAsOriginal"` (or `"specificFolder"`) to `app.export`; `dir` is then not needed.
|
||||
|
||||
### Open Library knows a Lightroom catalog
|
||||
- Choosing a Lightroom Classic catalog folder in File → Open Library… (or one of its `Previews.lrdata` /
|
||||
`.lrcat-data` folders, or the `.lrcat`) no longer makes an empty LightCraft library inside it (issue #891). The
|
||||
|
||||
Reference in new issue
Block a user