# Conflicts:
#	crates/engine/src/files.rs
#	docs/raw-preview-reads.md
#	docs/whats-new.md
This commit is contained in:
Brandon Thomas committed 2026-10-14 01:29:30 -04:00
commit 07f73c44da
10 files changed
+582 -40

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
//! Face tiles cut from a raw's embedded preview (issue #1440).
//!
//! A People card's face picture ([`crate::Session::face_job`]) is a small square around the face.
//! Developing the raw for it reads and demosaics most of a 24–50 MB file for a ~170 px picture; the
//! camera's own JPEG inside the file (read through the planned preview path,
//! [`crate::files::preview_window`], which reads the header and the preview and not the sensor
//! data) usually holds that square at the tile's size already. A [`PreviewCut`] says which preview
//! to read and how large it must be; [`crate::media::RenderJob::run`] tries it before the job's own
//! source and falls back to that source when the preview can't serve.
//!
//! Only a raw that still looks the way it was imported gets one: that is the photo whose grid
//! thumbnail is the same embedded JPEG ([`crate::Session::embedded_thumb_is_final`]). An edited raw
//! needs its edits rendered, which the camera's display-referred JPEG can't take the way the raw
//! does, so it keeps the full develop render.
use lightcraft_geom::Rect;
use lightcraft_raster::Rgba8;
use lightcraft_raster::resample::{Filter, resize};
use crate::media::PreviewLoader;
/// How much smaller than asked an embedded preview may be and still serve a face tile: at least
/// three quarters of the pixels the tile asks for across the face. The square is then enlarged by
/// at most 4⁄3 on its way to the tile, which at a 56–180 pt tile is not visible (the camera's JPEG
/// is sharpened and denoised, so its pixels hold more detail than a binned raw's), and it is the
/// same bound the raw path already accepts for an X-Trans sensor binned 3× for a preview
/// ([`crate::files::bin_factor`]). Below it the tile would look soft: the job's own source renders it.
pub const SOFT_LIMIT: f64 = 0.75;
/// Largest relative difference between the preview's and the photo's aspect ratios: a preview of
/// another shape (letterboxed, 16:9 from a 3:2 sensor, a 4:3 sensor's 3:2 JPEG) does not show the
/// same frame, and the face box would land beside the face. Real previews of the same frame differ
/// by rounding only (Sony A7S III: 1616 × 1080 against 4240 × 2832, 0.06 %).
const ASPECT_TOLERANCE: f64 = 0.02;
/// A face tile to cut from the photo's embedded preview.
#[derive(Clone)]
pub struct PreviewCut {
/// The raw file.
pub path: String,
/// Reads its largest embedded preview, display-ready and upright, no larger than the edge given.
pub loader: PreviewLoader,
/// The preview's long edge that gives the tile its own size across the face.
pub want: usize,
/// The smallest long edge accepted ([`SOFT_LIMIT`] of `want`).
pub min: usize,
/// The square around the face, normalized on the upright photo.
pub rect: Rect,
/// The photo's width / height, upright.
pub aspect: f64,
/// The tile's edge, pixels.
pub edge: usize,
}
impl PreviewCut {
/// The tile, or `None` when the preview can't give it: none could be read (no preview, an
/// offline or damaged file), it is smaller than [`Self::min`], or it shows another frame.
pub fn cut(&self) -> Option<Rgba8> {
let preview = (self.loader)(&self.path, self.want)?;
self.cut_from(&preview)
}
/// [`Self::cut`] from a preview already read.
pub fn cut_from(&self, img: &Rgba8) -> Option<Rgba8> {
let (w, h) = (img.width, img.height);
if w == 0 || h == 0 || w.max(h) < self.min || img.data.len() != w.checked_mul(h)? {
return None;
}
let aspect = w as f64 / h as f64;
if !(self.aspect.is_finite() && self.aspect > 0.0) || ((aspect - self.aspect) / self.aspect).abs() > ASPECT_TOLERANCE {
return None;
}
let r = self.rect;
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
return None;
}
// pixel bounds inside the preview (the clamps keep every value in 0..=w / 0..=h)
let px = |v: f64, n: usize| (v.clamp(0.0, 1.0) * n as f64).round() as usize;
let (x0, x1, y0, y1) = (px(r.x0, w), px(r.x1, w), px(r.y0, h), px(r.y1, h));
if x1 <= x0 || y1 <= y0 {
return None;
}
let edge = self.edge.clamp(1, crate::media::SourceLevel::Thumb.max_edge());
let square = img.crop(x0, y0, x1 - x0, y1 - y0).to_linear();
Some(resize(&square, edge, edge, Filter::Mitchell).to_srgb8())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
fn cut(rect: Rect, aspect: f64, min: usize) -> PreviewCut {
PreviewCut { path: String::new(), loader: Arc::new(|_, _| None), want: 300, min, rect, aspect, edge: 32 }
}
fn picture(w: usize, h: usize) -> Rgba8 {
let mut img = Rgba8::new(w, h);
for (i, p) in img.data.iter_mut().enumerate() {
*p = [(i % w * 255 / w) as u8, (i / w * 255 / h) as u8, 90, 255];
}
img
}
/// The square comes out the tile's size, from the place the box names.
#[test]
fn cuts_the_square_at_the_tiles_size() {
let img = picture(300, 200);
let out = cut(Rect { x0: 0.5, y0: 0.5, x1: 0.6, y1: 0.65 }, 1.5, 200).cut_from(&img).unwrap();
assert_eq!((out.width, out.height), (32, 32));
// red follows x: the box starts at half the width
let red = out.data[16 * 32 + 16][0];
assert!((125..=160).contains(&red), "{red}");
}
/// A preview too small, of another shape, empty, or a box that is no box: no tile, no panic.
#[test]
fn declines_what_it_cannot_cut() {
let img = picture(300, 200);
let ok = Rect { x0: 0.4, y0: 0.4, x1: 0.5, y1: 0.55 };
assert!(cut(ok, 1.5, 301).cut_from(&img).is_none(), "below the soft limit");
assert!(cut(ok, 16.0 / 9.0, 200).cut_from(&img).is_none(), "another frame");
assert!(cut(ok, f64::NAN, 200).cut_from(&img).is_none());
assert!(cut(ok, 0.0, 200).cut_from(&img).is_none());
assert!(cut(ok, 1.5, 0).cut_from(&Rgba8::new(0, 0)).is_none(), "empty preview");
let short = Rgba8 { width: 300, height: 200, data: vec![[0; 4]; 10] };
assert!(cut(ok, 1.5, 0).cut_from(&short).is_none(), "a buffer shorter than its size");
for bad in [
Rect { x0: f64::NAN, y0: 0.4, x1: 0.5, y1: 0.5 },
Rect { x0: 0.5, y0: 0.5, x1: 0.5, y1: 0.5 },
Rect { x0: 0.6, y0: 0.6, x1: 0.4, y1: 0.4 },
Rect { x0: 2.0, y0: 2.0, x1: 3.0, y1: 3.0 },
] {
assert!(cut(bad, 1.5, 0).cut_from(&img).is_none(), "{bad:?}");
}
// a box past the edges is clamped to the picture
let edge = cut(Rect { x0: -1.0, y0: -1.0, x1: 0.1, y1: 0.15 }, 1.5, 0).cut_from(&img).unwrap();
assert_eq!((edge.width, edge.height), (32, 32));
// and a loader with nothing to give leaves the job to its source
assert!(cut(ok, 1.5, 0).cut().is_none());
}
}
+39 -24
View File
@@ -721,7 +721,7 @@ pub fn fs_preview_loader() -> PreviewLoader {
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, max_edge).ok().flatten().and_then(|(header, window, _)| quick_image_srgb(&header, &window, max_edge))
preview_window(path, max_edge).ok().flatten().and_then(|(header, window, read)| quick_image_srgb(&header, &window, read.adobe_rgb, max_edge))
{
return Some(image);
}
@@ -743,6 +743,9 @@ pub struct PreviewRead {
/// path a Canon CR3 and the TIFF raws whose preview tag sits past the prefix take (`false` on
/// the header path, and on the whole-file fallback).
pub planned: bool,
/// Adobe RGB when the container says the preview is in it and the JPEG doesn't say otherwise
/// (`decode_raw_preview` applies the same declaration): the quick decode applies it too.
pub adobe_rgb: bool,
}
impl PreviewRead {
@@ -812,7 +815,7 @@ pub fn preview_window(path: &str, max_edge: usize) -> Result<Option<(Vec<u8>, Ve
if lightcraft_raw::embedded_preview_from_range(&window, range).is_none() {
return Ok(None);
}
let read = PreviewRead { read: bytes_read, total, header_path: true, planned: false };
let read = PreviewRead { read: bytes_read, total, header_path: true, planned: false, adobe_rgb: false };
// On a network share the bytes are the wait, so say what this preview actually cost. A window
// taken from the prefix cost nothing beyond the prefix itself.
if lightcraft_pipeline::profiling() {
@@ -890,14 +893,13 @@ fn planned_preview(file: &mut std::fs::File, path: &str, total: u64, max_edge: u
// 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
// the whole-file path applies through `decode_raw_preview`) and, when the JPEG carries
// no orientation of its own, the file's EXIF orientation. Where either is present and
// the header cannot answer for it, the whole-file path keeps the preview — exactly as
// it already does for a container-declared colour space. A container that says sRGB
// agrees with the JPEG-only decode (its own declaration, else sRGB), so only the other
// space declines.
if lightcraft_raw::embedded_preview_color_space(&buf) == Some(lightcraft_raw::PreviewColorSpace::AdobeRgb) {
return Ok(None);
}
// no orientation of its own, the file's EXIF orientation. The colour space is read
// here and handed on ([`PreviewRead::adobe_rgb`]: a Sony α1 writes Adobe RGB previews,
// which used to send each of them to the whole-file path, issue #1440); a container
// that says sRGB agrees with the JPEG-only decode (its own declaration, else sRGB).
// Where the orientation is present and the header cannot answer for it, the
// whole-file path keeps the preview.
let adobe_rgb = lightcraft_raw::embedded_preview_color_space(&buf) == Some(lightcraft_raw::PreviewColorSpace::AdobeRgb);
// `extract` copies a TIFF's bytes to hand on its Exif block, so ask the cheap side
// first: a JPEG that carries its own orientation needs nothing from the container.
if lightcraft_meta::extract(&window).orientation.is_none()
@@ -906,7 +908,7 @@ fn planned_preview(file: &mut std::fs::File, path: &str, total: u64, max_edge: u
{
return Ok(None);
}
let read = PreviewRead { read: loaded.total(), total, header_path: true, planned: true };
let read = PreviewRead { read: loaded.total(), total, header_path: true, planned: true, adobe_rgb };
// A plan-driven preview is the one that used to cost the whole file, so it is worth
// saying what it cost now too.
if lightcraft_pipeline::profiling() {
@@ -955,9 +957,15 @@ fn raf_preview_extent(prefix: &[u8]) -> Option<u64> {
/// The display-ready preview of a raw whose embedded JPEG was already located: the counterpart of
/// [`embedded_preview_srgb`] for [`preview_window`], which does not re-read the container.
/// `None` when the bytes are not a decodable image.
fn quick_image_srgb(header: &[u8], window: &[u8], max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
let mut d = lightcraft_codecs::decode(window, fit_box(max_edge)).ok()?;
/// `adobe_rgb`: the container declares the preview Adobe RGB ([`PreviewRead::adobe_rgb`]), applied
/// as [`decode_raw_preview`] applies it. `None` when the bytes are not a decodable image.
fn quick_image_srgb(header: &[u8], window: &[u8], adobe_rgb: bool, max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
let decoded = if adobe_rgb && window.starts_with(&[0xff, 0xd8]) {
lightcraft_codecs::decode_jpeg_with_fallback(window, fit_box(max_edge), lightcraft_codecs::NamedSpace::AdobeRgb)
} else {
lightcraft_codecs::decode(window, fit_box(max_edge))
};
let mut d = decoded.ok()?;
if d.image.width.max(d.image.height) > max_edge {
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
d.alpha = None;
@@ -1152,7 +1160,7 @@ mod tests {
assert!(window.len() < file.len() / 2, "the raw tail must not be read: {} of {} bytes", window.len(), file.len());
// The quick path and the whole-file path agree byte for byte.
let quick = quick_image_srgb(&header, &window, max_edge).expect("the window holds a decodable preview");
let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview");
let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too");
assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size");
assert_eq!(quick.data, whole.data, "byte for byte");
@@ -1258,7 +1266,7 @@ mod tests {
assert!(window.len() < file.len() / 2, "the sensor strip must not be read: {} of {} bytes", window.len(), file.len());
// The plan path and the whole-file path agree byte for byte.
let quick = quick_image_srgb(&header, &window, max_edge).expect("the window holds a decodable preview");
let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview");
let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too");
assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size");
assert_eq!(quick.data, whole.data, "byte for byte");
@@ -1292,7 +1300,7 @@ mod tests {
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 quick = quick_image_srgb(&header, &window, read.adobe_rgb, 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),
@@ -1332,12 +1340,12 @@ mod tests {
}
/// A container can declare what its preview JPEG is in, and the whole-file path applies that
/// declaration (`decode_raw_preview`). A JPEG-only decode has the JPEG's own space, else sRGB, so
/// a container that declares Adobe RGB must keep the whole-file path — while one that declares
/// sRGB (what most bodies write) must **not** take the fast path away: that half is a regression
/// test, since a guard that declined every declared space did exactly that to a real Sony ARW.
/// declaration (`decode_raw_preview`). The planned path reads it too and the quick decode applies
/// it, so a container that declares Adobe RGB (a Sony α1's ARW) is read in part like any other and
/// shows the same pixels as the whole-file path (issue #1440; it used to be read whole) — and one
/// that declares sRGB (what most bodies write) keeps the fast path as before.
#[test]
fn preview_loading_declines_only_a_container_colour_space_it_cannot_apply() {
fn preview_loading_applies_a_container_colour_space() {
use lightcraft_raw::PreviewColorSpace;
let (w, h) = (48u32, 32u32);
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 23 % 233) as u8).collect();
@@ -1350,13 +1358,20 @@ 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, usize::MAX).expect("the file reads").is_none(), "the whole-file path applies the space");
let (header, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("the plan answers for Adobe RGB too");
assert!(read.planned && read.adobe_rgb, "{read:?}");
assert!(read.fraction() < 0.5, "the sensor strip is not read: {read:?}");
let quick = quick_image_srgb(&header, &window, read.adobe_rgb, 512).expect("the window decodes");
let whole = embedded_preview_srgb(&adobe, 512).expect("the whole file decodes too");
assert_eq!(quick.data, whole.data, "the space is applied as the whole-file path applies it");
let plain = quick_image_srgb(&header, &window, false, 512).expect("the window decodes");
assert_ne!(plain.data, whole.data, "and it changes the pixels (the fixture's colours are saturated enough)");
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, usize::MAX).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes");
assert!(read.planned, "the plan still answers");
assert!(read.planned && !read.adobe_rgb, "the plan still answers");
assert!(window.starts_with(&[0xff, 0xd8]), "and hands on the JPEG");
}
+1
View File
@@ -1532,6 +1532,7 @@ impl Session {
stages: None,
view_cache: None,
display: None,
preview_cut: None,
};
let embedded = match (&self.media.preview_loader, c.kind) {
(Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)),
+4
View File
@@ -31,6 +31,7 @@ pub mod devices;
pub mod display;
pub mod export;
pub mod face_download;
pub mod face_tiles;
#[cfg(not(target_arch = "wasm32"))]
mod faces_index;
#[cfg(not(target_arch = "wasm32"))]
@@ -1001,6 +1002,9 @@ mod tests_face_models;
#[cfg(not(target_arch = "wasm32"))]
mod tests_face_recognize;
#[cfg(test)]
#[cfg(not(target_arch = "wasm32"))]
mod tests_face_tiles;
#[cfg(test)]
mod tests_filesystem_history;
#[cfg(test)]
mod tests_folders;
+63 -3
View File
@@ -67,6 +67,17 @@ pub const RENDER_CACHE_VERSION: u64 = 62;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
/// Face tiles are rendered at a multiple of this many pixels ([`crate::Session::face_job`]): the
/// tile's size follows the thumbnail slider and the screen's scale, and without steps every size
/// in between would render (and cache) every face again.
pub const FACE_EDGE_STEP: usize = 16;
/// The edge a face tile asked to be `edge` pixels across is rendered at: the next
/// [`FACE_EDGE_STEP`], 16..=512.
pub fn face_edge(edge: usize) -> usize {
edge.clamp(16, SourceLevel::Thumb.max_edge()).div_ceil(FACE_EDGE_STEP).saturating_mul(FACE_EDGE_STEP).min(SourceLevel::Thumb.max_edge())
}
pub fn thumb_bucket(long_edge: usize) -> usize {
THUMB_SIZES.iter().copied().find(|s| *s >= long_edge).unwrap_or(512)
}
@@ -741,6 +752,9 @@ pub struct RenderJob {
pub view_cache: Option<(Arc<PreviewCache>, Hash128)>,
/// Render for this monitor profile ([`Self::with_display`]).
pub display: Option<Arc<crate::display::Display>>,
/// A face tile that can be cut from the raw's embedded preview, tried before `source`
/// ([`crate::Session::face_job`]; `None` for every other job).
pub preview_cut: Option<crate::face_tiles::PreviewCut>,
}
pub struct RenderResult {
@@ -820,6 +834,7 @@ impl RenderJob {
self.settings = Arc::new(s);
self.cache = None;
self.view_cache = None;
self.preview_cut = None;
self
}
@@ -887,6 +902,26 @@ impl RenderJob {
display: None,
};
}
// a face tile of an unedited raw: cut from the camera's embedded preview when it is big enough
// (the sensor data is never read for it), else rendered from `source` below
if let Some(image) = self.preview_cut.as_ref().and_then(|c| c.cut()) {
if let Some((cache, key)) = &self.cache {
cache.put_at(self.cache_generation, *key, Arc::new(image.clone()));
}
let histogram = Histogram::of_srgb8(&image);
return RenderResult {
request_id: self.request_id,
source_key: self.source_key,
photo: self.photo,
level: self.level,
key: self.key,
rendered: Ok(Rendered { image, histogram, deep: None }),
gpu_fallback: None,
loaded: None,
quick: None,
display: None,
};
}
let was_loaded = matches!(self.source, SourceRef::Loaded(_));
// The app is closing (issue #620): nobody is left to show this, and quitting waits for the
// jobs that are running. Without this, one that lost the GPU mid-way would render on the CPU.
@@ -1192,6 +1227,7 @@ impl crate::Session {
stages: None,
view_cache: None,
display: None,
preview_cut: None,
})
}
@@ -1243,13 +1279,19 @@ impl crate::Session {
stages: None,
view_cache: None,
display: None,
preview_cut: None,
})
}
/// A square close-up of `face` (normalized, in the photo's upright frame) for a People card: the
/// photo's own look with its crop replaced by a square around the face (room for hair and chin),
/// `edge` pixels across (≤ 512). The source level is the smallest that keeps the face sharp, up
/// to the preview. Cached like [`Self::variant_job`], and `key` likewise follows the content.
/// at least `edge` pixels across (rounded up to [`FACE_EDGE_STEP`], ≤ 512, so a tile size
/// between two steps reuses the cached one). An unedited raw's tile is cut from the camera's
/// embedded preview when that holds the square at [`crate::face_tiles::SOFT_LIMIT`] of the
/// size asked or more ([`crate::face_tiles`]); else, and for every other photo, the source
/// level is the smallest that keeps the face sharp, up to the preview. Cached (memory and the
/// library's thumbnail cache on disk) like [`Self::variant_job`], and `key` likewise follows
/// the content: a later launch shows the tile without reading the original.
pub fn face_job(&mut self, id: PhotoId, face: lightcraft_geom::Rect, edge: usize) -> Option<RenderJob> {
let p = self.catalog.photo(id)?.clone();
// `face` is on the upright (EXIF-oriented) photo; the crop below is in the frame after the
@@ -1267,12 +1309,13 @@ impl crate::Session {
let rect = lightcraft_geom::Rect { x0: x0 / w, y0: y0 / h, x1: (x0 + side) / w, y1: (y0 + side) / h };
let mut settings = (*p.develop).clone();
settings.crop = lightcraft_develop::Crop { geometry: lightcraft_geom::CropGeometry { rect, angle: 0.0 }, ..Default::default() };
let edge = edge.clamp(16, SourceLevel::Thumb.max_edge());
let edge = face_edge(edge);
// pixels along the long edge that leave `edge` across the crop
let needed = (edge as f64 * w.max(h) / side).ceil().min(SourceLevel::Preview.max_edge() as f64);
let level = SourceLevel::for_size(needed as usize);
let source = self.media.source_ref(&p, level);
let ck = Self::variant_key(&p, &settings, edge);
let preview_cut = self.face_preview_cut(&p, rect, edge, needed as usize);
Some(RenderJob {
request_id: 0,
cache_generation: self.media.rendered.generation(),
@@ -1289,9 +1332,26 @@ impl crate::Session {
stages: None,
view_cache: None,
display: None,
preview_cut,
})
}
/// How a face tile of `p` can come from its embedded preview ([`crate::face_tiles`]): only an
/// unedited raw (whose grid thumbnail is that preview too) not known to be offline, upright as
/// imported, with a preview loader installed. `rect` is the square around the face, `edge` the
/// tile, `needed` the source's long edge the develop render would ask for.
fn face_preview_cut(&self, p: &Photo, rect: lightcraft_geom::Rect, edge: usize, needed: usize) -> Option<crate::face_tiles::PreviewCut> {
let (path, loader) = self.embedded_of(p)?;
if p.develop.orientation != lightcraft_geom::Orientation::Normal || self.media.availability.is_offline(&path) || p.width == 0 || p.height == 0
{
return None;
}
let want = needed.max(edge).max(1);
let min = (want as f64 * crate::face_tiles::SOFT_LIMIT).ceil() as usize;
let aspect = f64::from(p.width) / f64::from(p.height);
Some(crate::face_tiles::PreviewCut { path, loader, want, min, rect, aspect, edge })
}
/// Size-independent cache key of a photo's view render (loupe) for its current settings.
fn view_key(p: &Photo, apply_crop: bool) -> Hash128 {
Hasher128::new()
+275
View File
@@ -0,0 +1,275 @@
//! Face tiles from the embedded preview, kept in the thumbnail cache (issue #1440).
//!
//! The fixture is a TIFF-family raw with two embedded JPEG previews (a small flat red one and a
//! larger picture) and a sensor strip, as #1020's planned-read tests build them. Counting wrappers
//! around the session's file loader (the path that reads the whole original and develops the raw)
//! and its preview loader say which source a face tile came from.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use lightcraft_catalog::PhotoId;
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
use lightcraft_geom::Rect;
use lightcraft_raster::Rgba8;
use serde_json::json;
use crate::Session;
const W: u32 = 600;
const H: u32 = 400;
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-face-tiles-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// The big preview's pixel at (x, y): smooth ramps and a soft disc, so a resampled square of it
/// compares well between two resamplers.
fn big_pixel(x: u32, y: u32) -> [u8; 3] {
let (dx, dy) = (x as f64 - 300.0, y as f64 - 200.0);
let disc = (-(dx * dx + dy * dy) / (2.0 * 40.0 * 40.0)).exp();
[(40.0 + 150.0 * x as f64 / W as f64) as u8, (60.0 + 120.0 * y as f64 / H as f64) as u8, (50.0 + 160.0 * disc) as u8]
}
fn jpeg(w: u32, h: u32, px: impl Fn(u32, u32) -> [u8; 3]) -> Vec<u8> {
let rgb: Vec<u8> = (0..h).flat_map(|y| (0..w).map(move |x| (x, y))).flat_map(|(x, y)| px(x, y)).collect();
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
encode_jpeg(&img, 95, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap()
}
/// A raw (DNG-tagged) whose IFD0 is a small flat red preview, IFD1 the big picture and IFD2 a CFA
/// "sensor" strip of `tail` bytes. The sensor data can't be developed, so the import describes the
/// photo by its largest preview (600 × 400) and the develop path renders from it, after reading
/// the whole file.
fn raw_with_two_previews(tail: usize) -> Vec<u8> {
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
let preview = |w: u32, h: u32, bytes: Vec<u8>, first: bool| {
let mut ifd = IfdBuilder::new();
if first {
ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0]));
}
ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1]));
ifd.set(t::COMPRESSION, Value::Short(vec![7]));
ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB]));
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![bytes] });
ifd
};
let small = preview(120, 80, jpeg(120, 80, |_, _| [200, 40, 40]), true);
let big = preview(W, H, jpeg(W, H, big_pixel), false);
let mut raw = IfdBuilder::new();
raw.set(t::COMPRESSION, Value::Short(vec![1]));
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
raw.set(t::IMAGE_WIDTH, Value::Long(vec![6000]));
raw.set(t::IMAGE_LENGTH, Value::Long(vec![4000]));
raw.set_image(ImageData::Strips { rows_per_strip: 4000, strips: vec![vec![0x33u8; tail]] });
TiffWriter::default().write(&[small, big, raw]).unwrap()
}
/// How often each loader ran.
#[derive(Clone, Default)]
struct Loads {
/// The original read whole and developed (for a raw: its sensor data decoded).
files: Arc<AtomicUsize>,
/// The embedded preview read (planned read: header and preview only).
previews: Arc<AtomicUsize>,
}
impl Loads {
fn get(&self) -> (usize, usize) {
(self.files.load(Ordering::SeqCst), self.previews.load(Ordering::SeqCst))
}
}
/// Count the session's loads from now on.
fn count_loads(s: &mut Session) -> Loads {
let loads = Loads::default();
let file = s.media.file_loader.clone().unwrap();
let n = loads.files.clone();
s.media.file_loader = Some(Arc::new(move |p: &str, e: usize| {
n.fetch_add(1, Ordering::SeqCst);
file(p, e)
}));
let preview = s.media.preview_loader.clone().unwrap();
let n = loads.previews.clone();
s.media.preview_loader = Some(Arc::new(move |p: &str, e: usize| {
n.fetch_add(1, Ordering::SeqCst);
preview(p, e)
}));
loads
}
/// A session on the library in `dir` (created on first use).
fn open(dir: &Path) -> Session {
let mut s = Session::new().with_fs();
s.open_library(dir.join("library"), false).unwrap();
s
}
/// A library holding the fixture: (dir, session, the photo, the file).
fn setup(tag: &str) -> (PathBuf, Session, PhotoId, PathBuf) {
let dir = temp_dir(tag);
let file = dir.join("faces.dng");
std::fs::write(&file, raw_with_two_previews(400_000)).unwrap();
let mut s = open(&dir);
s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
let p = s.catalog.photos().next().unwrap();
assert_eq!((p.kind, p.width, p.height), (lightcraft_catalog::MediaKind::Raw, W, H), "described by its largest preview");
let id = p.id;
(dir, s, id, file)
}
/// A face of a tenth of the frame, centred: a 64 px tile needs a 377 px source, which the 600 px
/// preview has.
const FACE: Rect = Rect { x0: 0.45, y0: 0.45, x1: 0.55, y1: 0.55 };
/// A face of 2 %: a 64 px tile would need a 1883 px source; the preview is under ¾ of that.
const TINY_FACE: Rect = Rect { x0: 0.49, y0: 0.49, x1: 0.51, y1: 0.51 };
fn tile(s: &mut Session, id: PhotoId, face: Rect, edge: usize) -> Result<Rgba8, String> {
s.face_job(id, face, edge).unwrap().run().rendered.map(|r| r.image)
}
fn mean_abs_diff(a: &Rgba8, b: &Rgba8) -> f64 {
assert_eq!((a.width, a.height), (b.width, b.height));
let sum: u64 = a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |c| u64::from(p[c].abs_diff(q[c])))).sum();
sum as f64 / (a.data.len() * 3) as f64
}
/// An unedited raw's face tile is cut from its largest embedded preview: the original is never read
/// whole nor its raw developed, and the tile matches the develop path's at tile size.
#[test]
fn an_unedited_raws_face_comes_from_its_largest_preview() {
let (dir, mut s, id, file) = setup("preview");
// (the preview loader's planned read: the header and the preview, not the sensor strip)
let (_, _, read) = crate::files::preview_window(&file.to_string_lossy()).unwrap().unwrap();
assert!(read.fraction() < 0.5, "{read:?}");
let loads = count_loads(&mut s);
let from_preview = tile(&mut s, id, FACE, 64).unwrap();
assert_eq!((from_preview.width, from_preview.height), (64, 64));
assert_eq!(loads.get(), (0, 1), "one preview read, no whole-file read or raw decode");
// the big preview, not the small flat red one
let centre = from_preview.data[32 * 64 + 32];
assert!(centre[2] > 150 && centre[0] < 150, "the face's centre is the big preview's disc: {centre:?}");
// the develop path (no preview loader: what every face used before), from the memory cache's reach
s.media.preview_loader = None;
let mut job = s.face_job(id, FACE, 64).unwrap();
job.cache = None;
let developed = job.run().rendered.unwrap().image;
assert_eq!(loads.files.load(Ordering::SeqCst), 1, "the develop path reads the original");
let d = mean_abs_diff(&from_preview, &developed);
eprintln!("mean difference preview/develop: {d:.3}");
assert!(d < 4.0, "the preview's tile looks like the developed one: mean difference {d:.2} of 255");
let _ = std::fs::remove_dir_all(dir);
}
/// A preview under the soft limit (¾ of the pixels the tile needs across the face) leaves the tile to
/// the develop path; so does an edited photo, whose look the camera's JPEG doesn't have.
#[test]
fn a_preview_too_small_or_an_edited_photo_falls_back_to_the_develop_path() {
let (dir, mut s, id, _) = setup("fallback");
let loads = count_loads(&mut s);
let img = tile(&mut s, id, TINY_FACE, 64).unwrap();
assert_eq!((img.width, img.height), (64, 64));
assert_eq!(loads.get(), (1, 1), "the preview was tried, then the original developed");
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
let edited = tile(&mut s, id, FACE, 64).unwrap();
assert_eq!(loads.get(), (2, 1), "an edited photo's face is developed; its preview isn't read");
let _ = std::fs::remove_dir_all(dir);
drop(edited);
}
/// Rendered tiles are kept in the library's thumbnail cache: a new session (the next launch) shows
/// them without reading the original, from the preview or the develop path alike; and a size
/// between two steps reuses the tile of its step.
#[test]
fn face_tiles_are_kept_on_disk_for_the_next_launch() {
let (dir, mut s, id, _) = setup("disk");
let first = tile(&mut s, id, FACE, 64).unwrap();
let _ = tile(&mut s, id, TINY_FACE, 64).unwrap();
drop(s);
let mut s = open(&dir);
let loads = count_loads(&mut s);
let again = tile(&mut s, id, FACE, 64).unwrap();
let tiny = tile(&mut s, id, TINY_FACE, 64).unwrap();
assert_eq!(loads.get(), (0, 0), "both tiles come from the disk cache: the original is not touched");
assert_eq!((again.width, tiny.width), (64, 64));
assert!(mean_abs_diff(&first, &again) < 3.0, "the cached tile is the rendered one (as a JPEG)");
// 60 px asks for the 64 px step: the same tile, no new render
assert_eq!(s.face_job(id, FACE, 60).unwrap().key, s.face_job(id, FACE, 64).unwrap().key);
let _ = tile(&mut s, id, FACE, 60).unwrap();
assert_eq!(loads.get(), (0, 0));
let _ = std::fs::remove_dir_all(dir);
}
/// What can go wrong never panics: a preview loader that fails or answers nonsense, a damaged
/// preview, face boxes at the edge or degenerate, an offline original, a damaged cache entry.
#[test]
fn face_tiles_never_crash() {
let (dir, mut s, id, file) = setup("crash");
// a damaged cache entry: dropped, and the tile rendered again (from the preview)
let job = s.face_job(id, FACE, 64).unwrap();
let key = job.cache.as_ref().unwrap().1.to_string();
assert!(job.run().rendered.is_ok());
let thumbs = s.library.as_ref().unwrap().thumbs_dir();
drop(s);
let entry = thumbs.join(&key[..2]).join(format!("{key}.jpg"));
assert!(entry.exists(), "the tile was written to the thumbnail cache");
std::fs::write(&entry, b"not a jpeg").unwrap();
let mut s = open(&dir);
let loads = count_loads(&mut s);
assert_eq!(tile(&mut s, id, FACE, 64).unwrap().width, 64);
assert_eq!(loads.get(), (0, 1), "rendered again from the preview");
// face boxes at the corners, oversized, empty, not numbers: a tile or an error, never a panic
for face in [
Rect { x0: 0.0, y0: 0.0, x1: 0.05, y1: 0.05 },
Rect { x0: 0.95, y0: 0.95, x1: 1.0, y1: 1.0 },
Rect { x0: -5.0, y0: -5.0, x1: 5.0, y1: 5.0 },
Rect { x0: 0.5, y0: 0.5, x1: 0.5, y1: 0.5 },
Rect { x0: f64::NAN, y0: 0.5, x1: 0.6, y1: f64::INFINITY },
Rect { x0: f64::NEG_INFINITY, y0: f64::NAN, x1: f64::NAN, y1: f64::NAN },
] {
for edge in [0, 1, 64, usize::MAX] {
let img = tile(&mut s, id, face, edge).unwrap_or_else(|e| panic!("{face:?} at {edge}: {e}"));
assert_eq!(img.width, img.height, "{face:?} at {edge}");
}
}
// a preview loader that answers nonsense or nothing: the develop path renders the tile
let id2 = id;
for answer in [None, Some(Rgba8 { width: W as usize, height: H as usize, data: Vec::new() }), Some(Rgba8::new(1, 1))] {
s.media.preview_loader = Some(Arc::new(move |_: &str, _: usize| answer.clone()));
let mut job = s.face_job(id2, FACE, 64).unwrap();
job.cache = None;
assert_eq!(job.run().rendered.unwrap().image.width, 64);
}
// a damaged preview in the file: no tile from it, and no panic either way
s.media.preview_loader = Some(crate::files::fs_preview_loader());
let mut bytes = std::fs::read(&file).unwrap();
let n = bytes.len();
for b in bytes.iter_mut().take(n - 400_000).skip(1000) {
*b = 0x5a;
}
std::fs::write(&file, &bytes).unwrap();
let mut job = s.face_job(id, FACE, 64).unwrap();
job.cache = None;
let _ = job.run();
// the original gone (an offline drive): an error, no panic
std::fs::remove_file(&file).unwrap();
let mut job = s.face_job(id, FACE, 64).unwrap();
job.cache = None;
assert!(job.run().rendered.is_err());
let _ = std::fs::remove_dir_all(dir);
}
+31
View File
@@ -292,6 +292,37 @@ shot), so check a model on your own photos before trusting it:
(ignoring shots taken within a few seconds of it, which would make it too easy) and reports, for each threshold, how many
suggestions it would make and how many were right.
## Face pictures in the People view
Each face on a person card, in Unnamed faces and on a person's page is a square around the face, about 1.7 times the
face box, cut out of the photo with the photo's own look (`Session::face_job`). Where the pixels come from:
- **An unedited raw** (it still looks the way it was imported, the case in which its grid thumbnail is the camera's
embedded JPEG too) is cut from its **largest embedded preview** (`crates/engine/src/face_tiles.rs`). The preview is
read the planned way ([raw-preview-reads](raw-preview-reads.md)): the file's header and the JPEG, never the sensor
data, and the raw is not developed. The preview is used when it holds the square at **at least ¾ of the pixels the
tile asks for** (up to the 2,560 px the develop path would use); the square is then enlarged by at most 4⁄3, which
is not visible at tile size (the camera's JPEG is sharpened, so its pixels carry more detail than a binned raw's),
and is the bound the raw path already accepts when it bins an X-Trans sensor for a preview. A smaller preview, one of
another shape than the photo (letterboxed, another aspect ratio), one that does not decode, or a file that is offline
leaves the face to the develop path below.
- **Everything else** (an edited raw, whose edits the camera's JPEG can't take the way the raw does; JPEG, HEIC and
other files; a raw whose preview is too small) is a develop render of the photo with its crop replaced by the square,
from the smallest source level that keeps the face sharp, as before.
Measured on two Sony raws on an external 5400 rpm disk (file cache dropped before each face, a centred face box of 4 %
and 10 % of the frame, 176 and 352 px tiles): an α1 ARW (74 MB, 8640 × 5760 Adobe RGB preview) read 70.6 MiB in
0.77–0.92 s per face before and reads 7.8 MiB in 0.20–0.26 s; an A7S III ARW (25 MB, full-size 4240 × 2832 preview)
read 24.3 MiB in 0.38–0.56 s and reads 1.1 MiB in 0.04–0.09 s. The tiles differ from the develop path's by 2–6 levels
of 255 on average (the camera's rendering against LightCraft's camera look, which is fitted to it).
Rendered faces are kept like other thumbnails: in memory, and in the library's thumbnail cache on disk (`thumbs/`,
within the Settings ▸ Performance cache budget, least recently used files go first). The key is the photo's content
hash, its develop settings with the face's square as the crop, the tile size, the renderer version and the camera
profiles, so an edit, another square or a new renderer makes a new picture, and the next launch shows the faces
without reading the originals. Tile sizes are rounded up to a multiple of 16 px, so moving the thumbnail slider
reuses the pictures of its step instead of rendering every face again.
## Commands
All of this is reachable from the control channel, the CLI and MCP: `faces.models.list`, `faces.models.inspect {path}`,
+1 -1
View File
@@ -712,7 +712,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields |
| LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) |
| LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step |
| LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. No clusters with separators between groups, no persistent reject, no merge of two people
| LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs`, `crates/engine/src/face_tiles.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. Face pictures of unedited raws are cut from the largest embedded preview (planned read: header and JPEG only, never the sensor data) when it holds at least ¾ of the tile's pixels across the face, else developed as before; all are kept in the thumbnail disk cache at 16 px size steps, so a later launch shows them without reading the originals (#1440, `docs/faces.md`). No clusters with separators between groups, no persistent reject, no merge of two people
| LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | |
| LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) |
| LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`), `crates/ui-egui/src/panels/left.rs` (`locate_missing_item`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; a folder or disk row's Locate Missing Files… chooses its new location and scopes the same recursive background finder to that folder and its subfolders. Unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike |
+15 -12
View File
@@ -60,11 +60,13 @@ those:
header probe reads samples and whose header plan is `Whole`, which is where that plan was untested
before;
- the quick path must be able to show what the whole-file path would. Two container facts are not in
the JPEG, so a file that states one where the bounded header cannot see it declines and the whole
file keeps the preview: a preview colour space of **Adobe RGB** (the whole-file path applies it
through `decode_raw_preview`; a JPEG-only decode has the JPEG's own space, else sRGB — a container
that says sRGB does not decline, since that is what the decode assumes anyway), and the file's own
EXIF orientation when the preview JPEG carries none of its own;
the JPEG. A preview colour space of **Adobe RGB** is read from the planned buffer and handed on
(`PreviewRead::adobe_rgb`), and the quick decode applies it as `decode_raw_preview` does (issue
#1440: a Sony α1 writes Adobe RGB previews, which until then sent every one of them to the
whole-file read — 70 MB for an 8 MB JPEG); a container that says sRGB needs nothing, since that is
what the decode assumes anyway. The file's own EXIF orientation, when the preview JPEG carries none
of its own and the bounded header cannot see it, still declines and the whole file keeps the
preview;
- a format the planner cannot describe — NEF, ORF and PEF probe from samples, CRW/MRW/X3F are found
by scanning — a plan that does not fit the buffer or wants more than 60 % of the file (the bound the
import path's own partial read uses), or a window that does not decode all answer `None`, and the
@@ -75,8 +77,8 @@ route**, reading a median **7.8 %** of their bytes (0.9 %–27.7 %) — by forma
4.1 %, ARW 7.5 %, CR3 8.4 %, DNG 9.7 %, CR2 14.3 % — while the 21 RAFs stay on the header route
(median 4.5 %). The remaining 38 read whole: NEF, NRW, ORF, PEF and Samsung SRW because the planner
does not describe them (the first four say so; an SRW plans to the whole file, and the 60 % budget
then keeps the whole-file read), and the ARWs, CR2s, CR3s and DNGs that declare Adobe RGB or whose
plan wants more than that budget. Across the whole corpus the plan handed on **the same preview as
then keeps the whole-file read), and the ARWs, CR2s, CR3s and DNGs that declared Adobe RGB (these take
the plan route since #1440; the counts here are from before) or whose plan wants more than that budget. Across the whole corpus the plan handed on **the same preview as
the whole file, every time** — and wrote it down: `cargo test -p lightcraft-raw --release --test
sparse_corpus -- --nocapture` prints these numbers. `LIGHTCRAFT_PROFILE=1` says which route answered:
@@ -136,11 +138,12 @@ use the largest JPEG, so their geometry and colour do not depend on the size lev
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`).
guessing, and the caller keeps reading it whole. So does a file whose own orientation only the
whole-file path can show. One that states Adobe RGB takes the plan route and shows the same pixels
as the whole-file path (the space changes them: decoded as sRGB they differ), and 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.
+10
View File
@@ -58,6 +58,16 @@
- Nothing else changes: the same files are found new, already there, moved or missing, and sidecars get the same
names.
### Faces load faster in the People view
- Faces on unedited raws are cut from the camera's preview inside the file instead of developing the raw (issue
#1440): a Sony α1 face reads 8 MB instead of 70 MB, an A7S III face about 1 MB instead of 24 MB, several times
faster on an external hard disk. A face whose preview is too small, and any edited photo, is still developed from
the raw, so it keeps its look.
- Raws whose preview is in Adobe RGB (Sony α1 among others) no longer read the whole file for their grid thumbnail:
only the header and the preview are read, as for other raws.
- Faces are kept in the thumbnail cache at a few fixed sizes, so moving the thumbnail slider no longer renders them
all again.
### Slider drags on large raws
- Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you
let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's