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:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-01 21:12:43 -04:00
1 parent c68fe742ea
commit 3ce86032ff
22 files changed
+646 -84

No files matched your search

+37 -10
View File
@@ -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(())
}
+29
View File
@@ -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();