LR-EXP-PSD: Edit In makes a copy of every selected photo and opens them all (#920)

photo.editExternal takes the selection (or ids, at most 50): each photo gets its own -Edit copy
stacked on its original, the copies are selected and returned in copies (path / id / original
still name the first). Photo ▸ Edit In opens each copy in the editor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
This commit is contained in:
Claude committed 2026-10-11 12:40:58 +00:00
1 parent 4ec3494733
commit 255b23816d
13 files changed
+112 -32

No files matched your search

+36 -13
View File
@@ -124,15 +124,14 @@ pub fn specs() -> Vec<CommandSpec> {
/// 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<Value> {
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<Value> {
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<lightcraft_catalog::PhotoId> = 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<Value> {
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<Value> {
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<CommandSpec> {
"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 `<name>-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 `<name>-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
),
+35
View File
@@ -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<String> = ["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<u64> = 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]
+2 -1
View File
@@ -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"
}
+2 -1
View File
@@ -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"
}
+2 -1
View File
@@ -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"
}
+2 -1
View File
@@ -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}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています"
}
+2 -1
View File
@@ -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"
}
+2 -1
View File
@@ -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}; каждое объединено в стопку с оригиналом"
}
+2 -1
View File
@@ -2140,5 +2140,6 @@
"Charcoal": "Графіт",
"Midnight": "Опівніч",
"Silver": "Срібло",
"Paper": "Папір"
"Paper": "Папір",
"{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом"
}
+2 -1
View File
@@ -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} 张照片进行编辑;每张都与其原片堆叠在一起"
}
+2 -1
View File
@@ -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} 張相片進行編輯;每張都與其原始相片堆疊在一起"
}
+22 -9
View File
@@ -1913,8 +1913,8 @@ fn show_in_finder(app: &mut LightcraftApp) -> Result<Value, String> {
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<Value, String> {
@@ -1939,14 +1939,27 @@ fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft:
}
}
let r = app.session.execute("photo.editExternal", &params).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<u64>)> = 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)
}
+1 -1
View File
@@ -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)