diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index bc2137948..4d7f13aeb 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -124,15 +124,14 @@ pub fn specs() -> Vec { /// the library and stack it on top of the original. The host opens the file in the editor. fn edit_external(s: &mut Session, p: &Value) -> Result { const C: &str = "photo.editExternal"; - let id = s.active().ok_or_else(|| bad(C, "no active photo"))?; - let ph = s.catalog.photo(id).cloned().ok_or_else(|| bad(C, "no photo"))?; - let dir = match &ph.source { - Source::File { path } => Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(), - Source::Demo { .. } => match super::str_param(p, "dir") { - Some(d) => d.to_string(), - None => return Err(bad(C, "a generated demo photo has no folder: give `dir`")), - }, - }; + let mut ids = s.targets(p); + ids.dedup(); + if ids.is_empty() { + return Err(bad(C, "no active photo")); + } + if ids.len() > MAX_EDIT_COPIES { + return Err(bad(C, format!("{} photos selected: edit at most {MAX_EDIT_COPIES} at a time", ids.len()))); + } let space = match super::str_param(p, "colorSpace").unwrap_or("adobeRgb") { "srgb" => lightcraft_pipeline::OutputSpace::Srgb, "displayP3" => lightcraft_pipeline::OutputSpace::DisplayP3, @@ -157,6 +156,31 @@ fn edit_external(s: &mut Session, p: &Value) -> Result { conflict: crate::export::Conflict::Unique, ..Default::default() }; + let mut copies = Vec::new(); + for id in ids { + copies.push(edit_copy(s, p, id, &opts)?); + } + let new: Vec = copies.iter().filter_map(|c| c["id"].as_u64()).map(lightcraft_catalog::PhotoId).collect(); + s.selection = crate::Selection { active: new.first().copied(), ids: new }; + let first = copies.first().cloned().unwrap_or(Value::Null); + Ok(json!({"path": first["path"], "id": first["id"], "original": first["original"], "copies": copies})) +} + +/// Most photos one Edit In renders (each becomes a full-size file on disk and a library photo). +const MAX_EDIT_COPIES: usize = 50; + +/// One photo's edit copy for [`edit_external`]: rendered with `opts` next to its original (a demo +/// photo into `dir`), added to the library and stacked on top of the original, expanded. +fn edit_copy(s: &mut Session, p: &Value, id: lightcraft_catalog::PhotoId, opts: &crate::export::ExportOptions) -> Result { + const C: &str = "photo.editExternal"; + let ph = s.catalog.photo(id).cloned().ok_or_else(|| bad(C, format!("no photo {}", id.0)))?; + let dir = match &ph.source { + Source::File { path } => Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(), + Source::Demo { .. } => match super::str_param(p, "dir") { + Some(d) => d.to_string(), + None => return Err(bad(C, "a generated demo photo has no folder: give `dir`")), + }, + }; let mut written = Vec::new(); let mut write = |path: &str, bytes: &[u8]| -> std::result::Result<(), String> { // durable: the TIFF becomes a library photo the external editor then changes @@ -165,14 +189,13 @@ fn edit_external(s: &mut Session, p: &Value) -> Result { Ok(()) }; let to = crate::export::Destination { dir, exact: None }; - crate::export::export_batch(s, &[id], &opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?; - let ext = format!(".{}", format.extension()); + crate::export::export_batch(s, &[id], opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?; + let ext = format!(".{}", opts.format.extension()); let out = written.into_iter().find(|w| w.ends_with(&ext)).ok_or_else(|| bad(C, "nothing was written"))?; let r = s.execute("library.import", &json!({"paths": [out]}))?; let new = r["imported"].get(0).and_then(Value::as_u64).ok_or_else(|| bad(C, "the edit copy could not be added"))?; // stack: the edit on top of the original, expanded so both show let _ = s.execute("stack.group", &json!({"ids": [new, id.0], "top": new, "collapsed": false})); - s.selection = crate::Selection::single(lightcraft_catalog::PhotoId(new)); Ok(json!({"path": out, "id": new, "original": id.0})) } @@ -363,7 +386,7 @@ pub fn edit_specs() -> Vec { "Edit Copy for External Editor", [], None, - "{colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, format?: tiff (default) | psd, bitDepth?: 16 (default) | 8, dir?} — render the active photo with its edits as `-Edit.tif` (or `.psd`, flat, which PhotoCraft saves back to in place) next to it, add it stacked on the original and select it → {path, id, original} (the app then opens it in the external editor)", + "{ids?, colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, format?: tiff (default) | psd, bitDepth?: 16 (default) | 8, dir?} — render each selected photo (or `ids`, at most 50) with its edits as `-Edit.tif` (or `.psd`, flat, which PhotoCraft saves back to in place) next to it, add each stacked on its original and select the copies → {path, id, original (the first copy), copies: [{path, id, original}]} (the app then opens them in the external editor)", has_active, edit_external ), diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 14732d284..cf197593d 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -696,6 +696,41 @@ fn external_edit_copy_is_stacked() { let _ = std::fs::remove_dir_all(&dir); } +/// Issue #920: with several photos selected, each gets its own `-Edit` copy stacked on its +/// original, the copies end up selected and are all reported; `ids` picks photos explicitly. +#[test] +fn external_edit_copies_every_selected_photo() { + let dir = temp_dir("extedit-many"); + let paths: Vec = ["a", "b", "c"] + .iter() + .map(|n| { + let p = dir.join(format!("{n}.png")); + write_png(&p, 9); + p.to_string_lossy().into_owned() + }) + .collect(); + let mut s = Session::new().with_fs(); + let imported = s.execute("library.import", &json!({"paths": paths})).unwrap(); + let originals: Vec = imported["imported"].as_array().unwrap().iter().filter_map(serde_json::Value::as_u64).collect(); + assert_eq!(originals.len(), 3); + s.execute("library.select", &json!({"ids": originals})).unwrap(); + let r = s.execute("photo.editExternal", &json!({})).unwrap(); + let copies = r["copies"].as_array().unwrap(); + assert_eq!(copies.len(), 3, "{r}"); + for (copy, orig) in copies.iter().zip(&originals) { + assert_eq!(copy["original"].as_u64(), Some(*orig)); + assert!(copy["path"].as_str().unwrap().ends_with("-Edit.tif"), "{copy}"); + let new = lightcraft_catalog::PhotoId(copy["id"].as_u64().unwrap()); + let st = s.catalog.stack_of(new).expect("stacked"); + assert_eq!((st.top(), st.photos.contains(&lightcraft_catalog::PhotoId(*orig))), (new, true)); + assert!(s.selection.contains(new), "the copies are selected"); + } + assert_eq!(r["path"], copies[0]["path"], "the single-copy fields name the first copy"); + let one = s.execute("photo.editExternal", &json!({"ids": [originals[1]]})).unwrap(); + assert_eq!(one["copies"].as_array().map(Vec::len), Some(1)); + let _ = std::fs::remove_dir_all(&dir); +} + /// Edit in PhotoCraft (engine half): a flat 16-bit PSD `-Edit` copy, which PhotoCraft saves back /// to in place (it writes a TIFF only through Save As); its saved pixels come back on Reload. #[test] diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 4a6397632..5c7d81a97 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2104,5 +2104,6 @@ "Moves the files and their XMP sidecars into this folder; never overwrites": "Verschiebt die Dateien samt XMP-Filialdateien in diesen Ordner; nichts wird überschrieben", "Appearance Settings…": "Erscheinungsbild-Einstellungen…", "Add to favorites": "Zu Favoriten hinzufügen", - "Remove from favorites": "Aus Favoriten entfernen" + "Remove from favorites": "Aus Favoriten entfernen", + "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 86b42e8b1..5b9e12b1d 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1946,5 +1946,6 @@ "Moves the files and their XMP sidecars into this folder; never overwrites": "Mueve los archivos y sus archivos XMP auxiliares a esta carpeta; nunca sobrescribe", "Appearance Settings…": "Ajustes de apariencia…", "Add to favorites": "Añadir a favoritos", - "Remove from favorites": "Quitar de favoritos" + "Remove from favorites": "Quitar de favoritos", + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index afee27aac..9054e3fd4 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1946,5 +1946,6 @@ "Charcoal": "Anthracite", "Midnight": "Minuit", "Silver": "Argent", - "Paper": "Papier" + "Paper": "Papier", + "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index b5e21e09c..4c503519b 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1948,5 +1948,6 @@ "Moves the files and their XMP sidecars into this folder; never overwrites": "ファイルと XMP サイドカーをこのフォルダへ移動します。上書きはしません", "Appearance Settings…": "外観設定…", "Add to favorites": "お気に入りに追加", - "Remove from favorites": "お気に入りから削除" + "Remove from favorites": "お気に入りから削除", + "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 217cde981..b992e6cb1 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1946,5 +1946,6 @@ "Moves the files and their XMP sidecars into this folder; never overwrites": "Move os arquivos e seus arquivos XMP auxiliares para esta pasta; nunca sobrescreve", "Appearance Settings…": "Configurações de aparência…", "Add to favorites": "Adicionar aos favoritos", - "Remove from favorites": "Remover dos favoritos" + "Remove from favorites": "Remover dos favoritos", + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index cce9db2ab..e03913c2a 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2138,5 +2138,6 @@ "© Your Name": "© Ваше имя", "≤ at most": "≤ не более", "≥ at least": "≥ не менее", - "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘+перетаскивание — выровнять, ⇧G — направляемое выравнивание, сетка при трансформации." + "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘+перетаскивание — выровнять, ⇧G — направляемое выравнивание, сетка при трансформации.", + "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 9ca8ac132..1d70b3601 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2140,5 +2140,6 @@ "Charcoal": "Графіт", "Midnight": "Опівніч", "Silver": "Срібло", - "Paper": "Папір" + "Paper": "Папір", + "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 76620cfd6..b47aba485 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1984,5 +1984,6 @@ "Unnamed Faces": "未命名人脸", "save goes through.": "才能完成保存。", "smart album value|No": "智能相册取值|否", - "smart album value|Yes": "智能相册取值|是" + "smart album value|Yes": "智能相册取值|是", + "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 83417ded0..929747119 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1946,5 +1946,6 @@ "Moves the files and their XMP sidecars into this folder; never overwrites": "將檔案及其 XMP 附屬檔案移至此資料夾;絕不覆寫", "Appearance Settings…": "外觀設定…", "Add to favorites": "加入最愛", - "Remove from favorites": "從最愛移除" + "Remove from favorites": "從最愛移除", + "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起" } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d5e0ee2f1..daf0f08e0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1913,8 +1913,8 @@ fn show_in_finder(app: &mut LightcraftApp) -> Result { Ok(json!({"path": path})) } -/// Photo ▸ Edit In: render a copy of the active photo with its edits (stacked on the original) -/// as Settings ▸ External Editing asks for that editor, then open it there. `p` may override the +/// Photo ▸ Edit In: render a copy of each selected photo with its edits (stacked on its original) +/// as Settings ▸ External Editing asks for that editor, then open them there. `p` may override the /// file (`format`, `colorSpace`, `bitDepth`) and the editor (`app`). PhotoCraft is checked first, /// so a missing editor leaves no copy behind. fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: bool) -> Result { @@ -1939,14 +1939,27 @@ fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: } } let r = app.session.execute("photo.editExternal", ¶ms).map_err(|e| e.to_string())?; - let path = r["path"].as_str().unwrap_or_default().to_string(); - if let Some(id) = r["id"].as_u64() { - app.ui.external_edits.push(id); + // every selected photo got a copy (#920); each opens in the editor + let copies: Vec<(String, Option)> = r["copies"] + .as_array() + .map(|c| c.iter().map(|c| (c["path"].as_str().unwrap_or_default().to_string(), c["id"].as_u64())).collect()) + .unwrap_or_else(|| vec![(r["path"].as_str().unwrap_or_default().to_string(), r["id"].as_u64())]); + let mut failed = None; + for (path, id) in &copies { + if let Some(id) = id { + app.ui.external_edits.push(*id); + } + if let Some(Err(e)) = app.services.open_with.as_mut().map(|f| f(path, &editor)) { + failed = Some(e); + } } - let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - match app.services.open_with.as_mut().map(|f| f(&path, &editor)) { - Some(Err(e)) => app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)), - _ => app.toast(ctx, crate::i18n::tr_format!("{name} opened for editing; it is stacked with the original", name = name)), + match (failed, copies.as_slice()) { + (Some(e), _) => app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)), + (None, [(path, _)]) => { + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + app.toast(ctx, crate::i18n::tr_format!("{name} opened for editing; it is stacked with the original", name = name)); + } + _ => app.toast(ctx, crate::i18n::tr_format!("{n} photos opened for editing; each is stacked with its original", n = copies.len())), } Ok(r) } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 01878a641..74ecc224b 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -400,7 +400,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP or none; no embedded JPEG preview size, no lossy DNG output | | LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | | LR-EXP-PHOTOS | Export to the system photo library | P2 | ⬜ | | | -| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. No "Edit a copy / Edit original" choice for non-raw files yet | +| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. No "Edit a copy / Edit original" choice for non-raw files yet | ## T. Share (SHARE)