lightcraft/crates/engine/src/tests_export.rs

177 lines
8.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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);
}
#[test]
fn presets_conflicts_and_subfolders() {
use crate::export::{Destination, export_batch};
let mut s = Session::with_demo();
let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
// built-ins and user presets; the call's own params override the preset's
let names: Vec<String> =
s.execute("export.presets", &json!({})).unwrap().as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect();
assert!(names.contains(&"JPEG (Small)".to_string()) && names.contains(&"Original + Settings".to_string()), "{names:?}");
let p = s.export_params(&json!({"preset": "jpeg (small)", "quality": 50})).unwrap();
assert_eq!((p["longEdge"].as_u64(), p["quality"].as_u64(), p.get("preset")), (Some(2048), Some(50), None));
assert!(s.export_params(&json!({"preset": "nope"})).unwrap_err().contains("unknown export preset"));
assert!(s.execute("export.savePreset", &json!({"name": "JPEG (Large)", "params": {}})).is_err(), "built-in names are reserved");
s.execute("export.savePreset", &json!({"name": "Web", "params": {"format": "png", "width": 64, "ids": [1], "dir": "/x"}})).unwrap();
let web = s.export_params(&json!({"preset": "Web"})).unwrap();
assert_eq!(web, json!({"format": "png", "width": 64}), "targets are not part of a preset");
s.execute("export.savePreset", &json!({"name": "web", "params": {"format": "webp"}})).unwrap();
assert_eq!(s.export_presets.len(), 1, "same name (any case) replaces");
s.execute("export.deletePreset", &json!({"name": "WEB"})).unwrap();
assert!(s.export_presets.is_empty());
// a fake file system: what exists, what was written
let existing = ["out/Sub/LC01347.png".to_string()];
let to = Destination { dir: "out".into(), exact: None };
let batch = |s: &mut Session, ids: &[_], o: &ExportOptions| {
let mut written: Vec<String> = Vec::new();
let mut write = |p: &str, _: &[u8]| {
written.push(p.to_string());
Ok(())
};
let files = export_batch(s, ids, o, &to, &mut write, &|p| existing.contains(&p.to_string())).unwrap();
(files, written)
};
let o = ExportOptions::from_json(&json!({"format": "png", "width": 32, "subfolder": "Sub", "naming": "LC01347"}));
// both photos get the same name from the template: the first is renamed past the existing
// file, the second past the first
let (files, written) = batch(&mut s, &ids, &o);
assert_eq!(written, ["out/Sub/LC01347-2.png", "out/Sub/LC01347-3.png"]);
assert_eq!(files[0]["width"], 32);
let (files, written) = batch(&mut s, &ids, &ExportOptions { conflict: crate::export::Conflict::Skip, ..o.clone() });
assert_eq!(files.iter().filter(|f| f["skipped"] == "out/Sub/LC01347.png").count(), 2, "{files:?}");
assert!(written.is_empty());
let (_, written) = batch(&mut s, &ids[..1], &ExportOptions { conflict: crate::export::Conflict::Overwrite, ..o });
assert_eq!(written, ["out/Sub/LC01347.png"]);
}
#[test]
fn prepared_exports_run_on_another_thread() {
fn send<T: Send>(_: &T) {}
let mut s = Session::with_demo();
let id = s.active().unwrap();
let o = ExportOptions::from_json(&json!({"format": "png", "width": 48}));
let items: Vec<_> = (1..=2).map(|seq| crate::export::prepare_export(&mut s, id, &o, seq).unwrap()).collect();
send(&items);
let to = crate::export::Destination::default();
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let seen2 = seen.clone();
let files = std::thread::spawn(move || {
let mut written = Vec::new();
let mut write = |p: &str, b: &[u8]| {
written.push((p.to_string(), b.len()));
Ok(())
};
// cancel after the first photo
crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |done, name| {
seen2.lock().unwrap().push((done, name.to_string()));
done < 1
})
.unwrap()
})
.join()
.unwrap();
assert_eq!(files.len(), 1, "cancelled before the second: {files:?}");
assert_eq!(files[0]["width"], 48);
assert_eq!(seen.lock().unwrap().len(), 2);
}