diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index b63ccc2b4..fc7bef6b0 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -212,8 +212,10 @@ struct SmartCounts { fn original_only(source: crate::media::SourceRef) -> Option { use crate::media::SourceRef; match source { - SourceRef::File { path, max_edge, loader, denoise, .. } => Some(SourceRef::File { path, max_edge, loader, fallback: None, denoise }), - SourceRef::Loaded(d) if d.info.is_some() => Some(SourceRef::Loaded(d)), + SourceRef::File { path, max_edge, loader, denoise, .. } => { + Some(SourceRef::File { path, max_edge, loader, fallback: None, denoise, reduced: None }) + } + SourceRef::Loaded(d) if d.info.is_some() && !d.reduced => Some(SourceRef::Loaded(d)), _ => None, } } @@ -548,7 +550,8 @@ mod tests { let mut jobs = jobs_for(&mut s, id, Some(512)); assert_eq!(jobs.len(), 2, "view and thumbnail"); assert!(jobs[0].view_cache.is_some(), "the view renders first"); - jobs[1].source = SourceRef::File { path: "/nonexistent/photo.arw".into(), max_edge: 512, loader: None, fallback: None, denoise: None }; + jobs[1].source = + SourceRef::File { path: "/nonexistent/photo.arw".into(), max_edge: 512, loader: None, fallback: None, denoise: None, reduced: None }; assert!(run_photo(jobs), "the thumbnail rendered from the view's decoded source"); } diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 1f273f7ff..48e4ec20a 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -11,7 +11,7 @@ use lightcraft_pipeline::SourceInfo; use lightcraft_raster::Rgb32f; use lightcraft_raster::resample::{Filter, fit}; -use crate::media::{DenoiseSpec, FileLoader, FileProbe, PairLoader, PreviewLoader, ProbeInfo}; +use crate::media::{DenoiseSpec, FileLoader, FileProbe, PairLoader, PreviewLoader, ProbeInfo, ReducedLoader}; fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { let shutter = m.exposure_time.map(|t| if t >= 1.0 { format!("{t:.0}") } else { format!("1/{:.0}", 1.0 / t) }).unwrap_or_default(); @@ -366,20 +366,27 @@ pub fn bin_factor(raw: &lightcraft_raw::RawImage, max_edge: usize) -> Option Result<(Rgb32f, SourceInfo), String> { - rayon::scope(|_| load_bytes_now(std::borrow::Cow::Borrowed(bytes), max_edge, None).map(|(img, _, info)| (img, info))) + rayon::scope(|_| load_bytes_now(std::borrow::Cow::Borrowed(bytes), max_edge, None, false).map(|(img, _, info, _)| (img, info))) } /// [`load_bytes`] taking the file's bytes: a raw file's bytes are freed as soon as it is decoded /// (less memory held while it is developed). pub fn load_vec(bytes: Vec, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> { - rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None).map(|(img, _, info)| (img, info))) + rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None, false).map(|(img, _, info, _)| (img, info))) +} + +/// [`load_vec`] for a thumbnail: a DNG that keeps reduced-resolution copies of its raw image (Lightroom's HDR and +/// panorama merges) develops the smallest that covers `max_edge` instead of the whole image. The flag says whether +/// it did: such a picture is not the full image, and an export never renders from it. +pub fn load_vec_reduced(bytes: Vec, max_edge: usize) -> Result<(Rgb32f, SourceInfo, bool), String> { + rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None, true).map(|(img, _, info, reduced)| (img, info, reduced))) } /// [`load_vec`] that also develops the photo's cached denoised picture (`spec`) from the same decode: the plain /// picture, the denoised one (`None` when it cannot be read or does not fit this raw: the plain one is then all /// there is) and the source facts. Both pictures are the same size. pub fn load_vec_pair(bytes: Vec, max_edge: usize, spec: &DenoiseSpec) -> Result<(Rgb32f, Option, SourceInfo), String> { - rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, Some(spec))) + rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, Some(spec), false).map(|(img, twin, info, _)| (img, twin, info))) } /// The denoised camera RGB of `raw` from its cached product, as the plain development would have it: the default @@ -408,11 +415,15 @@ fn load_bytes_now( bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize, denoise: Option<&DenoiseSpec>, -) -> Result<(Rgb32f, Option, SourceInfo), String> { + allow_reduced: bool, +) -> Result<(Rgb32f, Option, SourceInfo, bool), String> { if lightcraft_raw::probe(&bytes).is_some() { - // a thumbnail or preview of a DNG that keeps smaller copies of its raw image (Lightroom's merges) develops - // the smallest that covers it; a denoised photo's twin needs the full mosaic - let reduced = (denoise.is_none() && max_edge < usize::MAX).then(|| lightcraft_raw::decode_reduced(&bytes, max_edge).ok().flatten()).flatten(); + // a thumbnail (`allow_reduced`, never an export) of a DNG that keeps smaller copies of its raw image + // (Lightroom's merges) develops the smallest that covers it; a denoised photo's twin needs the full mosaic + let reduced = (allow_reduced && denoise.is_none() && max_edge < usize::MAX) + .then(|| lightcraft_raw::decode_reduced(&bytes, max_edge).ok().flatten()) + .flatten(); + let used_reduced = reduced.is_some(); let decoded = match reduced { Some(r) => Ok(r), None => lightcraft_raw::decode(&bytes), @@ -422,7 +433,7 @@ fn load_bytes_now( Err(Ok(why)) => { // show the camera's embedded JPEG (rendered, not raw) until the variant is supported return load_embedded_preview(&bytes, max_edge) - .map(|(img, info)| (img, None, info)) + .map(|(img, info)| (img, None, info, false)) .ok_or(format!("unsupported raw ({why}) without an embedded preview")); } Err(Err(e)) => return Err(e), @@ -536,13 +547,14 @@ fn load_bytes_now( // (the catalog knows the original's size: `media::source_info`) native_long: None, }, + used_reduced, )); } let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?; drop(bytes); let img = d.to_working(); let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img }; - Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default())) + Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default(), false)) } /// Which white-balance scale a raw is edited on, decided from the file alone, the way the catalog decides it from @@ -1067,17 +1079,7 @@ pub fn fs_pair_loader() -> PairLoader { /// interactive loads (the loupe, exports) never wait — they are counted, so background work /// yields to them. pub fn fs_hooks() -> (FileLoader, FileProbe) { - let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| { - let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); - let weight = len * if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 3 } else { 6 }; - let gate = crate::memory::work_gate(); - let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) }; - let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; - let r = load_vec(bytes, max_edge); - // the file, the samples and the intermediate images are gone: give their pages back - crate::memory::release(); - r - }); + let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| fs_load(path, max_edge, |bytes| load_vec(bytes, max_edge))); let probe: FileProbe = Arc::new(|path: &str| { let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); let _permit = crate::memory::work_gate().acquire(len); @@ -1089,6 +1091,24 @@ pub fn fs_hooks() -> (FileLoader, FileProbe) { (loader, probe) } +/// The filesystem [`ReducedLoader`]: [`load_vec_reduced`] of the file, gated like [`fs_hooks`]' loader. +pub fn fs_reduced_loader() -> ReducedLoader { + Arc::new(|path: &str, max_edge: usize| fs_load(path, max_edge, |bytes| load_vec_reduced(bytes, max_edge))) +} + +/// Read `path` and `load` it, waiting on [`crate::memory::work_gate`] as [`fs_hooks`] explains. +fn fs_load(path: &str, max_edge: usize, load: impl FnOnce(Vec) -> Result) -> Result { + let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); + let weight = len.saturating_mul(if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 3 } else { 6 }); + let gate = crate::memory::work_gate(); + let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) }; + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + let r = load(bytes); + // the file, the samples and the intermediate images are gone: give their pages back + crate::memory::release(); + r +} + /// `path`'s modification time as ISO 8601 (see [`ProbeInfo::file_modified`]); `None` when the /// filesystem doesn't say or the time is before 1970. fn file_modified(path: &str) -> Option { @@ -1103,6 +1123,7 @@ impl crate::Session { pub fn with_fs(mut self) -> Self { let (l, p) = fs_hooks(); self.media.file_loader = Some(l); + self.media.reduced_loader = Some(fs_reduced_loader()); self.media.file_probe = Some(p); self.media.preview_loader = Some(fs_preview_loader()); self.media.denoise.loader = Some(fs_pair_loader()); @@ -2097,6 +2118,84 @@ pub(crate) mod tests { } } + /// A Lightroom merge (a LinearRaw DNG keeping a reduced copy of its raw image): its grid thumbnail develops the + /// copy, but an export never does, whatever its size, and not from the thumbnail's source in memory either. The + /// copy's samples are much brighter than the image's here, so a picture says which one it came from. + #[test] + fn exports_never_develop_a_dngs_reduced_copy() { + use lightcraft_tiff::tags::{self as t, photometric}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + use serde_json::json; + let linear = |subfile: u32, w: u32, h: u32, sample: u16| { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![subfile])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![photometric::LINEAR_RAW])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set(t::WHITE_LEVEL, Value::Long(vec![32768])); + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![sample.to_le_bytes().repeat((w * h * 3) as usize)] }); + ifd + }; + // (the copy must cover a thumbnail source's 512 px) + let (w, h) = (1200u32, 600u32); + let mut main = linear(0, w, h, 1500); + main.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![4, 2])); + main.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![w - 8, h - 4])); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 7, 0, 0])); + ifd0.set(t::MAKE, Value::Ascii("DJI".into())); + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); + ifd0.set(t::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![photometric::RGB])); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![4])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![2])); + ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd0.set(t::COMPRESSION, Value::Short(vec![1])); + ifd0.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![vec![128; 24]] }); + ifd0.add_sub_ifd(main); + ifd0.add_sub_ifd(linear(1, w / 2, h / 2, 24000)); + let bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + let g = |img: &Rgb32f| img.data.iter().map(|p| p[1] as f64).sum::() / img.data.len().max(1) as f64; + let mean = |img: &lightcraft_raster::Rgba8| img.data.iter().map(|p| p[1] as f64).sum::() / img.data.len().max(1) as f64; + + // the loaders: only the thumbnail's develops the copy, and only when it covers the size + let (copy, _, used) = load_vec_reduced(bytes.clone(), 512).unwrap(); + assert!(used, "a thumbnail develops the copy"); + assert!(!load_vec_reduced(bytes.clone(), 1024).unwrap().2, "no copy covers 1024 px"); + let (full, _) = load_vec(bytes.clone(), 512).unwrap(); + assert!(g(©) > 4.0 * g(&full), "the fixture's copy is much brighter: {} vs {}", g(©), g(&full)); + + let dir = std::env::temp_dir().join(format!("lc-reduced-export-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("merge-pano.dng"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = crate::Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); + let (space, depth) = (lightcraft_pipeline::OutputSpace::Srgb, lightcraft_pipeline::OutputDepth::U8); + // an export at a thumbnail's size, before any thumbnail + assert_eq!(s.export_job(id, 64, 64, space, depth).unwrap().level, crate::media::SourceLevel::Thumb); + let export = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image); + // the thumbnail develops the copy and its source stays in memory… + s.media.clear_sources(); + let thumb = s.thumb_job(id, 64).unwrap().run(); + assert!(thumb.loaded.as_ref().is_some_and(|l| l.reduced)); + let thumb_mean = mean(&thumb.rendered.as_ref().unwrap().image); + s.accept(&thumb); + assert!(thumb_mean > export + 20.0, "the thumbnail shows the copy: {thumb_mean} vs {export}"); + // …and an export after it still reads the full image + let job = s.export_job(id, 64, 64, space, depth).unwrap(); + assert!(matches!(&job.source, crate::media::SourceRef::File { reduced: None, .. }), "the file, not the copy"); + let again = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image); + assert!((again - export).abs() < 1.0, "{again} vs {export}"); + let _ = std::fs::remove_dir_all(&dir); + } + /// iPhone ProRAW-style semantic masks drive the Sky mask: Apple's sky matte (here the right /// half of the sensor, i.e. the bottom of the photo once turned 90° clockwise — where the sky /// heuristic would never look) is used; a matte with an unknown name is ignored. diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 22d76528d..5de0cc8b5 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -132,6 +132,11 @@ fn source_edge_needed(p: &Photo, settings: &DevelopSettings, apply_crop: bool, o /// Decodes a file into a linear Rec.2020 image no larger than `max_edge` (set by the app). pub type FileLoader = Arc Result<(Rgb32f, SourceInfo), String> + Send + Sync>; +/// [`FileLoader`] for a thumbnail: a raw file that keeps smaller copies of its raw image (Lightroom's merged DNGs) +/// develops the smallest that covers `max_edge` (set by the app); the flag says it did. Never used for an export, +/// which must render the full image. +pub type ReducedLoader = Arc Result<(Rgb32f, SourceInfo, bool), String> + Send + Sync>; + /// A raw file's embedded (camera-rendered) preview as display sRGB, oriented, no larger than /// `max_edge` (set by the app). `None`: no usable preview. pub type PreviewLoader = Arc Option + Send + Sync>; @@ -183,11 +188,14 @@ pub struct DecodedSource { pub camera_tone: Option, /// The same picture developed from the AI-denoised mosaic, when the photo has one cached. pub denoised: Option>, + /// Developed from a smaller copy of the raw image that the file keeps ([`ReducedLoader`]), not from the image + /// itself: good for a thumbnail, never for an export. + pub reduced: bool, } impl DecodedSource { pub fn new(image: Arc, info: Option) -> Self { - DecodedSource { image, info, camera_tone: None, denoised: None } + DecodedSource { image, info, camera_tone: None, denoised: None, reduced: false } } /// The picture to develop for a Denoise `amount` (0 to 1, see [`DevelopSettings::denoise_amount`]): the plain one, @@ -258,6 +266,8 @@ pub enum SourceRef { fallback: Option, /// The photo has a cached denoised picture: load it with the plain one (see [`PairLoader`]). denoise: Option<(DenoiseSpec, PairLoader)>, + /// A thumbnail's loader, used instead of `loader` (see [`ReducedLoader`]); `None` for every other job. + reduced: Option, }, /// A smart preview standing in for a missing original. Smart { @@ -281,10 +291,13 @@ impl SourceRef { return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() }); } SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?, - SourceRef::File { path, max_edge, loader, fallback, denoise } => { - let plain = || match loader { - Some(l) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))), - None => Err(format!("no decoder available for {path}")), + SourceRef::File { path, max_edge, loader, fallback, denoise, reduced } => { + let plain = || match (reduced, loader) { + (Some(l), _) => { + l(path, *max_edge).map(|(image, info, reduced)| DecodedSource { reduced, ..DecodedSource::new(Arc::new(image), Some(info)) }) + } + (None, Some(l)) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))), + (None, None) => Err(format!("no decoder available for {path}")), }; // the denoised picture comes with the plain one from a single decode; when it cannot be read the // pair loader gives the plain picture alone (the cached file is never a reason to fail a render) @@ -306,6 +319,7 @@ impl SourceRef { info: Some(info), camera_tone: None, denoised: twin.map(|t| Arc::new(Twin::new(Arc::new(t)))), + reduced: false, }) }) } else { @@ -351,6 +365,8 @@ pub struct MediaCache { /// share of [`crate::memory::budget`]); the least recently used entry of any of them goes first. budget: usize, pub file_loader: Option, + /// What grid thumbnails load files with (see [`ReducedLoader`]); `None`: [`Self::file_loader`]. + pub reduced_loader: Option, pub file_probe: Option, pub preview_loader: Option, /// Reads a photo file's bytes (Photo Merge); `None` = the local file system. @@ -426,6 +442,7 @@ impl Default for MediaCache { preview_capacity: 0, budget, file_loader: None, + reduced_loader: None, file_probe: None, preview_loader: None, file_bytes: None, @@ -718,11 +735,11 @@ impl MediaCache { } match self.scenes.iter().find(|s| s.id == *scene) { Some(s) => SourceRef::Demo { scene: Box::new(s.clone()), max_edge }, - None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None, denoise: None }, + None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None, denoise: None, reduced: None }, } } Source::File { path } => { - SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None, denoise: None } + SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None, denoise: None, reduced: None } } } } @@ -1201,7 +1218,12 @@ impl crate::Session { /// (≥ `long_edge`) and the result comes from / goes to the thumbnail cache (memory + disk). pub fn thumb_job(&mut self, id: PhotoId, long_edge: usize) -> Option { let b = thumb_bucket(long_edge); - self.build_job(id, b, b, false, true, Some(b)) + let mut job = self.build_job(id, b, b, false, true, Some(b))?; + // (a thumbnail may develop a smaller copy of the raw image that the file keeps) + if let SourceRef::File { reduced, .. } = &mut job.source { + reduced.clone_from(&self.media.reduced_loader); + } + Some(job) } fn build_job( @@ -1610,6 +1632,17 @@ impl crate::Session { depth: lightcraft_pipeline::OutputDepth, ) -> Result { let mut job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?; + // An export renders the full image: never a thumbnail's source developed from a smaller copy of the raw + // image that the file keeps (Lightroom's merged DNGs) + let reduced = match &job.source { + SourceRef::Loaded(s) => s.reduced, + SourceRef::Fit { from, .. } => from.reduced, + _ => false, + }; + if reduced { + let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); + job.source = self.media.source_ref_uncached(&p, job.level); + } // An export is made from the denoised picture its Denoise amount asks for, made when the job runs if the // background queue has not got to it. (A source kept in memory may lack the picture, so the job reads the file.) if let Some(wanted) = self.denoise_for_export(id, &job.settings)? { @@ -1866,6 +1899,7 @@ mod tests { })), fallback: None, denoise: None, + reduced: None, }; job.info = SourceInfo::default(); // Header facts cannot override decoder facts. job.settings = Arc::new(DevelopSettings::default()); @@ -1921,6 +1955,7 @@ mod tests { 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; diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs index 46b18e0c9..d26b8207e 100644 --- a/crates/engine/src/smart.rs +++ b/crates/engine/src/smart.rs @@ -356,7 +356,13 @@ pub fn mark_look_checked(dir: &Path) -> Result<(), String> { /// Load the proxy at `path`. pub fn load(path: &Path) -> Result { let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; - decode(&b).map(|(image, camera_tone)| crate::media::DecodedSource { image: Arc::new(image), info: None, camera_tone, denoised: None }) + decode(&b).map(|(image, camera_tone)| crate::media::DecodedSource { + image: Arc::new(image), + info: None, + camera_tone, + denoised: None, + reduced: false, + }) } #[cfg(test)]