LR-IMP-PREVIEW-READ: thumbnails of Lightroom HDR/panorama DNGs develop from the file's reduced raw copies

A DNG that keeps reduced-resolution copies of its LinearRaw image (Lightroom merges keep 256, 512 and
2048 px ones) now develops a thumbnail or preview from the smallest copy that covers it, with the main
image's levels and colour and its crop scaled. A 23836 x 7078 drone panorama's 512 px render went from
38 s to 1.1 s. Copies are skipped when the main image has opcode lists, a linearization table or
black-level deltas, and for denoised photos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Matthew James Sivak
2026-10-18 18:47:23 +02:00
co-authored by Claude Opus 5.5
parent 31a677fe3d
commit 7f97bdbb5f
5 changed files with 186 additions and 15 deletions
+8 -1
View File
@@ -410,7 +410,14 @@ fn load_bytes_now(
denoise: Option<&DenoiseSpec>,
) -> Result<(Rgb32f, Option<Rgb32f>, SourceInfo), 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 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();
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
+95 -13
View File
@@ -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,
+74
View File
@@ -359,6 +359,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
@@ -1182,6 +1193,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() {
File diff suppressed because one or more lines are too long
+8
View File
@@ -1,5 +1,13 @@
# What's new in LightCraft
## 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.
## 2026-10-14
### Model downloads time out on a name that doesn't resolve