mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
@@ -345,6 +345,7 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz
|
||||
let decoded = crate::files::decode_raw_preview(
|
||||
bytes,
|
||||
lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: FIT_PREVIEW_MAX_PIXELS },
|
||||
None,
|
||||
)?;
|
||||
let mut reference = decoded.to_working();
|
||||
let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height));
|
||||
|
||||
+91
-26
@@ -622,11 +622,16 @@ fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation {
|
||||
lightcraft_meta::extract(raw_bytes).orientation.unwrap_or(Orientation::Normal)
|
||||
}
|
||||
|
||||
/// What stands in for a raw file we can't decode: its largest embedded JPEG, else, for a TIFF-based
|
||||
/// raw, the file's own first image when the TIFF reader can show it (a reduced RGB copy: the only
|
||||
/// preview some containers carry). Returns image bytes for [`lightcraft_codecs::decode`].
|
||||
fn stand_in_image(bytes: &[u8]) -> Option<std::borrow::Cow<'_, [u8]>> {
|
||||
if let Some(jpeg) = lightcraft_raw::embedded_preview(bytes) {
|
||||
/// What stands in for a raw file we can't decode: its largest embedded JPEG (with `covering`, the smallest one that is
|
||||
/// at least that big, [`lightcraft_raw::embedded_preview_covering`]), else, for a TIFF-based raw, the file's own first
|
||||
/// image when the TIFF reader can show it (a reduced RGB copy: the only preview some containers carry). Returns image
|
||||
/// bytes for [`lightcraft_codecs::decode`].
|
||||
fn stand_in_image(bytes: &[u8], covering: Option<usize>) -> Option<std::borrow::Cow<'_, [u8]>> {
|
||||
let jpeg = match covering {
|
||||
Some(edge) => lightcraft_raw::embedded_preview_covering(bytes, u32::try_from(edge).unwrap_or(u32::MAX)),
|
||||
None => lightcraft_raw::embedded_preview(bytes),
|
||||
};
|
||||
if let Some(jpeg) = jpeg {
|
||||
return Some(std::borrow::Cow::Owned(jpeg));
|
||||
}
|
||||
let undecodable = lightcraft_raw::probe(bytes).is_some_and(|f| !f.is_supported());
|
||||
@@ -634,9 +639,14 @@ fn stand_in_image(bytes: &[u8]) -> Option<std::borrow::Cow<'_, [u8]>> {
|
||||
.then_some(std::borrow::Cow::Borrowed(bytes))
|
||||
}
|
||||
|
||||
/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting.
|
||||
pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOptions) -> Option<lightcraft_codecs::Decoded> {
|
||||
let jpeg = stand_in_image(bytes)?;
|
||||
/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting (`covering`: see
|
||||
/// [`stand_in_image`]).
|
||||
pub(crate) fn decode_raw_preview(
|
||||
bytes: &[u8],
|
||||
opts: lightcraft_codecs::DecodeOptions,
|
||||
covering: Option<usize>,
|
||||
) -> Option<lightcraft_codecs::Decoded> {
|
||||
let jpeg = stand_in_image(bytes, covering)?;
|
||||
// the colour space is a property of the JPEG; a TIFF stand-in is read by the TIFF reader
|
||||
let is_jpeg = matches!(jpeg, std::borrow::Cow::Owned(_));
|
||||
match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) {
|
||||
@@ -653,7 +663,7 @@ pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOp
|
||||
|
||||
/// Oriented size of the embedded preview of a raw file.
|
||||
fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let jpeg = stand_in_image(bytes)?;
|
||||
let jpeg = stand_in_image(bytes, None)?;
|
||||
// headers only, as for other images (issue #367): decoding a full-size preview just for its
|
||||
// size took about a second per file in an import; a decode remains the fallback
|
||||
let (mut w, mut h, orientation) = match lightcraft_codecs::read_header(&jpeg) {
|
||||
@@ -671,16 +681,18 @@ fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
|
||||
/// The embedded preview of a raw file as a working-space image no larger than `max_edge`, oriented.
|
||||
pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, SourceInfo)> {
|
||||
let d = decode_raw_preview(bytes, fit_box(max_edge))?;
|
||||
// always the largest JPEG: a source's geometry must not depend on its size level (previews match exports)
|
||||
let d = decode_raw_preview(bytes, fit_box(max_edge), None)?;
|
||||
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 };
|
||||
Some((img.oriented(preview_orientation(bytes, d.orientation)), SourceInfo::default()))
|
||||
}
|
||||
|
||||
/// The embedded preview of a raw file for display (sRGB, oriented, no larger than `max_edge`): the
|
||||
/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]).
|
||||
/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]). The smallest
|
||||
/// embedded JPEG that covers `max_edge` is decoded, not the largest.
|
||||
pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
|
||||
let mut d = decode_raw_preview(bytes, fit_box(max_edge))?;
|
||||
let mut d = decode_raw_preview(bytes, fit_box(max_edge), Some(max_edge))?;
|
||||
if d.image.width.max(d.image.height) > max_edge {
|
||||
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
|
||||
d.alpha = None;
|
||||
@@ -708,7 +720,9 @@ pub fn fs_preview_loader() -> PreviewLoader {
|
||||
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
|
||||
let gate = crate::memory::work_gate();
|
||||
let _permit = if crate::memory::is_background() { gate.acquire(len.saturating_mul(2)) } else { gate.acquire_urgent(len.saturating_mul(2)) };
|
||||
if let Some(image) = preview_window(path).ok().flatten().and_then(|(header, window, _)| quick_image_srgb(&header, &window, max_edge)) {
|
||||
if let Some(image) =
|
||||
preview_window(path, max_edge).ok().flatten().and_then(|(header, window, _)| quick_image_srgb(&header, &window, max_edge))
|
||||
{
|
||||
return Some(image);
|
||||
}
|
||||
embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge)
|
||||
@@ -743,7 +757,9 @@ impl PreviewRead {
|
||||
|
||||
/// The file's header and its embedded preview, read without the rest of the file: `(header bytes,
|
||||
/// preview bytes, what was read)`. `None` when the header points at no preview this build can find,
|
||||
/// which is the caller's cue to read the file whole.
|
||||
/// which is the caller's cue to read the file whole. `max_edge` is the size the preview is for: where a file keeps
|
||||
/// several JPEGs (the plan route), the window is the one [`embedded_preview_srgb`] decodes for that size, the smallest
|
||||
/// that covers it ([`lightcraft_raw::embedded_preview_covering`]).
|
||||
///
|
||||
/// Why it exists: a 56 MB Fuji RAF keeps its JPEG in the first 5.6 MB (measured: `DSCF9492.RAF`),
|
||||
/// so reading the file whole to extract it costs ten times the bytes — and on a network share ten
|
||||
@@ -751,7 +767,7 @@ impl PreviewRead {
|
||||
/// still needs the whole container, so the header comes back with the window; for a preview taken
|
||||
/// out of a container that carries no preview colour space of its own (Nikon's maker note) the
|
||||
/// whole-file fallback stays in charge.
|
||||
pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
|
||||
pub fn preview_window(path: &str, max_edge: usize) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
|
||||
let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
@@ -773,7 +789,7 @@ pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRea
|
||||
// preview tag sits past the prefix, the containers whose preview only a scan finds: ask the
|
||||
// sparse planner which ranges `embedded_preview` reads before handing the caller the
|
||||
// whole-file fallback.
|
||||
return planned_preview(&mut file, path, total);
|
||||
return planned_preview(&mut file, path, total, max_edge);
|
||||
};
|
||||
// A preview at the file's end may have been padded past it: clamp rather than fail.
|
||||
let end = range.offset.saturating_add(range.window_len).min(total);
|
||||
@@ -838,7 +854,7 @@ const PLAN_PREFIX: u64 = 64 * 1024;
|
||||
/// below). The planner's contract is the one its corpus test asserts over real files (what it loaded
|
||||
/// gives what the whole file gives), so a window that still fails to decode ends in the same
|
||||
/// whole-file fallback in [`fs_preview_loader`], never in a wrong picture.
|
||||
fn planned_preview(file: &mut std::fs::File, path: &str, total: u64) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
|
||||
fn planned_preview(file: &mut std::fs::File, path: &str, total: u64, max_edge: usize) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
|
||||
if total > MAX_PREVIEW_PLAN_BYTES {
|
||||
@@ -867,7 +883,9 @@ fn planned_preview(file: &mut std::fs::File, path: &str, total: u64) -> Result<O
|
||||
}
|
||||
}
|
||||
lightcraft_raw::sparse::HeaderPlan::Ready => {
|
||||
let Some(window) = lightcraft_raw::embedded_preview(&buf) else { return Ok(None) };
|
||||
// the plan loads every preview's strips, so the JPEG picked for `max_edge` is the whole file's pick
|
||||
let edge = u32::try_from(max_edge).unwrap_or(u32::MAX);
|
||||
let Some(window) = lightcraft_raw::embedded_preview_covering(&buf, edge) else { return Ok(None) };
|
||||
// The quick path hands the JPEG to the decoder alone, so what the *container* says
|
||||
// about the preview has to be something the bytes it is handed can show. Two container
|
||||
// facts change the pixels and are not in the JPEG: the preview's colour space (which
|
||||
@@ -1125,7 +1143,7 @@ mod tests {
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
let max_edge = 512;
|
||||
|
||||
let (header, window, read) = preview_window(&path).expect("the file reads").expect("it has a preview");
|
||||
let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("it has a preview");
|
||||
assert_eq!(header.len(), lightcraft_raw::PREVIEW_PREFIX, "the header prefix is bounded");
|
||||
assert!(read.header_path, "the header path is what answered");
|
||||
assert!(!read.planned, "a RAF pointer in the header needs no planner");
|
||||
@@ -1142,8 +1160,8 @@ mod tests {
|
||||
// A file with no locatable preview declines (the caller then reads it whole), a missing file errors.
|
||||
let plain = dir.join("plain.jpg");
|
||||
std::fs::write(&plain, &jpeg).unwrap();
|
||||
assert!(preview_window(&plain.to_string_lossy()).expect("a plain file reads").is_none());
|
||||
assert!(preview_window(&dir.join("nope.raf").to_string_lossy()).is_err());
|
||||
assert!(preview_window(&plain.to_string_lossy(), max_edge).expect("a plain file reads").is_none());
|
||||
assert!(preview_window(&dir.join("nope.raf").to_string_lossy(), max_edge).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -1163,6 +1181,12 @@ mod tests {
|
||||
/// `tail` bytes. The writer puts each IFD's table after its own chunk, so a `strip` bigger than
|
||||
/// the plan's bounded header puts IFD0 (and any orientation tag in it) out of that header's reach.
|
||||
fn tiff_with_preview_strip(strip: &[u8], tail: usize, what: Fixture) -> Vec<u8> {
|
||||
tiff_with_preview_strips(strip, &[], tail, what)
|
||||
}
|
||||
|
||||
/// [`tiff_with_preview_strip`] that also keeps the `smaller` JPEGs in sub-IFDs of IFD0, as an ARW keeps its
|
||||
/// 1616 px preview beside the full-size one.
|
||||
fn tiff_with_preview_strips(strip: &[u8], smaller: &[&[u8]], tail: usize, what: Fixture) -> Vec<u8> {
|
||||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
|
||||
let mut preview = IfdBuilder::new();
|
||||
if what.dng {
|
||||
@@ -1189,6 +1213,15 @@ mod tests {
|
||||
preview.set(t::IMAGE_WIDTH, Value::Long(vec![48]));
|
||||
preview.set(t::IMAGE_LENGTH, Value::Long(vec![32]));
|
||||
preview.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![strip.to_vec()] });
|
||||
for jpeg in smaller {
|
||||
let mut sub = IfdBuilder::new();
|
||||
sub.set(t::COMPRESSION, Value::Short(vec![7]));
|
||||
sub.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB]));
|
||||
sub.set(t::IMAGE_WIDTH, Value::Long(vec![48]));
|
||||
sub.set(t::IMAGE_LENGTH, Value::Long(vec![32]));
|
||||
sub.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![jpeg.to_vec()] });
|
||||
preview.add_sub_ifd(sub);
|
||||
}
|
||||
let mut raw = IfdBuilder::new();
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
|
||||
@@ -1216,7 +1249,7 @@ mod tests {
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
let max_edge = 512;
|
||||
|
||||
let (header, window, read) = preview_window(&path).expect("the file reads").expect("the plan locates its preview");
|
||||
let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("the plan locates its preview");
|
||||
assert_eq!(header.len(), PLAN_PREFIX as usize, "the plan's header is bounded");
|
||||
assert!(read.planned, "the planner is what answered");
|
||||
assert!(read.header_path, "and it is still a bounded read");
|
||||
@@ -1234,7 +1267,39 @@ mod tests {
|
||||
// caller's whole-file read is what answers (no guessing at what a file might hold).
|
||||
let not_a_raw = tiff_with_preview_strip(&jpeg, 400_000, Fixture::default());
|
||||
std::fs::write(&path, ¬_a_raw).unwrap();
|
||||
assert!(preview_window(&path).expect("the file reads").is_none(), "an unknown container falls back");
|
||||
assert!(preview_window(&path, max_edge).expect("the file reads").is_none(), "an unknown container falls back");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A raw that keeps several JPEGs is shown, on the plan route as on the whole-file path, from the smallest one that
|
||||
/// covers the box (`embedded_preview_covering`). The plan route used to hand on the largest whatever the size: an
|
||||
/// A7R VI ARW's 9984 × 6656 JPEG for a 512 px thumbnail instead of its 1616 × 1080 one.
|
||||
#[test]
|
||||
fn preview_loading_plans_the_smallest_covering_jpeg() {
|
||||
let jpeg = |w: u32, h: u32| {
|
||||
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 31 % 247) as u8).collect();
|
||||
encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&rgb)), 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap()
|
||||
};
|
||||
let (big, small) = (jpeg(96, 64), jpeg(48, 32));
|
||||
assert!(big.len() > small.len(), "the full-size JPEG is the largest");
|
||||
let file = tiff_with_preview_strips(&big, &[&small], 400_000, Fixture { dng: true, ..Default::default() });
|
||||
let dir = crate::tests_xmp::temp_dir("preview-plan-covering");
|
||||
let path = dir.join("two-previews.dng");
|
||||
std::fs::write(&path, &file).unwrap();
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
|
||||
for (edge, want, which) in [(48, &small, "small"), (49, &big, "full-size"), (usize::MAX, &big, "full-size")] {
|
||||
let (header, window, read) = preview_window(&path, edge).expect("the file reads").expect("the plan locates its preview");
|
||||
assert!(read.planned, "the planner is what answered");
|
||||
assert!(&window == want, "a {edge} px box takes the {which} JPEG");
|
||||
let quick = quick_image_srgb(&header, &window, edge).expect("the window holds a decodable preview");
|
||||
let whole = embedded_preview_srgb(&file, edge).expect("the whole file decodes too");
|
||||
assert_eq!(
|
||||
(quick.width, quick.height, quick.data),
|
||||
(whole.width, whole.height, whole.data),
|
||||
"{edge} px: the plan and the whole file agree"
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -1262,7 +1327,7 @@ mod tests {
|
||||
assert_eq!(lightcraft_meta::extract(&file).orientation, Some(Orientation::from_exif(6)), "the fixture states an orientation");
|
||||
assert!(lightcraft_meta::extract(&file[..PLAN_PREFIX as usize]).orientation.is_none(), "which the bounded header cannot see");
|
||||
assert!(lightcraft_meta::extract(&jpeg).orientation.is_none(), "and the preview JPEG carries none of its own");
|
||||
assert!(preview_window(&path).expect("the file reads").is_none(), "the whole-file path keeps it");
|
||||
assert!(preview_window(&path, usize::MAX).expect("the file reads").is_none(), "the whole-file path keeps it");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -1285,12 +1350,12 @@ mod tests {
|
||||
let adobe = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(0xFFFF), ..Default::default() });
|
||||
assert_eq!(lightcraft_raw::embedded_preview_color_space(&adobe), Some(PreviewColorSpace::AdobeRgb), "the fixture declares Adobe RGB");
|
||||
std::fs::write(&path, &adobe).unwrap();
|
||||
assert!(preview_window(&path).expect("the file reads").is_none(), "the whole-file path applies the space");
|
||||
assert!(preview_window(&path, usize::MAX).expect("the file reads").is_none(), "the whole-file path applies the space");
|
||||
|
||||
let srgb = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(1), ..Default::default() });
|
||||
assert_eq!(lightcraft_raw::embedded_preview_color_space(&srgb), Some(PreviewColorSpace::Srgb), "the fixture declares sRGB");
|
||||
std::fs::write(&path, &srgb).unwrap();
|
||||
let (_, window, read) = preview_window(&path).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes");
|
||||
let (_, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes");
|
||||
assert!(read.planned, "the plan still answers");
|
||||
assert!(window.starts_with(&[0xff, 0xd8]), "and hands on the JPEG");
|
||||
}
|
||||
|
||||
@@ -20,8 +20,9 @@ fn a_preview_reads_only_the_header_and_the_preview() {
|
||||
assert!(file_len > 0, "empty file");
|
||||
|
||||
let started = Instant::now();
|
||||
let window =
|
||||
lightcraft_engine::files::preview_window(&path).expect("the file reads").expect("it carries an embedded preview the header can point at");
|
||||
let window = lightcraft_engine::files::preview_window(&path, usize::MAX)
|
||||
.expect("the file reads")
|
||||
.expect("it carries an embedded preview the header can point at");
|
||||
let elapsed = started.elapsed();
|
||||
let (_, preview, read) = window;
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ pub use lightcraft_meta::Metadata;
|
||||
pub use lightcraft_raster::Rgb32f;
|
||||
pub use opcodes::{Opcode, OpcodeLists};
|
||||
pub use preview::{
|
||||
PREVIEW_PREFIX, PreviewColorSpace, PreviewRange, bounded_preview, embedded_preview, embedded_preview_color_space,
|
||||
PREVIEW_PREFIX, PreviewColorSpace, PreviewRange, bounded_preview, embedded_preview, embedded_preview_color_space, embedded_preview_covering,
|
||||
embedded_preview_dynamic_range_optimized, embedded_preview_from_range,
|
||||
};
|
||||
pub use semantic::{SemanticMask, semantic_masks};
|
||||
|
||||
+114
-19
@@ -65,31 +65,43 @@ pub fn embedded_preview_dynamic_range_optimized(bytes: &[u8]) -> Option<bool> {
|
||||
crate::vendor::arw::dynamic_range_optimizer(&mn)
|
||||
}
|
||||
|
||||
/// Whether `b` looks like a displayable (DCT) JPEG: SOI, and the first SOF marker is not lossless.
|
||||
fn is_dct_jpeg(b: &[u8]) -> bool {
|
||||
if b.len() < 4 || b[0] != 0xff || b[1] != 0xd8 {
|
||||
return false;
|
||||
/// The first frame (SOF) marker of a JPEG and its position, from the marker segments before the first scan.
|
||||
fn first_frame(b: &[u8]) -> Option<(u8, usize)> {
|
||||
if b.len() < 4 || !b.starts_with(&[0xff, 0xd8]) {
|
||||
return None;
|
||||
}
|
||||
let mut i = 2;
|
||||
while i + 4 <= b.len() {
|
||||
if b[i] != 0xff {
|
||||
return false;
|
||||
let mut i = 2usize;
|
||||
while i.checked_add(4)? <= b.len() {
|
||||
if *b.get(i)? != 0xff {
|
||||
return None;
|
||||
}
|
||||
let m = b[i + 1];
|
||||
let m = *b.get(i + 1)?;
|
||||
if m == 0xff {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
match m {
|
||||
0xc0..=0xc2 => return true,
|
||||
0xc3 | 0xc5..=0xc7 | 0xcb | 0xcd..=0xcf => return false,
|
||||
0xda | 0xd9 => return false,
|
||||
0xc0..=0xc3 | 0xc5..=0xc7 | 0xcb | 0xcd..=0xcf => return Some((m, i)),
|
||||
0xda | 0xd9 => return None,
|
||||
_ => {}
|
||||
}
|
||||
let len = u16::from_be_bytes([b[i + 2], b[i + 3]]) as usize;
|
||||
i += 2 + len;
|
||||
let len = usize::from(u16::from_be_bytes([*b.get(i + 2)?, *b.get(i + 3)?]));
|
||||
i = i.checked_add(2 + len)?;
|
||||
}
|
||||
false
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether `b` looks like a displayable (DCT) JPEG: SOI, and the first SOF marker is not lossless.
|
||||
fn is_dct_jpeg(b: &[u8]) -> bool {
|
||||
first_frame(b).is_some_and(|(m, _)| matches!(m, 0xc0..=0xc2))
|
||||
}
|
||||
|
||||
/// Width, height and component count of a displayable (DCT) JPEG, from its frame header.
|
||||
fn jpeg_frame(b: &[u8]) -> Option<(u32, u32, u8)> {
|
||||
let (_, i) = first_frame(b).filter(|(m, _)| matches!(m, 0xc0..=0xc2))?;
|
||||
let at = |k: usize| b.get(i.checked_add(k)?).copied();
|
||||
let be16 = |k: usize| Some(u32::from(u16::from_be_bytes([at(k)?, at(k + 1)?])));
|
||||
Some((be16(7)?, be16(5)?, at(9)?))
|
||||
}
|
||||
|
||||
/// Whether `b` is a JPEG XL file: a bare codestream or the ISO-BMFF container's signature box.
|
||||
@@ -139,6 +151,33 @@ fn candidates<'a>(data: &'a [u8], ifd: &Ifd, base: u64, out: &mut Vec<&'a [u8]>)
|
||||
/// The largest embedded preview, if any: a JPEG, or (DNG 1.7) a JPEG XL file — both decode with
|
||||
/// `lightcraft_codecs::decode`.
|
||||
pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
preview_covering(bytes, None)
|
||||
}
|
||||
|
||||
/// The smallest embedded JPEG whose long edge is at least `min_edge` and that looks like a copy of the largest preview
|
||||
/// (its aspect ratio within 1 %, so not a letterboxed or cropped thumbnail, and as many colour components, so not a
|
||||
/// grey mask or depth map), else [`embedded_preview`]. Only TIFF-based raws keep several: an A7R VI's 1616 × 1080
|
||||
/// preview is 0.3 MB beside its 9984 × 6656 one (5.6 MB), so a stand-in up to 1616 px decodes about a twentieth of the data.
|
||||
pub fn embedded_preview_covering(bytes: &[u8], min_edge: u32) -> Option<Vec<u8>> {
|
||||
preview_covering(bytes, Some(min_edge))
|
||||
}
|
||||
|
||||
/// Of the DCT JPEGs in `found`, the one with the shortest long edge of at least `min_edge` with `largest`'s aspect
|
||||
/// ratio (within 1 %) and component count.
|
||||
fn smallest_covering<'a>(found: &[&'a [u8]], largest: &[u8], min_edge: u32) -> Option<&'a [u8]> {
|
||||
let (bw, bh, bc) = jpeg_frame(largest)?;
|
||||
let like = |(w, h, c): (u32, u32, u8)| {
|
||||
let (a, b) = (u64::from(w) * u64::from(bh), u64::from(h) * u64::from(bw));
|
||||
w > 0 && h > 0 && c == bc && a.abs_diff(b) * 100 <= a
|
||||
};
|
||||
found
|
||||
.iter()
|
||||
.filter_map(|&s| jpeg_frame(s).filter(|&f| like(f) && f.0.max(f.1) >= min_edge).map(|(w, h, _)| (s, w.max(h))))
|
||||
.min_by_key(|&(_, long)| long)
|
||||
.map(|(s, _)| s)
|
||||
}
|
||||
|
||||
fn preview_covering(bytes: &[u8], min_edge: Option<u32>) -> Option<Vec<u8>> {
|
||||
if bytes.starts_with(b"FUJIFILMCCD-RAW") {
|
||||
let j = crate::vendor::raf::header(bytes).ok()?.jpeg?;
|
||||
return is_dct_jpeg(j).then(|| trim_eoi(j).to_vec());
|
||||
@@ -173,10 +212,11 @@ pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
if let Some(p) = crate::vendor::orf::preview(bytes) {
|
||||
found.push(p);
|
||||
}
|
||||
let best = found
|
||||
.into_iter()
|
||||
.filter(|s| is_dct_jpeg(s) || is_jxl(s))
|
||||
.max_by_key(|s| s.len())
|
||||
let largest = found.iter().copied().filter(|s| is_dct_jpeg(s) || is_jxl(s)).max_by_key(|s| s.len());
|
||||
let best = min_edge
|
||||
.zip(largest)
|
||||
.and_then(|(min, big)| smallest_covering(&found, big, min))
|
||||
.or(largest)
|
||||
.map(|s| if is_jxl(s) { s.to_vec() } else { trim_eoi(s).to_vec() });
|
||||
// a TIFF raw we can't decode whose preview no tag points to (Leaf MOS, Epson ERF)
|
||||
best.or_else(|| format.filter(|f| !f.is_supported()).and_then(|_| scan_for_jpeg(bytes)))
|
||||
@@ -543,6 +583,61 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A JPEG like [`fake_jpeg`] whose frame header says `w` x `h` with `nc` components.
|
||||
fn sized_jpeg(w: u16, h: u16, nc: u8, n: usize) -> Vec<u8> {
|
||||
let mut j = vec![0xff, 0xd8, 0xff, 0xc0, 0x00, 8 + 3 * nc, 8];
|
||||
j.extend_from_slice(&h.to_be_bytes());
|
||||
j.extend_from_slice(&w.to_be_bytes());
|
||||
j.push(nc);
|
||||
for c in 0..nc {
|
||||
j.extend_from_slice(&[c + 1, 0x11, 0]);
|
||||
}
|
||||
j.extend(std::iter::repeat_n(0x55u8, n));
|
||||
j.extend_from_slice(&[0xff, 0xd9]);
|
||||
j
|
||||
}
|
||||
|
||||
/// A TIFF raw holding `jpegs` as JPEG-compressed strips: the first in IFD0, the others in sub-IFDs.
|
||||
fn tiff_with_jpegs(jpegs: &[&[u8]]) -> Vec<u8> {
|
||||
let ifd = |j: &[u8]| {
|
||||
let mut ifd = IfdBuilder::new();
|
||||
ifd.set(t::COMPRESSION, Value::Short(vec![6]));
|
||||
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![1]));
|
||||
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![1]));
|
||||
ifd.set_image(ImageData::Strips { rows_per_strip: 1, strips: vec![j.to_vec()] });
|
||||
ifd
|
||||
};
|
||||
let mut ifd0 = ifd(jpegs[0]);
|
||||
for j in &jpegs[1..] {
|
||||
ifd0.add_sub_ifd(ifd(j));
|
||||
}
|
||||
TiffWriter::default().write(&[ifd0]).unwrap()
|
||||
}
|
||||
|
||||
/// A stand-in decodes the smallest JPEG that covers its box and shows the same picture as the largest: not a
|
||||
/// thumbnail of another shape (a letterboxed one) or a grey mask, and the largest when no smaller one is big enough.
|
||||
#[test]
|
||||
fn a_stand_in_takes_the_smallest_covering_jpeg_of_the_same_shape() {
|
||||
let (big, small, boxed) = (sized_jpeg(9984, 6656, 3, 500), sized_jpeg(1616, 1080, 3, 50), sized_jpeg(160, 120, 3, 10));
|
||||
// a ProRAW DNG keeps grey semantic masks of the picture's shape as JPEGs too
|
||||
let mask = sized_jpeg(768, 512, 1, 20);
|
||||
let file = tiff_with_jpegs(&[&small, &big, &boxed, &mask]);
|
||||
assert_eq!(embedded_preview(&file).unwrap(), big, "unchanged: the largest");
|
||||
assert_eq!(embedded_preview_covering(&file, 1600).unwrap(), small, "1616 px covers a 1600 px box");
|
||||
assert_eq!(embedded_preview_covering(&file, 1616).unwrap(), small);
|
||||
assert_eq!(embedded_preview_covering(&file, 1617).unwrap(), big, "too small: the largest");
|
||||
assert_eq!(embedded_preview_covering(&file, u32::MAX).unwrap(), big);
|
||||
assert_eq!(embedded_preview_covering(&file, 100).unwrap(), small, "the 4:3 thumbnail and the grey mask are not this picture");
|
||||
// a small copy of the same shape (within 1 %) is taken
|
||||
let thumb = sized_jpeg(160, 107, 3, 10);
|
||||
let file = tiff_with_jpegs(&[&small, &big, &thumb]);
|
||||
assert_eq!(embedded_preview_covering(&file, 100).unwrap(), thumb);
|
||||
assert_eq!(embedded_preview_covering(&tiff_with_jpegs(&[&big]), 100).unwrap(), big, "one preview: that one");
|
||||
for n in (0..file.len()).step_by(7) {
|
||||
let _ = embedded_preview_covering(&file[..n], 100);
|
||||
}
|
||||
}
|
||||
|
||||
/// CR3 with a full-size JPEG track: that JPEG wins over the smaller `PRVW` box.
|
||||
#[test]
|
||||
fn cr3_prefers_the_full_size_jpeg_track() {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -111,17 +111,36 @@ Measured on the real folder: idle for a minute, the app read 12 previews (**26.9
|
||||
**zero** raws for thumbnails, against 638 MB of whole-file reads for the same 12 photos before. The
|
||||
render queue stayed empty.
|
||||
|
||||
## The smallest JPEG that covers the box
|
||||
|
||||
Many TIFF-based raws keep more than one embedded JPEG. A Sony A7R VI ARW holds a 1616 × 1080 one
|
||||
(0.3 MB) beside the 9984 × 6656 one (5.6 MB), and Nikon NEF and Panasonic RW2 files have mid-sized
|
||||
copies too. The loupe stand-in and the grid thumbnail (`embedded_preview_srgb`) decode the smallest
|
||||
JPEG whose long edge is at least the size asked for (`lightcraft_raw::embedded_preview_covering`).
|
||||
They take the largest one when no other JPEG is big enough. The plan route picks the same way
|
||||
(`preview_window` takes the size asked for), so a raw read through the plan gets the JPEG the whole
|
||||
file would give. A smaller JPEG only counts as a copy of the picture when its aspect ratio is within
|
||||
1 % of the largest one's and it has the same number of colour components. That rules out letterboxed
|
||||
thumbnails of another shape and the grey masks an iPhone ProRAW DNG keeps at the picture's shape.
|
||||
Sources for editing (an unsupported raw's JPEG, `load_embedded_preview`) and the camera-look fit still
|
||||
use the largest JPEG, so their geometry and colour do not depend on the size level.
|
||||
|
||||
## Tests
|
||||
|
||||
- `crates/raw/src/preview.rs`: the header-only range matches `embedded_preview` byte for byte; a
|
||||
clamped prefix, a padded extent, a late pointer, a lying pointer and a non-JPEG window are all
|
||||
covered.
|
||||
covered. `a_stand_in_takes_the_smallest_covering_jpeg_of_the_same_shape` covers the picker: a
|
||||
smaller JPEG that covers the box is taken, the largest when none does, and a thumbnail of another
|
||||
shape or a grey mask never is.
|
||||
- `crates/engine/src/files.rs`: on a synthetic RAF with a 200 KB tail, the quick path and the
|
||||
whole-file path produce identical pixels, and the read stays under half the file. On a synthetic
|
||||
TIFF/DNG whose preview only its tags locate — the plan path — the same identity holds and the CFA
|
||||
strip is not read; the same structure without the tag that makes it a raw declines instead of
|
||||
guessing, and the caller keeps reading it whole. So do a file whose own orientation or Adobe RGB
|
||||
colour space only the whole-file path can show, while one that states sRGB stays on the fast path.
|
||||
A synthetic DNG with a full-size and a half-size JPEG gets, on the plan path, the half-size one for
|
||||
a box it covers and the full-size one otherwise, with the same pixels as the whole-file path
|
||||
(`preview_loading_plans_the_smallest_covering_jpeg`).
|
||||
- `crates/raw/tests/sparse_corpus.rs`: `preview_plan` is asked for **every** corpus file, not only
|
||||
the ones whose header plan is `Ready`, and the preview it yields must equal the whole file's with
|
||||
the skipped bytes `0` and `0xFF`. The test prints what each format reads.
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
- With Dehaze on, panning a photo zoomed to 1:1 or moving a slider while zoomed no longer samples the whole
|
||||
original again to find the haze colour. On a 64 MP raw (Sony A7R VI) that took ~0.2 s of CPU per pan and per
|
||||
slider step; a slider step now takes ~5 ms on the GPU.
|
||||
### Thumbnails of raw files appear sooner
|
||||
- Grid thumbnails and the first picture in the loupe of an unedited raw now come from the smallest JPEG the camera
|
||||
stored that is big enough, not always from the largest one. On a Sony A7R VI file a 512 px thumbnail decodes
|
||||
0.3 MB of JPEG instead of 5.6 MB and is ready several times sooner; Nikon and Panasonic raws with a mid-sized
|
||||
JPEG get faster too.
|
||||
## 2026-10-12
|
||||
|
||||
### Canon CR2 highlights
|
||||
|
||||
Reference in New Issue
Block a user