Merge pull request #2199 from skylartaylor/open-original-media-gate

Open Original: only file types LightCraft reads reach the system opener
This commit is contained in:
skylar taylor-barrick
2026-10-17 17:07:18 -06:00
committed by GitHub
8 changed files with 307 additions and 24 deletions
+1
View File
@@ -57,6 +57,7 @@ pub mod media;
pub mod memory;
pub mod merge;
mod model_download;
pub mod open_path;
pub mod originals;
mod pdf;
pub mod preset_import;
+89 -1
View File
@@ -562,10 +562,54 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
original,
});
}
refuse_non_media(&mut out);
ensure_read_active(cancel)?;
Ok(out)
}
/// Drop the catalog's images whose original is not a file type LightCraft reads, saying so in the
/// import's warnings.
///
/// The catalog decides these paths, and a record it leaves behind is one click from the system
/// opener (Photo ▸ Edit In ▸ Open Original in Other Editor). A record for `payload.exe`,
/// `start.lnk` or `Evil.app` would make a `.lrcat` someone was sent a way to have a program run
/// (issue #2135), so such an image never becomes a photo: not imported, not relinkable, not
/// openable. A virtual copy of a refused original goes with it — it has no original of its own.
///
/// Called where the catalog is read and again where its photos become records, so neither route
/// can keep one; the second pass finds nothing left to say.
pub(crate) fn refuse_non_media(data: &mut CatalogImport) {
// masters first: a virtual copy may come before its master in the table
let refused: HashSet<i64> = data
.photos
.iter()
.filter(|p| number(&p.image, "masterImage") == 0 && !crate::open_path::is_media(Path::new(&p.path)))
.map(|p| p.source_id)
.collect();
if refused.is_empty() {
return;
}
let mut warnings = Vec::new();
data.photos.retain(|p| {
let master = number(&p.image, "masterImage");
// `masterImage` is 0 for a master, so a refused id of 0 (a malformed catalog) must not
// take every master of the catalog with it
if master != 0 {
if !refused.contains(&master) {
return true;
}
warnings.push(format!("image {}: not imported; its original was not imported", p.source_id));
return false;
}
if !refused.contains(&p.source_id) {
return true;
}
warnings.push(format!("image {}: not imported; {} isn't one of the file types LightCraft reads", p.source_id, p.path));
false
});
data.warnings.extend(warnings);
}
fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> {
if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) }
}
@@ -741,8 +785,10 @@ pub(crate) struct ApplyPrepared<'a> {
pub(crate) undo0: usize,
}
pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result<AppliedImport> {
pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result<AppliedImport> {
let ApplyPrepared { update_existing, files, index, archive_path, missing_sources, undo0 } = context;
// nothing but a file type LightCraft reads becomes a record, whichever route got here (#2135)
refuse_non_media(&mut data);
validate(&data).map_err(error)?;
let mut warnings = data.warnings.clone();
let duplicates: HashMap<_, _> = files.duplicates.iter().filter_map(|d| d.existing.map(|id| (key(&d.path), PhotoId(id)))).collect();
@@ -1018,6 +1064,48 @@ mod tests {
warnings: Vec::new(),
}
}
/// A crafted `.lrcat` that names a program as a photo's original gets no record for it, so
/// Photo ▸ Edit In ▸ Open Original has nothing to hand the system opener (#2135). Its virtual
/// copies go with it; the catalog's real photos still import.
#[test]
fn a_catalog_original_that_is_not_a_photo_file_is_not_imported() {
let mut data = sample();
let mut exe = data.photos[1].clone();
exe.source_id = 3;
exe.uuid = "payload".into();
exe.path = "/Users/sky/Downloads/payload.exe".into();
exe.image = row(json!({"id_local":3,"fileFormat":"ARW","fileWidth":20,"fileHeight":10}));
let mut exe_copy = exe.clone();
exe_copy.source_id = 4;
exe_copy.uuid = "payload-copy".into();
exe_copy.path = "/Users/sky/Downloads/payload.exe".into();
exe_copy.image = row(json!({"id_local":4,"masterImage":3,"copyName":"Mono"}));
// the copy comes before its master, as a catalog's table order may have it
data.photos.insert(0, exe_copy);
data.photos.push(exe);
for bad in [r"C:\Users\sky\start.lnk", "/Applications/Evil.app", "/Users/sky/go.command", "/Users/sky/run.bat"] {
let mut one = data.clone();
one.photos.retain(|p| p.source_id < 3);
let mut planted = one.photos[1].clone();
planted.source_id = 5;
planted.uuid = "planted".into();
planted.path = bad.into();
planted.image = row(json!({"id_local":5,"fileFormat":"ARW"}));
one.photos.push(planted);
let mut s = crate::Session::new();
let r = apply(&mut s, one, false).unwrap();
assert_eq!(r["photos"], 2, "{bad} is not catalogued: {r}");
assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path == bad)), "{bad} became a library record");
}
let mut s = crate::Session::new();
let r = apply(&mut s, data, false).unwrap();
assert_eq!(r["photos"], 2, "only the real master and its copy: {r}");
assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path.ends_with(".exe"))), "the program was catalogued");
let warnings = r["warnings"].to_string();
assert!(warnings.contains("payload.exe") && warnings.contains("file types LightCraft reads"), "{warnings}");
assert!(warnings.contains("image 4: not imported"), "the virtual copy of the refused original goes with it: {warnings}");
}
#[test]
fn offline_local_master_and_copy_join_library_with_one_undo_step() {
let mut s = crate::Session::new();
+117
View File
@@ -0,0 +1,117 @@
//! Whether a path the library holds may be handed to the operating system.
//!
//! A photo's `Source::File` path is not always something a user typed: it can come from an
//! imported Lightroom catalog, an `.lrcat` someone was sent, a relink or a synchronised folder.
//! Photo ▸ Edit In ▸ *Open Original in Other Editor* and the system default opener run whatever
//! the path names, so a record pointing at `payload.exe`, `thing.lnk`, `run.bat`, `page.hta`
//! (Windows) or `Evil.app`, `go.command` (macOS) would be executed on one click (issue #2135).
//!
//! [`openable`] is the one gate every such hand-over goes through: the path must be a regular
//! file whose extension is one LightCraft itself reads ([`crate::import::EXTENSIONS`], which
//! already covers the Edit In copy formats `tif`/`tiff`/`psd`), and what a link resolves to must
//! be one too, so a `photo.jpg` pointing at `/bin/sh` is refused as well. What comes back is the
//! absolute path to hand over; what comes back on refusal is the sentence to show the user.
//!
//! Nothing here opens, stats-for-content or reads the file: it only decides whether the path may
//! leave LightCraft. Catalog import applies the same test ([`crate::lightroom_catalog`]), so a
//! refused original never becomes a library record in the first place.
use std::path::Path;
/// The absolute path to hand to an opener or external editor, or the reason it is refused.
///
/// `label` names the thing in the error (a file name, a photo's name); an empty `label` uses
/// the path's own file name.
pub fn openable(path: &str, label: &str) -> Result<String, String> {
let p = Path::new(path);
let name = match label.trim() {
"" => p.file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned()),
l => l.to_string(),
};
if !is_media(p) {
return Err(format!("{name} isn't one of the file types LightCraft reads, so it isn't handed to another application"));
}
match std::fs::metadata(p) {
Ok(m) if m.is_file() => {}
Ok(_) => return Err(format!("{name} isn't a file, so it isn't handed to another application")),
Err(e) => return Err(format!("the original is missing: {path} ({e})")),
}
// a link may name a photo and point at a program; what runs is what it points at
if let Ok(target) = p.canonicalize()
&& !is_media(&target)
{
return Err(format!("{name} points at {}, which isn't a file type LightCraft reads", target.display()));
}
let absolute = std::path::absolute(p).map_err(|e| format!("{name}: {e}"))?;
Ok(absolute.to_string_lossy().into_owned())
}
/// Whether `path`'s extension is one LightCraft reads (the Edit In copy formats included).
pub fn is_media(path: &Path) -> bool {
crate::import::is_supported(path)
}
#[cfg(test)]
mod tests {
use super::*;
fn write(dir: &Path, name: &str) -> String {
let p = dir.join(name);
std::fs::write(&p, b"x").unwrap();
p.to_string_lossy().into_owned()
}
#[test]
fn only_file_types_lightcraft_reads_are_handed_over() {
let dir = std::env::temp_dir().join(format!("lc-openable-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for good in ["DSC_1.NEF", "a.jpg", "copy-Edit.tif", "copy-Edit.psd"] {
let path = write(&dir, good);
assert_eq!(openable(&path, "").unwrap(), path, "{good} is a photo LightCraft reads");
}
for bad in ["payload.exe", "shortcut.lnk", "run.bat", "page.hta", "go.command", "no-extension"] {
let path = write(&dir, bad);
let e = openable(&path, "").unwrap_err();
assert!(e.contains(bad) && e.contains("file types LightCraft reads"), "{bad}: {e}");
}
// a bundle (macOS `.app`) and any other folder are not files
let bundle = dir.join("Evil.app");
std::fs::create_dir_all(&bundle).unwrap();
let e = openable(&bundle.to_string_lossy(), "").unwrap_err();
assert!(e.contains("isn't one of the file types") || e.contains("isn't a file"), "{e}");
let e = openable(&dir.join("gone.jpg").to_string_lossy(), "").unwrap_err();
assert!(e.contains("missing"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
/// A relative path is made absolute: the opener runs with its own working directory.
#[test]
fn the_path_handed_over_is_absolute() {
let dir = std::env::temp_dir().join(format!("lc-openable-abs-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = write(&dir, "a.jpg");
let dotted = Path::new(&path).parent().unwrap().join(".").join("a.jpg");
let out = openable(&dotted.to_string_lossy(), "").unwrap();
assert!(Path::new(&out).is_absolute(), "{out}");
assert!(out.ends_with("a.jpg"), "{out}");
let _ = std::fs::remove_dir_all(&dir);
}
/// A link named like a photo that points at a program is refused: what the opener runs is
/// the target, not the name.
#[cfg(unix)]
#[test]
fn a_link_to_a_program_is_refused() {
let dir = std::env::temp_dir().join(format!("lc-openable-link-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let target = write(&dir, "payload.command");
let link = dir.join("holiday.jpg");
std::os::unix::fs::symlink(&target, &link).unwrap();
let e = openable(&link.to_string_lossy(), "").unwrap_err();
assert!(e.contains("points at") && e.contains("payload.command"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
}
+1 -1
View File
@@ -903,7 +903,7 @@ fn shadow(img: &mut Rgba8, tl: &crate::text::TextLayout, x: f64, y: f64, sigma:
let my0 = (y - pad).floor().max(-pad);
let mx1 = (x + tl.width as f64 + pad).ceil().min(iw + pad);
let my1 = (y + tl.height as f64 + pad).ceil().min(ih + pad);
if !(mx1 > mx0 && my1 > my0) || !mx0.is_finite() || !my0.is_finite() {
if !(mx1 > mx0 && my1 > my0 && mx0.is_finite() && my0.is_finite()) {
return;
}
let (w, h) = ((mx1 - mx0) as usize, (my1 - my0) as usize);
+12 -9
View File
@@ -1024,21 +1024,24 @@ fn same_folder_as_original_refuses_photos_without_a_folder() {
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(())
// the writer lives in a block of its own, so its borrow of `written` has ended by the time
// the assertion below reads it
let files = {
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();
crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap()
};
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)
+22 -12
View File
@@ -2144,12 +2144,13 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
"app.exportPrevious" => app.session.active().is_some() && app.session.last_export.is_some(),
"dialog.pasteSettings" => app.session.active().is_some() && app.session.clipboard.is_some(),
"photo.openOriginalInExternal" => {
// only a file type LightCraft reads can be handed over (#2135); the extension alone
// decides, so no disk or share is touched while a menu is drawn
app.services.open_with.is_some()
&& app
.session
.active()
.and_then(|id| app.session.catalog.photo(id))
.is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. }))
&& app.session.active().and_then(|id| app.session.catalog.photo(id)).is_some_and(|p| match &p.source {
lightcraft_engine::catalog::Source::File { path } => lightcraft_engine::open_path::is_media(std::path::Path::new(path)),
_ => false,
})
}
"app.showInFinder" => {
app.services.reveal.is_some()
@@ -2324,8 +2325,15 @@ fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft:
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);
// the copy is LightCraft's own render, but it goes through the same gate as any other
// path handed to the system opener (#2135)
match lightcraft_engine::open_path::openable(path, "") {
Err(e) => failed = Some(e),
Ok(absolute) => {
if let Some(Err(e)) = app.services.open_with.as_mut().map(|f| f(&absolute, &editor)) {
failed = Some(e);
}
}
}
}
match (failed, copies.as_slice()) {
@@ -2377,11 +2385,13 @@ fn open_originals(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
let mut files = Vec::new();
let mut skipped = Vec::new();
for id in ids {
match app.session.catalog.photo(id).map(|ph| &ph.source) {
Some(lightcraft_engine::catalog::Source::File { path }) if std::path::Path::new(path).is_file() => files.push((id, path.clone())),
Some(lightcraft_engine::catalog::Source::File { path }) => {
skipped.push(json!({"id": id.0, "reason": format!("the original is missing: {path}")}))
}
// the path may come from an imported catalog: only a photo file LightCraft reads is
// handed to the opener, never a program (#2135)
match app.session.catalog.photo(id).map(|ph| (ph.file_name.clone(), ph.source.clone())) {
Some((name, lightcraft_engine::catalog::Source::File { path })) => match lightcraft_engine::open_path::openable(&path, &name) {
Ok(absolute) => files.push((id, absolute)),
Err(reason) => skipped.push(json!({"id": id.0, "reason": reason})),
},
Some(_) => skipped.push(json!({"id": id.0, "reason": "a demo photo has no original file"})),
None => skipped.push(json!({"id": id.0, "reason": "no such photo"})),
}
+64
View File
@@ -1177,6 +1177,70 @@ fn open_original_in_external_editor_hands_over_the_raw_itself() {
h.settle(SETTLE);
}
/// Issue #2135: a library record whose original is a program (an imported `.lrcat` names the
/// paths) is never handed to the opener — the launcher is asked for nothing — and the user is
/// told why. The path LightCraft does hand over is absolute, whatever the record's spelling.
#[test]
fn open_original_refuses_anything_that_is_not_a_photo_file() {
use lightcraft_catalog::{Op, Photo, Source};
let dir = std::env::temp_dir().join(format!("lc-ui-gate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let opened: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>> = Default::default();
let batches: std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>> = Default::default();
let (o, bt) = (opened.clone(), batches.clone());
let services = Services {
png: None,
open_with: Some(Box::new(move |path: &str, app: &str| {
o.lock().unwrap().push((path.to_string(), app.to_string()));
Ok(())
})),
open_all_with: Some(Box::new(move |paths: &[String], _: &str| {
bt.lock().unwrap().push(paths.to_vec());
Ok(())
})),
check_editor: Some(Box::new(|_: &str| Ok(()))),
..Default::default()
};
let mut session = lightcraft_engine::Session::with_demo().with_fs();
let mut record = |name: &str| {
let path = dir.join(name);
std::fs::write(&path, b"MZ").unwrap();
let id = session.catalog.alloc_photo_id();
let p = Photo::new(id, Source::File { path: path.to_string_lossy().into_owned() }, name, "ARW", 60, 40, "2026-01-01T10:00:00");
session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
id
};
// what a crafted catalog can name as a photo's original
let programs = ["payload.exe", "start.lnk", "run.bat", "page.hta", "go.command", "noext"];
let bad: Vec<_> = programs.iter().map(|n| record(n)).collect();
let good = record("DSC_0001.NEF");
let app = LightcraftApp::new(session, services);
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
h.app.ui.settings.external_editor = "DxO PhotoLab".into();
for (id, name) in bad.iter().zip(programs) {
let r = h.request("engine.execute", json!({"command": "photo.openOriginalInExternal", "params": {"ids": [id.0]}}), T);
assert_eq!(r["ok"], false, "{name} was opened: {r}");
assert!(r.to_string().contains("file types LightCraft reads"), "{name}: {r}");
// the menu item is greyed out for such a record, so it is never offered either
exec(&mut h, "library.select", json!({"ids": [id.0]}));
assert!(!crate::menus::ui_enabled(&h.app, "photo.openOriginalInExternal"), "{name} is offered in the menu");
}
assert!(opened.lock().unwrap().is_empty() && batches.lock().unwrap().is_empty(), "the launcher was asked to open a program");
// a program selected together with a photo: only the photo is handed over, by absolute path
let r = exec(&mut h, "photo.openOriginalInExternal", json!({"ids": [bad[0].0, good.0]}));
assert_eq!(r["opened"].as_array().map(Vec::len), Some(1), "{r}");
assert!(r["skipped"].to_string().contains("payload.exe"), "{r}");
let handed = batches.lock().unwrap().clone();
assert_eq!(handed.len(), 1);
assert_eq!(handed[0].len(), 1, "{handed:?}");
assert!(std::path::Path::new(&handed[0][0]).is_absolute() && handed[0][0].ends_with("DSC_0001.NEF"), "{handed:?}");
let _ = std::fs::remove_dir_all(&dir);
h.settle(SETTLE);
}
#[test]
fn second_window_shows_the_active_photo() {
let mut h = detail("panel.edit");
+1 -1
View File
@@ -407,7 +407,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| 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-RENDERDNG | Render to DNG (Classic 15.5) | P2 | 🟡 | `cmd:app.export` (`format: rendereddng`), `crates/raw/src/dngwrite.rs` (`write_rendered_dng`), `crates/raw/tests/rendered_dng.rs` | export format `rendereddng` (dialog: Format DNG, "DNG of: Rendered photo"): the rendered photo (edit, crop, size, sharpening, watermark baked in) as a LinearRaw DNG of 16-bit float Deflate tiles in the export colour space, with `ColorMatrix1` / `ForwardMatrix1` for that space, a neutral `AsShotNeutral` and an identity `ProfileToneCurve` so raw converters show it as rendered; HDR edits keep highlights above white. Not a menu command on selected photos (Lightroom adds the DNG to the catalog next to the original); export only |
| LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | `cmd:app.export` (`addToPhotos`, `photosAlbum`), `cmd:export.addToPhotos`, `cmd:export.photosImports`, `crates/engine/src/apple_photos.rs`, `crates/ui-egui/src/panels/dialogs.rs`, `packaging/macos/entitlements.plist`, `docs/apple-photos.md` | macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by `/usr/bin/osascript`; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and `lightcraft-cli` all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in `docs/apple-photos.md`). Other platforms' photo libraries are not covered |
| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `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. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos and missing files are skipped with the reason). 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`, `cmd:photo.openOriginalInExternal`, `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. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos, missing files and originals that are not a file type LightCraft reads are skipped with the reason, so a path an imported catalog named cannot hand a program to the system opener, issue #2135). No "Edit a copy / Edit original" choice for non-raw files yet |
## T. Share (SHARE)