mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 11:09:23 -06:00
JPEG XL export via the pure-Rust jixel crate (robmdunn; fixes #247). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
579 lines
31 KiB
Rust
579 lines
31 KiB
Rust
//! 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);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
/// Issue #78: a GPU render whose work never ran (a driver that drops a submission without an
|
||
/// error) gave an all-black export. The export must come from the CPU instead — the same
|
||
/// image — with the reason recorded on the export itself.
|
||
///
|
||
/// Issue #675: this test runs beside every other engine test, many of them rendering on the GPU.
|
||
/// It asks only about its own export (`Exported::gpu_fallback`), never the process-wide
|
||
/// `lightcraft_gpu::last_fallback` another thread's render overwrites, and the simulated failure
|
||
/// switches the GPU off for this thread alone, so neither the fault nor `reset_failures` reaches
|
||
/// the other tests.
|
||
#[test]
|
||
fn export_falls_back_to_the_cpu_when_gpu_work_is_lost() {
|
||
let mut s = Session::with_demo();
|
||
let id = s.active().unwrap();
|
||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.4})).unwrap();
|
||
s.execute("develop.set", &json!({"control": "effects.clarity", "value": 20})).unwrap();
|
||
let o = ExportOptions::from_json(&json!({"format": "png"}));
|
||
let mean = |b: &[u8]| {
|
||
let d = lightcraft_codecs::decode(b, Default::default()).unwrap().image;
|
||
d.data.iter().map(|p| (p[0] + p[1] + p[2]) as f64).sum::<f64>() / (3 * d.data.len()) as f64
|
||
};
|
||
let gpu = lightcraft_gpu::available();
|
||
let healthy = mean(&export_photo(&mut s, id, &o, 1).unwrap().bytes);
|
||
lightcraft_gpu::inject_fault(lightcraft_gpu::Fault::DropWork);
|
||
let faulted = export_photo(&mut s, id, &o, 1).unwrap();
|
||
if gpu {
|
||
let why = faulted.gpu_fallback.clone().unwrap_or_default();
|
||
assert!(why.contains("incomplete"), "{why}");
|
||
// the GPU is off for this thread now: the next export is not even tried on it
|
||
assert!(!lightcraft_gpu::available());
|
||
assert!(lightcraft_gpu::unavailable_reason().unwrap_or_default().contains("incomplete"), "{:?}", lightcraft_gpu::unavailable_reason());
|
||
// the thread's own `inject_fault` stopped only its own GPU use: the others still have it
|
||
let elsewhere = std::thread::spawn(|| (lightcraft_gpu::available(), lightcraft_gpu::unavailable_reason())).join().unwrap();
|
||
assert_eq!(elsewhere, (true, None), "a simulated failure must not switch the GPU off for the other threads");
|
||
} else {
|
||
assert_eq!(faulted.gpu_fallback, None);
|
||
}
|
||
let cpu = export_photo(&mut s, id, &o, 1).unwrap();
|
||
assert_eq!(cpu.gpu_fallback, None, "not tried on the GPU: no fallback to report");
|
||
lightcraft_gpu::reset_failures();
|
||
assert_eq!(lightcraft_gpu::available(), gpu, "reset_failures gives this thread the GPU back");
|
||
let px = |b: &[u8]| lightcraft_codecs::decode(b, Default::default()).unwrap().image.data;
|
||
assert!(px(&faulted.bytes) == px(&cpu.bytes), "the fallback is the CPU render");
|
||
let m = mean(&faulted.bytes);
|
||
assert!(m > 0.02, "not black: mean {m}");
|
||
assert!((m - healthy).abs() < 0.01, "GPU {healthy} vs CPU {m}");
|
||
}
|
||
|
||
/// A JPEG original `dir/IMG_1.jpg` imported into a library on disk.
|
||
fn library_with_jpeg(tag: &str) -> (Session, lightcraft_catalog::PhotoId, std::path::PathBuf, std::path::PathBuf, Vec<u8>) {
|
||
let dir = temp_dir(tag);
|
||
let src = dir.join("IMG_1.jpg");
|
||
let img = lightcraft_raster::Rgba8::from_fn(48, 32, |x, y| [(x * 5) as u8, (y * 7) as u8, 120, 255]);
|
||
let bytes = crate::export::encode_image(&img, &ExportOptions::default()).unwrap();
|
||
std::fs::write(&src, &bytes).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("develop.set", &json!({"ids": [id.0], "control": "light.exposure", "value": 1.0})).unwrap();
|
||
(s, id, dir, src, bytes)
|
||
}
|
||
|
||
fn disk_batch(
|
||
s: &mut Session,
|
||
id: lightcraft_catalog::PhotoId,
|
||
o: &ExportOptions,
|
||
to: &crate::export::Destination,
|
||
) -> Result<Vec<serde_json::Value>, String> {
|
||
crate::export::export_batch(s, &[id], o, to, &mut crate::export::write_file, &|p| std::path::Path::new(p).exists())
|
||
}
|
||
|
||
/// Issue #93: exporting into the photo's own folder as `{name}` with "Overwrite" replaced the
|
||
/// original with the re-encoded render. It is refused now, whatever the policy or exact path.
|
||
#[test]
|
||
fn export_never_overwrites_an_original() {
|
||
use crate::export::{Conflict, Destination};
|
||
let (mut s, id, dir, src, original) = library_with_jpeg("export-guard");
|
||
let folder = Destination { dir: dir.to_string_lossy().to_string(), exact: None };
|
||
let o = ExportOptions { naming: "{name}".into(), conflict: Conflict::Overwrite, ..Default::default() };
|
||
let err = disk_batch(&mut s, id, &o, &folder).unwrap_err();
|
||
assert!(err.contains("original of IMG_1.jpg"), "{err}");
|
||
assert_eq!(std::fs::read(&src).unwrap(), original, "the original is byte-identical");
|
||
|
||
// the exact path of the control channel / MCP, also spelled another way
|
||
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||
for exact in [src.to_string_lossy().to_string(), dir.join("sub/../IMG_1.jpg").to_string_lossy().to_string()] {
|
||
let to = Destination { dir: String::new(), exact: Some(exact) };
|
||
assert!(disk_batch(&mut s, id, &o, &to).unwrap_err().contains("never writes over an original"));
|
||
}
|
||
// the "Original" format re-writing the file onto itself (and its sidecar)
|
||
let orig = ExportOptions { format: ExportFormat::Original, ..o.clone() };
|
||
assert!(disk_batch(&mut s, id, &orig, &folder).is_err());
|
||
assert_eq!(std::fs::read(&src).unwrap(), original);
|
||
assert!(s.execute("export.checkTarget", &json!({"path": src.to_string_lossy()})).is_err());
|
||
assert!(s.execute("export.checkTarget", &json!({"path": dir.join("free.jpg").to_string_lossy()})).is_ok());
|
||
|
||
// Unique moves past it; Overwrite still replaces an ordinary earlier export
|
||
let unique = ExportOptions { conflict: Conflict::Unique, ..o.clone() };
|
||
let files = disk_batch(&mut s, id, &unique, &folder).unwrap();
|
||
assert!(files[0]["path"].as_str().unwrap().ends_with("IMG_1-2.jpg"), "{files:?}");
|
||
let earlier = dir.join("IMG_1-2.jpg");
|
||
std::fs::write(&earlier, b"an earlier export").unwrap();
|
||
let named = ExportOptions { naming: "{name}-2".into(), ..o };
|
||
disk_batch(&mut s, id, &named, &folder).unwrap();
|
||
assert_ne!(std::fs::read(&earlier).unwrap(), b"an earlier export", "an ordinary file is overwritten");
|
||
assert_eq!(std::fs::read(&src).unwrap(), original);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Issue #93: the XMP sidecar of an "Original" export ignored the conflict policy.
|
||
#[test]
|
||
fn export_sidecars_follow_the_conflict_policy() {
|
||
use crate::export::{Conflict, Destination};
|
||
let (mut s, id, dir, _src, original) = library_with_jpeg("export-sidecar");
|
||
let out = dir.join("out");
|
||
std::fs::create_dir_all(&out).unwrap();
|
||
let theirs = out.join("IMG_1.xmp");
|
||
std::fs::write(&theirs, b"someone else's sidecar").unwrap();
|
||
let to = Destination { dir: out.to_string_lossy().to_string(), exact: None };
|
||
let o = ExportOptions { format: ExportFormat::Original, naming: "{name}".into(), ..Default::default() };
|
||
|
||
// Unique: the photo and its sidecar both move to the next free name
|
||
let files = disk_batch(&mut s, id, &ExportOptions { conflict: Conflict::Unique, ..o.clone() }, &to).unwrap();
|
||
assert!(files[0]["path"].as_str().unwrap().ends_with("IMG_1-2.jpg"), "{files:?}");
|
||
assert!(files[0]["sidecars"][0].as_str().unwrap().ends_with("IMG_1-2.xmp"), "{files:?}");
|
||
assert_eq!(std::fs::read(&theirs).unwrap(), b"someone else's sidecar");
|
||
assert_eq!(std::fs::read(out.join("IMG_1-2.jpg")).unwrap(), original);
|
||
// Skip: a taken sidecar name skips the photo
|
||
std::fs::remove_file(out.join("IMG_1-2.jpg")).unwrap();
|
||
std::fs::remove_file(out.join("IMG_1-2.xmp")).unwrap();
|
||
let files = disk_batch(&mut s, id, &ExportOptions { conflict: Conflict::Skip, ..o.clone() }, &to).unwrap();
|
||
assert!(files[0]["skipped"].as_str().is_some(), "{files:?}");
|
||
assert!(!out.join("IMG_1.jpg").exists());
|
||
assert_eq!(std::fs::read(&theirs).unwrap(), b"someone else's sidecar");
|
||
// Overwrite: replaced, as asked
|
||
disk_batch(&mut s, id, &ExportOptions { conflict: Conflict::Overwrite, ..o }, &to).unwrap();
|
||
assert!(std::fs::read_to_string(&theirs).unwrap().contains("xmpmeta"));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Issue #93: the native writer truncated the target first; a failure mid-write (full disk,
|
||
/// unplugged drive) now leaves the previous file intact and no partial one.
|
||
#[test]
|
||
fn a_failed_export_write_keeps_the_previous_file() {
|
||
use crate::export::{Conflict, Destination};
|
||
let (mut s, id, dir, _src, _) = library_with_jpeg("export-midwrite");
|
||
let out = dir.join("out");
|
||
std::fs::create_dir_all(&out).unwrap();
|
||
let prev = out.join("IMG_1.jpg");
|
||
std::fs::write(&prev, b"last week's export").unwrap();
|
||
let to = Destination { dir: out.to_string_lossy().to_string(), exact: None };
|
||
let o = ExportOptions { conflict: Conflict::Overwrite, ..Default::default() };
|
||
{
|
||
let _fault = lightcraft_catalog::safe_file::fail_writes_after(100);
|
||
let err = disk_batch(&mut s, id, &o, &to).unwrap_err();
|
||
assert!(err.contains("injected"), "{err}");
|
||
}
|
||
assert_eq!(std::fs::read(&prev).unwrap(), b"last week's export");
|
||
assert_eq!(std::fs::read_dir(&out).unwrap().count(), 1, "no temp file left behind");
|
||
disk_batch(&mut s, id, &o, &to).unwrap();
|
||
assert_ne!(std::fs::read(&prev).unwrap(), b"last week's export");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn export_protects_the_target_of_an_imported_symlink() {
|
||
use crate::export::{Conflict, Destination};
|
||
let (original_session, _, dir, src, original) = library_with_jpeg("export-symlink-original");
|
||
drop(original_session);
|
||
let alias = dir.join("alias.jpg");
|
||
std::os::unix::fs::symlink(&src, &alias).unwrap();
|
||
let sidecar = dir.join("metadata.xmp");
|
||
std::fs::write(&sidecar, b"<x/>").unwrap();
|
||
std::os::unix::fs::symlink(&sidecar, alias.with_extension("xmp")).unwrap();
|
||
let mut s = Session::new().with_fs();
|
||
s.execute("library.import", &json!({"paths": [alias.to_string_lossy()]})).unwrap();
|
||
let id = s.catalog.photos().next().unwrap().id;
|
||
s.execute("develop.set", &json!({"ids": [id.0], "control": "light.exposure", "value": 1.0})).unwrap();
|
||
let o = ExportOptions { conflict: Conflict::Overwrite, ..Default::default() };
|
||
let to = Destination { dir: String::new(), exact: Some(src.to_string_lossy().to_string()) };
|
||
let result = disk_batch(&mut s, id, &o, &to);
|
||
assert!(result.is_err(), "export replaced the original through its imported alias: {result:?}");
|
||
assert_eq!(std::fs::read(&src).unwrap(), original);
|
||
assert!(s.execute("export.checkTarget", &json!({"path": src.to_string_lossy()})).is_err());
|
||
assert!(s.execute("export.checkTarget", &json!({"path": sidecar.to_string_lossy()})).is_err());
|
||
assert_eq!(std::fs::read(&sidecar).unwrap(), b"<x/>");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Issue #134: exports can always be made again, so they are written atomically but not synced to
|
||
/// disk file by file; an edit copy that becomes a library photo still is.
|
||
#[test]
|
||
fn exports_are_atomic_without_a_sync() {
|
||
use crate::export::{Conflict, Destination};
|
||
use lightcraft_catalog::safe_file::syncs_on_this_thread;
|
||
let (mut s, id, dir, _src, _) = library_with_jpeg("export-nosync");
|
||
let out = dir.join("out");
|
||
let to = Destination { dir: out.to_string_lossy().to_string(), exact: None };
|
||
let o = ExportOptions { conflict: Conflict::Unique, ..Default::default() };
|
||
let before = syncs_on_this_thread();
|
||
for _ in 0..3 {
|
||
disk_batch(&mut s, id, &o, &to).unwrap();
|
||
}
|
||
assert_eq!(syncs_on_this_thread(), before, "no per-file sync for exports");
|
||
assert_eq!(std::fs::read_dir(&out).unwrap().count(), 3, "three exports, no temp files");
|
||
crate::export::write_file_durable(&out.join("edit.tif").to_string_lossy(), b"tiff").unwrap();
|
||
assert!(syncs_on_this_thread() > before, "the durable writer syncs");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// The demo library's ocean sunset (a sun far above SDR white), selected and active.
|
||
fn sunset(s: &mut Session) -> lightcraft_catalog::PhotoId {
|
||
let id = s.catalog.photos().find(|p| p.meta.title == "Golden horizon").map(|p| p.id).unwrap();
|
||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||
id
|
||
}
|
||
|
||
#[test]
|
||
fn hdr_jpeg_export_writes_a_gain_map_and_sdr_edits_stay_plain() {
|
||
use lightcraft_codecs::gainmap;
|
||
let mut s = Session::with_demo();
|
||
let id = sunset(&mut s);
|
||
let o = ExportOptions::from_json(&json!({"longEdge": 320, "hdr": true}));
|
||
assert!(o.hdr_output());
|
||
// no HDR edit: an ordinary JPEG even with HDR output asked for
|
||
let plain = export_photo(&mut s, id, &o, 1).unwrap();
|
||
assert!(!gainmap::is_gain_map_jpeg(&plain.bytes));
|
||
|
||
let r = s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 3})).unwrap();
|
||
assert_eq!(r["enabled"], true);
|
||
let e = export_photo(&mut s, id, &o, 1).unwrap();
|
||
assert!(gainmap::is_gain_map_jpeg(&e.bytes));
|
||
let gm = gainmap::read_jpeg(&e.bytes).unwrap();
|
||
assert!(gm.meta.alternate_headroom > 0.5 && gm.meta.alternate_headroom <= 3.05, "headroom {}", gm.meta.alternate_headroom);
|
||
// the base is the SDR rendition: the same pixels as the SDR export (the decoders agree)
|
||
let a = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap();
|
||
let b = lightcraft_codecs::decode(&plain.bytes, Default::default()).unwrap();
|
||
assert_eq!((a.width, a.height), (b.width, b.height));
|
||
let diff = a.image.data.iter().zip(&b.image.data).map(|(p, q)| (p[1] - q[1]).abs()).fold(0.0f32, f32::max);
|
||
assert!(diff < 0.02, "base differs from the SDR export by {diff}");
|
||
// HDR off again: the gain map goes away
|
||
s.execute("develop.hdr", &json!({"enabled": false})).unwrap();
|
||
assert!(!gainmap::is_gain_map_jpeg(&export_photo(&mut s, id, &o, 1).unwrap().bytes));
|
||
}
|
||
|
||
#[test]
|
||
fn hdr_float_tiff_keeps_highlights_above_white() {
|
||
let mut s = Session::with_demo();
|
||
let id = sunset(&mut s);
|
||
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap();
|
||
let max_of = |s: &mut Session, hdr: bool| {
|
||
let o = ExportOptions::from_json(&json!({"longEdge": 240, "format": "tiff", "bitDepth": 32, "hdr": hdr}));
|
||
let e = export_photo(s, id, &o, 1).unwrap();
|
||
let d = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap();
|
||
d.image.data.iter().flat_map(|p| p.iter().copied()).fold(0.0f32, f32::max)
|
||
};
|
||
let hdr = max_of(&mut s, true);
|
||
let sdr = max_of(&mut s, false);
|
||
assert!(sdr <= 1.0 + 1e-3, "SDR float TIFF stays in 0..1 ({sdr})");
|
||
assert!(hdr > 1.2 && hdr <= 4.0 + 1e-3, "HDR float TIFF max {hdr}");
|
||
}
|
||
|
||
#[test]
|
||
fn hdr_avif_export_is_pq_rec2020() {
|
||
let mut s = Session::with_demo();
|
||
let id = sunset(&mut s);
|
||
let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "avif", "hdr": true, "quality": 70}));
|
||
assert!(o.hdr_output());
|
||
let sdr = export_photo(&mut s, id, &o, 1).unwrap();
|
||
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap();
|
||
let hdr = export_photo(&mut s, id, &o, 1).unwrap();
|
||
// the `colr` nclx box: BT.2020 primaries (9), PQ (16), BT.2020 NCL (9), full range
|
||
let nclx = |b: &[u8]| b.windows(4).position(|w| w == b"nclx").map(|i| b[i + 4..i + 11].to_vec());
|
||
let c = nclx(&hdr.bytes).expect("colr nclx box");
|
||
assert_eq!(c, vec![0, 9, 0, 16, 0, 9, 0x80]);
|
||
assert!(hdr.bytes.windows(4).any(|w| w == b"clli"), "content light level box");
|
||
// a photo without an HDR edit exports as an ordinary (sRGB) AVIF
|
||
assert!(nclx(&sdr.bytes).is_none_or(|c| c[3] != 16));
|
||
}
|
||
|
||
/// "Remove location info" holds for the file-copying formats too: the sidecar and the DNG's
|
||
/// packet carry no location, and an original that embeds GPS is refused rather than copied.
|
||
#[test]
|
||
fn remove_location_holds_for_original_and_dng_exports() {
|
||
let dir = temp_dir("export-noloc");
|
||
let gps =
|
||
lightcraft_meta::Metadata { gps: Some(lightcraft_meta::Gps { latitude: 48.85, longitude: 2.35, altitude: None }), ..Default::default() };
|
||
// a raw with no embedded GPS, located in the catalog
|
||
let raw = dir.join("Raw.dng");
|
||
std::fs::write(&raw, synthetic_dng_with(None, Default::default())).unwrap();
|
||
// a JPEG with GPS in its EXIF
|
||
let jpg = dir.join("Geo.jpg");
|
||
let img = lightcraft_raster::Rgba8::from_fn(32, 24, |x, y| [(x * 8) as u8, (y * 10) as u8, 90, 255]);
|
||
let exif = lightcraft_meta::write_exif(&gps);
|
||
let meta = lightcraft_codecs::EncodeMeta { exif: Some(&exif), ..Default::default() };
|
||
std::fs::write(&jpg, lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, Default::default(), &meta).unwrap())
|
||
.unwrap();
|
||
let mut s = Session::new().with_fs();
|
||
s.execute("library.import", &json!({"paths": [raw.to_string_lossy(), jpg.to_string_lossy()]})).unwrap();
|
||
let id = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id;
|
||
let (rid, jid) = (id(&s, "Raw.dng"), id(&s, "Geo.jpg"));
|
||
s.execute("photo.setMeta", &json!({"ids": [rid.0, jid.0], "gps": [48.85, 2.35], "city": "Paris"})).unwrap();
|
||
let opts = |f: &str, remove: bool| ExportOptions::from_json(&json!({"format": f, "removeLocation": remove}));
|
||
|
||
// original raw: the sidecar keeps the location unless it is removed
|
||
let side = |e: &crate::export::Exported| lightcraft_meta::parse_xmp(std::str::from_utf8(&e.sidecars[0].1).unwrap()).unwrap().metadata;
|
||
let kept = side(&export_photo(&mut s, rid, &opts("original", false), 1).unwrap());
|
||
assert!(kept.gps.is_some() && kept.city.is_some());
|
||
let gone = side(&export_photo(&mut s, rid, &opts("original", true), 1).unwrap());
|
||
assert!(gone.gps.is_none() && gone.city.is_none(), "{gone:?}");
|
||
|
||
// dng: the embedded packet
|
||
let e = export_photo(&mut s, rid, &opts("dng", false), 1).unwrap();
|
||
assert!(lightcraft_meta::extract(&e.bytes).gps.is_some());
|
||
let e = export_photo(&mut s, rid, &opts("dng", true), 1).unwrap();
|
||
let m = lightcraft_meta::extract(&e.bytes);
|
||
assert!(m.gps.is_none() && m.city.is_none(), "{m:?}");
|
||
|
||
// original JPEG embedding GPS: copied as is normally, refused when the location must go
|
||
let e = export_photo(&mut s, jid, &opts("original", false), 1).unwrap();
|
||
assert!(lightcraft_meta::extract(&e.bytes).gps.is_some());
|
||
let err = export_photo(&mut s, jid, &opts("original", true), 1).err().unwrap();
|
||
assert!(err.contains("can't remove the location") && err.contains("export as JPEG"), "{err}");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// An original raw whose own metadata holds GPS is refused under "Remove location info".
|
||
#[test]
|
||
fn remove_location_refuses_a_raw_that_embeds_gps() {
|
||
let dir = temp_dir("export-noloc-raw");
|
||
let gps = lightcraft_meta::Metadata { gps: Some(lightcraft_meta::Gps { latitude: 10.0, longitude: 20.0, altitude: None }), ..Default::default() };
|
||
let raw = dir.join("Geo.dng");
|
||
let bytes = synthetic_dng_with(Some(&lightcraft_meta::write_xmp(&gps, None)), Default::default());
|
||
assert!(lightcraft_meta::extract(&bytes).gps.is_some(), "fixture embeds GPS");
|
||
std::fs::write(&raw, bytes).unwrap();
|
||
let mut s = Session::new().with_fs();
|
||
s.execute("library.import", &json!({"paths": [raw.to_string_lossy()]})).unwrap();
|
||
let id = s.catalog.photos().next().unwrap().id;
|
||
let err = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "original", "removeLocation": true})), 1).err().unwrap();
|
||
assert!(err.contains("can't remove the location"), "{err}");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn hdr_png_export_is_16_bit_pq_rec2020() {
|
||
let mut s = Session::with_demo();
|
||
let id = sunset(&mut s);
|
||
let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "png", "hdr": true}));
|
||
assert!(o.hdr_output());
|
||
assert_eq!(o.effective_space(), lightcraft_pipeline::OutputSpace::Rec2020);
|
||
// a photo without an HDR edit exports as an ordinary (sRGB, 8-bit) PNG
|
||
let sdr = export_photo(&mut s, id, &o, 1).unwrap();
|
||
assert!(!sdr.bytes.windows(4).any(|w| w == b"cICP"));
|
||
assert_eq!(sdr.bytes[24], 8, "8-bit");
|
||
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap();
|
||
let hdr = export_photo(&mut s, id, &o, 1).unwrap();
|
||
let at = hdr.bytes.windows(4).position(|w| w == b"cICP").expect("cICP chunk");
|
||
assert_eq!(&hdr.bytes[at + 4..at + 8], &[9, 16, 0, 1], "BT.2020, PQ, RGB, full range");
|
||
assert_eq!(hdr.bytes[24], 16, "16-bit");
|
||
// the highlights above SDR white are there: MaxCLL beyond the 203 cd/m² of SDR white
|
||
let at = hdr.bytes.windows(4).position(|w| w == b"cLLI").expect("cLLI chunk");
|
||
let max_cll = u32::from_be_bytes(hdr.bytes[at + 4..at + 8].try_into().unwrap()) as f32 / 10_000.0;
|
||
assert!(max_cll > 250.0 && max_cll <= 4.0 * 203.0 + 1.0, "MaxCLL {max_cll}");
|
||
// HDR output off: the HDR edit exports its SDR rendition
|
||
let plain = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"longEdge": 160, "format": "png"})), 1).unwrap();
|
||
assert!(!plain.bytes.windows(4).any(|w| w == b"cICP"));
|
||
}
|
||
|
||
#[test]
|
||
fn hdr_jxl_export_is_pq_rec2020() {
|
||
let mut s = Session::with_demo();
|
||
let id = sunset(&mut s);
|
||
let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "jxl", "hdr": true, "quality": 90}));
|
||
assert!(o.hdr_output());
|
||
let sdr = export_photo(&mut s, id, &o, 1).unwrap();
|
||
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap();
|
||
let hdr = export_photo(&mut s, id, &o, 1).unwrap();
|
||
let encoding = |b: &[u8]| {
|
||
let img = jxl_oxide::JxlImage::builder().read(std::io::Cursor::new(b)).unwrap();
|
||
let m = &img.image_header().metadata;
|
||
(format!("{:?}", m.colour_encoding), m.bit_depth.bits_per_sample())
|
||
};
|
||
let (c, bits) = encoding(&hdr.bytes);
|
||
assert!(c.contains("Bt2100") && c.contains("Pq"), "{c}");
|
||
assert_eq!(bits, 16);
|
||
// a photo without an HDR edit exports as an ordinary SDR file
|
||
let (c, _) = encoding(&sdr.bytes);
|
||
assert!(!c.contains("Pq"), "{c}");
|
||
}
|
||
|
||
#[test]
|
||
fn jxl_export_writes_metadata() {
|
||
let mut s = Session::with_demo();
|
||
let id = sunset(&mut s);
|
||
let o = ExportOptions::from_json(&json!({"longEdge": 120, "format": "jxl", "quality": 80}));
|
||
let e = export_photo(&mut s, id, &o, 1).unwrap();
|
||
let d = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap();
|
||
assert_eq!(d.width.max(d.height), 120);
|
||
assert!(d.xmp.as_deref().is_some_and(|x| x.contains("Golden horizon")), "title in the XMP box");
|
||
assert!(d.exif.is_some(), "EXIF box");
|
||
}
|