mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Merge PR #2263: LR-IMP-PREVIEW-READ: thumbnails of merged HDR/panorama DNGs develop from their reduced raw copies (38 s → 1 s)
crates/engine/src/media.rs, crates/engine/src/smart.rs: DecodedSource keeps both new fields, from_proxy (#1809) and reduced (#2263); a smart preview loads with from_proxy true, reduced false. crates/engine/src/cmd/previews.rs: original_only refuses both proxy-loaded and reduced sources.
This commit is contained in:
@@ -219,8 +219,10 @@ struct SmartCounts {
|
||||
fn original_only(source: crate::media::SourceRef) -> Option<crate::media::SourceRef> {
|
||||
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() && !d.from_proxy => 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.from_proxy && !d.reduced => Some(SourceRef::Loaded(d)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -561,7 +563,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");
|
||||
}
|
||||
|
||||
|
||||
+125
-19
@@ -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<String>) {
|
||||
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<usi
|
||||
/// instead of each one waking the pool from outside and waiting for it (which costs more than the
|
||||
/// loops themselves when the machine is busy).
|
||||
pub fn load_bytes(bytes: &[u8], max_edge: usize) -> 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<u8>, 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<u8>, 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<u8>, max_edge: usize, spec: &DenoiseSpec) -> Result<(Rgb32f, Option<Rgb32f>, 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,14 +415,25 @@ fn load_bytes_now(
|
||||
bytes: std::borrow::Cow<'_, [u8]>,
|
||||
max_edge: usize,
|
||||
denoise: Option<&DenoiseSpec>,
|
||||
) -> Result<(Rgb32f, Option<Rgb32f>, SourceInfo), String> {
|
||||
allow_reduced: bool,
|
||||
) -> Result<(Rgb32f, Option<Rgb32f>, SourceInfo, bool), String> {
|
||||
if lightcraft_raw::probe(&bytes).is_some() {
|
||||
let mut raw = match lightcraft_raw::decode(&bytes).map_err(|e| preview_reason(&bytes, e)) {
|
||||
// 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),
|
||||
};
|
||||
let mut raw = match decoded.map_err(|e| preview_reason(&bytes, e)) {
|
||||
Ok(r) => r,
|
||||
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),
|
||||
@@ -542,13 +560,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
|
||||
@@ -1073,17 +1092,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);
|
||||
@@ -1095,6 +1104,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<T>(path: &str, max_edge: usize, load: impl FnOnce(Vec<u8>) -> Result<T, String>) -> Result<T, String> {
|
||||
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<String> {
|
||||
@@ -1109,6 +1136,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());
|
||||
@@ -2195,6 +2223,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::<f64>() / img.data.len().max(1) as f64;
|
||||
let mean = |img: &lightcraft_raster::Rgba8| img.data.iter().map(|p| p[1] as f64).sum::<f64>() / 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.
|
||||
|
||||
@@ -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<dyn Fn(&str, usize) -> 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<dyn Fn(&str, usize) -> 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<dyn Fn(&str, usize) -> Option<Rgba8> + Send + Sync>;
|
||||
@@ -186,11 +191,14 @@ pub struct DecodedSource {
|
||||
/// These pixels came from the photo's smart preview (an offline original), not from its file.
|
||||
/// A refresh must never rebuild a proxy from itself: that would stamp its older look as current.
|
||||
pub from_proxy: bool,
|
||||
/// 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<Rgb32f>, info: Option<SourceInfo>) -> Self {
|
||||
DecodedSource { image, info, camera_tone: None, denoised: None, from_proxy: false }
|
||||
DecodedSource { image, info, camera_tone: None, denoised: None, from_proxy: false, reduced: false }
|
||||
}
|
||||
|
||||
/// The picture to develop for a Denoise `amount` (0 to 1, see [`DevelopSettings::denoise_amount`]): the plain one,
|
||||
@@ -261,6 +269,8 @@ pub enum SourceRef {
|
||||
fallback: Option<std::path::PathBuf>,
|
||||
/// 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<ReducedLoader>,
|
||||
},
|
||||
/// A smart preview standing in for a missing original.
|
||||
Smart {
|
||||
@@ -286,10 +296,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)
|
||||
@@ -312,6 +325,7 @@ impl SourceRef {
|
||||
camera_tone: None,
|
||||
denoised: twin.map(|t| Arc::new(Twin::new(Arc::new(t)))),
|
||||
from_proxy: false,
|
||||
reduced: false,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
@@ -357,6 +371,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<FileLoader>,
|
||||
/// What grid thumbnails load files with (see [`ReducedLoader`]); `None`: [`Self::file_loader`].
|
||||
pub reduced_loader: Option<ReducedLoader>,
|
||||
pub file_probe: Option<FileProbe>,
|
||||
pub preview_loader: Option<PreviewLoader>,
|
||||
/// Reads a photo file's bytes (Photo Merge); `None` = the local file system.
|
||||
@@ -432,6 +448,7 @@ impl Default for MediaCache {
|
||||
preview_capacity: 0,
|
||||
budget,
|
||||
file_loader: None,
|
||||
reduced_loader: None,
|
||||
file_probe: None,
|
||||
preview_loader: None,
|
||||
file_bytes: None,
|
||||
@@ -724,11 +741,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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1242,7 +1259,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<RenderJob> {
|
||||
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(
|
||||
@@ -1654,6 +1676,17 @@ impl crate::Session {
|
||||
depth: lightcraft_pipeline::OutputDepth,
|
||||
) -> Result<RenderJob, String> {
|
||||
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)? {
|
||||
@@ -1932,6 +1965,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());
|
||||
@@ -1987,6 +2021,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;
|
||||
|
||||
@@ -494,6 +494,7 @@ pub fn load(path: &Path) -> Result<crate::media::DecodedSource, String> {
|
||||
camera_tone,
|
||||
denoised: None,
|
||||
from_proxy: true,
|
||||
reduced: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+95
-13
@@ -194,18 +194,74 @@ pub(crate) fn is_plain_cfa_tiff(tiff: &Tiff, bytes: &[u8]) -> bool {
|
||||
/// anchored at the sensor origin.
|
||||
pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<RawImage> {
|
||||
let tiff = Tiff::parse(bytes)?;
|
||||
let ifd0 = &tiff.ifds[0];
|
||||
let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Corrupt("DNG without a raw image IFD".into()))?;
|
||||
decode_ifd(bytes, &tiff, raw, raw, mode, format)
|
||||
}
|
||||
|
||||
/// The smallest reduced-resolution copy of a DNG's LinearRaw image whose default crop still has a long edge of
|
||||
/// `min_long` px: Lightroom keeps 256, 512 and 2048 px copies beside the full image of its HDR and panorama
|
||||
/// merges, so a thumbnail decodes a 512 px copy instead of a 24k × 7k image (about 19 s). Only a copy that the main
|
||||
/// image's tags describe as well: same samples per pixel and aspect ratio (within 1 %), and a main image without
|
||||
/// opcode lists, a linearization table or black-level deltas (all in full-size pixel coordinates).
|
||||
fn reduced_ifd<'a>(tiff: &'a Tiff, main: &Ifd, min_long: usize) -> Option<&'a Ifd> {
|
||||
if main.u16(t::PHOTOMETRIC) != Some(photometric::LINEAR_RAW) {
|
||||
return None;
|
||||
}
|
||||
let full_size_tags =
|
||||
[t::OPCODE_LIST_1, t::OPCODE_LIST_2, t::OPCODE_LIST_3, t::LINEARIZATION_TABLE, t::BLACK_LEVEL_DELTA_H, t::BLACK_LEVEL_DELTA_V];
|
||||
if full_size_tags.iter().any(|&tag| main.contains(tag)) {
|
||||
return None;
|
||||
}
|
||||
let (mw, mh) = (main.u64(t::IMAGE_WIDTH)?, main.u64(t::IMAGE_LENGTH)?);
|
||||
if mw == 0 || mh == 0 {
|
||||
return None;
|
||||
}
|
||||
let crop_long = match main.f64s(t::DEFAULT_CROP_SIZE).as_deref() {
|
||||
Some([w, h]) if w.is_finite() && h.is_finite() && *w >= 1.0 && *h >= 1.0 => w.max(*h).min(mw.max(mh) as f64),
|
||||
_ => mw.max(mh) as f64,
|
||||
};
|
||||
let cpp = main.u16(t::SAMPLES_PER_PIXEL).unwrap_or(1);
|
||||
tiff.all_ifds()
|
||||
.into_iter()
|
||||
.filter(|i| i.u32(t::NEW_SUBFILE_TYPE) == Some(1) && i.u16(t::PHOTOMETRIC) == Some(photometric::LINEAR_RAW))
|
||||
.filter(|i| i.u16(t::SAMPLES_PER_PIXEL).unwrap_or(1) == cpp)
|
||||
.filter_map(|i| {
|
||||
let (w, h) = (i.u64(t::IMAGE_WIDTH)?, i.u64(t::IMAGE_LENGTH)?);
|
||||
let (a, b) = (w.checked_mul(mh)?, h.checked_mul(mw)?);
|
||||
let same_aspect = w > 0 && h > 0 && a.abs_diff(b).saturating_mul(100) <= a;
|
||||
let covers = crop_long * w as f64 / mw as f64 >= min_long as f64;
|
||||
(same_aspect && covers && w < mw && h < mh).then_some((i, w))
|
||||
})
|
||||
.min_by_key(|&(_, w)| w)
|
||||
.map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// [`decode`] from the smallest reduced-resolution copy of the raw image that covers `min_long` ([`reduced_ifd`]):
|
||||
/// the main image's levels, colour and metadata, its active area and default crop scaled to the copy. `None` when the
|
||||
/// file keeps no such copy.
|
||||
pub(crate) fn decode_reduced(bytes: &[u8], min_long: usize) -> Result<Option<RawImage>> {
|
||||
let tiff = Tiff::parse(bytes)?;
|
||||
let Some(main) = raw_ifd(&tiff) else { return Ok(None) };
|
||||
let Some(small) = reduced_ifd(&tiff, main, min_long) else { return Ok(None) };
|
||||
decode_ifd(bytes, &tiff, small, main, Mode::Full, RawFormat::Dng).map(Some)
|
||||
}
|
||||
|
||||
/// [`decode_as`] of the samples in `pixels`, described by the raw IFD `main`: the same IFD, or (see
|
||||
/// [`decode_reduced`]) a reduced-resolution copy of it, whose own level tags win and whose geometry is `main`'s
|
||||
/// scaled to its size.
|
||||
fn decode_ifd(bytes: &[u8], tiff: &Tiff, pixels: &Ifd, main: &Ifd, mode: Mode, format: RawFormat) -> Result<RawImage> {
|
||||
let ifd0 = &tiff.ifds[0];
|
||||
let raw = main;
|
||||
// GoPro's GPR: a DNG whose raw data is VC-5 coded, which is not decoded (yet). Named here, before
|
||||
// the generic "TIFF compression 9" of the chunk decoder. Header mode still describes the file
|
||||
// (a GPR has no embedded preview, and a library keeps the photo with this reason shown, as it
|
||||
// does for any file whose pixels fail to decode, rather than refusing to import it).
|
||||
if mode == Mode::Full
|
||||
&& let Some(c) = raw.u16(t::COMPRESSION).filter(|&c| c == t::compression::VC5)
|
||||
&& let Some(c) = pixels.u16(t::COMPRESSION).filter(|&c| c == t::compression::VC5)
|
||||
{
|
||||
return Err(RawError::Unsupported(format!("DNG compression {c} (GoPro VC-5) is not decoded yet")));
|
||||
}
|
||||
let info = raw.image()?;
|
||||
let info = pixels.image()?;
|
||||
let (w, h, cpp) = (info.width as usize, info.height as usize, info.samples_per_pixel as usize);
|
||||
// one sample (CFA or monochrome), RGB or four colours; two are neither and can't be developed (issue #1511)
|
||||
if !matches!(cpp, 1 | 3 | 4) {
|
||||
@@ -217,22 +273,25 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
|
||||
// DNG 1.7 interleaved storage: the raster is a permutation of the mosaic, so undo it before
|
||||
// anything reads a sample as a pixel.
|
||||
if mode == Mode::Full {
|
||||
let factor = |tag| raw.u16(tag).map(usize::from).unwrap_or(1);
|
||||
let factor = |tag| pixels.u16(tag).map(usize::from).unwrap_or(1);
|
||||
deinterleave(&mut data, w, h, cpp, factor(t::ROW_INTERLEAVE_FACTOR), factor(t::COLUMN_INTERLEAVE_FACTOR))?;
|
||||
}
|
||||
|
||||
// linearization table (integer data only)
|
||||
let mut linearized = false;
|
||||
if let (RawData::U16(v), Some(table)) = (&mut data, raw.u64s(t::LINEARIZATION_TABLE).filter(|t| !t.is_empty())) {
|
||||
if let (RawData::U16(v), Some(table)) = (&mut data, pixels.u64s(t::LINEARIZATION_TABLE).filter(|t| !t.is_empty())) {
|
||||
let table: Vec<u16> = table.iter().map(|&x| x.min(65535) as u16).collect();
|
||||
let last = table.len() - 1;
|
||||
v.iter_mut().for_each(|s| *s = table[(*s as usize).min(last)]);
|
||||
linearized = true;
|
||||
}
|
||||
|
||||
// a reduced copy's own levels, where it states them
|
||||
let levels = |tag| if pixels.contains(tag) { pixels } else { raw };
|
||||
let float = matches!(data, RawData::F32(_));
|
||||
let default_white = if float { 1.0 } else { ((1u64 << bits.min(16)) - 1) as f32 };
|
||||
let mut white: Vec<f32> = raw.f64s(t::WHITE_LEVEL).unwrap_or_default().into_iter().map(|v| v as f32).filter(|v| *v > 0.0).collect();
|
||||
let mut white: Vec<f32> =
|
||||
levels(t::WHITE_LEVEL).f64s(t::WHITE_LEVEL).unwrap_or_default().into_iter().map(|v| v as f32).filter(|v| *v > 0.0).collect();
|
||||
if white.is_empty() {
|
||||
white = vec![default_white];
|
||||
}
|
||||
@@ -247,14 +306,14 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
|
||||
}
|
||||
}
|
||||
|
||||
let dim = raw.u64s(t::BLACK_LEVEL_REPEAT_DIM).unwrap_or_default();
|
||||
let dim = levels(t::BLACK_LEVEL).u64s(t::BLACK_LEVEL_REPEAT_DIM).unwrap_or_default();
|
||||
let (br, bc) = match dim.as_slice() {
|
||||
[r, c] if (1..=16).contains(r) && (1..=16).contains(c) => (*r as usize, *c as usize),
|
||||
_ => (1, 1),
|
||||
};
|
||||
// (a FLOAT / DOUBLE tag can hold NaN or infinity: such a level reads as 0, in its place)
|
||||
let level = |v: f64| Some(v as f32).filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let mut values: Vec<f32> = raw.f64s(t::BLACK_LEVEL).unwrap_or_default().into_iter().map(level).collect();
|
||||
let mut values: Vec<f32> = levels(t::BLACK_LEVEL).f64s(t::BLACK_LEVEL).unwrap_or_default().into_iter().map(level).collect();
|
||||
if values.len() != br * bc * cpp {
|
||||
values = vec![values.first().copied().unwrap_or(0.0)];
|
||||
}
|
||||
@@ -263,11 +322,20 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
|
||||
let black =
|
||||
BlackLevel { repeat_rows: br, repeat_cols: bc, values, delta_h: floats(t::BLACK_LEVEL_DELTA_H), delta_v: floats(t::BLACK_LEVEL_DELTA_V) };
|
||||
|
||||
// geometry in the main image's pixels (a reduced copy's is scaled below)
|
||||
let (full_w, full_h) = if std::ptr::eq(pixels, raw) {
|
||||
(w, h)
|
||||
} else {
|
||||
(raw.u64(t::IMAGE_WIDTH).unwrap_or(0) as usize, raw.u64(t::IMAGE_LENGTH).unwrap_or(0) as usize)
|
||||
};
|
||||
if full_w == 0 || full_h == 0 {
|
||||
return Err(RawError::Corrupt("raw image IFD without a size".into()));
|
||||
}
|
||||
let active_area = match raw.u64s(t::ACTIVE_AREA).as_deref() {
|
||||
Some([top, left, bottom, right]) if bottom > top && right > left && (*bottom as usize) <= h && (*right as usize) <= w => {
|
||||
Some([top, left, bottom, right]) if bottom > top && right > left && (*bottom as usize) <= full_h && (*right as usize) <= full_w => {
|
||||
Rect::new(*left as usize, *top as usize, (*right - *left) as usize, (*bottom - *top) as usize)
|
||||
}
|
||||
_ => Rect::new(0, 0, w, h),
|
||||
_ => Rect::new(0, 0, full_w, full_h),
|
||||
};
|
||||
let crop = match (raw.f64s(t::DEFAULT_CROP_ORIGIN).as_deref(), raw.f64s(t::DEFAULT_CROP_SIZE).as_deref()) {
|
||||
(Some([x, y]), Some([cw, ch])) if *cw >= 1.0 && *ch >= 1.0 && *x >= 0.0 && *y >= 0.0 => {
|
||||
@@ -275,6 +343,20 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
|
||||
}
|
||||
_ => Rect::new(0, 0, active_area.width, active_area.height),
|
||||
};
|
||||
// the developed size, which the metadata reports whatever copy the samples come from
|
||||
let developed = (crop.width, crop.height);
|
||||
let (active_area, crop) = if (full_w, full_h) == (w, h) {
|
||||
(active_area, crop)
|
||||
} else {
|
||||
let (sx, sy) = (w as f64 / full_w as f64, h as f64 / full_h as f64);
|
||||
let scale = |r: Rect| {
|
||||
let (x, y) = ((r.x as f64 * sx).round() as usize, (r.y as f64 * sy).round() as usize);
|
||||
let (x1, y1) = (((r.x + r.width) as f64 * sx).round() as usize, ((r.y + r.height) as f64 * sy).round() as usize);
|
||||
Rect::new(x, y, x1.saturating_sub(x).max(1), y1.saturating_sub(y).max(1))
|
||||
};
|
||||
let area = scale(active_area).clipped(w, h);
|
||||
(area, scale(crop).clipped(area.width, area.height))
|
||||
};
|
||||
|
||||
let cfa = if info.photometric == photometric::CFA {
|
||||
if raw.u16(t::CFA_LAYOUT).unwrap_or(1) != 1 {
|
||||
@@ -319,9 +401,9 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
|
||||
let opcodes =
|
||||
crate::OpcodeLists { list1: opcode_list(t::OPCODE_LIST_1), list2: opcode_list(t::OPCODE_LIST_2), list3: opcode_list(t::OPCODE_LIST_3) };
|
||||
|
||||
let mut metadata = lightcraft_meta::from_tiff(&tiff);
|
||||
metadata.width = Some(crop.width as u32);
|
||||
metadata.height = Some(crop.height as u32);
|
||||
let mut metadata = lightcraft_meta::from_tiff(tiff);
|
||||
metadata.width = Some(developed.0 as u32);
|
||||
metadata.height = Some(developed.1 as u32);
|
||||
let img = RawImage {
|
||||
format,
|
||||
width: w,
|
||||
|
||||
@@ -368,6 +368,17 @@ pub fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
decode_with(bytes, Mode::Full)
|
||||
}
|
||||
|
||||
/// [`decode`] at a smaller size, for a picture whose default crop needs a long edge of only `min_long` px: a DNG
|
||||
/// that keeps reduced-resolution copies of its LinearRaw image (Lightroom's HDR and panorama merges keep 256 to 2048
|
||||
/// px ones) decodes the smallest that covers it. The image has the full one's levels, colour and metadata; its
|
||||
/// active area and crop are scaled to its size. `Ok(None)` when the file keeps no such copy: decode it whole.
|
||||
pub fn decode_reduced(bytes: &[u8], min_long: usize) -> Result<Option<RawImage>> {
|
||||
if probe(bytes) != Some(RawFormat::Dng) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(dng::decode_reduced(bytes, min_long)?.map(lift_clipped))
|
||||
}
|
||||
|
||||
/// Everything [`decode`] learns about a raw file except its samples: geometry, orientation, CFA,
|
||||
/// colour data, as-shot white balance, opcode lists (embedded lens corrections), metadata — read
|
||||
/// from the headers without decompressing the pixel data, for imports. Equal to
|
||||
@@ -1197,6 +1208,69 @@ mod tests {
|
||||
assert_eq!((info.width, info.height), (32, 16));
|
||||
}
|
||||
|
||||
/// A LinearRaw DNG in the layout of Lightroom's merges: a `w` × `h` main image (samples 1000) with a default
|
||||
/// crop, and reduced copies (samples 2000 + their width) of the given sizes beside it.
|
||||
fn linear_dng_with_copies(w: u32, h: u32, copies: &[(u32, u32)], main_tags: &[(u16, Value)]) -> Vec<u8> {
|
||||
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![t::photometric::LINEAR_RAW]));
|
||||
ifd.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
ifd.set(t::WHITE_LEVEL, Value::Long(vec![32768]));
|
||||
let samples: Vec<u8> = std::iter::repeat_n(sample.to_le_bytes(), (w * h * 3) as usize).flatten().collect();
|
||||
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![samples] });
|
||||
ifd
|
||||
};
|
||||
let mut main = linear(0, w, h, 1000);
|
||||
main.set(t::BLACK_LEVEL, Value::Short(vec![100]));
|
||||
main.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![4, 2]));
|
||||
main.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![w - 8, h - 4]));
|
||||
for (tag, v) in main_tags {
|
||||
main.set(*tag, v.clone());
|
||||
}
|
||||
let mut ifd0 = rgb_ifd(4, 2);
|
||||
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.add_sub_ifd(main);
|
||||
for &(cw, ch) in copies {
|
||||
ifd0.add_sub_ifd(linear(1, cw, ch, 2000 + cw as u16));
|
||||
}
|
||||
write(&[ifd0])
|
||||
}
|
||||
|
||||
/// A thumbnail of a Lightroom merge decodes the smallest reduced copy of its raw image that covers it, with the
|
||||
/// main image's levels and its crop scaled; nothing covers it, or the main image has full-size opcodes: `None`.
|
||||
#[test]
|
||||
fn reduced_copies_of_a_linear_raw_dng_decode_at_their_size() {
|
||||
let file = linear_dng_with_copies(64, 32, &[(32, 16), (16, 8)], &[]);
|
||||
let full = decode(&file).unwrap();
|
||||
assert_eq!((full.width, full.height, full.crop), (64, 32, Rect::new(4, 2, 56, 28)));
|
||||
// a 12 px picture: the 16 px copy (its crop is 14 px long)
|
||||
let small = decode_reduced(&file, 12).unwrap().expect("a copy covers 12 px");
|
||||
assert_eq!((small.width, small.height), (16, 8));
|
||||
assert_eq!(small.crop, Rect::new(1, 1, 14, 7));
|
||||
let RawData::U16(v) = &small.data else { panic!("16-bit samples") };
|
||||
assert!(v.iter().all(|&s| s == 2016), "the 16 px copy's samples");
|
||||
assert_eq!((small.white.clone(), small.black.values.clone()), (vec![32768.0], vec![100.0]), "its own white, the main black");
|
||||
assert_eq!((small.metadata.width, small.metadata.height), (Some(56), Some(28)), "the developed size is the photo's");
|
||||
// 20 px: the 32 px copy (28 px); 40 px: none covers it
|
||||
assert_eq!(decode_reduced(&file, 20).unwrap().map(|r| r.width), Some(32));
|
||||
assert!(decode_reduced(&file, 40).unwrap().is_none());
|
||||
// a different aspect ratio is not a copy of the image
|
||||
assert!(decode_reduced(&linear_dng_with_copies(64, 32, &[(16, 16)], &[]), 8).unwrap().is_none());
|
||||
// opcodes are in full-size pixels
|
||||
let opcodes = linear_dng_with_copies(64, 32, &[(16, 8)], &[(t::OPCODE_LIST_3, Value::Undefined(vec![0, 0, 0, 0]))]);
|
||||
assert!(decode_reduced(&opcodes, 8).unwrap().is_none());
|
||||
// not a DNG
|
||||
assert!(decode_reduced(&write(&[rgb_ifd(16, 8)]), 8).unwrap().is_none());
|
||||
}
|
||||
|
||||
/// Compression 99 (not a registered TIFF value; Leaf MOS tiles) marks a raw even without a CFA tag.
|
||||
#[test]
|
||||
fn private_compression_99_is_a_raw() {
|
||||
|
||||
@@ -1128,6 +1128,8 @@ mod thumbnail_tests {
|
||||
image.data.fill([0.3, 0.2, 0.1]);
|
||||
Ok((image, Default::default()))
|
||||
}));
|
||||
// (`with_fs` sets the file system's thumbnail loader, which wins over `file_loader`: thumbnails use the stub too)
|
||||
s.media.reduced_loader = None;
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
|
||||
let mut app = crate::LightcraftApp::new(s, crate::Services::default());
|
||||
app.renderer.keep_pixels = true;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8,6 +8,12 @@
|
||||
|
||||
## 2026-10-18
|
||||
|
||||
### Thumbnails of Lightroom HDR and panorama merges in a second
|
||||
- Grid and filmstrip thumbnails of DNGs that Lightroom merged (HDR, Panorama, HDR Panorama) now develop from the
|
||||
small copies of the raw image Lightroom keeps inside the file (256 to 2048 px), not from the whole merge. A 23836 ×
|
||||
7078 drone panorama's 512 px thumbnail took 38 s and now takes about a second, with the same look; your edits still
|
||||
apply. Loupe previews larger than the biggest copy, 1:1 views and exports still read the full image.
|
||||
|
||||
### Fill Location from GPS: no more neighbouring town in City
|
||||
- The offline place names list towns of 15,000 people or more, so a photo taken in a smaller one got the nearest larger
|
||||
town in City, sometimes another municipality (issue #2242). The Fill Location tooltip and the command help now say so,
|
||||
|
||||
Reference in New Issue
Block a user