mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 11:17:05 -04:00
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:
13 files changed
+112
-32
No files matched your search
@@ -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
|
||||
),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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}; каждое объединено в стопку с оригиналом"
|
||||
}
|
||||
@@ -2140,5 +2140,6 @@
|
||||
"Charcoal": "Графіт",
|
||||
"Midnight": "Опівніч",
|
||||
"Silver": "Срібло",
|
||||
"Paper": "Папір"
|
||||
"Paper": "Папір",
|
||||
"{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом"
|
||||
}
|
||||
@@ -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} 张照片进行编辑;每张都与其原片堆叠在一起"
|
||||
}
|
||||
@@ -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} 張相片進行編輯;每張都與其原始相片堆疊在一起"
|
||||
}
|
||||
@@ -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", ¶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<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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user