* PERF-CULL-0: ui.inspect reports how the loupe showed each photo it was stepped to
perf.loupeSteps records, per step, the ms from the step to the first frame
with anything of the photo, with a full-size picture (camera JPEG, cached
or rendered) and with LightCraft's own render, measured on the frames the
app draws, so culling benchmarks exclude the control channel's round trip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 95f0343848fc0a238f80f07ceb292eaf6779aeaa)
* PERF-CULL-1: browsing a folder shows its first photos within a second
The Local view read and hashed every file of the folder before showing any
of them (2,933 EOS R5 C CR3s: 30.6 s to the first photo). It now hands the
files the library lacks straight to the import, ordered the way the view
sorts them (file time for the date sorts), which adds them batch by batch:
first photo 0.9 s, twelve thumbnails 1.4 s, the whole folder in the same
~34 s as before, in the background.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit b766558e7ef4f802919a144da53e444f70e0186b)
* PERF-CULL-2: the loupe makes the next photos' camera previews ahead of the step
Culling steps through photos, and each step waited for the next raw's
embedded JPEG to be read and decoded (p50 187 ms, p95 332 ms on EOS R5 C
CR3s) and then for a full raw develop. Now:
- the loupe's stand-ins for the next five photos in the direction of
travel and the one behind are made ahead (Slot::QuickAhead, priority
just below the loupe's own render) and taken over at the step, so a
step shows the camera JPEG at once (p50 0 ms);
- a slot keeps its photo while the photo stays wanted, so photos that
shift places as the user steps are not made twice;
- decoded camera JPEGs stay in the memory LRU (never on disk), so going
back shows them again without a decode;
- while an arrow key is held (a photo left within 0.25 s), the raw
develop waits until the photo has stayed 0.12 s, and a render queued
for a photo already left is dropped, so the workers make the previews
instead of renders nobody sees.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 0430b95636d2b6adce8887af7b9464687c95549e)
* PERF-CULL-3: CR3 previews take the smallest embedded JPEG that covers the view; plain sRGB JPEGs decode straight to 8-bit
Upstream's embedded_preview_covering (14e0d128) picks the smallest covering
JPEG of a TIFF-based raw; this extends it to Canon CR3 and adds the 8-bit
decode:
- A CR3 keeps an 8192 px JPEG track, a 1620 px PRVW box and a 160 px THMB.
embedded_preview_covering now picks among the three (cr3_boxes lists the
boxes), so a grid thumbnail or a loupe up to 1620 px decodes the PRVW.
Loader at 1280 px on 20 EOS R5 C CR3s: 168-322 ms -> 31 ms a photo.
- lightcraft_codecs::decode_jpeg_srgb8_fit decodes a plain 8-bit sRGB
JPEG (DCT-scaled) and box-averages it in linear light straight into
8-bit sRGB, without the float image the general path builds; anything
with an ICC profile, an Adobe RGB declaration, CMYK or 12-bit takes the
general path. 45 MP preview fitted to 2560 px: 182 -> 151 ms (min of
15, loaded machine).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit a67bb398c2f438cbe16f4db1431ae0270f1c8b04)
* PERF-CULL-4: Camera preview label; thumbnails ahead of the step; 100 % shows the camera JPEG first
- While the loupe shows a raw's embedded JPEG (or its thumbnail), a small
'Camera preview' label at the top right says so, with what that means
on hover (widget loupe:cameraPreview; nine languages), so colour and
tone are not judged on the camera's rendering.
- The look-ahead also asks for the next photos' thumbnails (a small
embedded JPEG each), so a held arrow key has something to show in full
screen too, where no filmstrip asks for them (2400x1500 window, 40 held
steps: camera JPEG on 36, nothing on 2; before 1 and 4). Stepping
through a whole wedding keeps at most 600 thumbnails, those around the
open photo.
- Zoomed in on an unedited raw, its camera JPEG at full size (8192 px for
an EOS R5 C) is drawn under the window render until that arrives:
100 % in ~0.9 s instead of ~5.6 s; released when not zoomed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 9f212b1e16815c3c0ca33130319a7ef41fb05a26)
* PERF-CULL-5: lightcraft-cli bench-cull and tools/cull-bench.py time culling a real shoot
bench-cull FOLDER (headless) times reading the folder into the Local view,
the first screen of grid thumbnails on the app's render threads, camera
previews at 1280 / 2560 px and full size, a cold 2560 px loupe render,
rating 50 photos in a row and importing N files in place / copying M,
as 'name: X ms wall' lines (p50, p95, n) and JSON. Libraries are made
under --work; nothing is written next to the photos.
tools/cull-bench.py drives the running app over the control channel
(browse, first photo, steps at rest, a held arrow key, 100 % zoom,
rating) and reads the app's own frame timings (perf.loupeSteps).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit bd2eaac591b72f8258acae4389c67aa4d1da3b27)
* PERF-CULL-5: bench-cull --files K samples a slow folder; e2e test; cull-bench usage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit c32c9556924ccdee6f8c103864df51c3e170df98)
* PERF-CULL: docs for culling speed (parity rows, gap 26, preview reads, opening a photo)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit fb69b5975b51f882d82b2ca684e0f7ddc8373908)
* PERF-CULL: the whole-file preview decodes a plain sRGB camera JPEG the quick path's way
Upstream's quick-path test wants the bounded read and the whole file to give the same picture
byte for byte; with the branch's straight-to-8-bit decode on the quick path only, a 49 px
preview came out differently. Both paths now take it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 0c8acab4c2f3e85d1c51fab5faf62b6e997d7c73)
* PERF-THUMB-FIRST: CR3 thumbnails decode the 1620 px PRVW copy, and grid thumbnails from camera JPEGs are kept in the thumbnail cache
A CR3 grid thumbnail decoded the full-size JPEG track (45 MP, ~150 ms each)
whatever size was asked; it now takes the smallest embedded copy that covers
the cell (PRVW, same shape), and the box search stops at mdat (was a scan of
the whole file). The decoded camera JPEG of an unedited raw is written to
the library's thumbnail cache, so a folder seen before fills from small
files. Measured on 250 R5 C raws (loaded machine): first screen fills in
294 ms (was 553), on a second visit 53 ms (was 610).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 12162f966dd859b7e53ac65b5c4564a8e837b7eb)
* PERF-CULL: cargo fmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: docs for culling speed (parity rows LR-IMP-LOCAL, LR-IMP-PREVIEW-READ, LR-VIEW-DETAIL; gap 26)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: review fixes (bench work folder, 8-bit APP1 cap, size sort)
- bench-cull makes its libraries in a new folder of the run's own under
--work and never deletes what is already there (it removed lib-browse,
lib-add and lib-copy under a folder the caller named).
- The 8-bit sRGB JPEG path refuses more than 128 APP1 segments before its
decoders walk them again, as the general decode does.
- Browsing by file size keeps unreadable files last in both directions
(they were sized 0, so first when ascending).
- --files K in the CLI synopsis and help.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: the loupe uses built previews; photos are prepared ahead and in the background
Built standard previews were never what the loupe showed: it rendered
every photo again (~1.8 s on a raw), the rendered previews in memory were
capped at 128 MB (~84 photos) whatever the budget, the disk cache was a
fixed 2 GB (a wedding's previews pruned each other), the cache's writer
skipped disk writes when it fell behind, and Build Previews with one photo
open built only that photo though the menu said "Visible".
- The loupe's render job takes a cached view render of the same settings
that is at least as big as asked for, fitted down (no render).
- Memory for rendered previews is a quarter of the cache share; the disk
cache's automatic size makes room for every photo (about 1.5 MB each);
Settings offers Automatic, 50 GB and 200 GB.
- A build skips photos whose previews are there, writes each to disk
before counting it, builds at least the loupe's size, and with one photo
selected builds the view.
- Prepare photos in the background (Settings > Performance), per mode
(culling, library, editing), app-wide (kept in ui.json) or per library:
photos ahead rendered for the loupe (a few at a time, 0 = off) and the
whole view's previews built in the spare time once the user steps
through photos: two lanes, waiting while the photo on screen renders,
nearest first in the direction of travel, one cancellable row, replaced
by a build that is asked for. Commands prefs.backgroundPrep /
prefs.setBackgroundPrep; ui.inspect perf.previewCache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: the bench work-folder test removes only a folder it made
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: browsing keeps the whole listing for the folder bookkeeping; cull-bench stops on small folders
The browse worker queues only the files the library lacks, but the folder
bookkeeping (browse times, records of files gone) needs every file listed:
a subfolder of known photos only was not stamped as browsed. The known
files now come along marked as already in the library. tools/cull-bench.py
stops waiting at min(12, photos) thumbnails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: review fixes (smart previews honour id, editing builds by the Library row, lenient prep settings)
- library.smartPreviews with an explicit id works on that photo, not the
whole view.
- While editing, the whole view's background build follows the Library
row (its switch and which photos); the Editing row's scope is for the
photos kept ready ahead.
- The app-wide and per-library preparation settings are read leniently:
a value this build doesn't know is the defaults (app) or none (library),
and the other settings are kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: cull-bench counts twelve thumbnails while the folder is still loading
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: a view change stops the background build for the old view
The running background build was only re-aimed at the photo on screen; after
another folder, filter or library it went on building photos no longer
shown. It is now stopped when the view's signature changes (not counted as
stopped by the user), and the view now shown is prepared.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: cull-bench picks the thumbnail threshold from the final photo count
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: the view signature covers every photo; the view-change test holds the build explicitly
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: cull-bench stops waiting for twelve thumbnails 30 s after the folder loaded
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: in the marked scope, marking a photo changes what the view's build prepares
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: cull-bench times only the thumbnails the browse adds
Thumbnails already cached before library.browse no longer count towards
the first-thumbnail and twelve-thumbnail times.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: count every thumbnail made, for the browse benchmark
thumbTextures counts resident thumbnails, and evicting old ones can hide
new ones. The renderer now also counts each grid thumbnail it stores
(perf.thumbsMade, never lowered by eviction), and cull-bench times the
browse from that.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: the marked-scope test waits for its builds before cleaning up
Both preview builds finish before the test library is removed, and the
removal is checked, so no worker writes into a folder being deleted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: stand-ins made ahead keep their order, size and photo
Review fixes for the loupe's stand-ins and the browse benchmark:
- a stand-in still queued ahead moves to its new place in the order
when the user steps;
- one made ahead still shows when the loupe's own request failed;
- one made ahead for a smaller loupe isn't taken over by a larger one;
- quick and full-size camera-preview keys include the photo, so a
virtual copy gets its own;
- perf.thumbsMade counts photos given a thumbnail, not a render
replacing a stand-in.
Each has a test that fails without it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: a late stand-in never takes another photo's place in the loupe
A stand-in made ahead takes over after the loupe's own request failed
only while the loupe still wants that photo and has no other request
pending; the test covers stepping on before it arrives.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: a stand-in made ahead and no longer wanted is dropped when it arrives
Freeing a look-ahead slot whose job is already running now also forgets
its request, so the late result isn't kept as a hidden texture, unless
the loupe is waiting for exactly that stand-in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: a running stand-in the loupe waits for keeps its look-ahead slot
When the next look-ahead no longer wants the photo the loupe has just
stepped to, the slot whose job is still making that photo's stand-in stays
reserved instead of going to a new photo, so the result still reaches
the loupe.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: CodeRabbit round 10 (browse order ties, library skip, CR3 boxes after mdat, badge once, look-ahead crop key, cli test binary)
- browse_order: import- and edit-date sorts queue new files by name (they
tie on those keys); ties go by name in the view's direction, as the
catalog breaks them.
- Local browsing never lists the library's own folder (browse_files, as
scan_with skips it).
- CR3 PRVW/THMB search skips only the mdat payload: boxes after it (a moov
holding THMB) are still found.
- The camera-preview badge and the loupe timing are settled once, after
the window renders (no double badge with the full-size camera JPEG).
- The loupe look-ahead key includes the crop.
- bench-cull CLI tests use binary() after upstream's test split.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: CodeRabbit round 11 (browse listing errors and library root, rating ties, every CR3 PRVW, checked CR3 box math, look-ahead view key, windows wait while settling)
- browse_files: a folder that can't be listed is an error (the scan ends
as failed, nothing is forgotten as gone), and the library's folder or
one inside it lists nothing.
- New photos tie on rating: queued by name in the view's direction.
- Every valid CR3 PRVW/THMB is a candidate (the smallest covering one is
picked); the box offsets use checked arithmetic.
- The loupe look-ahead is keyed to the visible list's generation (a new
sort, filter or source changes the neighbours).
- While stepping fast, zoomed-in window renders wait like the frame, and a
window drawn before the frame is timed as the render.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: CodeRabbit round 12 (loupe timing: the After window, over any stand-in)
In a split view only the After side's window says the raw's render is on
screen; and a window drawn over the camera JPEG counts as the render too
(the source shown is a render only when it is "render" or "cached").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: CodeRabbit round 13 (loupe timing: the Before window when the Before is shown alone)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: CodeRabbit round 14 (loupe timing: a Before window side by side with \ held is not the shown view)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: CodeRabbit round 15 (the split-view timing test asserts its precondition)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: CodeRabbit round 16 (side by side with \ held, each window stays in its pane)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: a cancelled browse stops ordering its files; browsing hands back earlier probes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: the web build runs the browse job in place as FnMut (it hands the probes back)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: a scan hands the probes it took back however it ends (CodeRabbit, #3077 / #3080)
- The browse worker sends a result on every path: the listing, or `Stopped` (the folder couldn't be listed, or
another folder was chosen) with the probes `ScanInput::new` took from the session. A listing failure is never
sent as an empty listing.
- A scan stopped without waiting (its cross, or a browse replaced by another) keeps its channel until the worker
ends, so the probes still come back; a finished scan's probes join the session's instead of replacing them.
- A cancelled scan whose worker ended without a result is not reported as "Scan failed".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: built previews are the ones on disk; stand-ins ahead follow the preparation settings (CodeRabbit, #3077)
- A build counts a photo as built only when its previews are in the disk cache (when it has one): a loupe
render kept only in memory, its deferred write skipped, is built again and written (`stored_dims`).
- `DiskCache::put` and `PreviewCache::put_at` say whether the file was written; a view preview the cache
folder can't take fails the photo instead of counting it as built.
- The loupe's stand-ins ahead honour Settings > Performance for the mode: none for "Only the photo on screen"
or no photos ahead (the queued ones are dropped), only marked photos in the marked scope. The settings are
part of what the look-ahead was made for.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: a preview for a discarded cache fails the photo; cache file sizes are read from 4 KiB first (CodeRabbit, #3077 / #3080)
- `PreviewCache::put_at` returns false for a job made before the cache was cleared or retired (nothing is
kept), so a build that overlaps Discard Preview Cache counts that photo as failed, not built. The warning
says which of the two happened.
- `DiskCache::dims` reads a file's first 4 KiB, and up to 64 KiB only when the size marker isn't there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: a photo another build renders counts once it is built; a write pruned away is not kept (CodeRabbit, #3077 / #3080)
- A build that meets a photo another build is rendering waits for that render, then counts the photo as
built only when its previews are there, else renders it itself (it was counted as shared at once).
- `DiskCache::put` returns false when the pruning its own write starts removes that file (a picture bigger
than the room pruning leaves).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: put_memory holds the cache generation through its insertion (CodeRabbit, #3080)
A clear can no longer finish between put_memory's look at the generation and its insertion.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: browsing skips the library's folder by any name it has (a symlink, a path through ..) (CodeRabbit, #3077)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: rustfmt the library-folder check
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: cancelled builds stop before their test library is removed (CodeRabbit, #3077)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: the library folder is skipped by its resolved path only; lib/../outside is browsed, an unresolvable folder reports its error (CodeRabbit, #3077)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: the replaced background build is held by TestHold, not a 250 ms deadline (CodeRabbit, #3077)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: a browsed folder has one spelling (. and .. taken out by name) for the view, its listing and the catalog's records; an unresolvable root inside the library is still skipped (CodeRabbit, #3055 / #3077 / #3080)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: TestHold counts its holders: one test's release doesn't free a build another test holds (CodeRabbit, #3077 / #3080)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: TestHold's release without the deprecated fetch_update
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: browsing resolves `..` the way the system does, and `.` is the current folder
`lexical_path` took `..` out by name, which is wrong on POSIX when the folder before it is a
symlink (`link/..` is the folder above where the link points). `browse_path` makes the root
absolute first (so `.` and `a/..` name the current folder, not an empty path), resolves a link
just before a `..` on POSIX, and keeps every other spelling as written (a mapped drive stays a
drive letter, matching the catalog's records). Windows takes `..` out by name, as before.
Tests: a folder link followed by `..` lists what the system lists there; `.` is the current folder.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: browsing resolves the folder on its worker, never on the window's thread
`browse()` called `browse_path` (which may canonicalize a link) and `is_dir()` before starting the
worker, so a link into a stalled network mount could freeze the window. The UI now spells the
folder with `browse_spelling` (`.`/`..` by name, no disk access) and shows it as given; the worker
resolves it with `browse_path`, checks it is a folder (else the scan ends as failed, with the
usual toast), and sends the resolved spelling, which the view takes before the listing arrives.
Tests: a browse whose resolution is held returns at once and waits, then reads the folder; a
missing folder fails on the worker; `browse_spelling` matches `browse_path` without links.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: the browse worker resolves the path as given, not the view's spelling (CodeRabbit, #3055 / #3077 / #3080)
On Unix `photos/link/../select` with `link` pointing at `real/day` is `real/select`; the spelling with `..`
taken out by name read `photos/select`. The view keeps the spelling until the worker answers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: bench-cull measures small folders and never writes its results among the photos (CodeRabbit, #3077)
A folder with no more photos than the grid sample was reported with empty camera-preview and loupe timings: the samples now fall back to the photos it has. A --json path in the source folder is refused before the run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: editing renders the loupe from its source; the loupe holds background work back only while its render is on the way (CodeRabbit, #3077)
In Develop a cached view render left the source undecoded until the first slider draft. foreground() was renewed every loupe frame even with the render shown, so background builds mostly waited.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: loupeSteps.total, a step cursor that survives the 256-step list (CodeRabbit, #3077)
tools/cull-bench.py sliced the list by its length, which stops growing at 256 steps, and reported no steps on repeated runs. Also documents perf.thumbsMade and perf.previewCache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: the camera preview is located once when the fast decode declines (CodeRabbit, #3077)
The general decode takes the stand-in JPEG the fast path already found instead of parsing the container again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: the uncapped preview cache warns once per session when its drive runs low
The automatic preview cache budget grows with the library and has no ceiling. When the library
opens, and at most every five minutes while previews are built, the engine reads the free space of
the drive holding the cache (fs4, MIT OR Apache-2.0, pure Rust over rustix / windows-sys); under
10 % or under 20 GB free it raises one alert per session. The desktop app shows it as a notice with
the drive's free space and the cache's size and a button to Settings > Performance, where a size
limit can be set. A drive that can't say its free space gives no alert (logged). library.info
reports lowDiskSpace; the notice is in ui.inspect's notices like the others.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: the free-space read runs off the UI thread; a queued low-disk notice goes with its library; --work outside the source folder (CodeRabbit, #3077)
- Session::check_disk_space reads the drive on a worker thread and takes the answer on a later
call (take_disk_space_alert, library.info, the next check): a slow network share no longer
holds up a library open or a UI frame. Test: a_slow_drive_does_not_hold_up_the_check.
- The UI keeps the low-disk notice with the library generation it was raised for and drops it
when another library opens. Test: a_queued_notice_goes_when_another_library_opens.
- The real-drive test runs on native targets only (read is None on wasm32).
- bench-cull refuses a --work folder inside the measured folder, also one not made yet, before
anything is created. Test: the_work_folder_never_goes_in_the_source_folder.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: one free-space read at a time across library switches, the cache counted on its worker; a --json link into the source folder is refused (CodeRabbit, #3077/#3080)
- A library opened while the drive's free-space read still runs keeps that read (marked stale)
instead of starting a second worker beside it; its answer is dropped and the current library's
drive is read once it has come. Switching libraries while a share hangs no longer piles up
workers. Test: switching_libraries_while_a_drive_hangs_starts_no_second_read.
- When the drive is low, the preview cache is counted on the read's worker and the count travels
with the answer: taking it on the UI thread no longer scans a big cache on a slow share.
DiskCache::known_size reads the count without touching the disk.
Test: the_preview_cache_is_counted_off_the_ui_thread.
- bench-cull: a --json path that exists is resolved itself (a link to a photo in the source folder
is that photo), a link that points nowhere yet by the file writing through it would create, a
loop of links is refused. Test: a_json_link_into_the_source_folder_is_refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: bench-cull --json replaces its file instead of writing into it, so a hard link to a photo can't overwrite the photo (CodeRabbit, #3077)
The results go to a new file next to the destination, renamed over it; the
temporary file is removed on error. Test:
a_json_hard_link_to_a_photo_leaves_the_photo_alone (fails with the old write).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: bench-cull refuses a --json entry in the source folder, also a link there to a file outside (CodeRabbit, #3080)
Writing the results replaces the --json entry itself, so a link sitting in the
source folder that points outside it would be replaced, removing that entry.
check_outputs now checks where the entry sits (its folder resolved, the final
link not followed) as well as where it resolves to.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: a frame without the loupe ends its step timing, so time away from Detail never counts (CodeRabbit, #3055)
LoupeTiming::left was never called: a photo's record stayed open while the grid, Compare, Survey,
Reference, People or the slideshow was up, so dwell / any / full / sharp ms counted time outside
Detail. observe() marks the frame; finish_ui_pass ends the step when the loupe drew nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: Build Standard-Sized Previews keeps 2560 px before a loupe was drawn (CodeRabbit, #3077)
On Automatic with no loupe size known yet (a fresh session) the command left `edge` out, so the
engine built its 2048 px minimum and a typical fit view rendered again. The loupe-aware engine
size still applies once a loupe was drawn; a chosen limit is used as is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: main's merge keeps one Russian "★ Favorites" and its browse test uses the scan's end channel
Both sides had added the same key; main's new Auto Import browse test sends through the scan
channel, which this branch carries as `ScanEnd`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: stepping fast drops the queued Before render too; the thumbnail count is per photo across eviction (CodeRabbit, #3055)
While an arrow is held the Before view's raw render waits like the loupe's own, and one still
queued for a photo left behind is dropped. `thumbsMade` counts each photo once per library, so a
thumbnail evicted and made again, or a library left and opened again, doesn't count twice.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: the free space is read every few minutes, not only while previews are built (CodeRabbit, #3080)
Thumbnails and the loupe's previews fill the cache too, so a drive could run low after the library
opened without the notice: the low-disk check now runs whatever fills the cache (still at most
every five minutes). The engine gains two hidden test hooks (a reader and forgetting the last
read).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: a slow free-space read shows in the activity stack and an idle window wakes for the disk check (CodeRabbit, #3077)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: background preparation advances on every logic pass, hidden ones too (CodeRabbit, #3077)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: a photo the loupe opens on is never a fast step; only a step inside the loupe has a predecessor (CodeRabbit, #3077)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: the cull benchmark rates one named photo and puts its rating back (CodeRabbit, #3077)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: the cull benchmark refuses a full step history, says when thumbnails were in memory, fails on a lost key and ends a zoom that needs no region (CodeRabbit, #3055)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: a forced build renders a photo another build was rendering itself (CodeRabbit, #3080)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: the cull benchmark stops on a folder with no photos and times a zoom by the region that ended it (CodeRabbit, #3055/#3080)
- browse: a folder that stays loaded with no supported photo ends the browse loop after 3 s
instead of waiting 30 minutes, and the run stops with a clear message before the loupe
section instead of failing on an empty selection;
- zoom to 100 %: the time reported is the moment the loop ended on (the region's arrival, or
the settled whole frame when no region is asked for), so a whole frame that settled before
a region was requested no longer gives a too-short zoom time.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: the cull benchmark says no photos are visible, keeps an earlier result, and records an unfinished zoom as missing (CodeRabbit, #3055/#3077/#3080)
- an empty selection after browsing means no photo is visible (a filter can hide them), not that
the folder has none: say so and point at the filters;
- LABEL.json is no longer overwritten: an existing one stops the run before it starts unless
--overwrite is given (the final write opens with "x" then);
- a zoom that did not finish within 60 s is written as null, not as a 60 s zoom time.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: a raw's preview pointer is read with checked bounds on its offset and size (FredApps, #3080)
The JPEGInterchangeFormat pointer and the undefined-tag JPEGs were sliced with `as usize` casts of
the file's own u64 numbers: safe on 64-bit (the slice is `get`, its end cut at the file's end), but
an offset past 4 GiB would wrap to a small, wrong one on a 32-bit build (wasm32). `file_span` now
converts with `try_from` and saturates the end.
Test: `a_preview_pointer_past_the_end_of_the_file_finds_no_preview` (pointers past the end, lengths
past it, u32::MAX for both; a real strip preview beside the lying pointer is still found; every
truncation of the file is safe).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: bench-cull refuses a --work or --json inside the photo folder (FredApps, #3055)
Nothing is ever written next to the photos: a --work folder or a --json file inside FOLDER (the
folder itself, a subfolder, or a path that goes there through `..`) is refused before anything is
made. Paths are compared resolved (links followed as far as they exist). The cleanup already
removes only what the run made (its own `bench-cull-run-…` folder's `lib-copy`), never --work.
Test: `work_and_json_inside_the_photo_folder_are_refused`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: a test that building previews writes nothing next to the photos (FredApps, #3077)
`building_previews_writes_nothing_next_to_the_photos`: Build Previews and the background
preparation while culling leave the shoot folder with the same files, byte for byte; previews go
only to the library's own cache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: cull-bench.py prints its usage on missing arguments and refuses a dangling LABEL.json link up front (CodeRabbit, #3055/#3077)
- PORT / FOLDER missing (or PORT not a number), or an option with no value or an unreadable one,
stop with the usage line instead of a traceback;
- the early "LABEL.json exists" check uses `os.path.lexists`, so a dangling link there is refused
before the run, not at the final exclusive write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: a preview cut short by the end of the file is not taken (CodeRabbit, #3077)
`file_span` keeps a span the file's end cuts short only while it still holds a JPEG end marker
(an overstated length with the whole JPEG inside). A truncated JPEG passed the frame-header check,
won as the largest preview and then failed to decode, so no preview was shown.
Test: `a_preview_cut_short_by_the_end_of_the_file_is_not_taken`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-PREVIEW-PREP: the shoot-folder test waits for the background build to finish (CodeRabbit, #3077)
`building_previews_writes_nothing_next_to_the_photos` used a 5 s capped poll and then went on; it
now asserts `wait_finished` (30 s), so a late write by a slow build can't slip past the check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: a cut-short preview must reach its own JPEG end marker; cull-bench refuses bad options up front (CodeRabbit, #3055/#3077/#3080)
- raw: file_span keeps a span the file's end cuts short only when walking the JPEG's marker
segments reaches its end marker inside it, and cuts it there. An FF D9 inside an APP payload no
longer counts (it is skipped by the segment length), and bytes that don't start with FF D8 are
refused without scanning the rest of the file. Tests: the cut-short test gains an APP1 payload
holding FF D9 (taken before this fix) and a whole JPEG followed by other bytes;
the_end_of_a_jpeg_is_its_own_end_marker covers stuffed bytes, restart markers, bad segment
lengths and every truncation.
- tools/cull-bench.py: an unknown option, an option where a value belongs (--label --overwrite),
--steps/--hold-ms below 1, or a port outside 1-65535 stop with the usage line before the run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: cull-bench refuses a run whose steps wouldn't fit the app's loupe history (CodeRabbit, #3055)
The app keeps its last 256 loupe steps. A --steps that can't fit a run (1 + 11 + STEPS + 1) stops
before the run, and each phase checks the room left before it starts, so a hold whose first steps
were dropped is never reported as the whole hold.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: bench-cull refuses ".." in --json and --work (CodeRabbit, #3544)
A ".." after a folder the run hasn't made yet can't be resolved by the source-folder check, and
the run then makes that folder: --json NEW/../FOLDER/r.json would land in the source folder.
Test: a_dot_dot_in_the_outputs_is_refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: a cut-short preview needs a scan before its end and the walks share a budget; cull-bench catches dropped steps and odd arguments (CodeRabbit, #3055/#3077/#3080)
- raw: jpeg_end accepts an end marker only after a start of scan (an end with no image data
before it is refused), and the walks of a file's pointers past its end share scan_budget, as
scan_for_jpeg's do. Tests: a whole-looking JPEG with no scan is not taken;
the_walks_to_a_jpegs_end_share_a_budget.
- tools/cull-bench.py: the port must be a decimal number int() takes (isdecimal, guarded); a
FOLDER that looks like an option stops with usage; --steps goes up to 254 (only the hold's
STEPS + 1 must fit, with one to spare); each phase starts with room to spare, so a full history
means other steps came in and the phase stops instead of reporting a shifted window.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: the camera JPEG goes only under the window render, and a stand-in decoded across a cache clear isn't kept (CodeRabbit, #3544)
- ui-egui: zoomed in, the full-size camera JPEG is painted only where the window render will go
(window_cover), so a window capped smaller than the view leaves the developed whole-frame render
in the strips beside it instead of an unlabelled camera JPEG. Test:
the_camera_jpeg_covers_only_where_the_window_goes.
- engine/preview: QuickJob's camera-JPEG cache carries the cache generation it was made in and
writes with put_deferred_at / the new put_memory_at, so a job decoding when Discard Preview
Cache clears the cache can't put its picture back (as render jobs already do). Test:
a_camera_jpeg_decoded_across_a_cache_clear_is_not_kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: a bad build request leaves the background build alone, and preparation settings that can't be saved aren't kept (CodeRabbit, #3544)
- library.buildPreviews stops a running background build only after its own ids are known to be
good, and a build whose thread can't start is marked finished instead of left "running".
- prep_tick clears started_for when the background build fails to start, so the view retries.
- set_prep_settings / clear_library_prep put the previous settings back when prefs.json can't be
written. Tests: a_bad_build_request_leaves_the_background_build_running,
a_prep_setting_that_cant_be_saved_is_not_kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: a background build that can't start waits 30 s before the view tries again
Clearing `started_for` on a failed start made every tick call library.buildPreviews again while
the cause lasted (CodeRabbit on #3080). The view now waits RETRY_AFTER first. Test
`a_build_that_cant_start_waits_before_trying_again`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: a build waiting to retry asks for a frame at its time; a build whose worker can't start leaves the background one running
CodeRabbit on #3077/#3080: a background build that couldn't start retried only on
the next input. The session now says how long until the retry (prep_retry_in) and the
app asks for a frame then.
CodeRabbit on #3544: a build asked for whose worker thread couldn't start cancelled the
background preparation and left nothing running. The background build is now stopped
only once the new one has started; otherwise it stays the session's build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: a retry time that has passed asks for no more frames
CodeRabbit on #3077 / #3080: once the retry deadline passed, prep_retry_in returned
zero, so a view that couldn't be tried (grid, preparation off) asked for a frame at
once on every pass and kept an idle window drawing. It returns None then.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: CodeRabbit items from #3577 that live in #3077 and #3055
- disk space: a read whose thread can't start (or whose worker ends without
answering) is tried again after 10 s, not after the 5-minute check interval;
a hung read is polled every 5 s after its first 5 s instead of every 250 ms
- the low-disk notice of the library before is not drawn in the frame after a
library switch
- library.smartPreviews reads its ids before stopping the background build
- a forced build renders the grid thumbnail again instead of reading its cache
- the loupe's look-ahead asks for its stand-ins again after a renderer reset
- browse: a broken link before `..` is an error, not the link's parent folder
- the probes of two stopped scans: the later scan's wins whichever ends first
- camera JPEG fast path: a JPEG with an incomplete ICC sequence goes the
general way; a CR3 without a JPEG track picks its box preview by size too
- tests: bench-cull compares the source photos' bytes, the disk notice test
waits for the open's read
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PERF-CULL: CodeRabbit round 4 on #3077 and the #3583 items in its code
- A background smart-preview request cancels the running build only once its worker started;
a worker that can't start leaves the earlier build running and the session's (test hook).
- The notice logic looks again at the next disk check (10 s after a read that couldn't start).
- CR3 boxes: largest by pixels; every top-level mdat left out; whole-file fallback takes the
first box of a kind; a copy without an end marker isn't chosen to cover a size; a test
decodes the chosen copy.
- The fitted JPEG decode reads a SEF file as the image before its trailer; its doc says a
header that can't be read is Ok(None).
- Settings: a saved cache size the list doesn't offer shows as its own option.
- bench-cull rejects a second FOLDER; a browse through `..` reads again.
- cull-bench.py: --size checked, baseline waits for renders, --skip-browse claims no folder,
a short rest phase warns.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Format the quick-full draw call
* Loupe stand-in choice moves to its own function
* Render results: the newer-draft and display checks move to their own functions
* Render results: a frame left on the GPU is shown by its own function
* Format the frame's texture entry
* Docs: one row each for the import and performance entries
* Format the quick view's key
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
12 KiB
Reading a raw's preview without reading the raw
A grid thumbnail and a loupe preview of an unedited raw both show the camera's embedded JPEG, not a
decode of the sensor data. That JPEG lives near the start of the file, but the rest of the file is
the sensor data — for a 24 MP Fuji RAF, 50 of 56 MB. Until this change the preview loader called
std::fs::read on the whole file and threw the sensor data away, so every preview paid for bytes
nobody looked at.
That is free on a local SSD and expensive on a network share, which is where photographers keep
libraries. Measured on one real file (DSCF9492.RAF, 56.5 MB) over a GVFS/SMB share on Wi-Fi:
| read | time | |
|---|---|---|
| whole file | 59 277 632 B (100 %) | ~10 s |
| header + preview | 5 857 257 B (9.9 %) | 0.96 s |
How it works
read_prefixreads the firstPREVIEW_PREFIX(4 KiB); every format whose preview is locatable from the header keeps its pointer well inside that.- For Fuji RAF the header's two big-endian
u32s at 84 (offset) and 88 (length) are the same fieldslightcraft_raw's RAF reader uses. When they declare more than the prefix holds, the prefix is re-read once at exactly that extent:lightcraft_raw::bounded_previewreports the range its buffer covers (sliceclamps tob.len()), so a short prefix yields a plausible but truncated range — a real 4 KiB prefix reports 3 948 bytes where the file holds 5.6 MB. embedded_preview_from_rangechecks the window really starts with a JPEG SOI and trims it at the last EOI marker, exactly asembedded_previewtrims the whole-file answer.- When the prefix already covers the range — the usual case, since it was grown to it — the window is a slice of the prefix. Reading it again would double a 5.6 MB preview.
Formats whose preview pointer is not in the header — a Canon CR3's boxes, a TIFF raw whose preview
tag sits in an IFD the prefix cuts short — are planned instead (next section but one), and what the
planner cannot describe keeps the whole-file path, so nothing regresses: preview_window returns
None and the loader reads the file as before. A pointer that lies, a short read, a plan that does
not fit or a file that changes underneath us ends in the same fallback — the user's preview is never
lost to a damaged header.
LIGHTCRAFT_PROFILE=1 prints what each preview cost:
[profile] preview …/DSCF9492.RAF: read 5857257 of 59277632 bytes (header 5857257, window 5857109)
What the header cannot point at: the plan
A Canon CR3 keeps its preview in a JPEG track, and a TIFF raw (CR2, ARW, DNG, RW2, 3FR …) can keep
its preview tag in an IFD the header route's prefix cuts short. Neither has a field step 2 above can
read, so preview_window asks lightcraft_raw::sparse::preview_plan — the module the
Lightroom-catalog import probes with — which ranges embedded_preview reads, and reads exactly
those:
- the buffer is the file's length with only the planned ranges filled in; every other byte stays zero and its pages are never written. Its cost is the bytes read — on macOS a freed buffer of that size may stay in the allocator's cache instead of going back to the OS (measured on a 38k-photo import, #832) — and the loader's memory gate counts the whole file either way;
- the module's own guarantee is what this path needs, and its corpus test asserts it: with the
skipped bytes
0and again0xFF, what a partly loaded buffer yields is what the whole file yields. A second corpus pass now askspreview_planfor every file, including the ones whose header probe reads samples and whose header plan isWhole, 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. 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 asdecode_raw_previewdoes (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 loader reads the file whole exactly as before: the plan can cost bytes but never the picture.
Measured over the CC0 corpus (111 raws, cargo xtask corpus --download): 52 files take the plan
route, reading a median 7.8 % of their bytes (0.9 %–27.7 %) — by format RW2 3.3 %, Leica RWL
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 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:
[profile] preview …/arw-sony-a7rm4a-compressed.arw: read 688128 of 61832192 bytes (plan, window 483441)
A Canon CR3's preview is a JPEG track instead (issue #832 measured 3 MB of one R5 Mark II file, ~15 %), and Canon is the largest bucket in gaps.md's ranking (33 % of photographers), so before this change every unedited CR3 thumbnail paid for the whole file.
The grid thumbnail is the embedded JPEG too
A 512 px grid thumbnail of an unedited raw now keeps the camera's own JPEG instead of rendering the sensor behind it. The grid showed that JPEG first in any case and then queued a decode of the whole raw to replace it — for a folder of 149 Fuji RAFs that is 8.3 GB of traffic, 45 s a photo at 1.2 MB/s, and a queue that delayed the render the user was actually waiting for (measured: the first loupe render of a cold photo finished 174 s after the click, most of it spent behind thumbnails).
Session::embedded_thumb_is_final(photo)says whether the photo is an unedited raw whose camera JPEG the viewer is showing (the same "unedited raw" gate as the preview path:has_import_look).grid::skips_the_real_render(embedded_is_final, from_embedded)is the decision, unit-tested: a cached render as the stand-in, an edited photo and a raw with no embedded JPEG all still render, because their thumbnail must show the pipeline's output rather than the camera's.- Whether the embedded JPEG can actually be read is only known when the quick job runs, so
Session::quick_thumb_jobcarries the real thumbnail job as its last resort: a raw whose preview the loader can't read renders the raw instead of leaving the cell empty (issue #1448). - The thumbnail key still changes with the settings hash, so editing a photo re-renders its thumbnail through the ordinary background path.
Measured on the real folder: idle for a minute, the app read 12 previews (26.9 MB) and decoded 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.
A Canon CR3 picks the same way among its three JPEGs: an EOS R5 C file keeps an 8192 × 5464 JPEG
track, a 1620 × 1080 PRVW box and a 160 × 120 THMB. Decoding the 8192 px one costs about 100 ms
of entropy decoding however small it is asked for (DCT scaling only saves the inverse transform),
against a few milliseconds for the 1620 px one. So a grid thumbnail and a loupe up to 1620 px read
the PRVW, and a bigger loupe still reads the track (cr3_preview_box_list lists the boxes for
embedded_preview_covering).
A plain 8-bit sRGB JPEG (no ICC profile, no Adobe RGB declaration from the container) is then decoded
straight to 8-bit (lightcraft_codecs::decode_jpeg_srgb8_fit: DCT-scaled, box-averaged in linear
light) without the float image the general decode builds; anything else keeps the general path.
lightcraft-cli bench-cull FOLDER times these steps (and the rest of a culling pass) on a real
folder; tools/cull-bench.py times them in the running app.
Measured on 20 EOS R5 C CR3s from a wedding (local NVMe SSD, machine shared with other work): the loader at 1280 px went from 168–322 ms to 31 ms a photo; at 2560 px the decode of the 8192 px JPEG went from 182 to 151 ms (minimum of 15 runs; most of what remains is entropy decoding).
Tests
-
crates/raw/src/preview.rs: the header-only range matchesembedded_previewbyte for byte; a clamped prefix, a padded extent, a late pointer, a lying pointer and a non-JPEG window are all covered.a_stand_in_takes_the_smallest_covering_jpeg_of_the_same_shapecovers 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 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/it/sparse_corpus.rs:preview_planis asked for every corpus file, not only the ones whose header plan isReady, and the preview it yields must equal the whole file's with the skipped bytes0and0xFF. The test prints what each format reads. -
crates/engine/tests/it/preview_read.rs(opt-in,#[ignore]): the same measurement on a real file, typically on a share:LC_PREVIEW_RAW="/mnt/nas/…/DSCF0001.RAF" \ cargo test --release -p lightcraft-engine --test it preview_read:: -- --ignored --nocaptureNo raw fixture is committed (media never is, see
AGENTS.md).
A network share is still slower than a local disk — the preview itself is 5.6 MB — and the sensor
data still has to come over the wire for the developed picture, which is a separate path
(fs_pair_loader, the full decode). This change removes the preview's share of it, nothing more.