Cut edited thumbnails from the decoded source, not the file (#3810)

* Cut edited thumbnails from the decoded source, not the file

In Detail view the decoded source of the photo on screen is held at full size, but the thumbnail branch of `source_ref_plain` only looks for a preview-level source, so each edit re-queued a thumbnail that decoded the original again —1.4 GB of gate usage for one 489 MB file, matching the reported stall on the SMB share.

When building a thumbnail job, reuse the photo's decoded source at whatever level it is cached (preview or full), fitting it to the thumbnail's edge, so an edited photo's thumbnail no longer re-reads the original file while its source is already in memory.

Test: a_thumbnail_is_cut_from_the_decoded_full_source failed before the change and passes after.

* Reuse decoded sources for thumbnail rendering

Assisted-by: OpenAI Codex

* Web worker: box the fitted source, as SourceRef::Fit holds it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
eliets2
2026-11-05 21:32:41 +01:00
committed by GitHub
co-authored by Claude Opus 5.5 eliets2 FredApps FredApps
parent 1b6e722292
commit e50db33453
3 changed files with 37 additions and 4 deletions
+14 -1
View File
@@ -33,6 +33,9 @@ pub struct WireJob {
pub origin: Source,
/// Decode the source at most this long.
pub max_edge: usize,
/// Resize an already decoded source exactly as the engine's cached-source path does.
#[serde(default)]
pub fit_edge: Option<usize>,
pub raw: bool,
#[serde(default)]
pub relative_wb: bool,
@@ -67,7 +70,8 @@ impl WireJob {
/// `view`: the slot name, for jobs that keep a stage cache.
pub fn from_job(job: &RenderJob, thumb_cached: bool, view: &str) -> WireJob {
let max_edge = match &job.source {
SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } | SourceRef::Fit { max_edge, .. } => *max_edge,
SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } => *max_edge,
SourceRef::Fit { from, .. } => from.image.width.max(from.image.height),
SourceRef::Loaded(img) => img.image.width.max(img.image.height),
SourceRef::Smart { .. } => 2560,
};
@@ -77,6 +81,10 @@ impl WireJob {
level: job.level,
origin: job.origin.clone(),
max_edge,
fit_edge: match &job.source {
SourceRef::Fit { max_edge, .. } => Some(*max_edge),
_ => None,
},
raw: job.info.raw,
relative_wb: job.info.relative_wb,
legacy_relative_wb: job.info.legacy_relative_wb,
@@ -196,6 +204,10 @@ impl WorkerCore {
src
}
};
let src = match job.fit_edge {
Some(max_edge) => SourceRef::Fit { from: Box::new(src), max_edge }.load_source()?,
None => src,
};
let info = src.info_or(job.info());
let mut rendered = match &job.stages {
Some(view) => {
@@ -472,6 +484,7 @@ mod tests {
level: SourceLevel::Thumb,
origin,
max_edge: 512,
fit_edge: None,
raw: false,
relative_wb: false,
legacy_relative_wb: false,
+7 -3
View File
@@ -344,13 +344,17 @@ impl MediaCache {
{
return SourceRef::Loaded(Box::new(a));
}
// a thumbnail of a photo whose preview is decoded (the one in the loupe) is cut from it; not
// a thumbnail of a photo whose preview or full source is decoded (the one in the loupe) is cut from it; not
// with a denoised picture, which the thumbnail would lose, nor from one decoded with another camera profile
if cached
&& level == SourceLevel::Thumb
&& self.denoise.spec(p.id).is_none()
&& let Some(big) = self.get_source(p.id, SourceLevel::Preview).filter(|b| b.fits_outliers(outliers))
&& (big.camera_profile == wanted.as_ref().map(|(tag, _)| tag.clone()) || (big.from_proxy && self.original_offline(p)))
&& let Some(big) = [SourceLevel::Preview, SourceLevel::Full].into_iter().find_map(|level| {
self.get_source(p.id, level).filter(|b| {
b.fits_outliers(outliers)
&& (b.camera_profile.as_deref() == wanted.as_ref().map(|(tag, _)| tag.as_str()) || (b.from_proxy && self.original_offline(p)))
})
})
{
return SourceRef::Fit { from: Box::new(big), max_edge: level.max_edge() };
}
+16
View File
@@ -644,3 +644,19 @@ fn an_export_develops_from_a_source_twice_its_size() {
}
assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Preview, "a view keeps the preview");
}
#[test]
fn a_thumbnail_is_cut_from_the_decoded_full_source() {
let mut s = crate::Session::new();
let p = Photo::new(PhotoId(1), Source::File { path: "/nowhere/a.ARW".into() }, "a.ARW", "ARW", 2400, 1600, "");
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap();
s.media.insert(p.id, SourceLevel::Full, Arc::new(Rgb32f::from_fn(2400, 1600, |x, _| [x as f32 / 2400.0, 0.5, 0.25])));
let job = s.thumb_job(p.id, 512).unwrap();
assert!(matches!(job.source, SourceRef::Fit { .. }));
let r = job.run();
let thumb = r.rendered.as_ref().unwrap();
assert_eq!((thumb.image.width, thumb.image.height), (512, 341));
s.accept(&r);
let cached = s.media.get(p.id, SourceLevel::Thumb).unwrap();
assert_eq!((cached.width, cached.height), (512, 341), "a thumbnail-sized source is kept, not the full source");
}