From 3ce86032ffec22802e1f907ce2914cd1191afa8c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:12:43 -0400 Subject: [PATCH] LR-EXP-TYPE + LR-EXP-DIM: export size modes, ppi, Original + XMP and DNG export - Resize modes: long edge, short edge, width, height, W x H (either orientation), megapixels, percent; "don't enlarge" (default on); ppi written to JFIF density / PNG pHYs / TIFF tags. - Full-size export of a cropped photo now renders the crop at native resolution (it was upscaled to the uncropped long edge). - Formats: Original (file copied byte for byte + XMP sidecar with the edits, named after the output) and DNG (raw photos re-encoded losslessly, edits in the embedded XMP). - Export dialog: Resize-to dropdown, values, Don't enlarge, Resolution; DNG/Original hide pixel options. - lightcraft-cli render goes through the shared exporter (colour space, metadata, ppi) and takes any app.export option via --opt KEY=VALUE; MCP export schema lists the new params. - xtask resolves the workspace root at run time (a moved checkout broke ci). Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft-cli/src/main.rs | 47 ++++- apps/lightcraft-cli/tests/cli.rs | 29 +++ crates/codecs/src/encode.rs | 17 ++ crates/codecs/src/jpeg_par.rs | 5 +- crates/codecs/tests/fuzz.rs | 2 +- crates/codecs/tests/roundtrip.rs | 33 +++- crates/engine/src/export.rs | 270 +++++++++++++++++++++++++-- crates/engine/src/lib.rs | 2 + crates/engine/src/media.rs | 5 +- crates/engine/src/tests_export.rs | 99 ++++++++++ crates/engine/src/tests_xmp.rs | 2 +- crates/mcp/src/headless.rs | 14 +- crates/mcp/src/tools.rs | 18 +- crates/pipeline/src/geometry.rs | 6 + crates/pipeline/src/lib.rs | 6 + crates/ui-egui/src/control.rs | 8 +- crates/ui-egui/src/menus.rs | 11 +- crates/ui-egui/src/panels/dialogs.rs | 125 ++++++++++--- crates/ui-egui/src/state.rs | 5 +- docs/mcp.md | 2 +- docs/parity.md | 16 +- xtask/src/main.rs | 8 +- 22 files changed, 646 insertions(+), 84 deletions(-) create mode 100644 crates/engine/src/tests_export.rs diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 28c88911..469906a7 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -17,7 +17,7 @@ use std::path::Path; use std::process::ExitCode; use lightcraft_engine::Session; -use lightcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server, expand_paths, write_image}; +use lightcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server, expand_paths}; use serde_json::{Value, json}; const USAGE: &str = "\ @@ -32,12 +32,17 @@ USAGE: --library DIR headless: open (or create) a persistent LightCraft library; edits are saved --compact list only the helper tools (every command stays reachable via run_command) lightcraft-cli render -o [OPTIONS] - Develop one file and write the result (.png, .jpg, .tif or .webp by extension). Options: + Develop one file and export it (.jpg, .png, .tif, .webp, .avif or .dng by extension) with the + same encoder as the app's Export dialog. Options: --set CONTROL=VALUE set a develop slider, repeatable (e.g. --set light.exposure=0.5) --settings FILE merge a partial develop-settings JSON file --preset ID apply a preset (see `commands`/presets.list) - --size N long edge in pixels (default: full size) - --quality Q JPEG quality 1..100 (default 92) + --size N long edge in pixels (default: full size, cropped) + --quality Q JPEG/AVIF quality 1..100 (default 92) + --opt KEY=VALUE any export option of `app.export`, repeatable (VALUE is JSON or a + string), e.g. --opt colorSpace=displayP3 --opt bitDepth=16 + --opt percent=50 --opt shortEdge=1080 --opt ppi=300 + --opt format=original (copy + XMP sidecar) --opt metadata=none lightcraft-cli snapshot [OPTIONS] [FILES/FOLDERS…] Run the full app UI headlessly (no window, no GPU: CPU-rasterized egui) and write PNGs. Options: @@ -298,6 +303,7 @@ fn render(args: &[String]) -> Result<(), String> { let mut preset = None; let mut size: Option = None; let mut quality = 92u8; + let mut opts = serde_json::Map::new(); let mut i = 0; while i < args.len() { match args[i].as_str() { @@ -316,6 +322,11 @@ fn render(args: &[String]) -> Result<(), String> { "--preset" => preset = Some(take_value(args, &mut i, "--preset")?.to_string()), "--size" => size = Some(take_value(args, &mut i, "--size")?.parse().map_err(|_| "--size expects a number")?), "--quality" => quality = take_value(args, &mut i, "--quality")?.parse().map_err(|_| "--quality expects 1..100")?, + "--opt" => { + let kv = take_value(args, &mut i, "--opt")?; + let (k, v) = kv.split_once('=').ok_or_else(|| format!("--opt expects key=value, got `{kv}`"))?; + opts.insert(k.trim().to_string(), serde_json::from_str(v).unwrap_or_else(|_| json!(v))); + } a if a.starts_with('-') => return Err(format!("unknown option `{a}`")), f if input.is_none() => input = Some(f.to_string()), f => return Err(format!("unexpected argument `{f}`")), @@ -339,12 +350,28 @@ fn render(args: &[String]) -> Result<(), String> { if !values.is_empty() { run(&mut s, "develop.set", json!({"values": values}))?; } - let photo = s.catalog.photo(lightcraft_engine::catalog::PhotoId(id)).ok_or("photo vanished")?; - let full = photo.width.max(photo.height).max(1) as u64; - let edge = size.unwrap_or(full) as usize; - let img = s.render_now(lightcraft_engine::catalog::PhotoId(id), edge, edge)?.image; - write_image(Path::new(&output), &img, quality)?; - eprintln!("lightcraft-cli: wrote {output} ({}×{})", img.width, img.height); + use lightcraft_engine::export::{ExportFormat, ExportOptions, export_photo}; + let mut p = json!({"quality": quality}); + if let Some(n) = size { + p["longEdge"] = json!(n); + } + for (k, v) in opts { + p[k] = v; + } + let mut o = ExportOptions::from_json(&p); + if p.get("format").is_none() { + let ext = Path::new(&output).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); + o.format = ExportFormat::parse(&ext) + .ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?; + } + let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?; + std::fs::write(&output, &e.bytes).map_err(|err| format!("{output}: {err}"))?; + for (ext, bytes) in &e.sidecars { + let sc = Path::new(&output).with_extension(ext); + std::fs::write(&sc, bytes).map_err(|err| format!("{}: {err}", sc.display()))?; + eprintln!("lightcraft-cli: wrote {}", sc.display()); + } + eprintln!("lightcraft-cli: wrote {output} ({}×{})", e.width, e.height); Ok(()) } diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 9b3991c2..0ee59fa8 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -86,6 +86,35 @@ fn render_subcommand() { assert!(String::from_utf8_lossy(&o.stderr).contains("unknown control")); } +#[test] +fn render_export_options() { + let input = tmp("o-in.png"); + gradient_png(&input); + let out = tmp("o-out.tif"); + let st = Command::new(BIN) + .args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap()]) + .args(["--opt", "shortEdge=40", "--opt", "colorSpace=displayP3", "--opt", "bitDepth=16"]) + .status() + .unwrap(); + assert!(st.success()); + let d = lightcraft_codecs::decode(&std::fs::read(&out).unwrap(), Default::default()).unwrap(); + assert_eq!((d.width, d.height), (60, 40)); + assert_eq!(d.space.named, Some(lightcraft_codecs::NamedSpace::DisplayP3)); + // original + sidecar named after the output + let out = tmp("o-copy.png"); + let st = Command::new(BIN) + .args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "light.exposure=1", "--opt", "format=original"]) + .status() + .unwrap(); + assert!(st.success()); + assert_eq!(std::fs::read(&out).unwrap(), std::fs::read(&input).unwrap()); + let xmp = std::fs::read_to_string(out.with_extension("xmp")).unwrap(); + assert!(xmp.contains("exposure"), "{xmp}"); + // unknown extension fails with a hint + let o = Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", tmp("x.bmp").to_str().unwrap()]).output().unwrap(); + assert!(!o.status.success() && String::from_utf8_lossy(&o.stderr).contains("unknown extension")); +} + #[test] fn commands_subcommand_lists_registry() { let o = Command::new(BIN).args(["commands", "--json"]).output().unwrap(); diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs index 6dcb44ad..0e56dfe9 100644 --- a/crates/codecs/src/encode.rs +++ b/crates/codecs/src/encode.rs @@ -63,6 +63,8 @@ pub struct EncodeMeta<'a> { /// TIFF-structured EXIF (starting at `II`/`MM`, no `Exif\0\0` prefix). pub exif: Option<&'a [u8]>, pub xmp: Option<&'a str>, + /// Print resolution in pixels per inch (JPEG JFIF density, PNG `pHYs`, TIFF X/YResolution). + pub ppi: Option, } /// JPEG chroma subsampling. @@ -96,6 +98,11 @@ pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsamplin if subsampling != ChromaSubsampling::S422 { // Parallel encoder (restart-interval bands on all cores). let mut segs: Vec<(u8, Vec)> = Vec::new(); + let ppi = meta.ppi.filter(|p| *p > 0).map_or([0u8, 0, 1, 0, 1], |p| { + let [hi, lo] = p.to_be_bytes(); + [1, hi, lo, hi, lo] + }); + segs.push((0xE0, [&b"JFIF\0\x01\x02"[..], &ppi, &[0, 0]].concat())); if let Some(exif) = meta.exif { if exif.len() > 65_527 { return Err(Error::Encode("EXIF larger than one APP1 segment (64 KiB)".into())); @@ -133,6 +140,9 @@ pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsamplin // Standard tables: jpeg-encoder's optimized-table mode writes non-interleaved scans, which some // decoders (incl. zune-jpeg 0.5) mishandle. Interleaved baseline is universally supported. let e = |e: jpeg_encoder::EncodingError| Error::Encode(e.to_string()); + if let Some(ppi) = meta.ppi.filter(|p| *p > 0) { + enc.set_density(jpeg_encoder::PixelDensity::dpi(ppi)); + } if let Some(exif) = meta.exif { if exif.len() > 65_527 { return Err(Error::Encode("EXIF larger than one APP1 segment (64 KiB)".into())); @@ -173,6 +183,10 @@ pub fn encode_png(img: &EncodeImage, meta: &EncodeMeta) -> Result> { info.bit_depth = depth; info.icc_profile = meta.icc.map(|b| b.to_vec().into()); info.exif_metadata = meta.exif.map(|b| b.to_vec().into()); + info.pixel_dims = meta.ppi.filter(|p| *p > 0).map(|p| { + let ppm = (p as f64 / 0.0254).round() as u32; + png::PixelDimensions { xppu: ppm, yppu: ppm, unit: png::Unit::Meter } + }); let mut out = Vec::new(); let e = |e: png::EncodingError| Error::Encode(e.to_string()); { @@ -224,6 +238,9 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod macro_rules! write { ($ct:ty, $data:expr, $alpha:expr) => {{ let mut im = enc.new_image::<$ct>(w, h).map_err(e)?; + if let Some(ppi) = meta.ppi.filter(|p| *p > 0) { + im.resolution(tiff::tags::ResolutionUnit::Inch, tiff::encoder::Rational { n: ppi as u32, d: 1 }); + } if let Some(icc) = meta.icc { im.encoder().write_tag(tiff::tags::Tag::IccProfile, icc).map_err(e)?; } diff --git a/crates/codecs/src/jpeg_par.rs b/crates/codecs/src/jpeg_par.rs index 4483b060..1dfe5ecd 100644 --- a/crates/codecs/src/jpeg_par.rs +++ b/crates/codecs/src/jpeg_par.rs @@ -371,7 +371,10 @@ pub fn encode( // headers let mut out = Vec::with_capacity(coded.iter().map(Vec::len).sum::() + 4096); out.extend_from_slice(&[0xFF, 0xD8]); - segment(&mut out, 0xE0, &[b'J', b'F', b'I', b'F', 0, 1, 2, 0, 0, 1, 0, 1, 0, 0]); + // JFIF APP0 first (callers may pass their own, e.g. with a print density) + if !app_segments.iter().any(|(m, _)| *m == 0xE0) { + segment(&mut out, 0xE0, &[b'J', b'F', b'I', b'F', 0, 1, 2, 0, 0, 1, 0, 1, 0, 0]); + } for (marker, payload) in app_segments { segment(&mut out, *marker, payload); } diff --git a/crates/codecs/tests/fuzz.rs b/crates/codecs/tests/fuzz.rs index fff23e18..51f8096a 100644 --- a/crates/codecs/tests/fuzz.rs +++ b/crates/codecs/tests/fuzz.rs @@ -42,7 +42,7 @@ fn seeds() -> &'static Vec> { let img = Rgba8::from_fn(24, 16, |x, y| [(x * 10) as u8, (y * 15) as u8, ((x ^ y) * 8) as u8, (x * y) as u8]); let icc = write_named(NamedSpace::DisplayP3); let exif = minimal_exif(6); - let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some("") }; + let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(""), ..Default::default() }; let e = EncodeImage::rgba8(&img); let u16s: Vec = (0..24 * 16 * 3).map(|i| (i * 331) as u16).collect(); let f32s: Vec = (0..24 * 16 * 3).map(|i| i as f32 * 0.01).collect(); diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index e4e338ce..259ee835 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -36,7 +36,7 @@ fn jpeg_roundtrip_with_metadata() { let icc = write_named(NamedSpace::Srgb); let exif = minimal_exif(6); let xmp = r#""#; - let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(xmp) }; + let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(xmp), ..Default::default() }; for sub in [ChromaSubsampling::S444, ChromaSubsampling::S422, ChromaSubsampling::S420] { let bytes = encode_jpeg(&EncodeImage::rgba8(&img), 95, sub, &meta).unwrap(); assert_eq!(sniff(&bytes), Some(Format::Jpeg)); @@ -130,7 +130,7 @@ fn png_8_and_16_bit_exact() { fn png_metadata_and_gray_alpha() { let data: Vec = (0..20 * 10).flat_map(|i| [(i % 256) as u8, 128]).collect(); let exif = minimal_exif(3); - let meta = EncodeMeta { icc: None, exif: Some(&exif), xmp: Some("") }; + let meta = EncodeMeta { exif: Some(&exif), xmp: Some("<xmp/>"), ..Default::default() }; let bytes = encode_png(&EncodeImage::new(20, 10, 2, Samples::U8(&data)), &meta).unwrap(); let d = decode(&bytes, DecodeOptions::default()).unwrap(); assert!(d.grayscale && d.has_alpha); @@ -250,7 +250,7 @@ fn webp_lossless_exact() { let img = gradient(31, 19); let icc = write_named(NamedSpace::Srgb); let exif = minimal_exif(2); - let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some("<w/>") }; + let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some("<w/>"), ..Default::default() }; let bytes = encode_webp_lossless(&EncodeImage::rgba8(&img), &meta).unwrap(); assert_eq!(sniff(&bytes), Some(Format::WebP)); let d = decode(&bytes, DecodeOptions::default()).unwrap(); @@ -534,3 +534,30 @@ fn thumbnail_from_mpf_preview() { assert_eq!((t.image.width, t.image.height), (256, 192)); assert!(t.image.get(5, 5)[1] > 150); } + +#[test] +fn print_resolution_is_written() { + let data = vec![128u8; 4 * 4 * 3]; + let img = EncodeImage::new(4, 4, 3, Samples::U8(&data)); + let meta = EncodeMeta { ppi: Some(300), ..Default::default() }; + for sub in [ChromaSubsampling::S444, ChromaSubsampling::S422] { + let j = encode_jpeg(&img, 90, sub, &meta).unwrap(); + // SOI, APP0 "JFIF\0" v1.2, units = 1 (dpi), Xdensity, Ydensity + assert_eq!(&j[2..4], &[0xFF, 0xE0], "{sub:?}"); + assert_eq!(&j[6..11], b"JFIF\0"); + assert_eq!(&j[13..18], &[1, 1, 44, 1, 44], "{sub:?}"); + assert_eq!(j.windows(4).filter(|w| *w == [0xFF, 0xE0, 0, 16]).count(), 1, "one APP0"); + } + let png = encode_png(&img, &meta).unwrap(); + let i = png.windows(4).position(|w| w == b"pHYs").expect("pHYs chunk"); + assert_eq!(u32::from_be_bytes(png[i + 4..i + 8].try_into().unwrap()), 11811, "300 ppi in pixels per metre"); + assert_eq!(png[i + 12], 1, "unit = metre"); + let tif = encode_tiff(&img, TiffCompression::Deflate, &meta).unwrap(); + let mut d = tiff::decoder::Decoder::new(std::io::Cursor::new(tif)).unwrap(); + let x = d.get_tag(tiff::tags::Tag::XResolution).unwrap(); + assert_eq!(x.into_u32_vec().ok(), Some(vec![300, 1])); + assert_eq!(d.get_tag_u32(tiff::tags::Tag::ResolutionUnit).unwrap(), 2, "inch"); + // without a ppi: JFIF density stays an aspect ratio + let j = encode_jpeg(&img, 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + assert_eq!(&j[13..18], &[0, 0, 1, 0, 1]); +} diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index cbb3269b..7b9b1bab 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -1,4 +1,5 @@ -//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF in sRGB, Display P3, Adobe RGB +//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF (or copy the original / write a +//! DNG, each with the edits in XMP) in sRGB, Display P3, Adobe RGB //! (1998) compatible, ProPhoto RGB or Rec. 2020 with an embedded ICC profile we generate from the //! published primaries and curves, optional output sharpening and a JPEG file-size limit. Pure (bytes in, bytes out) so the desktop //! app, CLI, MCP and the web build share it; writing the file is the caller's job. @@ -18,6 +19,10 @@ pub enum ExportFormat { Tiff, Webp, Avif, + /// The original file, unchanged, with an XMP sidecar holding the edits. + Original, + /// A DNG of the raw data (raw photos only), the edits embedded as XMP. + Dng, } impl ExportFormat { @@ -28,6 +33,8 @@ impl ExportFormat { "tiff" | "tif" => Self::Tiff, "webp" => Self::Webp, "avif" => Self::Avif, + "original" => Self::Original, + "dng" => Self::Dng, _ => return None, }) } @@ -38,8 +45,89 @@ impl ExportFormat { Self::Tiff => "tif", Self::Webp => "webp", Self::Avif => "avif", + // the original keeps its own extension (see `ExportOptions::file_name_for`) + Self::Original => "", + Self::Dng => "dng", } } + + /// Formats that render pixels (everything but `Original` and `Dng`). + pub fn is_rendered(self) -> bool { + !matches!(self, Self::Original | Self::Dng) + } +} + +/// How the output size is chosen (see [`Resize`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ResizeMode { + /// `value` = long edge in pixels. + #[default] + LongEdge, + /// `value` = short edge in pixels. + ShortEdge, + /// `value` = width in pixels. + Width, + /// `value` = height in pixels. + Height, + /// Fit inside `value × height` pixels, whichever way round the photo is (long edge ≤ the larger). + Dimensions, + /// `value` = megapixels. + Megapixels, + /// `value` = percent of the full size. + Percent, +} + +/// Output size: a mode, its value(s) and whether smaller photos are left at their own size. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Resize { + pub mode: ResizeMode, + pub value: f32, + /// Second dimension for [`ResizeMode::Dimensions`]. + pub height: u32, + /// Never upscale past the photo's own (cropped) size. + pub dont_enlarge: bool, +} + +impl Default for Resize { + fn default() -> Self { + Self { mode: ResizeMode::LongEdge, value: 2048.0, height: 2048, dont_enlarge: true } + } +} + +impl Resize { + pub fn long_edge(px: u32) -> Self { + Self { value: px as f32, ..Default::default() } + } + + /// Scale factor from a `w × h` (cropped, full-resolution) photo to the output. + pub fn scale(&self, w: f64, h: f64) -> f64 { + let (w, h) = (w.max(1.0), h.max(1.0)); + let (long, short) = (w.max(h), w.min(h)); + let v = (self.value as f64).max(0.0); + let k = match self.mode { + ResizeMode::LongEdge => v / long, + ResizeMode::ShortEdge => v / short, + ResizeMode::Width => v / w, + ResizeMode::Height => v / h, + ResizeMode::Dimensions => { + let (a, b) = (v, self.height as f64); + (a.max(b) / long).min(a.min(b) / short) + } + ResizeMode::Megapixels => (v * 1e6 / (w * h)).sqrt(), + ResizeMode::Percent => v / 100.0, + }; + let k = if self.dont_enlarge { k.min(1.0) } else { k }; + // at least one pixel, at most the encoders' 65535 limit + k.clamp(1.0 / short, 65_535.0 / long) + } + + /// Output size for a `w × h` (cropped, full-resolution) photo. + pub fn apply(&self, w: f64, h: f64) -> (usize, usize) { + let k = self.scale(w, h); + (((w * k).round() as usize).max(1), ((h * k).round() as usize).max(1)) + } } /// Output sharpening target (applied after resizing, in display-encoded values). @@ -217,8 +305,10 @@ pub struct ExportOptions { pub format: ExportFormat, /// 1–100 (JPEG, AVIF). pub quality: u8, - /// Resize so the long edge is at most this many pixels (`None` = full size). - pub long_edge: Option<u32>, + /// Output size (`None` = full size). + pub resize: Option<Resize>, + /// Print resolution written into the file (pixels per inch). + pub ppi: u16, /// JPEG only: largest quality whose file fits in this many KB. pub limit_kb: Option<u32>, pub sharpen: SharpenFor, @@ -242,7 +332,8 @@ impl Default for ExportOptions { Self { format: ExportFormat::Jpeg, quality: 90, - long_edge: None, + resize: None, + ppi: 240, limit_kb: None, sharpen: SharpenFor::None, sharpen_amount: SharpenAmount::Standard, @@ -257,8 +348,11 @@ impl Default for ExportOptions { } impl ExportOptions { - /// Read options from command params (`format`, `quality`, `longEdge`, `limitKb`, `sharpen`, - /// `sharpenAmount`, `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`). + /// Read options from command params (`format`, `quality`, `limitKb`, `sharpen`, `sharpenAmount`, + /// `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`, `ppi`) and the + /// size: one of `longEdge`, `shortEdge`, `width`, `height` (both = fit inside W × H), + /// `megapixels`, `percent` (absent or 0 = full size), or a [`Resize`] object as `resize`; plus + /// `dontEnlarge` (default true). pub fn from_json(p: &serde_json::Value) -> Self { use serde_json::Value; let d = Self::default(); @@ -270,7 +364,8 @@ impl ExportOptions { Self { format: s("format").and_then(ExportFormat::parse).unwrap_or(d.format), quality: u("quality").map_or(d.quality, |q| q.clamp(1, 100) as u8), - long_edge: u("longEdge").filter(|v| *v > 0).map(|v| v.min(65_535) as u32), + resize: Self::resize_from_json(p), + ppi: u("ppi").filter(|v| *v > 0).map_or(d.ppi, |v| v.min(u16::MAX as u64) as u16), limit_kb: u("limitKb").filter(|v| *v > 0).map(|v| v.min(u32::MAX as u64) as u32), sharpen: enm(p, "sharpen").unwrap_or(d.sharpen), sharpen_amount: enm(p, "sharpenAmount").unwrap_or(d.sharpen_amount), @@ -288,10 +383,52 @@ impl ExportOptions { } } + fn resize_from_json(p: &serde_json::Value) -> Option<Resize> { + use serde_json::Value; + let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| *v > 0.0); + let dont_enlarge = p.get("dontEnlarge").and_then(Value::as_bool).unwrap_or(true); + if let Some(r) = p.get("resize").filter(|v| v.is_object()) { + let r: Resize = serde_json::from_value(r.clone()).ok()?; + return (r.value > 0.0).then_some(Resize { dont_enlarge: p.get("dontEnlarge").and_then(Value::as_bool).unwrap_or(r.dont_enlarge), ..r }); + } + let r = |mode, value: f64| Resize { mode, value: value.min(65_535.0) as f32, height: 0, dont_enlarge }; + Some(match (f("width"), f("height")) { + (Some(w), Some(h)) => Resize { height: h.min(65_535.0) as u32, ..r(ResizeMode::Dimensions, w) }, + (Some(w), None) => r(ResizeMode::Width, w), + (None, Some(h)) => r(ResizeMode::Height, h), + (None, None) => { + if let Some(v) = f("longEdge") { + r(ResizeMode::LongEdge, v) + } else if let Some(v) = f("shortEdge") { + r(ResizeMode::ShortEdge, v) + } else if let Some(v) = f("megapixels") { + r(ResizeMode::Megapixels, v) + } else if let Some(v) = f("percent") { + r(ResizeMode::Percent, v) + } else { + return None; + } + } + }) + } + + /// Whether `p` names an output size (any of the size params, even 0 = full size). + pub fn has_size_param(p: &serde_json::Value) -> bool { + ["resize", "longEdge", "shortEdge", "width", "height", "megapixels", "percent"].iter().any(|k| p.get(k).is_some()) + } + + /// The size params of [`ExportOptions::from_json`] for `resize` (`{}` = full size). + pub fn resize_json(resize: Option<&Resize>) -> serde_json::Value { + match resize { + None => serde_json::json!({}), + Some(r) => serde_json::json!({"resize": r, "dontEnlarge": r.dont_enlarge}), + } + } + /// The sample format the file is written with (unsupported requests fall back to the closest). pub fn effective_depth(&self) -> OutputDepth { match (self.format, self.bit_depth) { - (ExportFormat::Jpeg | ExportFormat::Webp, _) => OutputDepth::U8, + (ExportFormat::Jpeg | ExportFormat::Webp | ExportFormat::Original | ExportFormat::Dng, _) => OutputDepth::U8, (ExportFormat::Png, Some(16 | 32)) => OutputDepth::U16, (ExportFormat::Png, _) => OutputDepth::U8, (ExportFormat::Tiff, Some(8)) => OutputDepth::U8, @@ -305,7 +442,7 @@ impl ExportOptions { /// The bit depths `format` offers (value, label); the first is its default. pub fn bit_depths(format: ExportFormat) -> &'static [(u8, &'static str)] { match format { - ExportFormat::Jpeg | ExportFormat::Webp => &[(8, "8-bit")], + 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::Avif => &[(8, "8-bit"), (10, "10-bit")], @@ -324,6 +461,19 @@ impl ExportOptions { let name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect(); format!("{name}.{}", self.format.extension()) } + + /// Output file name for photo file `file_name` (the original's extension is kept for + /// [`ExportFormat::Original`]). + pub fn file_name_for(&self, file_name: &str, seq: usize) -> String { + let (stem, ext) = file_name.rsplit_once('.').unwrap_or((file_name, "")); + let name = self.file_name(stem, seq); + if self.format == ExportFormat::Original { + let base = name.trim_end_matches('.'); + if ext.is_empty() { base.to_string() } else { format!("{base}.{ext}") } + } else { + name + } + } } /// Unsharp mask on an 8-bit image (separable box-blur approximation of a small Gaussian). @@ -422,7 +572,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada let profile = icc::write_named(named_space(space)); let exif = meta.map(lightcraft_meta::write_exif); let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); - let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref() }; + let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; let e = EncodeImage::rgba8(&img); let r = match o.format { ExportFormat::Jpeg => { @@ -456,6 +606,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada ExportFormat::Tiff => encode::encode_tiff(&e, TiffCompression::Deflate, &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")), }; r.map_err(|e| e.to_string()) } @@ -508,7 +659,7 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) }; let exif = meta.map(lightcraft_meta::write_exif); let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); - let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref() }; + let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; let (w, h) = (img.width as u32, img.height as u32); let e = match &img.samples { DeepSamples::U16(v) => EncodeImage::new(w, h, 3, Samples::U16(v)), @@ -570,23 +721,67 @@ pub struct Exported { pub bytes: Vec<u8>, pub width: usize, pub height: usize, + /// Files written next to it, named like it with another extension: `(extension, bytes)` (the + /// XMP sidecar of an `Original` export). + pub sidecars: Vec<(&'static str, Vec<u8>)>, } -/// Render photo `id` at the requested size (full size when `long_edge` is `None`) and encode it. +/// Output size of photo `p` under `o` (its cropped full size when `o.resize` is `None`). +pub fn output_size(p: &lightcraft_catalog::Photo, o: &ExportOptions) -> (usize, usize) { + let (w, h) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop); + match &o.resize { + Some(r) => r.apply(w, h), + None => ((w.round() as usize).max(1), (h.round() as usize).max(1)), + } +} + +/// Render photo `id` at the requested size and encode it (or copy / convert its original for +/// [`ExportFormat::Original`] / [`ExportFormat::Dng`]). pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoId, o: &ExportOptions, seq: usize) -> Result<Exported, String> { let p = session.catalog.photo(id).ok_or("no such photo")?; - let stem = p.file_name.rsplit_once('.').map_or(p.file_name.as_str(), |(a, _)| a).to_string(); - let full = p.width.max(p.height).max(1) as usize; - let size = o.long_edge.map_or(full, |l| l as usize); + let file_name = o.file_name_for(&p.file_name, seq); + if !o.format.is_rendered() { + return export_file(session, id, o, file_name); + } + let (w, h) = output_size(p, o); let meta = export_metadata(p, o); - let r = session.render_export(id, size, o.effective_space(), o.effective_depth())?; + let r = session.render_export(id, w, h, o.effective_space(), o.effective_depth())?; let bytes = encode_rendered(&r, o, meta.as_ref())?; - Ok(Exported { file_name: o.file_name(&stem, seq), bytes, width: r.image.width, height: r.image.height }) + Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new() }) +} + +/// [`ExportFormat::Original`] (the file's bytes + an XMP sidecar) or [`ExportFormat::Dng`] (the raw +/// data re-encoded as a lossless DNG with the edits in its XMP). +fn export_file(session: &mut crate::Session, id: lightcraft_catalog::PhotoId, o: &ExportOptions, file_name: String) -> Result<Exported, String> { + let p = session.catalog.photo(id).ok_or("no such photo")?; + let lightcraft_catalog::Source::File { path } = &p.source else { + return Err(format!("{} is a generated demo photo: it has no original file to export", p.file_name)); + }; + let read = session.media.file_bytes.clone(); + let bytes = match &read { + Some(r) => r(path)?, + None => std::fs::read(path).map_err(|e| format!("{path}: {e}"))?, + }; + let packet = crate::sidecar::sidecar_packet(p); + let (width, height) = (p.width as usize, p.height as usize); + match o.format { + ExportFormat::Dng => { + if lightcraft_raw::probe(&bytes).is_none() { + return Err(format!("{}: DNG export needs a raw photo", p.file_name)); + } + let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{}: {e}", p.file_name))?; + let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), ..Default::default() }) + .map_err(|e| e.to_string())?; + Ok(Exported { file_name, bytes: dng, width: raw.width, height: raw.height, sidecars: Vec::new() }) + } + _ => Ok(Exported { file_name, bytes, width, height, sidecars: vec![("xmp", packet.into_bytes())] }), + } } #[cfg(test)] mod tests { use super::*; + use serde_json::json; fn test_image() -> Rgba8 { let mut img = Rgba8::new(96, 64); @@ -678,8 +873,47 @@ mod tests { let o = ExportOptions::from_json(&serde_json::json!({"format": "jpg", "quality": 150, "longEdge": 2048, "naming": "{name}-{seq}"})); assert_eq!(o.format, ExportFormat::Jpeg); assert_eq!(o.quality, 100); - assert_eq!(o.long_edge, Some(2048)); + assert_eq!(o.resize, Some(Resize { dont_enlarge: true, height: 0, ..Resize::long_edge(2048) })); assert_eq!(o.file_name("IMG/1", 7), "IMG_1-007.jpg"); + assert_eq!(o.ppi, 240); + let orig = ExportOptions { format: ExportFormat::Original, naming: "{name}-{seq}".into(), ..Default::default() }; + assert_eq!(orig.file_name_for("DSC_1.NEF", 2), "DSC_1-002.NEF"); + assert_eq!(orig.file_name_for("noext", 1), "noext-001"); + assert_eq!(ExportOptions { format: ExportFormat::Dng, ..Default::default() }.file_name_for("a.cr2", 1), "a.dng"); + } + + #[test] + fn resize_modes() { + let size = |p: serde_json::Value, w: f64, h: f64| ExportOptions::from_json(&p).resize.map(|r| r.apply(w, h)); + // a 6000 × 4000 landscape and a 4000 × 6000 portrait + assert_eq!(size(json!({"longEdge": 1500}), 6000.0, 4000.0), Some((1500, 1000))); + assert_eq!(size(json!({"longEdge": 1500}), 4000.0, 6000.0), Some((1000, 1500))); + assert_eq!(size(json!({"shortEdge": 1080}), 6000.0, 4000.0), Some((1620, 1080))); + assert_eq!(size(json!({"width": 3000}), 4000.0, 6000.0), Some((3000, 4500))); + assert_eq!(size(json!({"height": 1000}), 6000.0, 4000.0), Some((1500, 1000))); + // W × H fits either orientation: the long edge gets the larger number + assert_eq!(size(json!({"width": 1000, "height": 800}), 6000.0, 4000.0), Some((1000, 667))); + assert_eq!(size(json!({"width": 1000, "height": 800}), 4000.0, 6000.0), Some((667, 1000))); + assert_eq!(size(json!({"width": 1000, "height": 500}), 6000.0, 4000.0), Some((750, 500))); + let (w, h) = size(json!({"megapixels": 6}), 6000.0, 4000.0).unwrap(); + assert!(((w * h) as f64 - 6e6).abs() < 6e6 * 0.002, "{w}×{h}"); + assert_eq!(size(json!({"percent": 25}), 6000.0, 4000.0), Some((1500, 1000))); + // don't enlarge (default) vs enlarge + assert_eq!(size(json!({"longEdge": 8000}), 6000.0, 4000.0), Some((6000, 4000))); + assert_eq!(size(json!({"longEdge": 9000, "dontEnlarge": false}), 6000.0, 4000.0), Some((9000, 6000))); + // full size / absent + assert_eq!(size(json!({"longEdge": 0}), 6000.0, 4000.0), None); + assert_eq!(size(json!({}), 6000.0, 4000.0), None); + assert!(ExportOptions::has_size_param(&json!({"longEdge": 0})) && !ExportOptions::has_size_param(&json!({"quality": 3}))); + // the `resize` object form round-trips (what the dialog and prefs store) + let r = Resize { mode: ResizeMode::ShortEdge, value: 720.0, height: 0, dont_enlarge: false }; + let back = ExportOptions::from_json(&ExportOptions::resize_json(Some(&r))).resize; + assert_eq!(back, Some(r)); + assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(None)).resize, None); + assert_eq!(ExportOptions::from_json(&json!({"ppi": 300})).ppi, 300); + // never larger than the encoders' limit, never empty + assert_eq!(size(json!({"percent": 10000, "dontEnlarge": false}), 6000.0, 4000.0), Some((65535, 43690))); + assert_eq!(size(json!({"width": 1}), 6000.0, 40.0).map(|s| s.1), Some(1)); } /// A flat field of a saturated green inside Display P3 but outside sRGB, rendered into `space`. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 290fee45..9c894ad2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -384,6 +384,8 @@ mod tests; #[cfg(test)] mod tests_color; #[cfg(test)] +mod tests_export; +#[cfg(test)] mod tests_import; #[cfg(test)] mod tests_libops; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 3447a357..995fbfcf 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -648,11 +648,12 @@ impl crate::Session { pub fn render_export( &mut self, id: PhotoId, - max: usize, + max_w: usize, + max_h: usize, space: lightcraft_pipeline::OutputSpace, depth: lightcraft_pipeline::OutputDepth, ) -> Result<Rendered, String> { - let mut job = self.render_job(id, max, max, false, true).ok_or("no such photo")?; + let mut job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?; job.request.space = space; job.request.depth = depth; job.key ^= (space as u64 + 1).wrapping_mul(0xa076_1d64_78bd_642f) ^ (depth as u64 + 1).wrapping_mul(0xe703_7ed1_a0b4_28db); diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs new file mode 100644 index 00000000..0af2e6df --- /dev/null +++ b/crates/engine/src/tests_export.rs @@ -0,0 +1,99 @@ +//! Export end to end: output sizes, original + XMP, DNG. + +use serde_json::json; + +use crate::Session; +use crate::export::{ExportFormat, ExportOptions, export_photo}; +use crate::tests_xmp::{synthetic_dng_with, temp_dir}; + +#[test] +fn full_size_export_of_a_cropped_photo_is_not_upscaled() { + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + let (pw, ph) = { + let p = s.catalog.photo(id).unwrap(); + (p.width as usize, p.height as usize) + }; + s.execute("crop.set", &json!({"rect": [0.25, 0.25, 0.75, 0.75]})).unwrap(); + let e = export_photo(&mut s, id, &ExportOptions::default(), 1).unwrap(); + assert!(e.width.abs_diff(pw / 2) <= 1 && e.height.abs_diff(ph / 2) <= 1, "{}×{} from {pw}×{ph}", e.width, e.height); + // and the size modes apply to the cropped size + let o = ExportOptions::from_json(&json!({"percent": 50})); + let e = export_photo(&mut s, id, &o, 1).unwrap(); + assert!(e.width.abs_diff(pw / 4) <= 1, "{} vs {}", e.width, pw / 4); + let o = ExportOptions::from_json(&json!({"width": 200, "height": 200, "format": "png"})); + let e = export_photo(&mut s, id, &o, 1).unwrap(); + assert_eq!(e.width.max(e.height), 200); + // the print resolution lands in the file + let i = e.bytes.windows(4).position(|w| w == b"pHYs").expect("pHYs"); + assert_eq!(u32::from_be_bytes(e.bytes[i + 4..i + 8].try_into().unwrap()), 9449, "240 ppi"); +} + +#[test] +fn original_export_copies_the_file_and_writes_its_edits_beside_it() { + let dir = temp_dir("export-orig"); + let src = dir.join("Shot.dng"); + let dng = synthetic_dng_with(None, Default::default()); + std::fs::write(&src, &dng).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let id = s.catalog.photos().next().unwrap().id; + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.7})).unwrap(); + + let o = ExportOptions::from_json(&json!({"format": "original", "naming": "{name}-{seq}"})); + let e = export_photo(&mut s, id, &o, 3).unwrap(); + assert_eq!(e.file_name, "Shot-003.dng"); + assert_eq!(e.bytes, dng, "the original, byte for byte"); + assert_eq!(e.sidecars.len(), 1); + let (ext, xmp) = &e.sidecars[0]; + assert_eq!(*ext, "xmp"); + let name = "Shot-003.xmp"; + let copy = dir.join("copy"); + std::fs::create_dir_all(&copy).unwrap(); + std::fs::write(copy.join(&e.file_name), &e.bytes).unwrap(); + std::fs::write(copy.join(name), xmp).unwrap(); + let mut s2 = Session::new().with_fs(); + s2.execute("library.import", &json!({"paths": [copy.join(&e.file_name).to_string_lossy()]})).unwrap(); + assert_eq!(s2.catalog.photos().next().unwrap().develop.light.exposure, 0.7, "edits travel in the sidecar"); + + // DNG: re-encoded raw data with the edits embedded + let o = ExportOptions { format: ExportFormat::Dng, ..Default::default() }; + let e = export_photo(&mut s, id, &o, 1).unwrap(); + assert_eq!(e.file_name, "Shot.dng"); + assert!(e.sidecars.is_empty()); + let back = lightcraft_raw::decode(&e.bytes).expect("our DNG decodes"); + let orig = lightcraft_raw::decode(&dng).unwrap(); + assert_eq!((back.width, back.height), (orig.width, orig.height)); + assert_eq!(back.data, orig.data, "lossless"); + let out = dir.join("out.dng"); + std::fs::write(&out, &e.bytes).unwrap(); + let mut s2 = Session::new().with_fs(); + s2.execute("library.import", &json!({"paths": [out.to_string_lossy()]})).unwrap(); + assert_eq!(s2.catalog.photos().next().unwrap().develop.light.exposure, 0.7, "edits embedded in the DNG are read back"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn original_and_dng_need_a_file() { + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + for f in ["original", "dng"] { + let err = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": f})), 1).err().unwrap(); + assert!(err.contains("no original file"), "{err}"); + } +} + +#[test] +fn dng_export_rejects_non_raw_photos() { + let dir = temp_dir("export-dng-jpeg"); + let src = dir.join("a.png"); + let img = lightcraft_raster::Rgba8::from_fn(32, 24, |x, y| [(x * 8) as u8, (y * 10) as u8, 90, 255]); + std::fs::write(&src, crate::export::encode_image(&img, &ExportOptions { format: ExportFormat::Png, ..Default::default() }).unwrap()).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let id = s.catalog.photos().next().unwrap().id; + let err = export_photo(&mut s, id, &ExportOptions { format: ExportFormat::Dng, ..Default::default() }, 1).err().unwrap(); + assert!(err.contains("needs a raw photo"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index d919cfcf..c2aa158f 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -8,7 +8,7 @@ use serde_json::json; use crate::Session; -fn temp_dir(tag: &str) -> PathBuf { +pub(crate) fn temp_dir(tag: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("lc-xmp-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index b0e5386d..c35c7a51 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -57,10 +57,10 @@ impl Headless { /// (see `lightcraft_engine::export::ExportOptions::from_json`, plus `ids`, `dir`, `path`). /// With `path` and no `format`, the format follows the path's extension. fn export(&mut self, p: &Value) -> Result<Value, String> { - use lightcraft_engine::export::{ExportFormat, ExportOptions, export_photo}; + use lightcraft_engine::export::{ExportFormat, ExportOptions, Resize, export_photo}; let mut opts = ExportOptions::from_json(p); - if p.get("longEdge").is_none() { - opts.long_edge = Some(3000); + if !ExportOptions::has_size_param(p) { + opts.resize = Some(Resize::long_edge(3000)); } let exact = p.get("path").and_then(Value::as_str); if let (Some(path), None) = (exact, p.get("format")) { @@ -84,7 +84,13 @@ impl Headless { std::fs::create_dir_all(parent).map_err(|err| err.to_string())?; } std::fs::write(&path, &e.bytes).map_err(|err| format!("{path}: {err}"))?; - files.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len()})); + let mut sidecars = Vec::new(); + for (ext, bytes) in &e.sidecars { + let sc = Path::new(&path).with_extension(ext).to_string_lossy().to_string(); + std::fs::write(&sc, bytes).map_err(|err| format!("{sc}: {err}"))?; + sidecars.push(sc); + } + files.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars})); } // Single-photo exports also report path/width/height at the top level (back-compat). let mut out = files.first().cloned().unwrap_or_else(|| json!({})); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 2f441eb9..81d9a811 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -179,8 +179,15 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> { "ids": {"type": "array", "items": {"type": "integer"}, "description": "Photos to export (default: the given/active photo)"}, "path": {"type": "string"}, "dir": {"type": "string"}, - "format": {"type": "string", "enum": ["jpeg", "png", "tiff", "webp", "avif"]}, - "longEdge": {"type": "integer", "description": "Default 3000; 0 = full size"}, + "format": {"type": "string", "enum": ["jpeg", "png", "tiff", "webp", "avif", "original", "dng"], "description": "original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"}, + "longEdge": {"type": "integer", "description": "Output long edge in px. When no size param is given: 3000; 0 = full size (cropped, native resolution)"}, + "shortEdge": {"type": "integer", "description": "Output short edge in px"}, + "width": {"type": "integer", "description": "Output width in px (with height: fit inside width × height, either orientation)"}, + "height": {"type": "integer", "description": "Output height in px"}, + "megapixels": {"type": "number", "description": "Output size in megapixels"}, + "percent": {"type": "number", "description": "Output size as a percentage of full size"}, + "dontEnlarge": {"type": "boolean", "description": "Never upscale past the photo's own size (default true)"}, + "ppi": {"type": "integer", "description": "Print resolution written into the file (default 240)"}, "quality": {"type": "integer", "description": "JPEG/AVIF quality 1..100"}, "limitKb": {"type": "integer", "description": "JPEG: largest quality that fits this many KB"}, "sharpen": {"type": "string", "enum": ["none", "screen", "matte", "glossy"]}, @@ -528,6 +535,13 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { "dir", "format", "longEdge", + "shortEdge", + "width", + "height", + "megapixels", + "percent", + "dontEnlarge", + "ppi", "quality", "limitKb", "sharpen", diff --git a/crates/pipeline/src/geometry.rs b/crates/pipeline/src/geometry.rs index a8ed0cd2..e2f3fcc1 100644 --- a/crates/pipeline/src/geometry.rs +++ b/crates/pipeline/src/geometry.rs @@ -135,6 +135,12 @@ impl Frame { r.width() / r.height() } + /// Size of the (cropped) output at the source's own resolution, in pixels. + pub fn native_size(&self) -> (f64, f64) { + let r = self.crop.rect_px(self.ow, self.oh); + (r.width(), r.height()) + } + pub fn fit(&self, max_w: usize, max_h: usize) -> (usize, usize) { let a = self.aspect(); let (mw, mh) = (max_w.max(1) as f64, max_h.max(1) as f64); diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 7ffd65f1..58746687 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -133,6 +133,12 @@ pub fn output_size(src_w: usize, src_h: usize, s: &DevelopSettings, req: &Render geometry::Frame::new(src_w, src_h, s, req.apply_crop).fit(req.max_w, req.max_h) } +/// Size of the cropped output of a `src_w × src_h` source under `s` at the source's own resolution +/// (what a full-size export produces). +pub fn native_output_size(src_w: usize, src_h: usize, s: &DevelopSettings) -> (f64, f64) { + geometry::Frame::new(src_w, src_h, s, true).native_size() +} + /// The geometric frame `render` uses for `src` (lens data, automatic CA estimate included). pub fn frame_for(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, apply_crop: bool) -> geometry::Frame { let mut frame = geometry::Frame::with_lens(src.width, src.height, s, apply_crop, info.lens.as_ref()); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4ea36eb7..57a5dac6 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -346,7 +346,13 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String let path = exact.clone().unwrap_or_else(|| if dir.is_empty() { e.file_name.clone() } else { format!("{dir}/{}", e.file_name) }); let w = app.services.write.as_mut().ok_or("no writer")?; w(&path, &e.bytes)?; - out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len()})); + let mut sidecars = Vec::new(); + for (ext, bytes) in &e.sidecars { + let sc = std::path::Path::new(&path).with_extension(ext).to_string_lossy().to_string(); + w(&sc, bytes)?; + sidecars.push(sc); + } + out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars})); } // remember for Export with Previous (and to prefill the dialog) let mut last = p.clone(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 765a1d48..e57e4832 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -521,12 +521,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re let prev = app.session.last_export.clone().unwrap_or_default(); let u = |k: &str, d: u64| prev.get(k).and_then(Value::as_u64).unwrap_or(d); let dir = prev.get("dir").and_then(Value::as_str).map(str::to_string).unwrap_or_else(crate::control::default_export_dir); - app.ui.dialog = Some(Dialog::Export { - opts: lightcraft_engine::export::ExportOptions::from_json(&prev), - long_edge: u("longEdge", 2048) as u32, - limit_kb: u("limitKb", 0) as u32, - dir, - }); + let opts = lightcraft_engine::export::ExportOptions::from_json(&prev); + // no previous export: 2048 px long edge; a previous full-size export: full size + let full_size = opts.resize.is_none() && lightcraft_engine::export::ExportOptions::has_size_param(&prev); + let resize = opts.resize.unwrap_or_default(); + app.ui.dialog = Some(Dialog::Export { opts, full_size, resize, limit_kb: u("limitKb", 0) as u32, dir }); Ok(Value::Null) } "dialog.mergeHdr" => crate::merge::open(app, "merge.hdr"), diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 6c3ef6bf..e39646c6 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -233,7 +233,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { group_checklist(ui, "presetInclude", groups); } Dialog::CopySettings { groups } => group_checklist(ui, "copyGroup", groups), - Dialog::Export { opts, long_edge, limit_kb, dir } => { + Dialog::Export { opts, full_size, resize, limit_kb, dir } => { use lightcraft_engine::export::{Anchor as P, ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; let n = app.session.selection.ids.len().max(1); ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(t.text_dim)); @@ -243,20 +243,30 @@ 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::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Dng, "DNG"), (F::Original, "Original")], &mut opts.format, ); + if !opts.format.is_rendered() { + let note = if opts.format == F::Dng { + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply." + } else { + "The original files, unchanged, each with an XMP sidecar holding its edits." + }; + ui.label(egui::RichText::new(note).color(t.text_dim)); + } if opts.format != before { // each format starts at its own default depth (TIFF 16-bit, others 8-bit) opts.bit_depth = None; } + let rendered = opts.format.is_rendered(); let depths = lightcraft_engine::export::ExportOptions::bit_depths(opts.format); - if depths.len() > 1 { + if rendered && depths.len() > 1 { let mut bd = opts.bit_depth.filter(|b| depths.iter().any(|d| d.0 == *b)).unwrap_or(depths[0].0); choices(ui, "Bit depth", "exportBitDepth", depths, &mut bd); opts.bit_depth = Some(bd); } - if opts.format == F::Avif { + if !rendered { + } else if opts.format == F::Avif { ui.label(egui::RichText::new("Color space: sRGB (AVIF)").color(t.text_dim)); } else { use lightcraft_engine::export::OutputSpace as C; @@ -286,16 +296,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { *limit_kb = k as u32; } } - let mut full = *long_edge == 0; - if ui.checkbox(&mut full, "Full size").changed() { - *long_edge = if full { 0 } else { 2048 }; - } - if !full { - let mut e = *long_edge as f64; - if num(ui, &LONG_EDGE, &mut e) { - *long_edge = e as u32; - } + if rendered { + export_size(ui, full_size, resize, &mut opts.ppi); } + if rendered { choices( ui, "Sharpen", @@ -312,6 +316,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { &mut opts.sharpen_amount, ); } + } + if rendered { choices( ui, "Metadata", @@ -347,6 +353,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } ui.checkbox(&mut wm.shadow, "Shadow"); } + } ui.add_space(4.0); field(ui, "File name", |ui| { ui.add(egui::TextEdit::singleline(&mut opts.naming).hint_text("{name}-{seq}").desired_width(f32::INFINITY)) @@ -508,15 +515,24 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso }), ), Dialog::CopySettings { groups } => app.run("develop.copy", json!({"groups": groups})), - Dialog::Export { opts, long_edge, limit_kb, dir } => app.run( - "app.export", - json!({ - "format": opts.format, "quality": opts.quality, "longEdge": long_edge, "limitKb": limit_kb, + Dialog::Export { opts, full_size, resize, limit_kb, dir } => { + let mut p = json!({ + "format": opts.format, "quality": opts.quality, "limitKb": limit_kb, "sharpen": opts.sharpen, "sharpenAmount": opts.sharpen_amount, "naming": opts.naming, "dir": dir, "metadata": opts.metadata, "removeLocation": opts.remove_location, "watermark": opts.watermark, - "colorSpace": opts.color_space, "bitDepth": opts.bit_depth, - }), - ), + "colorSpace": opts.color_space, "bitDepth": opts.bit_depth, "ppi": opts.ppi, + }); + // full size is an explicit `longEdge: 0` (an absent size means "the default size" to agents) + let size = if *full_size { + json!({"longEdge": 0, "dontEnlarge": resize.dont_enlarge}) + } else { + lightcraft_engine::export::ExportOptions::resize_json(Some(resize)) + }; + if let (Some(o), Some(sz)) = (p.as_object_mut(), size.as_object()) { + o.extend(sz.clone()); + } + app.run("app.export", p) + } Dialog::Merge { opts } => crate::merge::start_final(app, opts), Dialog::Import { opts } => crate::import::start(app, opts), Dialog::ConfirmDelete { .. } => app.run("photo.delete", json!({})), @@ -566,7 +582,74 @@ const fn spec(id: &'static str, label: &'static str, min: f64, max: f64, default } const QUALITY: ControlSpec = spec("export.quality", "Quality", 1.0, 100.0, 90.0, 1.0); const LIMIT_KB: ControlSpec = spec("export.limitKb", "Limit file size (KB, 0 = off)", 0.0, 20_000.0, 0.0, 10.0); -const LONG_EDGE: ControlSpec = spec("export.longEdge", "Long edge (px)", 256.0, 12_000.0, 2048.0, 16.0); +const SIZE_PX: ControlSpec = spec("export.sizePx", "Pixels", 16.0, 20_000.0, 2048.0, 16.0); +const SIZE_W: ControlSpec = spec("export.sizeW", "Width (px)", 16.0, 20_000.0, 2048.0, 16.0); +const SIZE_H: ControlSpec = spec("export.sizeH", "Height (px)", 16.0, 20_000.0, 2048.0, 16.0); +const SIZE_MP: ControlSpec = ControlSpec { decimals: 1, ..spec("export.sizeMp", "Megapixels", 0.1, 100.0, 12.0, 0.1) }; +const SIZE_PCT: ControlSpec = spec("export.sizePercent", "Percent", 1.0, 400.0, 50.0, 1.0); +const PPI: ControlSpec = spec("export.ppi", "Resolution (ppi)", 1.0, 1200.0, 240.0, 1.0); + +/// Image Sizing: full size, or a resize mode and its value(s), don't enlarge, ppi. +fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::export::Resize, ppi: &mut u16) { + use lightcraft_engine::export::ResizeMode as R; + const MODES: [(R, &str); 7] = [ + (R::LongEdge, "Long Edge"), + (R::ShortEdge, "Short Edge"), + (R::Width, "Width"), + (R::Height, "Height"), + (R::Dimensions, "Width × Height"), + (R::Megapixels, "Megapixels"), + (R::Percent, "Percentage"), + ]; + let r_full = ui.checkbox(full, "Full size"); + crate::widgets::register(ui.ctx(), "check:exportFullSize", r_full.rect); + if !*full { + field(ui, "Resize to", |ui| { + let cur = MODES.iter().find(|m| m.0 == r.mode).map_or("Long Edge", |m| m.1); + let before = r.mode; + let c = egui::ComboBox::from_id_salt("exportResizeMode").width(150.0).selected_text(cur).show_ui(ui, |ui| { + for (m, l) in MODES { + ui.selectable_value(&mut r.mode, m, l); + } + }); + crate::widgets::register(ui.ctx(), "combo:exportResizeMode", c.response.rect); + if r.mode != before { + // a sensible value for the new unit + r.value = match r.mode { + R::Megapixels => 12.0, + R::Percent => 50.0, + _ if matches!(before, R::Megapixels | R::Percent) => 2048.0, + _ => r.value, + }; + if r.mode == R::Dimensions && r.height == 0 { + r.height = r.value as u32; + } + } + }); + let mut v = r.value as f64; + let spec = match r.mode { + R::Megapixels => &SIZE_MP, + R::Percent => &SIZE_PCT, + R::Dimensions => &SIZE_W, + _ => &SIZE_PX, + }; + if num(ui, spec, &mut v) { + r.value = v as f32; + } + if r.mode == R::Dimensions { + let mut h = r.height as f64; + if num(ui, &SIZE_H, &mut h) { + r.height = h as u32; + } + } + let c = ui.checkbox(&mut r.dont_enlarge, "Don't enlarge"); + crate::widgets::register(ui.ctx(), "check:exportDontEnlarge", c.rect); + } + let mut p = *ppi as f64; + if num(ui, &PPI, &mut p) { + *ppi = p as u16; + } +} const WM_SIZE: ControlSpec = spec("export.watermarkSize", "Size (% of short edge)", 1.0, 15.0, 3.5, 0.5); const WM_OPACITY: ControlSpec = spec("export.watermarkOpacity", "Opacity (%)", 5.0, 100.0, 70.0, 1.0); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 41729890..6922caef 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -329,10 +329,11 @@ pub enum Dialog { CopySettings { groups: Vec<String>, }, - /// `long_edge` 0 = full size; `limit_kb` 0 = no limit; `dir` empty = default export folder. + /// `resize` is used unless `full_size`; `limit_kb` 0 = no limit; `dir` empty = default export folder. Export { opts: lightcraft_engine::export::ExportOptions, - long_edge: u32, + full_size: bool, + resize: lightcraft_engine::export::Resize, limit_kb: u32, dir: String, }, diff --git a/docs/mcp.md b/docs/mcp.md index f343d88a..780d9bf8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -85,7 +85,7 @@ During development you can also point the client at `cargo run --release -p ligh | `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) | | `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle | | `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) | -| `export {path, id?, longEdge?, quality?, colorSpace?, bitDepth?}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` (headless; the desktop app writes PNG) | +| `export {path \| dir, id? \| ids?, format?, longEdge? \| shortEdge? \| width?/height? \| megapixels? \| percent?, dontEnlarge?, ppi?, quality?, colorSpace?, bitDepth?, …}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` / `.avif`; `format: "original"` copies the file + an XMP sidecar with the edits, `format: "dng"` writes raw photos as DNG with the edits embedded. No size param = 3000 px long edge; `longEdge: 0` = full size (cropped, native resolution); `width` + `height` fit either orientation; `dontEnlarge` defaults to true | Tools taking `id` make that photo active first; without it they act on the active photo. diff --git a/docs/parity.md b/docs/parity.md index ad7c0381..3dad234a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -39,7 +39,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — | | Q. HDR (HDR) | 0 | 0 | 5 | 0 | — | — | | R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) | -| S. Export (EXP) | 6 | 6 | 6 | 0 | 3/7 (43%) | 3/7 (43%) | +| S. Export (EXP) | 9 | 4 | 5 | 0 | 5/7 (71%) | 4/7 (57%) | | T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 4 | 1 | 3 | 3 | 1/1 (100%) | 3/4 (75%) | @@ -48,18 +48,16 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 53 | 17 | 15 | 8 | 38/47 (81%) | 13/23 (57%) | | Z. Keyboard shortcuts (desktop) | 53 | 14 | 13 | 1 | 44/52 (85%) | 9/23 (39%) | | Lightroom Classic extras | 4 | 20 | 55 | 9 | — | 3/21 (14%) | -| **Total** | 250 | 87 | 163 | 37 | 171/198 (86%) | 69/144 (48%) | +| **Total** | 253 | 85 | 162 | 37 | 173/198 (87%) | 70/144 (49%) | <!-- /parity:summary --> ## Top gaps Ordered by tier, then user value, then (low) effort. Take the first one nobody is working on. -1. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in - `crates/raw/src/dngwrite.rs`), short edge / width / height / megapixels, "don't enlarge", ppi. -2. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no +1. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no permissive pure-Rust decoder yet). High value, high effort (clean sources needed). -3. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). +2. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). ## Shortcuts: conflicts and missing bindings @@ -408,8 +406,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-EXP-DIALOG | Export dialog | P0 | 🟡 | `cmd:dialog.export`, `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export works; no built-in or saved export presets | -| LR-EXP-TYPE | File types | P0 | 🟡 | `crates/engine/src/export.rs` (`ExportFormat`) | JPEG, PNG, TIFF, WebP, AVIF; no DNG, JXL or original (+XMP) | -| LR-EXP-DIM | Output size | P0 | 🟡 | `crates/ui-egui/src/panels/dialogs.rs` (long edge / full size) | no short edge / width / height / megapixels, ppi, don't-enlarge | +| 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-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 | | LR-EXP-COMPRESSION | TIFF compression | P1 | ⬜ | | always Deflate | @@ -422,7 +420,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EXP-LOCATION | Destination folder | P0 | 🟡 | `crates/ui-egui/src/panels/dialogs.rs` (folder field) | no folder picker, subfolder or name-conflict policy | | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet | | LR-EXP-DNGOPT | DNG options | P2 | ⬜ | | | -| LR-EXP-ORIGINAL | Original + XMP | P1 | ⬜ | | | +| LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`) | file copied byte for byte, sidecar named after the output | | LR-EXP-PHOTOS | Export to the system photo library | P2 | ⬜ | | | | LR-EXP-PSD | Round trip to an external editor | P2 | ⬜ | | | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 268ba72e..7f4af926 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -10,7 +10,7 @@ mod parity; mod stats; mod web; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::process::{Command, ExitCode}; const USAGE: &str = "\ @@ -65,7 +65,11 @@ fn main() -> ExitCode { /// Workspace root (parent of the xtask crate). pub fn root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf() + // `cargo run` sets it at run time; prefer that over the compile-time value, which goes stale + // when the checkout moves and the cached xtask binary isn't rebuilt + let dir = std::env::var_os("CARGO_MANIFEST_DIR").map(PathBuf::from).filter(|d| d.join("Cargo.toml").is_file()); + let dir = dir.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR"))); + dir.parent().expect("xtask has a parent dir").to_path_buf() } pub fn cargo() -> Command {