//! 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 = 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 = 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) {} 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. #[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::() / (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().bytes; if gpu { let why = lightcraft_gpu::last_fallback().unwrap_or_default(); assert!(why.contains("incomplete"), "{why}"); // the GPU is off for the process now: this export renders on the CPU assert!(!lightcraft_gpu::available()); } let cpu = export_photo(&mut s, id, &o, 1).unwrap().bytes; lightcraft_gpu::reset_failures(); let px = |b: &[u8]| lightcraft_codecs::decode(b, Default::default()).unwrap().image.data; assert!(px(&faulted) == px(&cpu), "the fallback is the CPU render"); let m = mean(&faulted); 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) { 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, 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"").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""); 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); }