mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
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 <[email protected]>
This commit is contained in:
1 parent
c68fe742ea
commit
3ce86032ff
22 files changed
+646
-84
No files matched your search
@@ -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 <IN> -o <OUT> [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<u64> = 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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<u16>,
|
||||
}
|
||||
|
||||
/// 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<u8>)> = 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<Vec<u8>> {
|
||||
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)?;
|
||||
}
|
||||
|
||||
@@ -371,7 +371,10 @@ pub fn encode(
|
||||
// headers
|
||||
let mut out = Vec::with_capacity(coded.iter().map(Vec::len).sum::<usize>() + 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);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ fn seeds() -> &'static Vec<Vec<u8>> {
|
||||
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("<x/>") };
|
||||
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some("<x/>"), ..Default::default() };
|
||||
let e = EncodeImage::rgba8(&img);
|
||||
let u16s: Vec<u16> = (0..24 * 16 * 3).map(|i| (i * 331) as u16).collect();
|
||||
let f32s: Vec<f32> = (0..24 * 16 * 3).map(|i| i as f32 * 0.01).collect();
|
||||
|
||||
@@ -36,7 +36,7 @@ fn jpeg_roundtrip_with_metadata() {
|
||||
let icc = write_named(NamedSpace::Srgb);
|
||||
let exif = minimal_exif(6);
|
||||
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF/></x:xmpmeta>"#;
|
||||
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<u8> = (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("<xmp/>") };
|
||||
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]);
|
||||
}
|
||||
+252
-18
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(©).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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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!({}));
|
||||
|
||||
+16
-2
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
+7
-9
@@ -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 | ⬜ | | |
|
||||
|
||||
|
||||
+6
-2
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user