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 288cb095f..42435e39b 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; @@ -445,28 +446,8 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> pick_folder: Some(Box::new(|| { file_dialog().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 02ba7b319..36392dbcd 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 79763ca2e..bd573248c 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -38,7 +38,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_png_pq, encode_tiff, encode_webp_lossless, + encode_png, encode_png_pq, 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 ebfaf92ef..fdfbf5509 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`) @@ -1079,6 +1083,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, @@ -1091,6 +1097,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")], } } @@ -1259,6 +1266,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")), @@ -1369,6 +1377,7 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) (ExportFormat::Png, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => lightcraft_codecs::encode_png_pq(w, h, v, &meta), (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 bd20ddcc2..299728743 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -140,6 +140,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>; @@ -191,6 +194,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 e07e744e1..ea027ef1c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -156,7 +156,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"), @@ -1095,30 +1096,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) @@ -1668,6 +1647,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" => { @@ -1775,3 +1755,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 00830f4e6..661224af2 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -676,7 +676,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 226a42987..a03fbe944 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -907,6 +907,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 fefe8ef55..75c00706e 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -143,19 +143,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 07f2f4b23..f3e64e001 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -181,8 +181,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. @@ -208,6 +215,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, @@ -218,6 +228,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 a6444c0f2..dde3b6073 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -922,6 +922,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 ff41676e9..7c345055d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -383,7 +383,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; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) | -| 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 | @@ -399,7 +399,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) @@ -501,7 +501,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) | @@ -597,7 +597,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 6291ae308..95b298ba0 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -22,6 +22,16 @@ "File exists". - Agents use `app.newLibrary {path, name}`, `app.recentLibraries` and `app.clearRecentLibraries`. +### 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 ### Dialogs fit the window