refactor(lightcraft-engine): move media.rs tests to crates/engine/src/media/tests.rs

This commit is contained in:
aiongg
2026-10-26 18:15:50 +00:00
parent 55c9eac7c7
commit 858046cc4f
2 changed files with 523 additions and 524 deletions
+1 -524
View File
@@ -1872,527 +1872,4 @@ mod thumbnail_hash_tests {
}
#[cfg(test)]
mod tests {
use super::*;
/// A loupe job with a display fit returns its image shrunk to the drawn size, aspect kept;
/// a draft, and a render no larger than the drawn size, are left as rendered.
#[test]
fn a_display_fit_shrinks_the_loupe_render_to_the_drawn_size() {
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let plain = s.loupe_job(id, 1024, 1024, true).unwrap().run().rendered.unwrap().image;
let long = plain.width.max(plain.height);
assert!(long >= 600, "the demo renders at least 600 px at 1024: {long}");
let fitted = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400).run().rendered.unwrap().image;
assert_eq!(fitted.width.max(fitted.height), 400, "{}x{}", fitted.width, fitted.height);
let aspect = |i: &Rgba8| i.width as f32 / i.height as f32;
assert!((aspect(&fitted) - aspect(&plain)).abs() < 0.02, "aspect kept");
let draft = s.loupe_job(id, 1024, 1024, true).unwrap().draft().with_display_fit(400, 400).run().rendered.unwrap().image;
assert_eq!(draft.width.max(draft.height), long, "a draft is not shrunk");
let same = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(plain.width, plain.height).run().rendered.unwrap().image;
assert_eq!((same.width, same.height), (plain.width, plain.height), "no larger than drawn: left as rendered");
let a = s.loupe_job(id, 1024, 1024, true).unwrap();
let b = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400);
assert_ne!(a.key, b.key, "the fit is part of the result key");
}
#[test]
fn renders_know_the_original_size_whichever_level_they_come_from() {
// issue #960: noise reduction works in the original's pixels, so a grid cell drawn from the
// 512-px thumbnail level denoises like a scaled-down export
let p = Photo::new(PhotoId(1), Source::File { path: "/photos/a.ARW".into() }, "a.ARW", "ARW", 6000, 4000, "");
assert_eq!(source_info(&p).native_long, Some(6000));
let decoded = SourceInfo { raw: true, as_shot_temp: 4800.0, ..Default::default() };
let thumb = DecodedSource::new(Arc::new(Rgb32f::new(8, 8)), Some(decoded.clone()));
let info = thumb.info_or(source_info(&p));
assert_eq!(info, SourceInfo { native_long: Some(6000), ..decoded }, "the decoder's facts, the catalog's size");
let unknown = Photo::new(PhotoId(2), Source::File { path: "/photos/b.jpg".into() }, "b.jpg", "JPEG", 0, 0, "");
assert_eq!(source_info(&unknown).native_long, None);
assert_eq!(source_info(&Photo::new(PhotoId(3), Source::Demo { scene: 0 }, "d", "ARW", 600, 400, "")).native_long, None);
}
#[test]
fn denoise_mix_is_reserved_before_the_source_enters_the_cache() {
let mut plain = Rgb32f::new(8, 8);
plain.data.fill([1.0; 3]);
let mut source = DecodedSource::new(Arc::new(plain), None);
source.denoised = Some(Arc::new(Twin::new(Arc::new(Rgb32f::new(8, 8)))));
let reserved = source.bytes();
assert!(reserved >= 3 * 8 * 8 * 12);
assert_eq!(source.image_for(0.5).data[0], [0.5; 3]);
assert_eq!(source.bytes(), reserved, "creating a mix cannot evade the LRU's recorded cost");
let _ = source.image_for(0.25);
assert_eq!(source.bytes(), reserved, "changing Amount replaces the reserved mix");
}
#[test]
fn hidden_grading_wheels_render_neutral_without_touching_the_photo() {
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
s.execute("develop.set", &serde_json::json!({"values": {"grading.shadows.sat": 60, "grading.highlights.sat": 40}})).unwrap();
let job = s.loupe_job(id, 64, 64, true).unwrap();
let same = job.clone().with_hidden_grading([false; 3]);
assert_eq!(same.key, job.key, "nothing hidden: the job is unchanged");
assert!(same.view_cache.is_some());
let hidden = job.clone().with_hidden_grading([true, false, false]);
assert_eq!(hidden.settings.grading.shadows.sat, 0.0);
assert_eq!(hidden.settings.grading.highlights.sat, 40.0);
assert_ne!(hidden.key, job.key, "a hidden wheel gets its own result key");
assert!(hidden.view_cache.is_none() && hidden.cache.is_none(), "never cached as the photo's preview");
assert_eq!(s.catalog.photo(id).unwrap().develop.grading.shadows.sat, 60.0, "the edit is untouched");
}
#[test]
fn decoder_info_survives_render_jobs_cache_and_eviction() {
let tone = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| {
let x = 0.01 * (i + 1) as f32;
[x, (x * 2.0).min(0.9)]
}))
.unwrap();
let info = SourceInfo { raw: true, relative_wb: true, camera_tone: Some(tone), ..Default::default() };
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let mut job = s.render_job(id, 64, 64, false, true).unwrap();
let loaded_info = info.clone();
job.source = SourceRef::File {
path: "synthetic.arw".into(),
max_edge: 64,
loader: Some(Arc::new(move |_, _| {
let mut image = Rgb32f::new(64, 64);
image.data.fill([0.1; 3]);
Ok((image, loaded_info.clone()))
})),
fallback: None,
denoise: None,
reduced: None,
};
job.info = SourceInfo::default(); // Header facts cannot override decoder facts.
job.settings = Arc::new(DevelopSettings::default());
let r = job.clone().run();
assert_eq!(r.loaded.as_ref().unwrap().info.as_ref(), Some(&info));
let expected = lightcraft_pipeline::render(&r.loaded.as_ref().unwrap().image, &info, &job.settings, &job.request);
assert_eq!(r.rendered.as_ref().unwrap().image.data, expected.image.data);
s.accept(&r);
job.source = s.media.source_ref(s.catalog.photo(id).unwrap(), job.level);
let again = job.run();
assert!(again.loaded.is_none());
assert_eq!(r.rendered.unwrap().image.data, again.rendered.unwrap().image.data);
s.media.forget(id);
assert!(s.media.get_source(id, r.level).is_none());
}
/// Issue #730: an ILCE-7M3 ARW from an older catalog (white balance relative to the as-shot look, 6500 / 0 as
/// its as-shot white, a Custom value on that scale) renders pixel for pixel as it did once the camera's matrices
/// give it a Kelvin scale; the decode teaches the catalog the real as-shot white (bookkeeping, not an undo step),
/// and the panel then reads As Shot in Kelvin while the stored settings keep their look.
#[test]
fn old_relative_white_balance_on_a_kelvin_camera_keeps_its_look_and_learns_the_as_shot_white() {
use lightcraft_develop::{WbMode, WbScale};
let mut s = crate::Session::new();
let id = s.catalog.alloc_photo_id();
let mut p = Photo::new(id, Source::File { path: "/photos/DSC00001.ARW".into() }, "DSC00001.ARW", "ARW", 64, 64, "2026-01-01T00:00:00");
p.kind = MediaKind::Raw;
p.meta.camera = "SONY ILCE-7M3".into();
p.as_shot_wb = Some((6500.0, 0.0));
let mut d = lightcraft_develop::DevelopSettings::for_raw(6500.0, 0.0);
(d.wb.mode, d.wb.temp, d.wb.tint) = (WbMode::Custom, 5800.0, 3.0);
assert_eq!(d.wb.scale, WbScale::Legacy);
p.develop = Arc::new(d.clone());
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
s.selection.ids = vec![id];
s.selection.active = Some(id);
// the catalog's view of the source before the file is read: Kelvin scale, as-shot white not yet known
let header = s.source_info(id);
assert!(header.raw && !header.relative_wb && header.legacy_relative_wb);
assert_eq!((header.as_shot_temp, header.as_shot_tint), (6500.0, 0.0));
// the decode: what `files::load_bytes` reports for this camera (the as-shot white through its matrices)
let decoded = SourceInfo { raw: true, legacy_relative_wb: true, as_shot_temp: 4986.0, as_shot_tint: -2.0, ..Default::default() };
let mut pixels = Rgb32f::new(64, 64);
for (i, px) in pixels.data.iter_mut().enumerate() {
let v = (i % 64) as f32 / 64.0;
*px = [0.05 + 0.6 * v, 0.04 + 0.4 * v, 0.03 + 0.3 * v];
}
let mut job = s.render_job(id, 64, 64, false, true).unwrap();
let (loaded_info, loaded_pixels) = (decoded.clone(), pixels.clone());
job.source = SourceRef::File {
path: "/photos/DSC00001.ARW".into(),
max_edge: 64,
loader: Some(Arc::new(move |_, _| Ok((loaded_pixels.clone(), loaded_info.clone())))),
fallback: None,
denoise: None,
reduced: None,
};
let undo_before = s.undo.len();
let request = job.request;
let r = job.run();
let new = r.rendered.as_ref().unwrap().image.data.clone();
// the old pipeline: the same settings against the relative-scale source facts of an older build
let old_info = SourceInfo { raw: true, relative_wb: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, ..Default::default() };
let old = lightcraft_pipeline::render(&pixels, &old_info, &d, &request).image.data;
assert_eq!(new, old, "the look of an existing edit changed");
assert_ne!(
new,
lightcraft_pipeline::render(&pixels, &decoded, &lightcraft_develop::DevelopSettings::for_raw(4986.0, -2.0), &request).image.data
);
// accepting the result records the as-shot white, journaled but not undoable, and the settings are untouched
s.accept(&r);
let p = s.catalog.photo(id).unwrap();
assert_eq!(p.as_shot_wb, Some((4986.0, -2.0)));
assert_eq!(*p.develop, d);
assert_eq!(s.undo.len(), undo_before);
assert!(s.pending_log.iter().any(|op| matches!(op, Op::SetAsShotWb { id: i, wb: Some((4986.0, -2.0)) } if *i == id)));
assert!(!p.is_edited() || p.develop.wb.mode == WbMode::Custom);
// the panel reads the white balance in effect in Kelvin: the value the relative edit renders as
let info = s.source_info(id);
assert_eq!((info.as_shot_temp, info.as_shot_tint), (4986.0, -2.0));
let shown = lightcraft_pipeline::local::effective_wb(&info, &d);
assert_eq!(shown, lightcraft_pipeline::local::legacy_to_kelvin(5800.0, 3.0, (4986.0, -2.0)));
assert!(shown.0 < 4986.0 && shown.0 > 4000.0, "{shown:?}");
// accepting again changes nothing more
let log = s.pending_log.len();
s.accept(&r);
assert_eq!(s.pending_log.len(), log);
// a catalog-only (proxy) render of the same settings before the decode gave the same pixels too
let proxy = lightcraft_pipeline::render(&pixels, &header, &d, &request).image.data;
assert_eq!(proxy, old, "a smart preview of an existing edit changed");
}
#[test]
fn budget_evicts_least_recently_used_across_caches() {
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
let mut m = MediaCache::default();
let mb = 1 << 20;
m.set_budget(30 * mb);
// 3 previews of 12 MB (1000² × 12 B) don't fit 30 MB: the least recently used goes
m.insert(PhotoId(1), SourceLevel::Preview, img(1000));
m.insert(PhotoId(2), SourceLevel::Preview, img(1000));
assert!(m.get(PhotoId(1), SourceLevel::Preview).is_some()); // 1 is now more recent than 2
m.insert(PhotoId(3), SourceLevel::Preview, img(1000));
assert!(m.get(PhotoId(2), SourceLevel::Preview).is_none());
assert!(m.get(PhotoId(1), SourceLevel::Preview).is_some() && m.get(PhotoId(3), SourceLevel::Preview).is_some());
// a thumbnail source and a rendered preview compete in the same budget
m.insert(PhotoId(4), SourceLevel::Thumb, img(500));
m.rendered.put(Hash128(7), Arc::new(lightcraft_raster::Rgba8::new(1000, 1000)));
assert!(m.held() <= 30 * mb, "{}", m.held());
// the newest preview (the one on screen) is never evicted, even alone over budget
m.set_budget(mb);
assert!(m.get(PhotoId(3), SourceLevel::Preview).is_some());
assert_eq!(m.usage().1.count, 1);
assert_eq!(m.usage().0.count, 0);
assert_eq!(m.rendered.mem_usage().0, 0);
}
// Given Compare zoomed to 1:1 (two photos' originals), both stay decoded; back to one view,
// the least recently used goes, and the budget still evicts them like any source
#[test]
fn full_size_originals_are_kept_up_to_the_capacity() {
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
let mut m = MediaCache::default();
let mb = 1 << 20;
m.set_budget(100 * mb);
m.insert(PhotoId(1), SourceLevel::Full, img(1000));
m.insert(PhotoId(2), SourceLevel::Full, img(1000));
assert!(!m.has_source(PhotoId(1), SourceLevel::Full), "one original by default");
m.set_full_capacity(2);
m.insert(PhotoId(1), SourceLevel::Full, img(1000));
assert!(m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full));
assert_eq!(m.usage().2.count, 2);
// re-inserting a held photo replaces it instead of taking a second place
m.insert(PhotoId(2), SourceLevel::Full, img(1000));
assert_eq!(m.usage().2.count, 2);
// 1 is the least recently used now
m.set_full_capacity(1);
assert!(!m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full));
m.set_full_capacity(0);
assert_eq!(m.usage().2.count, 1, "never less than one");
m.set_full_capacity(2);
m.insert(PhotoId(3), SourceLevel::Full, img(1000));
m.set_budget(15 * mb);
assert_eq!(m.usage().2.count, 1, "the budget evicts the older original");
assert!(m.has_source(PhotoId(3), SourceLevel::Full));
}
// Given the on-screen original alone over the budget, the photo's other sources and its neighbour's stay
// (counted, it evicted them all: each edit decoded the raw again for the thumbnail and the whole-frame render)
#[test]
fn the_on_screen_original_does_not_evict_the_other_sources() {
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
let mut m = MediaCache::default();
let mb = 1 << 20;
m.set_budget(20 * mb);
m.set_on_screen(Some(PhotoId(1)));
m.insert(PhotoId(1), SourceLevel::Preview, img(500));
m.insert(PhotoId(1), SourceLevel::Thumb, img(200));
m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB
m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour
for (id, level) in [(1, SourceLevel::Preview), (1, SourceLevel::Thumb), (1, SourceLevel::Full), (2, SourceLevel::Preview)] {
assert!(m.has_source(PhotoId(id), level), "{id} {level:?}");
}
}
// A 64 MP original (797 MB as linear float) is bigger than the default cache share (768 MiB):
// evicted on insert, every pan of a zoomed view decoded the raw again. The original of the photo
// on screen stays, whatever is decoded after it (prefetched neighbours), until another is shown.
#[test]
fn the_original_on_screen_stays_even_over_budget() {
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
let mut m = MediaCache::default();
let mb = 1 << 20;
m.set_budget(20 * mb);
m.set_on_screen(Some(PhotoId(1)));
m.insert(PhotoId(1), SourceLevel::Preview, img(500));
m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB
assert!(m.has_source(PhotoId(1), SourceLevel::Full));
m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour
m.insert(PhotoId(3), SourceLevel::Thumb, img(100));
assert!(m.has_source(PhotoId(1), SourceLevel::Full));
m.set_on_screen(Some(PhotoId(2)));
assert!(!m.has_source(PhotoId(1), SourceLevel::Full));
assert!(m.held() <= 20 * mb, "{}", m.held());
}
#[test]
fn work_gate_waits_for_room() {
use crate::memory::WorkGate;
let g = Arc::new(WorkGate::new(100));
let a = g.acquire(80);
// more than fits waits until `a` is released; urgent work never waits
let urgent = g.acquire_urgent(500);
assert_eq!(g.usage().0, 580);
drop(urgent);
let g2 = g.clone();
let t = std::thread::spawn(move || {
let _b = g2.acquire(50);
g2.usage().0
});
std::thread::sleep(std::time::Duration::from_millis(50));
assert_eq!(g.usage().0, 80, "the second holder is still waiting");
drop(a);
assert_eq!(t.join().unwrap(), 50);
assert_eq!(g.usage().0, 0);
// a single holder may exceed the limit
drop(g.acquire(1000));
}
#[test]
fn levels_cover_sizes_and_large_exports_use_the_original() {
assert_eq!(SourceLevel::for_size(256), SourceLevel::Thumb);
assert_eq!(SourceLevel::for_size(2560), SourceLevel::Preview);
assert_eq!(SourceLevel::for_size(2561), SourceLevel::Full);
let mut s = crate::Session::with_demo();
let p = s.catalog.photos().next().unwrap().clone();
let long = p.width.max(p.height) as usize;
assert!(long > 2560, "demo photos are camera-sized");
// a full-size export reads the source at its native size, not an upscaled preview
let job = s.render_job(p.id, long, long, false, true).unwrap();
assert_eq!(job.level, SourceLevel::Full);
match &job.source {
SourceRef::Demo { max_edge, .. } => assert_eq!(*max_edge, long),
_ => panic!("demo source expected"),
}
let loupe = s.render_job(p.id, 1600, 1600, false, true).unwrap();
assert_eq!(loupe.level, SourceLevel::Preview);
assert_ne!(loupe.key, s.render_job(p.id, 1600, 1600, false, true).unwrap().draft().key);
// two photos with identical settings and size never share a result key
let plain: Vec<_> = s.catalog.photos().filter(|p| !p.is_edited()).map(|p| p.id).take(2).collect();
let (a, b) = (s.render_job(plain[0], 1600, 1600, false, true).unwrap(), s.render_job(plain[1], 1600, 1600, false, true).unwrap());
assert_ne!(a.key, b.key);
}
// Issue #323: a window of a 1:1 view is its own job: the window's pixels, at the zoom scale,
// from the original, with a key of its own
#[test]
fn region_jobs_render_a_window_of_the_zoomed_frame() {
use lightcraft_pipeline::PixelWindow;
let mut s = crate::Session::with_demo();
let p = s.catalog.photos().next().unwrap().clone();
let (w, h) = (p.width as usize, p.height as usize);
assert!(w.max(h) > 2560, "demo photos are camera-sized");
let win = PixelWindow { x: w / 3, y: h / 3, w: 300, h: 200 };
let job = s.region_job(p.id, w, h, win, true).unwrap();
assert_eq!(job.level, SourceLevel::Full, "a 1:1 window reads the original");
let img = job.run().rendered.unwrap().image;
assert_eq!((img.width, img.height), (300, 200));
// other windows, and the whole-frame job, never share a key
let other = s.region_job(p.id, w, h, PixelWindow { x: win.x + 64, ..win }, true).unwrap();
let whole = s.render_job(p.id, w, h, false, true).unwrap();
let again = s.region_job(p.id, w, h, win, true).unwrap();
assert_ne!(other.key, again.key);
assert_ne!(whole.key, again.key);
assert_eq!(s.region_job(p.id, w, h, win, true).unwrap().key, again.key, "the same window keeps its key");
// a window never writes the photo's view preview or the thumbnail cache
assert!(again.view_cache.is_none() && again.cache.is_none());
}
// Issue #323: a tight crop shown at its own pixels needs the original, not the 2560 px preview
#[test]
fn a_tight_crop_reads_the_source_level_its_pixels_need() {
let mut s = crate::Session::with_demo();
let p = s.catalog.photos().next().unwrap().clone();
let long = p.width.max(p.height) as usize;
assert!(long > 2560);
let id = p.id;
let mut d = (*s.develop_of(id).unwrap()).clone();
// the uncropped photo at 1600 px: the preview is plenty
assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Preview);
// a crop to 30 % of each side shown 1600 px long needs 1600 / 0.3 px of the source
d.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.2, y0: 0.2, x1: 0.5, y1: 0.5 };
s.set_develop(id, d, "Crop").unwrap();
assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Full);
// …but with the crop tool open (the whole photo shown) it does not
assert_eq!(s.render_job(id, 1600, 1600, false, false).unwrap().level, SourceLevel::Preview);
// and a crop shown small enough still reads the preview
assert_eq!(s.render_job(id, 600, 600, false, true).unwrap().level, SourceLevel::Preview);
}
// a window whose spot reads from further away than a render can hold is refused
#[test]
fn a_region_job_is_refused_when_a_spot_reads_beyond_what_fits() {
use lightcraft_pipeline::PixelWindow;
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let p = s.catalog.photo(id).unwrap().clone();
let (w, h) = (p.width as usize * 8, p.height as usize * 8);
let mut d = (*s.develop_of(id).unwrap()).clone();
d.spots.push(lightcraft_develop::Spot {
points: vec![lightcraft_geom::Point::new(0.2, 0.5)],
size: 0.01,
source_offset: Some(lightcraft_geom::Point::new(0.6, 0.0)),
..Default::default()
});
s.set_develop(id, d, "Spot").unwrap();
let win = PixelWindow { x: (w as f64 * 0.2) as usize - 100, y: h / 2 - 100, w: 400, h: 300 };
assert!(s.region_job(id, w, h, win, true).is_none());
// a window elsewhere is fine
assert!(s.region_job(id, w, h, PixelWindow { x: 5000, y: 5000, w: 400, h: 300 }, true).is_some());
}
// Issue #323: the Before side of a Before/After view at 1:1 is a window too, of the photo
// without its edits (the crop is kept), with a key of its own
#[test]
fn a_before_window_shows_the_unedited_look() {
use lightcraft_pipeline::PixelWindow;
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let p = s.catalog.photo(id).unwrap().clone();
let (w, h) = (p.width as usize, p.height as usize);
let mut d = (*s.develop_of(id).unwrap()).clone();
d.light.exposure = 2.0;
s.set_develop(id, d, "Exposure").unwrap();
let win = PixelWindow { x: w / 3, y: h / 3, w: 200, h: 150 };
let after = s.region_job(id, w, h, win, true).unwrap();
let before = s.region_job_before(id, w, h, win, true).unwrap();
assert_ne!(after.key, before.key);
let (a, b) = (after.run().rendered.unwrap().image, before.run().rendered.unwrap().image);
assert_eq!((a.width, a.height), (b.width, b.height));
let mean = |i: &Rgba8| i.data.iter().map(|p| p[1] as f64).sum::<f64>() / i.data.len() as f64;
assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b));
}
// LR-VIEW-ZOOM: what the loupe shows at 1:1 is what an export at full size writes. A window of
// the zoomed frame (the photo's own pixels) is pixel-for-pixel the export, at the centre and
// at a corner, with tone, colour and detail edits on a camera-sized photo
#[test]
fn one_to_one_window_pixels_are_export_pixels() {
use lightcraft_pipeline::{OutputDepth, OutputSpace, PixelWindow};
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let p = s.catalog.photo(id).unwrap().clone();
let (w, h) = (p.width as usize, p.height as usize);
assert!(w.max(h) > 2560, "larger than the preview cap: {w}×{h}");
let mut d = (*s.develop_of(id).unwrap()).clone();
d.light.exposure = 0.7;
d.light.contrast = 25.0;
d.color.saturation = 20.0;
s.set_develop(id, d, "Edits").unwrap();
let export = s.render_export(id, w, h, OutputSpace::Srgb, OutputDepth::U8).unwrap().image;
assert_eq!((export.width, export.height), (w, h), "a full-size export");
for win in [PixelWindow { x: w / 2 - 256, y: h / 2 - 256, w: 512, h: 384 }, PixelWindow { x: 0, y: h - 300, w: 400, h: 300 }] {
let tile = s.region_job(id, w, h, win, true).unwrap().run().rendered.unwrap().image;
assert_eq!((tile.width, tile.height), (win.w, win.h));
let mut worst = 0u8;
for y in 0..win.h {
for x in 0..win.w {
let (a, b) = (tile.data[y * win.w + x], export.data[(win.y + y) * w + win.x + x]);
for k in 0..3 {
worst = worst.max(a[k].abs_diff(b[k]));
}
}
}
assert!(worst <= 1, "window {win:?}: the 1:1 view differs from the export by up to {worst} levels");
}
}
/// The thumbnail of a photo whose preview-level source is decoded is cut from it: the file (here
/// one nothing can decode) is not read again.
#[test]
fn a_thumbnail_is_cut_from_the_decoded_preview() {
let mut s = crate::Session::new();
let p = Photo::new(PhotoId(1), Source::File { path: "/nowhere/a.ARW".into() }, "a.ARW", "ARW", 2400, 1600, "");
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap();
s.media.insert(p.id, SourceLevel::Preview, Arc::new(Rgb32f::from_fn(2400, 1600, |x, _| [x as f32 / 2400.0, 0.5, 0.25])));
let job = s.thumb_job(p.id, 512).unwrap();
assert!(matches!(job.source, SourceRef::Fit { .. }));
let r = job.run();
let thumb = r.rendered.as_ref().unwrap();
assert_eq!((thumb.image.width, thumb.image.height), (512, 341));
s.accept(&r);
let cached = s.media.get(p.id, SourceLevel::Thumb).unwrap();
assert_eq!((cached.width, cached.height), (512, 341), "a thumbnail-sized source is kept, not the preview");
}
#[test]
fn has_source_follows_what_the_session_accepted() {
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let p = s.catalog.photo(id).unwrap().clone();
let long = p.width.max(p.height) as usize;
assert!(!s.media.has_source(id, SourceLevel::Full));
let r = s.render_job(id, long, long, false, true).unwrap().run();
s.accept(&r);
assert!(s.media.has_source(id, SourceLevel::Full));
assert!(!s.media.has_source(id, SourceLevel::Preview));
assert!(!s.media.has_source(PhotoId(id.0 + 1), SourceLevel::Full));
}
#[test]
fn variant_jobs_have_their_own_keys_and_hit_the_cache() {
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let mine = (*s.develop_of(id).unwrap()).clone();
let with = |pid: &str| {
let mut d = mine.clone();
d.profile.id = pid.into();
d
};
let (a, b) = (with("lc.vivid"), with("lc.bw.sepia"));
let ja = s.variant_job(id, &a, 128).unwrap();
let jb = s.variant_job(id, &b, 128).unwrap();
assert_ne!(ja.key, jb.key, "keys differ per settings");
assert_eq!(ja.key, s.variant_job(id, &a, 128).unwrap().key, "stable");
assert_ne!(ja.key, s.variant_job(id, &a, 256).unwrap().key, "and per size");
assert_ne!(ja.key, s.thumb_job(id, 128).unwrap().key, "not the photo's own thumbnail");
assert_eq!(ja.level, SourceLevel::Thumb);
let (cache, ck) = ja.cache.clone().unwrap();
assert!(cache.get(ck).is_none());
let r = ja.run();
s.accept(&r);
let first = r.rendered.unwrap().image;
assert!(first.width.max(first.height) <= 128);
assert!(cache.get(ck).is_some(), "rendered variants are cached");
assert!(cache.get(jb.cache.as_ref().unwrap().1).is_none());
// a second job for the same variant is served from the cache, pixel for pixel
let again = s.variant_job(id, &a, 128).unwrap().run();
assert!(again.loaded.is_none());
assert_eq!(again.rendered.unwrap().image.as_bytes(), first.as_bytes());
let other = jb.run().rendered.unwrap().image;
assert_ne!(other.as_bytes(), first.as_bytes());
// the photo's own settings never changed
assert_eq!(*s.develop_of(id).unwrap(), mine);
// hover previews: a loupe-sized job with other settings, its own key
let pv = s.preview_job(id, 800, 600, true, &a).unwrap();
assert_ne!(pv.key, s.render_job(id, 800, 600, false, true).unwrap().key);
assert_eq!(*pv.settings, a);
}
}
mod tests;
+522
View File
@@ -0,0 +1,522 @@
use super::*;
/// A loupe job with a display fit returns its image shrunk to the drawn size, aspect kept;
/// a draft, and a render no larger than the drawn size, are left as rendered.
#[test]
fn a_display_fit_shrinks_the_loupe_render_to_the_drawn_size() {
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let plain = s.loupe_job(id, 1024, 1024, true).unwrap().run().rendered.unwrap().image;
let long = plain.width.max(plain.height);
assert!(long >= 600, "the demo renders at least 600 px at 1024: {long}");
let fitted = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400).run().rendered.unwrap().image;
assert_eq!(fitted.width.max(fitted.height), 400, "{}x{}", fitted.width, fitted.height);
let aspect = |i: &Rgba8| i.width as f32 / i.height as f32;
assert!((aspect(&fitted) - aspect(&plain)).abs() < 0.02, "aspect kept");
let draft = s.loupe_job(id, 1024, 1024, true).unwrap().draft().with_display_fit(400, 400).run().rendered.unwrap().image;
assert_eq!(draft.width.max(draft.height), long, "a draft is not shrunk");
let same = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(plain.width, plain.height).run().rendered.unwrap().image;
assert_eq!((same.width, same.height), (plain.width, plain.height), "no larger than drawn: left as rendered");
let a = s.loupe_job(id, 1024, 1024, true).unwrap();
let b = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400);
assert_ne!(a.key, b.key, "the fit is part of the result key");
}
#[test]
fn renders_know_the_original_size_whichever_level_they_come_from() {
// issue #960: noise reduction works in the original's pixels, so a grid cell drawn from the
// 512-px thumbnail level denoises like a scaled-down export
let p = Photo::new(PhotoId(1), Source::File { path: "/photos/a.ARW".into() }, "a.ARW", "ARW", 6000, 4000, "");
assert_eq!(source_info(&p).native_long, Some(6000));
let decoded = SourceInfo { raw: true, as_shot_temp: 4800.0, ..Default::default() };
let thumb = DecodedSource::new(Arc::new(Rgb32f::new(8, 8)), Some(decoded.clone()));
let info = thumb.info_or(source_info(&p));
assert_eq!(info, SourceInfo { native_long: Some(6000), ..decoded }, "the decoder's facts, the catalog's size");
let unknown = Photo::new(PhotoId(2), Source::File { path: "/photos/b.jpg".into() }, "b.jpg", "JPEG", 0, 0, "");
assert_eq!(source_info(&unknown).native_long, None);
assert_eq!(source_info(&Photo::new(PhotoId(3), Source::Demo { scene: 0 }, "d", "ARW", 600, 400, "")).native_long, None);
}
#[test]
fn denoise_mix_is_reserved_before_the_source_enters_the_cache() {
let mut plain = Rgb32f::new(8, 8);
plain.data.fill([1.0; 3]);
let mut source = DecodedSource::new(Arc::new(plain), None);
source.denoised = Some(Arc::new(Twin::new(Arc::new(Rgb32f::new(8, 8)))));
let reserved = source.bytes();
assert!(reserved >= 3 * 8 * 8 * 12);
assert_eq!(source.image_for(0.5).data[0], [0.5; 3]);
assert_eq!(source.bytes(), reserved, "creating a mix cannot evade the LRU's recorded cost");
let _ = source.image_for(0.25);
assert_eq!(source.bytes(), reserved, "changing Amount replaces the reserved mix");
}
#[test]
fn hidden_grading_wheels_render_neutral_without_touching_the_photo() {
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
s.execute("develop.set", &serde_json::json!({"values": {"grading.shadows.sat": 60, "grading.highlights.sat": 40}})).unwrap();
let job = s.loupe_job(id, 64, 64, true).unwrap();
let same = job.clone().with_hidden_grading([false; 3]);
assert_eq!(same.key, job.key, "nothing hidden: the job is unchanged");
assert!(same.view_cache.is_some());
let hidden = job.clone().with_hidden_grading([true, false, false]);
assert_eq!(hidden.settings.grading.shadows.sat, 0.0);
assert_eq!(hidden.settings.grading.highlights.sat, 40.0);
assert_ne!(hidden.key, job.key, "a hidden wheel gets its own result key");
assert!(hidden.view_cache.is_none() && hidden.cache.is_none(), "never cached as the photo's preview");
assert_eq!(s.catalog.photo(id).unwrap().develop.grading.shadows.sat, 60.0, "the edit is untouched");
}
#[test]
fn decoder_info_survives_render_jobs_cache_and_eviction() {
let tone = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| {
let x = 0.01 * (i + 1) as f32;
[x, (x * 2.0).min(0.9)]
}))
.unwrap();
let info = SourceInfo { raw: true, relative_wb: true, camera_tone: Some(tone), ..Default::default() };
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let mut job = s.render_job(id, 64, 64, false, true).unwrap();
let loaded_info = info.clone();
job.source = SourceRef::File {
path: "synthetic.arw".into(),
max_edge: 64,
loader: Some(Arc::new(move |_, _| {
let mut image = Rgb32f::new(64, 64);
image.data.fill([0.1; 3]);
Ok((image, loaded_info.clone()))
})),
fallback: None,
denoise: None,
reduced: None,
};
job.info = SourceInfo::default(); // Header facts cannot override decoder facts.
job.settings = Arc::new(DevelopSettings::default());
let r = job.clone().run();
assert_eq!(r.loaded.as_ref().unwrap().info.as_ref(), Some(&info));
let expected = lightcraft_pipeline::render(&r.loaded.as_ref().unwrap().image, &info, &job.settings, &job.request);
assert_eq!(r.rendered.as_ref().unwrap().image.data, expected.image.data);
s.accept(&r);
job.source = s.media.source_ref(s.catalog.photo(id).unwrap(), job.level);
let again = job.run();
assert!(again.loaded.is_none());
assert_eq!(r.rendered.unwrap().image.data, again.rendered.unwrap().image.data);
s.media.forget(id);
assert!(s.media.get_source(id, r.level).is_none());
}
/// Issue #730: an ILCE-7M3 ARW from an older catalog (white balance relative to the as-shot look, 6500 / 0 as
/// its as-shot white, a Custom value on that scale) renders pixel for pixel as it did once the camera's matrices
/// give it a Kelvin scale; the decode teaches the catalog the real as-shot white (bookkeeping, not an undo step),
/// and the panel then reads As Shot in Kelvin while the stored settings keep their look.
#[test]
fn old_relative_white_balance_on_a_kelvin_camera_keeps_its_look_and_learns_the_as_shot_white() {
use lightcraft_develop::{WbMode, WbScale};
let mut s = crate::Session::new();
let id = s.catalog.alloc_photo_id();
let mut p = Photo::new(id, Source::File { path: "/photos/DSC00001.ARW".into() }, "DSC00001.ARW", "ARW", 64, 64, "2026-01-01T00:00:00");
p.kind = MediaKind::Raw;
p.meta.camera = "SONY ILCE-7M3".into();
p.as_shot_wb = Some((6500.0, 0.0));
let mut d = lightcraft_develop::DevelopSettings::for_raw(6500.0, 0.0);
(d.wb.mode, d.wb.temp, d.wb.tint) = (WbMode::Custom, 5800.0, 3.0);
assert_eq!(d.wb.scale, WbScale::Legacy);
p.develop = Arc::new(d.clone());
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
s.selection.ids = vec![id];
s.selection.active = Some(id);
// the catalog's view of the source before the file is read: Kelvin scale, as-shot white not yet known
let header = s.source_info(id);
assert!(header.raw && !header.relative_wb && header.legacy_relative_wb);
assert_eq!((header.as_shot_temp, header.as_shot_tint), (6500.0, 0.0));
// the decode: what `files::load_bytes` reports for this camera (the as-shot white through its matrices)
let decoded = SourceInfo { raw: true, legacy_relative_wb: true, as_shot_temp: 4986.0, as_shot_tint: -2.0, ..Default::default() };
let mut pixels = Rgb32f::new(64, 64);
for (i, px) in pixels.data.iter_mut().enumerate() {
let v = (i % 64) as f32 / 64.0;
*px = [0.05 + 0.6 * v, 0.04 + 0.4 * v, 0.03 + 0.3 * v];
}
let mut job = s.render_job(id, 64, 64, false, true).unwrap();
let (loaded_info, loaded_pixels) = (decoded.clone(), pixels.clone());
job.source = SourceRef::File {
path: "/photos/DSC00001.ARW".into(),
max_edge: 64,
loader: Some(Arc::new(move |_, _| Ok((loaded_pixels.clone(), loaded_info.clone())))),
fallback: None,
denoise: None,
reduced: None,
};
let undo_before = s.undo.len();
let request = job.request;
let r = job.run();
let new = r.rendered.as_ref().unwrap().image.data.clone();
// the old pipeline: the same settings against the relative-scale source facts of an older build
let old_info = SourceInfo { raw: true, relative_wb: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, ..Default::default() };
let old = lightcraft_pipeline::render(&pixels, &old_info, &d, &request).image.data;
assert_eq!(new, old, "the look of an existing edit changed");
assert_ne!(
new,
lightcraft_pipeline::render(&pixels, &decoded, &lightcraft_develop::DevelopSettings::for_raw(4986.0, -2.0), &request).image.data
);
// accepting the result records the as-shot white, journaled but not undoable, and the settings are untouched
s.accept(&r);
let p = s.catalog.photo(id).unwrap();
assert_eq!(p.as_shot_wb, Some((4986.0, -2.0)));
assert_eq!(*p.develop, d);
assert_eq!(s.undo.len(), undo_before);
assert!(s.pending_log.iter().any(|op| matches!(op, Op::SetAsShotWb { id: i, wb: Some((4986.0, -2.0)) } if *i == id)));
assert!(!p.is_edited() || p.develop.wb.mode == WbMode::Custom);
// the panel reads the white balance in effect in Kelvin: the value the relative edit renders as
let info = s.source_info(id);
assert_eq!((info.as_shot_temp, info.as_shot_tint), (4986.0, -2.0));
let shown = lightcraft_pipeline::local::effective_wb(&info, &d);
assert_eq!(shown, lightcraft_pipeline::local::legacy_to_kelvin(5800.0, 3.0, (4986.0, -2.0)));
assert!(shown.0 < 4986.0 && shown.0 > 4000.0, "{shown:?}");
// accepting again changes nothing more
let log = s.pending_log.len();
s.accept(&r);
assert_eq!(s.pending_log.len(), log);
// a catalog-only (proxy) render of the same settings before the decode gave the same pixels too
let proxy = lightcraft_pipeline::render(&pixels, &header, &d, &request).image.data;
assert_eq!(proxy, old, "a smart preview of an existing edit changed");
}
#[test]
fn budget_evicts_least_recently_used_across_caches() {
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
let mut m = MediaCache::default();
let mb = 1 << 20;
m.set_budget(30 * mb);
// 3 previews of 12 MB (1000² × 12 B) don't fit 30 MB: the least recently used goes
m.insert(PhotoId(1), SourceLevel::Preview, img(1000));
m.insert(PhotoId(2), SourceLevel::Preview, img(1000));
assert!(m.get(PhotoId(1), SourceLevel::Preview).is_some()); // 1 is now more recent than 2
m.insert(PhotoId(3), SourceLevel::Preview, img(1000));
assert!(m.get(PhotoId(2), SourceLevel::Preview).is_none());
assert!(m.get(PhotoId(1), SourceLevel::Preview).is_some() && m.get(PhotoId(3), SourceLevel::Preview).is_some());
// a thumbnail source and a rendered preview compete in the same budget
m.insert(PhotoId(4), SourceLevel::Thumb, img(500));
m.rendered.put(Hash128(7), Arc::new(lightcraft_raster::Rgba8::new(1000, 1000)));
assert!(m.held() <= 30 * mb, "{}", m.held());
// the newest preview (the one on screen) is never evicted, even alone over budget
m.set_budget(mb);
assert!(m.get(PhotoId(3), SourceLevel::Preview).is_some());
assert_eq!(m.usage().1.count, 1);
assert_eq!(m.usage().0.count, 0);
assert_eq!(m.rendered.mem_usage().0, 0);
}
// Given Compare zoomed to 1:1 (two photos' originals), both stay decoded; back to one view,
// the least recently used goes, and the budget still evicts them like any source
#[test]
fn full_size_originals_are_kept_up_to_the_capacity() {
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
let mut m = MediaCache::default();
let mb = 1 << 20;
m.set_budget(100 * mb);
m.insert(PhotoId(1), SourceLevel::Full, img(1000));
m.insert(PhotoId(2), SourceLevel::Full, img(1000));
assert!(!m.has_source(PhotoId(1), SourceLevel::Full), "one original by default");
m.set_full_capacity(2);
m.insert(PhotoId(1), SourceLevel::Full, img(1000));
assert!(m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full));
assert_eq!(m.usage().2.count, 2);
// re-inserting a held photo replaces it instead of taking a second place
m.insert(PhotoId(2), SourceLevel::Full, img(1000));
assert_eq!(m.usage().2.count, 2);
// 1 is the least recently used now
m.set_full_capacity(1);
assert!(!m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full));
m.set_full_capacity(0);
assert_eq!(m.usage().2.count, 1, "never less than one");
m.set_full_capacity(2);
m.insert(PhotoId(3), SourceLevel::Full, img(1000));
m.set_budget(15 * mb);
assert_eq!(m.usage().2.count, 1, "the budget evicts the older original");
assert!(m.has_source(PhotoId(3), SourceLevel::Full));
}
// Given the on-screen original alone over the budget, the photo's other sources and its neighbour's stay
// (counted, it evicted them all: each edit decoded the raw again for the thumbnail and the whole-frame render)
#[test]
fn the_on_screen_original_does_not_evict_the_other_sources() {
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
let mut m = MediaCache::default();
let mb = 1 << 20;
m.set_budget(20 * mb);
m.set_on_screen(Some(PhotoId(1)));
m.insert(PhotoId(1), SourceLevel::Preview, img(500));
m.insert(PhotoId(1), SourceLevel::Thumb, img(200));
m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB
m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour
for (id, level) in [(1, SourceLevel::Preview), (1, SourceLevel::Thumb), (1, SourceLevel::Full), (2, SourceLevel::Preview)] {
assert!(m.has_source(PhotoId(id), level), "{id} {level:?}");
}
}
// A 64 MP original (797 MB as linear float) is bigger than the default cache share (768 MiB):
// evicted on insert, every pan of a zoomed view decoded the raw again. The original of the photo
// on screen stays, whatever is decoded after it (prefetched neighbours), until another is shown.
#[test]
fn the_original_on_screen_stays_even_over_budget() {
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
let mut m = MediaCache::default();
let mb = 1 << 20;
m.set_budget(20 * mb);
m.set_on_screen(Some(PhotoId(1)));
m.insert(PhotoId(1), SourceLevel::Preview, img(500));
m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB
assert!(m.has_source(PhotoId(1), SourceLevel::Full));
m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour
m.insert(PhotoId(3), SourceLevel::Thumb, img(100));
assert!(m.has_source(PhotoId(1), SourceLevel::Full));
m.set_on_screen(Some(PhotoId(2)));
assert!(!m.has_source(PhotoId(1), SourceLevel::Full));
assert!(m.held() <= 20 * mb, "{}", m.held());
}
#[test]
fn work_gate_waits_for_room() {
use crate::memory::WorkGate;
let g = Arc::new(WorkGate::new(100));
let a = g.acquire(80);
// more than fits waits until `a` is released; urgent work never waits
let urgent = g.acquire_urgent(500);
assert_eq!(g.usage().0, 580);
drop(urgent);
let g2 = g.clone();
let t = std::thread::spawn(move || {
let _b = g2.acquire(50);
g2.usage().0
});
std::thread::sleep(std::time::Duration::from_millis(50));
assert_eq!(g.usage().0, 80, "the second holder is still waiting");
drop(a);
assert_eq!(t.join().unwrap(), 50);
assert_eq!(g.usage().0, 0);
// a single holder may exceed the limit
drop(g.acquire(1000));
}
#[test]
fn levels_cover_sizes_and_large_exports_use_the_original() {
assert_eq!(SourceLevel::for_size(256), SourceLevel::Thumb);
assert_eq!(SourceLevel::for_size(2560), SourceLevel::Preview);
assert_eq!(SourceLevel::for_size(2561), SourceLevel::Full);
let mut s = crate::Session::with_demo();
let p = s.catalog.photos().next().unwrap().clone();
let long = p.width.max(p.height) as usize;
assert!(long > 2560, "demo photos are camera-sized");
// a full-size export reads the source at its native size, not an upscaled preview
let job = s.render_job(p.id, long, long, false, true).unwrap();
assert_eq!(job.level, SourceLevel::Full);
match &job.source {
SourceRef::Demo { max_edge, .. } => assert_eq!(*max_edge, long),
_ => panic!("demo source expected"),
}
let loupe = s.render_job(p.id, 1600, 1600, false, true).unwrap();
assert_eq!(loupe.level, SourceLevel::Preview);
assert_ne!(loupe.key, s.render_job(p.id, 1600, 1600, false, true).unwrap().draft().key);
// two photos with identical settings and size never share a result key
let plain: Vec<_> = s.catalog.photos().filter(|p| !p.is_edited()).map(|p| p.id).take(2).collect();
let (a, b) = (s.render_job(plain[0], 1600, 1600, false, true).unwrap(), s.render_job(plain[1], 1600, 1600, false, true).unwrap());
assert_ne!(a.key, b.key);
}
// Issue #323: a window of a 1:1 view is its own job: the window's pixels, at the zoom scale,
// from the original, with a key of its own
#[test]
fn region_jobs_render_a_window_of_the_zoomed_frame() {
use lightcraft_pipeline::PixelWindow;
let mut s = crate::Session::with_demo();
let p = s.catalog.photos().next().unwrap().clone();
let (w, h) = (p.width as usize, p.height as usize);
assert!(w.max(h) > 2560, "demo photos are camera-sized");
let win = PixelWindow { x: w / 3, y: h / 3, w: 300, h: 200 };
let job = s.region_job(p.id, w, h, win, true).unwrap();
assert_eq!(job.level, SourceLevel::Full, "a 1:1 window reads the original");
let img = job.run().rendered.unwrap().image;
assert_eq!((img.width, img.height), (300, 200));
// other windows, and the whole-frame job, never share a key
let other = s.region_job(p.id, w, h, PixelWindow { x: win.x + 64, ..win }, true).unwrap();
let whole = s.render_job(p.id, w, h, false, true).unwrap();
let again = s.region_job(p.id, w, h, win, true).unwrap();
assert_ne!(other.key, again.key);
assert_ne!(whole.key, again.key);
assert_eq!(s.region_job(p.id, w, h, win, true).unwrap().key, again.key, "the same window keeps its key");
// a window never writes the photo's view preview or the thumbnail cache
assert!(again.view_cache.is_none() && again.cache.is_none());
}
// Issue #323: a tight crop shown at its own pixels needs the original, not the 2560 px preview
#[test]
fn a_tight_crop_reads_the_source_level_its_pixels_need() {
let mut s = crate::Session::with_demo();
let p = s.catalog.photos().next().unwrap().clone();
let long = p.width.max(p.height) as usize;
assert!(long > 2560);
let id = p.id;
let mut d = (*s.develop_of(id).unwrap()).clone();
// the uncropped photo at 1600 px: the preview is plenty
assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Preview);
// a crop to 30 % of each side shown 1600 px long needs 1600 / 0.3 px of the source
d.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.2, y0: 0.2, x1: 0.5, y1: 0.5 };
s.set_develop(id, d, "Crop").unwrap();
assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Full);
// …but with the crop tool open (the whole photo shown) it does not
assert_eq!(s.render_job(id, 1600, 1600, false, false).unwrap().level, SourceLevel::Preview);
// and a crop shown small enough still reads the preview
assert_eq!(s.render_job(id, 600, 600, false, true).unwrap().level, SourceLevel::Preview);
}
// a window whose spot reads from further away than a render can hold is refused
#[test]
fn a_region_job_is_refused_when_a_spot_reads_beyond_what_fits() {
use lightcraft_pipeline::PixelWindow;
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let p = s.catalog.photo(id).unwrap().clone();
let (w, h) = (p.width as usize * 8, p.height as usize * 8);
let mut d = (*s.develop_of(id).unwrap()).clone();
d.spots.push(lightcraft_develop::Spot {
points: vec![lightcraft_geom::Point::new(0.2, 0.5)],
size: 0.01,
source_offset: Some(lightcraft_geom::Point::new(0.6, 0.0)),
..Default::default()
});
s.set_develop(id, d, "Spot").unwrap();
let win = PixelWindow { x: (w as f64 * 0.2) as usize - 100, y: h / 2 - 100, w: 400, h: 300 };
assert!(s.region_job(id, w, h, win, true).is_none());
// a window elsewhere is fine
assert!(s.region_job(id, w, h, PixelWindow { x: 5000, y: 5000, w: 400, h: 300 }, true).is_some());
}
// Issue #323: the Before side of a Before/After view at 1:1 is a window too, of the photo
// without its edits (the crop is kept), with a key of its own
#[test]
fn a_before_window_shows_the_unedited_look() {
use lightcraft_pipeline::PixelWindow;
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let p = s.catalog.photo(id).unwrap().clone();
let (w, h) = (p.width as usize, p.height as usize);
let mut d = (*s.develop_of(id).unwrap()).clone();
d.light.exposure = 2.0;
s.set_develop(id, d, "Exposure").unwrap();
let win = PixelWindow { x: w / 3, y: h / 3, w: 200, h: 150 };
let after = s.region_job(id, w, h, win, true).unwrap();
let before = s.region_job_before(id, w, h, win, true).unwrap();
assert_ne!(after.key, before.key);
let (a, b) = (after.run().rendered.unwrap().image, before.run().rendered.unwrap().image);
assert_eq!((a.width, a.height), (b.width, b.height));
let mean = |i: &Rgba8| i.data.iter().map(|p| p[1] as f64).sum::<f64>() / i.data.len() as f64;
assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b));
}
// LR-VIEW-ZOOM: what the loupe shows at 1:1 is what an export at full size writes. A window of
// the zoomed frame (the photo's own pixels) is pixel-for-pixel the export, at the centre and
// at a corner, with tone, colour and detail edits on a camera-sized photo
#[test]
fn one_to_one_window_pixels_are_export_pixels() {
use lightcraft_pipeline::{OutputDepth, OutputSpace, PixelWindow};
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let p = s.catalog.photo(id).unwrap().clone();
let (w, h) = (p.width as usize, p.height as usize);
assert!(w.max(h) > 2560, "larger than the preview cap: {w}×{h}");
let mut d = (*s.develop_of(id).unwrap()).clone();
d.light.exposure = 0.7;
d.light.contrast = 25.0;
d.color.saturation = 20.0;
s.set_develop(id, d, "Edits").unwrap();
let export = s.render_export(id, w, h, OutputSpace::Srgb, OutputDepth::U8).unwrap().image;
assert_eq!((export.width, export.height), (w, h), "a full-size export");
for win in [PixelWindow { x: w / 2 - 256, y: h / 2 - 256, w: 512, h: 384 }, PixelWindow { x: 0, y: h - 300, w: 400, h: 300 }] {
let tile = s.region_job(id, w, h, win, true).unwrap().run().rendered.unwrap().image;
assert_eq!((tile.width, tile.height), (win.w, win.h));
let mut worst = 0u8;
for y in 0..win.h {
for x in 0..win.w {
let (a, b) = (tile.data[y * win.w + x], export.data[(win.y + y) * w + win.x + x]);
for k in 0..3 {
worst = worst.max(a[k].abs_diff(b[k]));
}
}
}
assert!(worst <= 1, "window {win:?}: the 1:1 view differs from the export by up to {worst} levels");
}
}
/// The thumbnail of a photo whose preview-level source is decoded is cut from it: the file (here
/// one nothing can decode) is not read again.
#[test]
fn a_thumbnail_is_cut_from_the_decoded_preview() {
let mut s = crate::Session::new();
let p = Photo::new(PhotoId(1), Source::File { path: "/nowhere/a.ARW".into() }, "a.ARW", "ARW", 2400, 1600, "");
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap();
s.media.insert(p.id, SourceLevel::Preview, Arc::new(Rgb32f::from_fn(2400, 1600, |x, _| [x as f32 / 2400.0, 0.5, 0.25])));
let job = s.thumb_job(p.id, 512).unwrap();
assert!(matches!(job.source, SourceRef::Fit { .. }));
let r = job.run();
let thumb = r.rendered.as_ref().unwrap();
assert_eq!((thumb.image.width, thumb.image.height), (512, 341));
s.accept(&r);
let cached = s.media.get(p.id, SourceLevel::Thumb).unwrap();
assert_eq!((cached.width, cached.height), (512, 341), "a thumbnail-sized source is kept, not the preview");
}
#[test]
fn has_source_follows_what_the_session_accepted() {
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let p = s.catalog.photo(id).unwrap().clone();
let long = p.width.max(p.height) as usize;
assert!(!s.media.has_source(id, SourceLevel::Full));
let r = s.render_job(id, long, long, false, true).unwrap().run();
s.accept(&r);
assert!(s.media.has_source(id, SourceLevel::Full));
assert!(!s.media.has_source(id, SourceLevel::Preview));
assert!(!s.media.has_source(PhotoId(id.0 + 1), SourceLevel::Full));
}
#[test]
fn variant_jobs_have_their_own_keys_and_hit_the_cache() {
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let mine = (*s.develop_of(id).unwrap()).clone();
let with = |pid: &str| {
let mut d = mine.clone();
d.profile.id = pid.into();
d
};
let (a, b) = (with("lc.vivid"), with("lc.bw.sepia"));
let ja = s.variant_job(id, &a, 128).unwrap();
let jb = s.variant_job(id, &b, 128).unwrap();
assert_ne!(ja.key, jb.key, "keys differ per settings");
assert_eq!(ja.key, s.variant_job(id, &a, 128).unwrap().key, "stable");
assert_ne!(ja.key, s.variant_job(id, &a, 256).unwrap().key, "and per size");
assert_ne!(ja.key, s.thumb_job(id, 128).unwrap().key, "not the photo's own thumbnail");
assert_eq!(ja.level, SourceLevel::Thumb);
let (cache, ck) = ja.cache.clone().unwrap();
assert!(cache.get(ck).is_none());
let r = ja.run();
s.accept(&r);
let first = r.rendered.unwrap().image;
assert!(first.width.max(first.height) <= 128);
assert!(cache.get(ck).is_some(), "rendered variants are cached");
assert!(cache.get(jb.cache.as_ref().unwrap().1).is_none());
// a second job for the same variant is served from the cache, pixel for pixel
let again = s.variant_job(id, &a, 128).unwrap().run();
assert!(again.loaded.is_none());
assert_eq!(again.rendered.unwrap().image.as_bytes(), first.as_bytes());
let other = jb.run().rendered.unwrap().image;
assert_ne!(other.as_bytes(), first.as_bytes());
// the photo's own settings never changed
assert_eq!(*s.develop_of(id).unwrap(), mine);
// hover previews: a loupe-sized job with other settings, its own key
let pv = s.preview_job(id, 800, 600, true, &a).unwrap();
assert_ne!(pv.key, s.render_job(id, 800, 600, false, true).unwrap().key);
assert_eq!(*pv.settings, a);
}