From 44b12efea3405e94b0992832fe3621870536d822 Mon Sep 17 00:00:00 2001 From: cookingwithsimon <335935942+cookingwithsimon@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:35:43 +0000 Subject: [PATCH] LR-EXP-PSD: Edit in PhotoCraft (right-click Edit In, flat PSD hand-off) Photo > Edit In > Edit in PhotoCraft (Cmd+Shift+E, also on the photo context menu) renders a copy with the edits as a flat 16-bit PSD, which PhotoCraft saves back to in place, stacks it on the original and opens it in PhotoCraft, found where its installers put it. Edit in Other Editor (Cmd+Alt+E) keeps the configured program. Per-editor format, colour space and bit depth in Settings > External Editing. Export gains a flat PSD format. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T4kqvR9KVCPia3SSXyB8WG --- apps/lightcraft-web/src/web.rs | 1 + apps/lightcraft/src/editors.rs | 226 ++++++++++++++++++++++++++ apps/lightcraft/src/main.rs | 25 +-- crates/codecs/src/encode.rs | 10 +- crates/codecs/src/lib.rs | 2 +- crates/codecs/src/psd.rs | 179 ++++++++++++++++++++ crates/engine/src/cmd/convert.rs | 23 ++- crates/engine/src/export.rs | 9 + crates/engine/src/tests_xmp.rs | 39 +++++ crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/menus.rs | 68 +++++--- crates/ui-egui/src/panels/dialogs.rs | 2 +- crates/ui-egui/src/panels/grid.rs | 12 ++ crates/ui-egui/src/panels/settings.rs | 39 ++++- crates/ui-egui/src/state.rs | 70 +++++++- crates/ui-egui/src/tests_masking.rs | 54 ++++++ docs/parity-checklist.md | 8 +- docs/whats-new.md | 12 ++ 18 files changed, 717 insertions(+), 68 deletions(-) create mode 100644 apps/lightcraft/src/editors.rs diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 592d05cd1..ba6da7254 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -227,6 +227,7 @@ fn services(originals: Originals, backend: Option, files: Files, frozen reveal: None, log_file: None, open_with: None, + check_editor: None, open_url: Some(Box::new(|url: &str| { let w = web_sys::window().ok_or("no window")?; w.open_with_url_and_target(url, "_blank").map(|_| ()).map_err(|_| "the browser blocked the new tab".to_string()) diff --git a/apps/lightcraft/src/editors.rs b/apps/lightcraft/src/editors.rs new file mode 100644 index 000000000..8b246a5f8 --- /dev/null +++ b/apps/lightcraft/src/editors.rs @@ -0,0 +1,226 @@ +//! Starting external editors for Photo ▸ Edit In: PhotoCraft, found where its installers put it, +//! or any other application the user named in Settings ▸ External Editing. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// The name the UI asks for when it means PhotoCraft (`lightcraft_ui_egui::state::PHOTOCRAFT`). +const PHOTOCRAFT: &str = lightcraft_ui_egui::state::PHOTOCRAFT; +/// PhotoCraft's application id (Flatpak, desktop entry). +const PHOTOCRAFT_ID: &str = "ai.storyteller.photocraft"; + +/// How an editor is started. +#[derive(Debug, PartialEq)] +enum Launcher { + /// The system's default application for the file. + Default, + /// A program file, given the photo as its argument. + Program(PathBuf), + /// macOS: `open -a ` (an application name or bundle path). + MacApp(String), + /// The PhotoCraft Flatpak, with the file forwarded into its sandbox. + Flatpak, +} + +/// Whether `app` can be started (Settings ▸ External Editing names it; "" = the system default): +/// for PhotoCraft, that it is installed. The error says what to do instead. +pub fn check(app: &str) -> Result<(), String> { + launcher(app).map(|_| ()) +} + +/// Open `path` in `app` (see [`check`]). Spawned, never waited for: the editor runs alongside. +pub fn open_with(path: &str, app: &str) -> Result<(), String> { + let mut c = match launcher(app)? { + Launcher::Default if cfg!(target_os = "macos") => { + let mut c = Command::new("open"); + c.arg(path); + c + } + Launcher::Default if cfg!(target_os = "windows") => { + let mut c = Command::new("cmd"); + c.args(["/C", "start", "", path]); + c + } + Launcher::Default => { + let mut c = Command::new("xdg-open"); + c.arg(path); + c + } + Launcher::MacApp(app) => { + let mut c = Command::new("open"); + c.args(["-a", &app, path]); + c + } + Launcher::Program(p) => { + let mut c = Command::new(p); + c.arg(path); + c + } + Launcher::Flatpak => { + let mut c = Command::new("flatpak"); + c.args(["run", "--file-forwarding", PHOTOCRAFT_ID, "@@", path, "@@"]); + c + } + }; + c.spawn().map(|_| ()).map_err(|e| e.to_string()) +} + +fn launcher(app: &str) -> Result { + let app = app.trim(); + if app.is_empty() { + return Ok(Launcher::Default); + } + if !app.eq_ignore_ascii_case(PHOTOCRAFT) { + return Ok(if cfg!(target_os = "macos") { Launcher::MacApp(app.to_string()) } else { Launcher::Program(PathBuf::from(app)) }); + } + find_photocraft().ok_or_else(|| { + "PhotoCraft isn't installed. Get it from getartcraft.com/apps/photocraft, or give where it is in Settings ▸ External Editing.".to_string() + }) +} + +fn find_photocraft() -> Option { + let env = |k: &str| std::env::var_os(k).map(|v| v.to_string_lossy().to_string()); + if cfg!(target_os = "macos") { + // LaunchServices finds the app wherever it is; these are only the usual places + let home = env("HOME").map(|h| Path::new(&h).join("Applications/PhotoCraft.app")); + let found = [Some(PathBuf::from("/Applications/PhotoCraft.app")), home].into_iter().flatten().find(|p| p.is_dir()); + return Some(Launcher::MacApp(found.map_or_else(|| PHOTOCRAFT.to_string(), |p| p.to_string_lossy().to_string()))); + } + if cfg!(target_os = "windows") { + let install_dir = reg_query(r"HKLM\Software\PhotoCraft", Some("InstallDir")); + let app_path = reg_query(r"HKLM\Software\Microsoft\Windows\CurrentVersion\App Paths\photocraft.exe", None); + return windows_candidates(install_dir, app_path, env).into_iter().find(|p| p.is_file()).map(Launcher::Program); + } + // Linux, FreeBSD: on the PATH (.deb, .rpm, tarball), a desktop entry (AppImage, which + // registers one on first run), else the Flatpak + let on_path = + env("PATH").into_iter().flat_map(|p| std::env::split_paths(&p).collect::>()).map(|d| d.join("photocraft")).find(|p| p.is_file()); + let from_entry = || { + desktop_entry_dirs(env).into_iter().find_map(|d| { + let text = std::fs::read_to_string(d.join(format!("{PHOTOCRAFT_ID}.desktop"))).ok()?; + desktop_exec(&text).filter(|p| p.is_file()) + }) + }; + if let Some(p) = on_path.or_else(from_entry) { + return Some(Launcher::Program(p)); + } + let flatpak = Command::new("flatpak").args(["info", PHOTOCRAFT_ID]).output().is_ok_and(|o| o.status.success()); + flatpak.then_some(Launcher::Flatpak) +} + +/// Where the Windows installers put `photocraft.exe`, most specific first: the folder the MSI +/// recorded (`HKLM\Software\PhotoCraft\InstallDir`), its App Paths entry, then the default +/// folders (per machine, then per user). +fn windows_candidates(install_dir: Option, app_path: Option, env: impl Fn(&str) -> Option) -> Vec { + let mut out: Vec = Vec::new(); + out.extend(install_dir.map(|d| Path::new(&d).join("photocraft.exe"))); + out.extend(app_path.map(|p| PathBuf::from(p.trim_matches('"')))); + for var in ["ProgramW6432", "ProgramFiles"] { + out.extend(env(var).map(|d| Path::new(&d).join("PhotoCraft").join("photocraft.exe"))); + } + out.extend(env("LOCALAPPDATA").map(|d| Path::new(&d).join("Programs").join("PhotoCraft").join("photocraft.exe"))); + out +} + +/// A string value from the Windows registry (`reg query`; `None` = the key's default value). +fn reg_query(key: &str, name: Option<&str>) -> Option { + let mut c = Command::new("reg"); + c.args(["query", key]); + match name { + Some(n) => c.args(["/v", n]), + None => c.arg("/ve"), + }; + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + c.creation_flags(CREATE_NO_WINDOW); + } + let out = c.output().ok().filter(|o| o.status.success())?; + reg_value(&String::from_utf8_lossy(&out.stdout)) +} + +/// The value in `reg query` output: the text after `REG_SZ` (or `REG_EXPAND_SZ`) on its line. +fn reg_value(output: &str) -> Option { + output.lines().find_map(|l| { + let (_, v) = l.split_once("REG_EXPAND_SZ").or_else(|| l.split_once("REG_SZ"))?; + Some(v.trim().to_string()).filter(|v| !v.is_empty()) + }) +} + +/// The folders desktop entries live in (XDG base directories). +fn desktop_entry_dirs(env: impl Fn(&str) -> Option) -> Vec { + let mut dirs = Vec::new(); + match env("XDG_DATA_HOME") { + Some(d) if !d.is_empty() => dirs.push(PathBuf::from(d)), + _ => dirs.extend(env("HOME").map(|h| Path::new(&h).join(".local/share"))), + } + let system = env("XDG_DATA_DIRS").filter(|d| !d.is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + dirs.extend(system.split(':').filter(|d| !d.is_empty()).map(PathBuf::from)); + dirs.into_iter().map(|d| d.join("applications")).collect() +} + +/// The program a desktop entry's `Exec=` line starts (quoted or not, its field codes dropped). +fn desktop_exec(entry: &str) -> Option { + let exec = entry.lines().find_map(|l| l.strip_prefix("Exec="))?.trim(); + let program = match exec.strip_prefix('"') { + Some(rest) => rest.split('"').next()?, + None => exec.split_whitespace().next()?, + }; + // a bare name (`photocraft %F`) was already looked for on the PATH + Some(PathBuf::from(program)).filter(|p| p.is_absolute()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn windows_lookup_prefers_the_recorded_install_folder() { + let env = |k: &str| match k { + "ProgramFiles" => Some(r"C:\Program Files".to_string()), + "LOCALAPPDATA" => Some(r"C:\Users\Simon\AppData\Local".to_string()), + _ => None, + }; + let c = windows_candidates(Some(r"D:\Apps\PhotoCraft\".into()), Some(r#""C:\Program Files\PhotoCraft\photocraft.exe""#.into()), env); + let c: Vec = c.iter().map(|p| p.to_string_lossy().replace('/', "\\")).collect(); + assert!(c[0].starts_with(r"D:\Apps\PhotoCraft") && c[0].ends_with("photocraft.exe"), "{c:?}"); + assert_eq!(c[1], r"C:\Program Files\PhotoCraft\photocraft.exe", "App Paths, quotes dropped"); + assert!(c[2].starts_with(r"C:\Program Files") && c[2].ends_with("photocraft.exe"), "{c:?}"); + assert!(c[3].contains("Programs") && c[3].ends_with("photocraft.exe"), "{c:?}"); + assert_eq!(c.len(), 4); + } + + #[test] + fn reg_query_output_is_read() { + let out = "\r\nHKEY_LOCAL_MACHINE\\Software\\PhotoCraft\r\n InstallDir REG_SZ C:\\Program Files\\PhotoCraft\\\r\n\r\n"; + assert_eq!(reg_value(out).as_deref(), Some(r"C:\Program Files\PhotoCraft\")); + let default = "\r\nHKEY_LOCAL_MACHINE\\...\\photocraft.exe\r\n (Default) REG_SZ C:\\Program Files\\PhotoCraft\\photocraft.exe\r\n"; + assert_eq!(reg_value(default).as_deref(), Some(r"C:\Program Files\PhotoCraft\photocraft.exe")); + assert_eq!(reg_value("ERROR: The system was unable to find the specified registry key or value."), None); + } + + #[test] + fn desktop_entries_give_their_program() { + let appimage = "[Desktop Entry]\nName=PhotoCraft\nExec=\"/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage\" %F\n"; + assert_eq!(desktop_exec(appimage), Some(PathBuf::from("/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage"))); + assert_eq!(desktop_exec("Exec=/opt/photocraft/photocraft %F\n"), Some(PathBuf::from("/opt/photocraft/photocraft"))); + assert_eq!(desktop_exec("Exec=photocraft %F\nTryExec=photocraft\n"), None, "a bare name is a PATH lookup"); + assert_eq!(desktop_exec("[Desktop Entry]\n"), None); + let dirs = desktop_entry_dirs(|k| (k == "HOME").then(|| "/home/simon".to_string())); + assert_eq!(dirs[0], PathBuf::from("/home/simon/.local/share/applications")); + assert!(dirs.contains(&PathBuf::from("/usr/share/applications"))); + } + + #[test] + fn other_editors_are_used_as_named() { + assert_eq!(launcher("").unwrap(), Launcher::Default); + assert_eq!(launcher(" ").unwrap(), Launcher::Default); + let named = launcher("/usr/bin/gimp").unwrap(); + if cfg!(target_os = "macos") { + assert_eq!(named, Launcher::MacApp("/usr/bin/gimp".into())); + } else { + assert_eq!(named, Launcher::Program("/usr/bin/gimp".into())); + } + } +} diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 09cb14343..a24bec62b 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -25,6 +25,7 @@ mod alloc_release; mod control_server; mod dialog_filter; +mod editors; mod logging; #[cfg(target_os = "macos")] mod native_menu; @@ -423,28 +424,8 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services pick_folder: Some(Box::new(|| { rfd::FileDialog::new().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string()) })), - open_with: Some(Box::new(|path: &str, app: &str| { - // spawned, never waited for: the editor runs alongside - let app = app.trim(); - let mut c = if cfg!(target_os = "macos") { - let mut c = std::process::Command::new("open"); - if !app.is_empty() { - c.args(["-a", app]); - } - c - } else if app.is_empty() { - if cfg!(target_os = "windows") { - let mut c = std::process::Command::new("cmd"); - c.args(["/C", "start", ""]); - c - } else { - std::process::Command::new("xdg-open") - } - } else { - std::process::Command::new(app) - }; - c.arg(path).spawn().map(|_| ()).map_err(|e| e.to_string()) - })), + open_with: Some(Box::new(editors::open_with)), + check_editor: Some(Box::new(editors::check)), open_url: Some(Box::new(|url: &str| { if !url.starts_with("https://") { return Err("only https links are opened".into()); diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs index c002abd98..d676ec1f1 100644 --- a/crates/codecs/src/encode.rs +++ b/crates/codecs/src/encode.rs @@ -1,4 +1,4 @@ -//! Encoders: JPEG (`jpeg-encoder`), PNG (`png`), TIFF (`tiff`), lossless WebP (`image-webp`) and, +//! Encoders: JPEG (`jpeg-encoder`), PNG (`png`), TIFF (`tiff`), flat PSD (our own), lossless WebP (`image-webp`) and, //! on native targets with the `avif` feature, AVIF (`ravif`/`rav1e`). Metadata (ICC/EXIF/XMP) is //! embedded where the format allows. @@ -32,7 +32,7 @@ impl<'a> EncodeImage<'a> { EncodeImage { width, height, channels, samples } } - fn validate(&self) -> Result<()> { + pub(crate) fn validate(&self) -> Result<()> { if !(1..=4).contains(&self.channels) { return Err(Error::Encode(format!("unsupported channel count {}", self.channels))); } @@ -291,6 +291,12 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod Ok(cur.into_inner()) } +/// Encode a flat PSD (8/16-bit gray or RGB, no layers; alpha dropped): the hand-off an image +/// editor such as PhotoCraft saves back to in place. ICC, EXIF, XMP and resolution are embedded. +pub fn encode_psd(img: &EncodeImage, meta: &EncodeMeta) -> Result> { + crate::psd::encode(img, meta) +} + /// Encode lossless WebP (8-bit). ICC/EXIF/XMP are embedded in the extended (VP8X) container. pub fn encode_webp_lossless(img: &EncodeImage, meta: &EncodeMeta) -> Result> { img.validate()?; diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 657987189..744b386a2 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -37,7 +37,7 @@ mod webp; pub use encode::{ ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, TiffCompression, encode_avif, encode_avif_pq, encode_jpeg, - encode_png, encode_tiff, encode_webp_lossless, + encode_png, encode_psd, encode_tiff, encode_webp_lossless, }; pub use sniff::{Format, sniff}; pub use space::{NamedSpace, SourceSpace, SpaceOrigin, Trc}; diff --git a/crates/codecs/src/psd.rs b/crates/codecs/src/psd.rs index 3d26984ef..10e0819b6 100644 --- a/crates/codecs/src/psd.rs +++ b/crates/codecs/src/psd.rs @@ -1,8 +1,11 @@ //! Photoshop PSD/PSB merged-composite reader (clean-room, from the public file-format description): //! 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw or PackBits/RLE image data; //! ICC (resource 1039), EXIF (1058), XMP (1060); transparency when the layer count is negative. +//! Also a flat-file writer ([`encode`]): the merged image of an 8- or 16-bit gray/RGB document +//! with no layers, so an image editor opens it as one background layer and saves back to it. use crate::convert::{Buf, Meta, Model, Raw, check_size, finish}; +use crate::encode::{EncodeImage, EncodeMeta, Samples}; use crate::{DecodeOptions, Decoded, Error, Format, Result}; const F: Format = Format::Psd; @@ -211,6 +214,131 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (width as u32, height as u32), opts) } +/// The largest side a PSD (version 1) may have; larger documents need PSB. +pub const MAX_SIDE: u32 = 30_000; + +/// Write a flat PSD: header, the image resources for `meta` (resolution 1005, ICC 1039, EXIF +/// 1058, XMP 1060), an empty layer section and the merged image. 8-bit planes are PackBits +/// compressed, 16-bit ones stored raw (PackBits saves little on 16-bit photos). Gray or RGB; +/// alpha is dropped (the render is opaque). Float samples are refused. +pub(crate) fn encode(img: &EncodeImage, meta: &EncodeMeta) -> Result> { + img.validate()?; + let (w, h) = (img.width, img.height); + if w > MAX_SIDE || h > MAX_SIDE { + return Err(Error::Encode(format!("{w} × {h} is larger than a PSD can hold ({MAX_SIDE} px a side): use TIFF"))); + } + let ch = usize::from(img.channels); + let color = if ch <= 2 { 1 } else { 3 }; + let (depth, bpc) = match img.samples { + Samples::U8(_) => (8u16, 1usize), + Samples::U16(_) => (16, 2), + Samples::F32(_) => return Err(Error::Encode("32-bit float PSD is not supported: use TIFF".into())), + }; + let (wu, hu) = (w as usize, h as usize); + let mut b = Vec::new(); + b.extend_from_slice(b"8BPS"); + b.extend_from_slice(&1u16.to_be_bytes()); + b.extend_from_slice(&[0; 6]); + b.extend_from_slice(&(color as u16).to_be_bytes()); + b.extend_from_slice(&h.to_be_bytes()); + b.extend_from_slice(&w.to_be_bytes()); + b.extend_from_slice(&depth.to_be_bytes()); + b.extend_from_slice(&(if color == 1 { 1u16 } else { 3 }).to_be_bytes()); + b.extend_from_slice(&0u32.to_be_bytes()); // colour mode data + let res = resource_section(meta); + b.extend_from_slice(&u32::try_from(res.len()).map_err(|_| Error::Encode("metadata too large for PSD".into()))?.to_be_bytes()); + b.extend_from_slice(&res); + b.extend_from_slice(&0u32.to_be_bytes()); // layer and mask information: none + // planar samples, one plane per colour channel (big-endian) + let plane = |c: usize| -> Vec { + let mut p = Vec::with_capacity(wu * hu * bpc); + match img.samples { + Samples::U8(s) => p.extend(s.iter().skip(c).step_by(ch).take(wu * hu)), + Samples::U16(s) => s.iter().skip(c).step_by(ch).take(wu * hu).for_each(|v| p.extend_from_slice(&v.to_be_bytes())), + Samples::F32(_) => {} + } + p + }; + if bpc == 1 { + b.extend_from_slice(&1u16.to_be_bytes()); + let mut counts = Vec::with_capacity(color * hu * 2); + let mut data = Vec::new(); + for c in 0..color { + for row in plane(c).chunks_exact(wu) { + let start = data.len(); + packbits(row, &mut data); + let n = u16::try_from(data.len() - start).map_err(|_| Error::Encode("PSD row too long".into()))?; + counts.extend_from_slice(&n.to_be_bytes()); + } + } + b.extend_from_slice(&counts); + b.extend_from_slice(&data); + } else { + b.extend_from_slice(&0u16.to_be_bytes()); + for c in 0..color { + b.extend_from_slice(&plane(c)); + } + } + Ok(b) +} + +/// The image resources section for `meta`. +fn resource_section(meta: &EncodeMeta) -> Vec { + let mut out = Vec::new(); + let mut add = |id: u16, data: &[u8]| { + out.extend_from_slice(b"8BIM"); + out.extend_from_slice(&id.to_be_bytes()); + out.extend_from_slice(&[0, 0]); // empty Pascal name, padded to even + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(data); + if data.len() % 2 == 1 { + out.push(0); + } + }; + if let Some(ppi) = meta.ppi.filter(|p| *p > 0) { + // ResolutionInfo: 16.16 fixed pixels per inch, unit 1 (ppi), display unit 1 (inches), twice + let fixed = (u32::from(ppi) << 16).to_be_bytes(); + let one = 1u16.to_be_bytes(); + add(1005, &[&fixed[..], &one, &one, &fixed, &one, &one].concat()); + } + if let Some(icc) = meta.icc { + add(1039, icc); + } + if let Some(exif) = meta.exif { + add(1058, exif); + } + if let Some(xmp) = meta.xmp { + add(1060, xmp.as_bytes()); + } + out +} + +/// PackBits: append one encoded row of `src` to `out` (runs of 2+ equal bytes as repeats, the +/// rest as literals of at most 128 bytes). +fn packbits(src: &[u8], out: &mut Vec) { + let mut i = 0; + while i < src.len() { + let b = src[i]; + let mut run = 1; + while run < 128 && i + run < src.len() && src[i + run] == b { + run += 1; + } + if run >= 2 { + out.push((1i16 - run as i16) as i8 as u8); + out.push(b); + i += run; + continue; + } + // a literal: up to the next pair of equal bytes + let start = i; + while i < src.len() && i - start < 128 && !(i + 1 < src.len() && src[i] == src[i + 1]) { + i += 1; + } + out.push((i - start - 1) as u8); + out.extend_from_slice(&src[start..i]); + } +} + /// Stored dimensions and EXIF orientation (resource 1058) from the header and image resources, /// without reading the merged image data. Refused, as a decode would refuse it: an unsupported /// depth, colour mode or compression, or merged image data that runs past the end of the file @@ -434,4 +562,55 @@ mod tests { let q = d.image.get(0, 0); assert!(p[0] > 0.0 && (p[0] - q[0]).abs() < 1e-6, "{p:?}"); } + #[test] + fn packbits_round_trips() { + let rows: [&[u8]; 6] = [&[], &[7], &[1, 1], &[1, 2, 3, 3, 3, 4, 5, 5], &[9; 300], &(0..=255u8).cycle().take(700).collect::>()]; + for row in rows { + let mut enc = Vec::new(); + super::packbits(row, &mut enc); + let mut out = vec![0u8; row.len()]; + unpackbits(&enc, &mut out).unwrap(); + assert_eq!(out, row); + } + } + + #[test] + fn flat_psd_round_trips_8_and_16_bit() { + let icc = crate::icc::write_named(crate::NamedSpace::AdobeRgb); + let meta = EncodeMeta { icc: Some(&icc), xmp: Some(""), ppi: Some(300), ..Default::default() }; + // 8-bit RGBA (alpha dropped), 5 × 3 with a flat run and a gradient + let px: Vec = (0..15u8).flat_map(|i| [i * 10, if i < 8 { 200 } else { i }, 255 - i, 255]).collect(); + let b = encode(&EncodeImage::new(5, 3, 4, Samples::U8(&px)), &meta).unwrap(); + assert_eq!(super::header(&b).unwrap(), (5, 3, 1)); + let d = decode(&b, &DecodeOptions::default()).unwrap(); + assert_eq!((d.width, d.height), (5, 3)); + assert_eq!(d.icc.as_deref(), Some(&icc[..]), "the ICC profile is kept"); + assert_eq!(d.xmp.as_deref(), Some("")); + // 16-bit RGB, raw planes: the samples come back exactly + let px16: Vec = (0..4 * 2 * 3).map(|i| (i as u16) * 2731).collect(); + let b = encode(&EncodeImage::new(4, 2, 3, Samples::U16(&px16)), &meta).unwrap(); + assert_eq!(&b[22..24], &16u16.to_be_bytes()); + let mut r = Rd { b: &b, p: 26 }; + let cm = r.u32().unwrap() as usize; + r.take(cm).unwrap(); + let res = r.u32().unwrap() as usize; + r.take(res).unwrap(); + assert_eq!(r.u32().unwrap(), 0, "no layers"); + assert_eq!(r.u16().unwrap(), 0, "16-bit planes are raw"); + let red: Vec = (0..8).map(|_| r.u16().unwrap()).collect(); + assert_eq!(red, px16.iter().step_by(3).copied().collect::>()); + assert!(decode(&b, &DecodeOptions::default()).is_ok()); + // gray 16-bit + let g = encode(&EncodeImage::new(2, 2, 1, Samples::U16(&[0, 1000, 40000, 65535])), &EncodeMeta::default()).unwrap(); + assert_eq!((&g[12..14], &g[24..26]), (&1u16.to_be_bytes()[..], &1u16.to_be_bytes()[..]), "one channel, grayscale mode"); + assert!(decode(&g, &DecodeOptions::default()).is_ok()); + } + + #[test] + fn flat_psd_refuses_what_it_cannot_hold() { + let e = encode(&EncodeImage::new(1, 1, 3, Samples::F32(&[0.5; 3])), &EncodeMeta::default()).unwrap_err(); + assert!(e.to_string().contains("float"), "{e}"); + let e = encode(&EncodeImage::new(MAX_SIDE + 1, 1, 1, Samples::U8(&vec![0; MAX_SIDE as usize + 1])), &EncodeMeta::default()).unwrap_err(); + assert!(e.to_string().contains("TIFF"), "{e}"); + } } diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 2b1b368b2..229f34ebc 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -120,8 +120,8 @@ pub fn specs() -> Vec { } /// Edit in an external editor, the engine half: render the photo with its edits as a 16-bit -/// TIFF next to the original (`-Edit.tif`, never overwriting), add it to the library and -/// stack it on top of the original. The host opens the file in the editor. +/// TIFF (or PSD, or 8-bit) next to the original (`-Edit.tif`, never overwriting), add it to +/// 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"))?; @@ -139,9 +139,19 @@ fn edit_external(s: &mut Session, p: &Value) -> Result { "prophoto" | "proPhoto" => lightcraft_pipeline::OutputSpace::ProPhoto, _ => lightcraft_pipeline::OutputSpace::AdobeRgb, }; + let format = match super::str_param(p, "format").unwrap_or("tiff") { + "tiff" | "tif" => crate::export::ExportFormat::Tiff, + "psd" => crate::export::ExportFormat::Psd, + f => return Err(bad(C, format!("format {f:?}: use tiff or psd"))), + }; + let bit_depth = match p.get("bitDepth").and_then(Value::as_u64).unwrap_or(16) { + 8 => 8, + 16 => 16, + d => return Err(bad(C, format!("bitDepth {d}: use 8 or 16"))), + }; let opts = crate::export::ExportOptions { - format: crate::export::ExportFormat::Tiff, - bit_depth: Some(16), + format, + bit_depth: Some(bit_depth), color_space: space, naming: "{name}-Edit".into(), conflict: crate::export::Conflict::Unique, @@ -156,7 +166,8 @@ fn edit_external(s: &mut Session, p: &Value) -> Result { }; 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 out = written.into_iter().find(|w| w.ends_with(".tif")).ok_or_else(|| bad(C, "nothing was written"))?; + let ext = format!(".{}", 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 @@ -342,7 +353,7 @@ pub fn edit_specs() -> Vec { "Edit Copy for External Editor", [], None, - "{colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, dir?} — render the active photo with its edits as a 16-bit TIFF `-Edit.tif` next to it, add it stacked on the original and select it → {path, id, original} (the app then opens it in the external editor)", + "{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)", has_active, edit_external ), diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 6a9e9147c..6ee07f641 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -18,6 +18,8 @@ pub enum ExportFormat { Jpeg, Png, Tiff, + /// A flat PSD (8/16-bit, no layers): what an image editor such as PhotoCraft saves back to. + Psd, Webp, Avif, /// The original file, unchanged, with an XMP sidecar holding the edits. @@ -32,6 +34,7 @@ impl ExportFormat { "jpeg" | "jpg" => Self::Jpeg, "png" => Self::Png, "tiff" | "tif" => Self::Tiff, + "psd" => Self::Psd, "webp" => Self::Webp, "avif" => Self::Avif, "original" => Self::Original, @@ -44,6 +47,7 @@ impl ExportFormat { Self::Jpeg => "jpg", Self::Png => "png", Self::Tiff => "tif", + Self::Psd => "psd", Self::Webp => "webp", Self::Avif => "avif", // the original keeps its own extension (see `ExportOptions::file_name_for`) @@ -1001,6 +1005,8 @@ impl ExportOptions { (ExportFormat::Tiff, Some(32)) if self.hdr => OutputDepth::F32Hdr, (ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear, (ExportFormat::Tiff, _) => OutputDepth::U16, + (ExportFormat::Psd, Some(8)) => OutputDepth::U8, + (ExportFormat::Psd, _) => OutputDepth::U16, (ExportFormat::Avif, _) if self.hdr => OutputDepth::F32Hdr, (ExportFormat::Avif, Some(10 | 16 | 32)) => OutputDepth::U16, (ExportFormat::Avif, _) => OutputDepth::U8, @@ -1013,6 +1019,7 @@ impl ExportOptions { ExportFormat::Jpeg | ExportFormat::Webp | ExportFormat::Original | ExportFormat::Dng => &[(8, "8-bit")], ExportFormat::Png => &[(8, "8-bit"), (16, "16-bit")], ExportFormat::Tiff => &[(16, "16-bit"), (8, "8-bit"), (32, "32-bit float")], + ExportFormat::Psd => &[(16, "16-bit"), (8, "8-bit")], ExportFormat::Avif => &[(8, "8-bit"), (10, "10-bit")], } } @@ -1176,6 +1183,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada } ExportFormat::Png => encode::encode_png(&e, &meta), ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta), + ExportFormat::Psd => encode::encode_psd(&e, &meta), ExportFormat::Webp => encode::encode_webp_lossless(&e, &meta), ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta), f @ (ExportFormat::Original | ExportFormat::Dng) => return Err(format!("{f:?} export does not encode pixels")), @@ -1285,6 +1293,7 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) let r = match (o.format, &img.samples) { (ExportFormat::Png, _) => encode::encode_png(&e, &meta), (ExportFormat::Tiff, _) => encode::encode_tiff(&e, o.tiff_compression, &meta), + (ExportFormat::Psd, _) => encode::encode_psd(&e, &meta), (ExportFormat::Avif, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => { lightcraft_codecs::encode_avif_pq(w, h, v, o.quality, 8, &meta) } diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index e3137da00..57981d920 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -566,6 +566,45 @@ fn external_edit_copy_is_stacked() { 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] +fn external_edit_copy_as_psd_round_trips() { + let dir = temp_dir("extedit-psd"); + let src = dir.join("pier.png"); + write_png(&src, 40); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let orig = s.active().unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap(); + let r = s.execute("photo.editExternal", &json!({"format": "psd", "bitDepth": 16})).unwrap(); + let path = r["path"].as_str().unwrap().to_string(); + assert!(path.ends_with("pier-Edit.psd"), "{path}"); + let d = lightcraft_codecs::decode(&std::fs::read(&path).unwrap(), Default::default()).unwrap(); + assert_eq!(d.bit_depth, 16); + assert!(d.icc.is_some(), "the colour space travels with the file"); + let new = lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap()); + assert_eq!(s.catalog.stack_of(new).map(|st| (st.top(), st.photos.contains(&orig))), Some((new, true))); + // the editor saves the document back over the same file: Reload shows its pixels + let before = s.render_now(new, 16, 16).unwrap().image.data; + let (w, h) = (d.width as u32, d.height as u32); + let px = vec![1000u16; (w * h * 3) as usize]; + let saved = + lightcraft_codecs::encode_psd(&lightcraft_codecs::EncodeImage::new(w, h, 3, lightcraft_codecs::Samples::U16(&px)), &Default::default()) + .unwrap(); + std::fs::write(&path, saved).unwrap(); + assert_eq!(s.execute("photo.reload", &json!({"ids": [new.0]})).unwrap()["reloaded"], json!([new.0])); + assert_ne!(s.render_now(new, 16, 16).unwrap().image.data, before); + // 8-bit, and what it can't do + s.execute("library.select", &json!({"ids": [orig.0]})).unwrap(); + let r8 = s.execute("photo.editExternal", &json!({"format": "psd", "bitDepth": 8})).unwrap(); + let d8 = lightcraft_codecs::decode(&std::fs::read(r8["path"].as_str().unwrap()).unwrap(), Default::default()).unwrap(); + assert_eq!(d8.bit_depth, 8); + assert!(s.execute("photo.editExternal", &json!({"format": "jpeg"})).is_err()); + assert!(s.execute("photo.editExternal", &json!({"bitDepth": 32})).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} + /// Reload: a file changed on disk (an external editor saved it) gets its new size / hash, the /// cached source is dropped and renders change; unchanged files are left alone. #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f853fe166..92c9abad2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -119,6 +119,9 @@ pub type RevealFn = Box Result<(), String>>; pub type OpenUrlFn = Box Result<(), String>>; /// Open a file in an application (`app` = "" for the system's default one). pub type OpenWithFn = Box Result<(), String>>; +/// Whether an external editor can be started (its name as in [`Services::open_with`]); the +/// error says why not and what to do (e.g. PhotoCraft isn't installed). +pub type CheckEditorFn = Box Result<(), String>>; /// Something the host does with the session (web: back up or restore the library in browser /// storage); the work may finish asynchronously. pub type HostAction = Box Result>; @@ -170,6 +173,9 @@ pub struct Services { pub open_url: Option, /// Open a file in an external editor (Edit in External Editor; desktop only). pub open_with: Option, + /// Checked before Edit In renders a copy, so a missing editor leaves no copy behind (none = + /// assume it can). + pub check_editor: Option, /// File ▸ Back Up Library…: save the whole library (catalog and originals) as one file the /// user keeps (web only: there the library lives in browser storage, which the browser may /// clear; on the desktop it is a folder backed up like any other). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 81a46eb98..c0382d24b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -154,7 +154,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("dialog.export", "Export…", None, "File"), ("dialog.contactSheet", "Contact Sheet PDF…", None, "File"), ("app.contactSheet", "Export Contact Sheet PDF", None, ""), - ("photo.editInExternal", "Edit in External Editor", Some("Cmd+Shift+E"), "Photo"), + ("photo.editInPhotoCraft", "Edit in PhotoCraft", Some("Cmd+Shift+E"), "Photo>Edit In"), + ("photo.editInExternal", "Edit in Other Editor", Some("Cmd+Alt+E"), "Photo>Edit In"), ("dialog.mergeHdr", "HDR…", Some("Ctrl+H"), "Photo>Photo Merge"), ("dialog.mergePanorama", "Panorama…", Some("Ctrl+M"), "Photo>Photo Merge"), ("dialog.mergeHdrPanorama", "HDR Panorama…", None, "Photo>Photo Merge"), @@ -1032,30 +1033,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - // render an edit copy (stacked on the original), then open it in the editor - let mut params = p.clone(); - if !params.is_object() { - params = json!({}); - } - let r = match app.session.execute("photo.editExternal", ¶ms) { - Ok(r) => r, - Err(e) => return Some(Err(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); - } - let editor = p.get("app").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| app.ui.settings.external_editor.clone()); - if let Some(f) = app.services.open_with.as_mut() - && let Err(e) = f(&path, &editor) - { - app.toast(&ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)); - } - 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)); - Ok(r) - } + "photo.editInPhotoCraft" => edit_in(app, &ctx, p, true), + "photo.editInExternal" => edit_in(app, &ctx, p, false), "dialog.cull" => { app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true }); Ok(Value::Null) @@ -1605,6 +1584,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { app.session.active().is_some() } "photo.tagFromTracklog" => app.session.active().is_some() && app.services.pick_tracklog.is_some(), + "photo.editInPhotoCraft" | "photo.editInExternal" => app.session.active().is_some() && app.services.open_with.is_some(), "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(), "app.showInFinder" => { @@ -1711,3 +1691,41 @@ fn show_in_finder(app: &mut LightcraftApp) -> Result { reveal(&path)?; 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 +/// 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 { + let settings = &app.ui.settings; + let (file, configured) = if photocraft { + let path = settings.photocraft_path.trim(); + (settings.photocraft_file, if path.is_empty() { crate::state::PHOTOCRAFT.to_string() } else { path.to_string() }) + } else { + (settings.external_file, settings.external_editor.clone()) + }; + let editor = p.get("app").and_then(Value::as_str).map_or(configured, str::to_string); + if let Some(check) = app.services.check_editor.as_ref() + && let Err(e) = check(&editor) + { + app.toast(ctx, e.clone()); + return Err(e); + } + let mut params = file.params(); + if let (Some(o), Some(given)) = (params.as_object_mut(), p.as_object()) { + for (k, v) in given.iter().filter(|(k, _)| k.as_str() != "app") { + o.insert(k.clone(), v.clone()); + } + } + 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); + } + 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)), + } + Ok(r) +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index aa287c952..9d6ed1948 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -531,7 +531,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui, "Format", "exportFormat", - &[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Dng, "DNG"), (F::Original, "Original files")], + &[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Psd, "PSD"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Dng, "DNG"), (F::Original, "Original files")], &mut opts.format, ); if !opts.format.is_rendered() { diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 4264e4290..d7bbfe901 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -881,6 +881,18 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Open in Detail")).clicked() { let _ = app.run("view.detail", json!({})); } + // Photo ▸ Edit In: a copy with this photo's edits, opened in PhotoCraft or another editor + // (desktop only: the web can't start programs) + ui.add_enabled_ui(crate::menus::ui_enabled(app, "photo.editInPhotoCraft"), |ui| { + ui.menu_button(crate::i18n::tr("Edit In"), |ui| { + if ui.button(crate::i18n::tr("Edit in PhotoCraft")).clicked() { + let _ = app.run("photo.editInPhotoCraft", json!({})); + } + if ui.button(crate::i18n::tr("Edit in Other Editor")).clicked() { + let _ = app.run("photo.editInExternal", json!({})); + } + }); + }); if ui.button(crate::i18n::tr("Find Similar Photos")).clicked() { match app.run("library.findSimilar", json!({"id": id.0})) { Ok(r) => { diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 7036e10f1..507f3f716 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -138,19 +138,46 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { heading(ui, t, crate::i18n::tr("Culling")); check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging"); check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted"); - heading(ui, t, crate::i18n::tr("External Editor")); - row(ui, t, crate::i18n::tr("Application"), |ui| { - let r = ui - .add(egui::TextEdit::singleline(&mut app.ui.settings.external_editor).hint_text(crate::i18n::tr("System default")).desired_width(220.0)); - register(ui.ctx(), "field:externalEditor", r.rect); + heading(ui, t, crate::i18n::tr("External Editing")); + row(ui, t, crate::i18n::tr("PhotoCraft"), |ui| { + let r = ui.add( + egui::TextEdit::singleline(&mut app.ui.settings.photocraft_path).hint_text(crate::i18n::tr("Found automatically")).desired_width(220.0), + ); + register(ui.ctx(), "field:photocraftPath", r.rect); }); + edit_file_rows(ui, t, "photocraftFile", &mut app.ui.settings.photocraft_file); hint( ui, t, crate::i18n::tr( - "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).", + "Photo ▸ Edit In ▸ Edit in PhotoCraft (⇧⌘E, or right-click a photo) renders a copy with your edits, stacks it with the original and opens it in PhotoCraft. Save there and the copy updates here. As PSD, PhotoCraft saves straight back to it. Leave the location empty unless you use a portable copy.", ), ); + row(ui, t, crate::i18n::tr("Other editor"), |ui| { + let r = ui + .add(egui::TextEdit::singleline(&mut app.ui.settings.external_editor).hint_text(crate::i18n::tr("System default")).desired_width(220.0)); + register(ui.ctx(), "field:externalEditor", r.rect); + }); + edit_file_rows(ui, t, "externalFile", &mut app.ui.settings.external_file); + hint(ui, t, crate::i18n::tr("Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).")); +} + +/// The file Edit In hands an editor: format, colour space and bit depth (`button:{id}Format-…`). +fn edit_file_rows(ui: &mut egui::Ui, t: &Tokens, id: &str, file: &mut crate::state::EditFile) { + use crate::state::{EditFormat, EditSpace}; + row(ui, t, "", |ui| { + choices(ui, &format!("{id}Format"), &[(EditFormat::Psd, "PSD"), (EditFormat::Tiff, "TIFF")], &mut file.format); + ui.separator(); + choices(ui, &format!("{id}Depth"), &[(16u8, "16-bit"), (8, "8-bit")], &mut file.bit_depth); + }); + row(ui, t, "", |ui| { + choices( + ui, + &format!("{id}Space"), + &[(EditSpace::AdobeRgb, "Adobe RGB"), (EditSpace::ProPhoto, "ProPhoto"), (EditSpace::DisplayP3, "P3"), (EditSpace::Srgb, "sRGB")], + &mut file.color_space, + ); + }); } // -------------------------------------------------------------------------------------- Import diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6a50e2baf..29193c6de 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -144,8 +144,15 @@ pub struct AppSettings { /// drawn at, up to the photo's own pixels and [`LOUPE_EDGE_CEILING`]). Not the old /// `previewEdge` key: its 2560 px default was the soft-image bug (issue #323). pub preview_limit: u32, - /// Edit in External Editor: the application ("" = the system's default for TIFF files). + /// Edit in Other Editor: the application ("" = the system's default for the file type). pub external_editor: String, + /// The file Edit in Other Editor hands over. + pub external_file: EditFile, + /// Edit in PhotoCraft: where PhotoCraft is ("" = found where its installer put it; a portable + /// copy needs its path). + pub photocraft_path: String, + /// The file Edit in PhotoCraft hands over. + pub photocraft_file: EditFile, /// Memory the caches may hold together, in MB (0 = automatic; `app.memoryBudget`). pub memory_mb: u32, /// Filmstrip: file names above the thumbnails. @@ -170,6 +177,9 @@ impl Default for AppSettings { gpu: true, preview_limit: 0, external_editor: String::new(), + external_file: EditFile::default(), + photocraft_path: String::new(), + photocraft_file: EditFile { format: EditFormat::Psd, ..EditFile::default() }, memory_mb: 0, film_names: true, film_badges: true, @@ -180,6 +190,64 @@ impl Default for AppSettings { } } +/// The editor name that means PhotoCraft (Photo ▸ Edit In ▸ Edit in PhotoCraft): the host finds +/// where it is installed. +pub const PHOTOCRAFT: &str = "PhotoCraft"; + +/// The file format Edit In renders the copy as. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EditFormat { + #[default] + Tiff, + /// A flat PSD: PhotoCraft saves back to it in place (a TIFF it saves only through Save As). + Psd, +} + +/// The colour space of the Edit In copy. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EditSpace { + #[default] + AdobeRgb, + ProPhoto, + DisplayP3, + Srgb, +} + +/// The file Photo ▸ Edit In renders for an editor (Settings ▸ External Editing). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct EditFile { + pub format: EditFormat, + pub color_space: EditSpace, + /// 16 or 8. + pub bit_depth: u8, +} + +impl Default for EditFile { + fn default() -> Self { + EditFile { format: EditFormat::Tiff, color_space: EditSpace::AdobeRgb, bit_depth: 16 } + } +} + +impl EditFile { + /// The `photo.editExternal` params for this file. + pub fn params(&self) -> serde_json::Value { + let format = match self.format { + EditFormat::Tiff => "tiff", + EditFormat::Psd => "psd", + }; + let space = match self.color_space { + EditSpace::AdobeRgb => "adobeRgb", + EditSpace::ProPhoto => "proPhoto", + EditSpace::DisplayP3 => "displayP3", + EditSpace::Srgb => "srgb", + }; + serde_json::json!({"format": format, "colorSpace": space, "bitDepth": if self.bit_depth == 8 { 8 } else { 16 }}) + } +} + /// Preview size limits offered in Settings → Performance (0 = Automatic). pub const PREVIEW_LIMITS: [u32; 5] = [0, 1600, 2560, 3840, 5120]; diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index b95fa1ab2..8ef3cf1b3 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -896,6 +896,60 @@ fn edit_in_external_editor_opens_the_copy() { h.settle(SETTLE); } +/// Edit in PhotoCraft: a PSD copy (what PhotoCraft saves back to), handed to "PhotoCraft" for +/// the host to find; when the host can't find it, no copy is made. +#[test] +fn edit_in_photocraft_opens_a_psd_copy() { + let dir = std::env::temp_dir().join(format!("lc-ui-pcraft-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let opened: std::sync::Arc>> = Default::default(); + let installed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (o, i) = (opened.clone(), installed.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(()) + })), + check_editor: Some(Box::new(move |app: &str| { + if app == crate::state::PHOTOCRAFT && !i.load(std::sync::atomic::Ordering::SeqCst) { + Err("PhotoCraft isn't installed".into()) + } else { + Ok(()) + } + })), + ..Default::default() + }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + let n = h.app.session.catalog.len(); + let params = json!({"command": "photo.editInPhotoCraft", "params": {"dir": dir.to_string_lossy()}}); + let r = h.request("engine.execute", params.clone(), T); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!((h.app.session.catalog.len(), opened.lock().unwrap().len()), (n, 0), "no copy without PhotoCraft"); + installed.store(true, std::sync::atomic::Ordering::SeqCst); + let r = h.request("engine.execute", params, T); + assert_eq!(r["ok"], true, "{r}"); + let calls = opened.lock().unwrap().clone(); + assert_eq!(calls.len(), 1); + assert!(calls[0].0.ends_with("-Edit.psd") && std::path::Path::new(&calls[0].0).exists(), "{calls:?}"); + assert_eq!(calls[0].1, "PhotoCraft"); + let d = lightcraft_codecs::decode(&std::fs::read(&calls[0].0).unwrap(), Default::default()).unwrap(); + assert_eq!(d.bit_depth, 16, "16-bit by default"); + // a portable copy: its path is what the host is given; the file follows the settings + h.app.ui.settings.photocraft_path = "/opt/pc/photocraft".into(); + h.app.ui.settings.photocraft_file.format = crate::state::EditFormat::Tiff; + h.app.ui.settings.photocraft_file.bit_depth = 8; + let r = h.request("engine.execute", json!({"command": "photo.editInPhotoCraft", "params": {"dir": dir.to_string_lossy()}}), T); + assert_eq!(r["ok"], true, "{r}"); + let last = opened.lock().unwrap().last().cloned().unwrap(); + assert!(last.0.ends_with(".tif") && last.1 == "/opt/pc/photocraft", "{last:?}"); + assert!(crate::menus::ui_enabled(&h.app, "photo.editInPhotoCraft")); + let _ = std::fs::remove_dir_all(&dir); + h.settle(SETTLE); +} + #[test] fn second_window_shows_the_active_photo() { let mut h = detail("panel.edit"); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 099411089..3011a1a8d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -382,7 +382,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only | -| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, WebP, AVIF, DNG (raw photos: lossless re-encode with the edits in the embedded XMP), Original (+ XMP sidecar). No JXL encoder; non-raw → DNG not supported | +| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, DNG (raw photos: lossless re-encode with the edits in the embedded XMP), Original (+ XMP sidecar). No JXL encoder; non-raw → DNG not supported | | LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); W × H fits either orientation; ppi written to JFIF / pHYs / TIFF tags | | LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | | | LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU | @@ -398,7 +398,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.editInExternal`, `crates/engine/src/cmd/convert.rs` | a 16-bit TIFF `-Edit` copy with the edits (Adobe RGB / ProPhoto / P3 / sRGB) next to the original, added stacked on top of it and opened in the editor set in Settings ▸ General (or the system default) | +| 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 | ## T. Share (SHARE) @@ -499,7 +499,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-FILE-EXPORTPREV | Export with Previous | P0 | ✅ | `cmd:app.exportPrevious` | ⌥⇧⌘E | | MENU-FILE-EXPORTPRESETS | Export preset submenu | P0 | ✅ | `cmd:app.export` (`preset`), `crates/ui-egui/src/menubar.rs` (Export with Preset) | built-ins, then user presets, then Custom… (the dialog); exports to the last folder | | MENU-FILE-SHARE | Share / get link / invite | OOS | 🚫 | | | -| MENU-FILE-PHOTOSHOP | Edit in external editor | P2 | ✅ | `cmd:photo.editInExternal` | Photo ▸ Edit in External Editor | +| MENU-FILE-PHOTOSHOP | Edit in external editor | P2 | ✅ | `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal` | Photo ▸ Edit In ▸ Edit in PhotoCraft / Edit in Other Editor, and Edit In on a photo's right-click menu | | MENU-FILE-SHOWFINDER | Show in Finder | P0 | ✅ | `cmd:app.showInFinder` | ⌘R; Explorer on Windows, the folder on Linux; disabled for demo scenes and on the web | | MENU-FILE-OFFLINE | Store album locally | P2 | 🚫 | | not applicable: local-first | | MENU-FILE-CLOSE | Close Window | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | the system's Close Window item at the end of File (⌘W) | @@ -593,7 +593,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-UNDOREDO | Undo / redo — ⌘Z / ⇧⌘Z | P0 | ✅ | `cmd:edit.undo`, `cmd:edit.redo` | | | KEY-MINIMIZE | Minimize — ⌘M | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | native Window ▸ Minimize | | KEY-AUTO | Auto — ⇧A | P0 | ✅ | `cmd:develop.auto` | | -| KEY-PHOTOSHOP | External editor — ⇧⌘E | P2 | ✅ | `cmd:photo.editInExternal` | ⇧⌘E as observed; the export dialog is ⇧E | +| KEY-PHOTOSHOP | External editor — ⇧⌘E | P2 | ✅ | `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal` | ⇧⌘E as observed (Edit in PhotoCraft, LightCraft's Photoshop); ⌥⌘E the other editor; the export dialog is ⇧E | | KEY-ROTATE | Rotate — ⌘[ / ⌘] | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | | | KEY-ZOOM | Zoom in / out — ⌘= / ⌘− | P0 | ✅ | `cmd:view.zoomIn`, `cmd:view.zoomOut`, `crates/ui-egui/src/tests_zoom_keys.rs` | the keys zoom the photo only: egui's own interface zoom on ⌘= / ⌘− / ⌘0 (Ctrl on Windows and Linux, where no native menu bar took the keys first) is off (issue #566) | | KEY-SELECTALL | Select all — ⌘A | P0 | ✅ | `cmd:library.selectAll` | | diff --git a/docs/whats-new.md b/docs/whats-new.md index f3fb3a143..b1b154371 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,17 @@ # What's new in LightCraft +## 2026-10-11 + +### Edit in PhotoCraft +- Right-click a photo ▸ Edit In ▸ Edit in PhotoCraft (or Photo ▸ Edit In, ⇧⌘E) renders a copy with your edits, stacks it + with the original and opens it in PhotoCraft. LightCraft finds PhotoCraft where its installer put it (Windows, + macOS, Linux packages, AppImage, Flatpak), and says so if it isn't installed, before making a copy. +- The copy is a 16-bit PSD by default, which PhotoCraft saves straight back to with File ▸ Save; when you come back to + LightCraft the copy shows what you saved. +- Settings ▸ General ▸ External Editing: the format (PSD or TIFF), colour space and bit depth for PhotoCraft and for + the other editor (Edit in Other Editor, now ⌥⌘E), and where PhotoCraft is if you use a portable copy. +- Export can write PSD too (flat, 8- or 16-bit). + ## 2026-10-10 ### Activity stack