mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-07 19:03:37 +00:00
Culling: the loupe uses built previews; photos prepared ahead and in the background (#3077)
* 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>
This commit is contained in:
Generated
+11
@@ -2139,6 +2139,16 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||
|
||||
[[package]]
|
||||
name = "fs4"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5"
|
||||
dependencies = [
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
@@ -3732,6 +3742,7 @@ dependencies = [
|
||||
"ab_glyph",
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
"fs4",
|
||||
"harfrust 0.13.3",
|
||||
"jxl-oxide",
|
||||
"libc",
|
||||
|
||||
@@ -58,6 +58,8 @@ log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
rayon = "1"
|
||||
# free/total space of a drive (engine's low-disk alert); MIT OR Apache-2.0, pure Rust (rustix / windows-sys)
|
||||
fs4 = { version = "1.1", default-features = false }
|
||||
winapi-util = "0.1" # lightcraft-catalog reads a file's volume serial number and file index with it on Windows
|
||||
proptest = "1"
|
||||
bytemuck = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
//! `lightcraft-cli bench-cull FOLDER`: time what culling a shoot costs, headless, on real files.
|
||||
//!
|
||||
//! What a wedding photographer does most is open a folder of a few thousand raws, look at each
|
||||
//! one, rate it and move on. This times the engine side of each step on the files themselves (the
|
||||
//! desktop app's own frame timings come from `ui.inspect` → `perf.loupeSteps`, see
|
||||
//! `tools/cull-bench.py`):
|
||||
//!
|
||||
//! - browse: reading the folder into the Local view (`library.browse`, the whole folder);
|
||||
//! - grid thumbnails: the camera JPEG at 512 px for the first screen, on as many threads as the
|
||||
//! app renders with;
|
||||
//! - camera preview at loupe sizes (1280, 2560) and at full size (the 100 % view), one at a time;
|
||||
//! - LightCraft's render of the loupe (2560 px) from a cold source;
|
||||
//! - rating: `photo.rate` on many photos in a row, journal included;
|
||||
//! - import: adding N files in place to a fresh library, and copying M of them into it (`--n 0`:
|
||||
//! no import).
|
||||
//!
|
||||
//! `--files K` reads only the first K files (by name) of the folder, as browsing reads them: a
|
||||
//! sample of a folder on a network share, where reading thousands of raws takes hours.
|
||||
//!
|
||||
//! Each number is printed as `name: X ms wall` (median, with p95 and n), the format
|
||||
//! `cargo xtask bench` parses, and the lot as one JSON object with `--json FILE`. Nothing is
|
||||
//! written next to the photos: every library is made in a new folder of the run's own under
|
||||
//! `--work` (default: a folder in the system temp directory; nothing already there is deleted),
|
||||
//! XMP auto-write stays off, and the source folder is only read.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use lightcraft_engine::Session;
|
||||
use lightcraft_engine::catalog::PhotoId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const USAGE: &str = "lightcraft-cli bench-cull FOLDER [--files K] [--n N] [--copy M] [--work DIR] [--json FILE]";
|
||||
|
||||
/// Threads the app renders thumbnails with (`Renderer::default`: cores − 1, at most 6).
|
||||
fn render_threads() -> usize {
|
||||
std::thread::available_parallelism().map(|n| n.get().saturating_sub(1)).unwrap_or(2).clamp(2, 6)
|
||||
}
|
||||
|
||||
/// Median, p95 and count of `ms` (milliseconds).
|
||||
fn stats(ms: &[f64]) -> Value {
|
||||
if ms.is_empty() {
|
||||
return Value::Null;
|
||||
}
|
||||
let mut v = ms.to_vec();
|
||||
v.sort_by(f64::total_cmp);
|
||||
let at = |q: f64| v.get(((v.len() - 1) as f64 * q).round() as usize).copied().unwrap_or(0.0);
|
||||
json!({"p50": round(at(0.5)), "p95": round(at(0.95)), "max": round(at(1.0)), "n": v.len()})
|
||||
}
|
||||
|
||||
fn round(x: f64) -> f64 {
|
||||
(x * 10.0).round() / 10.0
|
||||
}
|
||||
|
||||
fn ms_since(t: Instant) -> f64 {
|
||||
t.elapsed().as_secs_f64() * 1000.0
|
||||
}
|
||||
|
||||
/// Print one result line the way `xtask bench` parses it (`name: X ms wall`).
|
||||
fn line(name: &str, s: &Value) {
|
||||
if let Some(p50) = s.get("p50").and_then(Value::as_f64) {
|
||||
println!("{name}: {p50:.1} ms wall (p95 {} ms, n {})", s["p95"], s["n"]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A new, empty folder `name` in this run's own folder; never deletes anything already there.
|
||||
fn fresh_dir(root: &Path, name: &str) -> Result<PathBuf, String> {
|
||||
let d = root.join(name);
|
||||
std::fs::create_dir(&d).map_err(|e| format!("{}: {e}", d.display()))?;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// A folder of this run's own under `work`, which must not exist yet: a run never deletes or
|
||||
/// reuses what is already under `--work`.
|
||||
fn run_dir(work: &Path) -> Result<PathBuf, String> {
|
||||
std::fs::create_dir_all(work).map_err(|e| format!("{}: {e}", work.display()))?;
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
|
||||
static RUNS: AtomicUsize = AtomicUsize::new(0);
|
||||
let n = RUNS.fetch_add(1, Ordering::Relaxed);
|
||||
let d = work.join(format!("bench-cull-run-{}-{stamp}-{n}", std::process::id()));
|
||||
std::fs::create_dir(&d).map_err(|e| format!("{}: {e}", d.display()))?;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// `take` items of `v` after the first `skip` (photos the stages before have read, so cold in the
|
||||
/// file cache), or, when `v` has no more than `skip`, its first `take`: a small folder is measured
|
||||
/// on photos already read rather than not at all.
|
||||
fn sample_after<T>(v: &[T], skip: usize, take: usize) -> &[T] {
|
||||
let from = if v.len() > skip { skip } else { 0 };
|
||||
let rest = v.get(from..).unwrap_or_default();
|
||||
rest.get(..take.min(rest.len())).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether `path` names a file in `folder` (the source folder, which a run only reads), checked
|
||||
/// twice: where its own entry sits (the folder holding it, the final link not followed: writing the
|
||||
/// results replaces that entry, so a link to a file elsewhere that sits in the source folder, maybe
|
||||
/// standing for a photo, would be replaced; CodeRabbit on #3080), and where it resolves to: a file
|
||||
/// that is there by itself (a link to a photo there is the photo), a link that points nowhere yet
|
||||
/// by where writing through it would create the file, and anything else by its nearest folder that
|
||||
/// exists (a `--work` folder may not be there yet).
|
||||
fn inside_source(path: &Path, folder: &Path) -> bool {
|
||||
let Ok(folder) = std::fs::canonicalize(folder) else { return false };
|
||||
entry_inside(path, &folder) || resolves_inside(path, &folder, 0)
|
||||
}
|
||||
|
||||
/// Whether the folder holding `path`'s own entry (its nearest folder that exists, resolved) is in
|
||||
/// `folder` (already resolved). The entry itself is not followed.
|
||||
fn entry_inside(path: &Path, folder: &Path) -> bool {
|
||||
let mut p = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
||||
loop {
|
||||
if let Ok(c) = std::fs::canonicalize(p) {
|
||||
return c.starts_with(folder);
|
||||
}
|
||||
match p.parent() {
|
||||
Some(up) if !up.as_os_str().is_empty() => p = up,
|
||||
Some(_) => p = Path::new("."),
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`inside_source`] for a `folder` already resolved; `hops`: links followed so far (a loop of
|
||||
/// links counts as inside: refused).
|
||||
fn resolves_inside(path: &Path, folder: &Path, hops: u32) -> bool {
|
||||
if let Ok(c) = std::fs::canonicalize(path) {
|
||||
return c.starts_with(folder);
|
||||
}
|
||||
if let Ok(target) = std::fs::read_link(path) {
|
||||
if hops >= 40 {
|
||||
return true;
|
||||
}
|
||||
let target = path.parent().map_or_else(|| target.clone(), |dir| dir.join(&target));
|
||||
return resolves_inside(&target, folder, hops + 1);
|
||||
}
|
||||
entry_inside(path, folder)
|
||||
}
|
||||
|
||||
/// Refuse `--json` or `--work` in the folder being measured: nothing is written next to the photos,
|
||||
/// the results and the run's libraries included. Checked before anything is made.
|
||||
fn check_outputs(folder: &Path, json: Option<&Path>, work: &Path) -> Result<(), String> {
|
||||
// a `..` after a folder not made yet can't be resolved now, and the run makes that folder:
|
||||
// `--json NEW/../FOLDER/r.json` would pass the checks below and land in the source folder
|
||||
// (CodeRabbit on #3544)
|
||||
for (name, p) in [("--json", json), ("--work", Some(work))] {
|
||||
if let Some(p) = p.filter(|p| p.components().any(|c| c == std::path::Component::ParentDir)) {
|
||||
return Err(format!("{}: {name} must not contain \"..\"; give the folder without it", p.display()));
|
||||
}
|
||||
}
|
||||
if let Some(j) = json.filter(|j| inside_source(j, folder)) {
|
||||
return Err(format!("{}: --json must not be in the folder being measured", j.display()));
|
||||
}
|
||||
if inside_source(&work.join("run"), folder) {
|
||||
return Err(format!("{}: --work must not be in the folder being measured", work.display()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn library(dir: &Path) -> Result<Session, String> {
|
||||
let mut s = Session::new().with_fs().with_system_clock();
|
||||
s.open_library(dir, false).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
// nothing is ever written next to the photos
|
||||
s.execute("library.xmpPreferences", &json!({"autoWrite": false})).map_err(|e| e.to_string())?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut folder = None;
|
||||
let mut n = 2000usize;
|
||||
let mut copy = 200usize;
|
||||
let mut work = None;
|
||||
let mut json_out = None;
|
||||
let mut files = None;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let value = |i: usize| args.get(i + 1).cloned().ok_or_else(|| format!("{} needs a value\n{USAGE}", args[i]));
|
||||
match args[i].as_str() {
|
||||
"--n" => {
|
||||
n = value(i)?.parse().map_err(|_| "--n needs a number".to_string())?;
|
||||
i += 1;
|
||||
}
|
||||
"--copy" => {
|
||||
copy = value(i)?.parse().map_err(|_| "--copy needs a number".to_string())?;
|
||||
i += 1;
|
||||
}
|
||||
"--files" => {
|
||||
files = Some(value(i)?.parse::<usize>().map_err(|_| "--files needs a number".to_string())?);
|
||||
i += 1;
|
||||
}
|
||||
"--work" => {
|
||||
work = Some(PathBuf::from(value(i)?));
|
||||
i += 1;
|
||||
}
|
||||
"--json" => {
|
||||
json_out = Some(PathBuf::from(value(i)?));
|
||||
i += 1;
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
println!("{USAGE}");
|
||||
return Ok(());
|
||||
}
|
||||
a if a.starts_with("--") => return Err(format!("unknown option {a}\n{USAGE}")),
|
||||
// (a second folder would replace the first and be timed in its place: CodeRabbit on #3583)
|
||||
a if folder.is_some() => return Err(format!("{a}: one FOLDER only\n{USAGE}")),
|
||||
a => folder = Some(PathBuf::from(a)),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let folder = folder.ok_or_else(|| USAGE.to_string())?;
|
||||
if !folder.is_dir() {
|
||||
return Err(format!("{}: not a folder", folder.display()));
|
||||
}
|
||||
let work = work.unwrap_or_else(|| std::env::temp_dir().join(format!("lightcraft-bench-cull-{}", std::process::id())));
|
||||
check_outputs(&folder, json_out.as_deref(), &work)?;
|
||||
let work = run_dir(&work)?;
|
||||
let mut out = json!({"folder": folder.display().to_string(), "threads": render_threads()});
|
||||
|
||||
// --- browse: the whole folder into the Local view -------------------------------------------
|
||||
let mut s = library(&fresh_dir(&work, "lib-browse")?)?;
|
||||
// `--files K`: only the first K files by name, read as browsing reads them (a sample of a slow
|
||||
// network folder)
|
||||
let t = Instant::now();
|
||||
let (photos, ids): (u64, Vec<PhotoId>) = match files {
|
||||
None => {
|
||||
let r = s.execute("library.browse", &json!({"path": folder.display().to_string()})).map_err(|e| e.to_string())?;
|
||||
(r["photos"].as_u64().unwrap_or(0), s.visible_cloned())
|
||||
}
|
||||
Some(k) => {
|
||||
let mut list: Vec<String> = std::fs::read_dir(&folder)
|
||||
.map_err(|e| format!("{}: {e}", folder.display()))?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_file() && lightcraft_engine::import::is_supported(p))
|
||||
.map(|p| p.display().to_string())
|
||||
.collect();
|
||||
list.sort();
|
||||
list.truncate(k);
|
||||
let r = s.execute("library.import", &json!({"paths": list, "mode": "add", "local": true})).map_err(|e| e.to_string())?;
|
||||
let ids: Vec<PhotoId> = r["imported"].as_array().into_iter().flatten().filter_map(Value::as_u64).map(PhotoId).collect();
|
||||
(ids.len() as u64, ids)
|
||||
}
|
||||
};
|
||||
let browse_ms = ms_since(t);
|
||||
let what = if files.is_some() { "first files" } else { "whole folder" };
|
||||
println!("browse {photos} photos ({what}): {browse_ms:.1} ms wall");
|
||||
out["browse"] = json!({"ms": round(browse_ms), "photos": photos, "perPhotoMs": round(browse_ms / photos.max(1) as f64)});
|
||||
let paths: Vec<String> = ids
|
||||
.iter()
|
||||
.filter_map(|id| match &s.catalog.photo(*id)?.source {
|
||||
lightcraft_engine::catalog::Source::File { path } => Some(path.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if paths.is_empty() {
|
||||
return Err(format!("{}: no photos", folder.display()));
|
||||
}
|
||||
let loader = s.media.preview_loader.clone().ok_or("no preview loader")?;
|
||||
|
||||
// --- grid thumbnails: the first screen, in parallel ------------------------------------------
|
||||
let screen: Vec<String> = paths.iter().take(48).cloned().collect();
|
||||
let next = Arc::new(AtomicUsize::new(0));
|
||||
let t = Instant::now();
|
||||
let per: Vec<(f64, f64)> = std::thread::scope(|sc| {
|
||||
let workers: Vec<_> = (0..render_threads())
|
||||
.map(|_| {
|
||||
let (next, loader, screen) = (next.clone(), loader.clone(), &screen);
|
||||
sc.spawn(move || {
|
||||
let mut done = Vec::new();
|
||||
while let Some(p) = screen.get(next.fetch_add(1, Ordering::Relaxed)) {
|
||||
let a = Instant::now();
|
||||
let _ = loader(p, 512);
|
||||
done.push((ms_since(a), ms_since(t)));
|
||||
}
|
||||
done
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
workers.into_iter().flat_map(|w| w.join().unwrap_or_default()).collect()
|
||||
});
|
||||
let first = per.iter().map(|p| p.1).fold(f64::INFINITY, f64::min);
|
||||
let all = per.iter().map(|p| p.1).fold(0.0, f64::max);
|
||||
let each = stats(&per.iter().map(|p| p.0).collect::<Vec<_>>());
|
||||
println!("grid: first thumbnail: {first:.1} ms wall");
|
||||
println!("grid: {} thumbnails: {all:.1} ms wall", per.len());
|
||||
line("grid: one 512 px thumbnail", &each);
|
||||
out["thumbs"] = json!({"firstMs": round(first), "allMs": round(all), "count": per.len(), "each": each});
|
||||
|
||||
// --- camera previews at loupe sizes, one at a time (what a step costs without look-ahead) -----
|
||||
let sample = sample_after(&paths, 48, 20);
|
||||
for edge in [1280usize, 2560, usize::MAX] {
|
||||
let ms: Vec<f64> = sample
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let a = Instant::now();
|
||||
let _ = loader(p, edge);
|
||||
ms_since(a)
|
||||
})
|
||||
.collect();
|
||||
let name = if edge == usize::MAX { "camera preview full size (100 %)".to_string() } else { format!("camera preview {edge} px") };
|
||||
let st = stats(&ms);
|
||||
line(&name, &st);
|
||||
out[if edge == usize::MAX { "cameraFull".to_string() } else { format!("camera{edge}") }] = st;
|
||||
}
|
||||
|
||||
// --- LightCraft's loupe render (2560 px) from a cold source --------------------------------
|
||||
let ms: Vec<f64> = sample_after(&ids, 80, 5)
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let job = s.loupe_job(*id, 2560, 1708, true)?;
|
||||
let a = Instant::now();
|
||||
let r = job.run();
|
||||
let t = ms_since(a);
|
||||
r.rendered.is_ok().then_some(t)
|
||||
})
|
||||
.collect();
|
||||
let st = stats(&ms);
|
||||
line("loupe render 2560 px (cold)", &st);
|
||||
out["loupeRender"] = st;
|
||||
|
||||
// --- rating: a culling pass of keypresses ---------------------------------------------------
|
||||
let ms: Vec<f64> = ids
|
||||
.iter()
|
||||
.take(50)
|
||||
.enumerate()
|
||||
.filter_map(|(k, id)| {
|
||||
// the key acts on the selected photo, as in the loupe
|
||||
s.execute("library.select", &json!({"ids": [id.0], "active": id.0})).ok()?;
|
||||
let a = Instant::now();
|
||||
s.execute("photo.rate", &json!({"rating": (k % 5) + 1})).ok()?;
|
||||
Some(ms_since(a))
|
||||
})
|
||||
.collect();
|
||||
let st = stats(&ms);
|
||||
line("rate one photo (journal written)", &st);
|
||||
out["rate"] = st;
|
||||
drop(s);
|
||||
|
||||
// --- import: add in place, then copy ----------------------------------------------------------
|
||||
if n == 0 {
|
||||
return finish(out, json_out);
|
||||
}
|
||||
let add: Vec<String> = paths.iter().take(n).cloned().collect();
|
||||
let mut s = library(&fresh_dir(&work, "lib-add")?)?;
|
||||
let t = Instant::now();
|
||||
let r = s.execute("library.import", &json!({"paths": add, "mode": "add"})).map_err(|e| e.to_string())?;
|
||||
let add_ms = ms_since(t);
|
||||
let added = r["imported"].as_array().map(Vec::len).unwrap_or(0);
|
||||
println!("import {added} photos in place: {add_ms:.1} ms wall");
|
||||
out["importAdd"] = json!({"ms": round(add_ms), "photos": added, "perPhotoMs": round(add_ms / added.max(1) as f64)});
|
||||
drop(s);
|
||||
if copy > 0 {
|
||||
let some: Vec<String> = paths.iter().take(copy).cloned().collect();
|
||||
let lib = fresh_dir(&work, "lib-copy")?;
|
||||
let mut s = library(&lib)?;
|
||||
let t = Instant::now();
|
||||
let r = s
|
||||
.execute("library.import", &json!({"paths": some, "mode": "copy", "destination": lib.join("Originals").display().to_string()}))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let copy_ms = ms_since(t);
|
||||
let copied = r["imported"].as_array().map(Vec::len).unwrap_or(0);
|
||||
println!("import {copied} photos by copying: {copy_ms:.1} ms wall");
|
||||
out["importCopy"] = json!({"ms": round(copy_ms), "photos": copied, "perPhotoMs": round(copy_ms / copied.max(1) as f64)});
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
finish(out, json_out)
|
||||
}
|
||||
|
||||
fn finish(out: Value, json_out: Option<PathBuf>) -> Result<(), String> {
|
||||
if let Some(p) = json_out {
|
||||
write_replacing(&p, serde_json::to_string_pretty(&out).unwrap_or_default().as_bytes())?;
|
||||
}
|
||||
println!("{}", serde_json::to_string(&out).unwrap_or_default());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` through a new file next to it, renamed over `path`: an existing `path`
|
||||
/// is replaced as a name, never written into. A hard link there shares its data with another file
|
||||
/// (maybe a photo in the source folder, which [`inside_source`] can't tell), and writing into it
|
||||
/// would overwrite that file too (CodeRabbit on #3077). The temporary file is removed on error.
|
||||
fn write_replacing(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
use std::io::Write;
|
||||
let err = |e: std::io::Error| format!("{}: {e}", path.display());
|
||||
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
||||
let name = path.file_name().ok_or_else(|| format!("{}: not a file name", path.display()))?;
|
||||
static TEMPS: AtomicUsize = AtomicUsize::new(0);
|
||||
let n = TEMPS.fetch_add(1, Ordering::Relaxed);
|
||||
let mut tmp_name = std::ffi::OsString::from(".");
|
||||
tmp_name.push(name);
|
||||
tmp_name.push(format!(".{}-{n}.tmp", std::process::id()));
|
||||
let tmp = dir.join(tmp_name);
|
||||
let written =
|
||||
std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp).and_then(|mut f| f.write_all(bytes).and_then(|()| f.sync_all()));
|
||||
if let Err(e) = written.and_then(|()| std::fs::rename(&tmp, path)) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(err(e));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_second_folder_is_an_error() {
|
||||
let e = run(&["a".to_string(), "b".to_string()]).unwrap_err();
|
||||
assert!(e.starts_with("b: one FOLDER only"), "{e}");
|
||||
}
|
||||
|
||||
/// `--work` may hold anything: a run makes a new folder of its own there and never deletes
|
||||
/// what is already in it (it used to remove `lib-browse`, `lib-add` and `lib-copy` there).
|
||||
#[test]
|
||||
fn a_run_never_deletes_what_is_under_work() {
|
||||
// a folder this test owns (made new), so the cleanup below removes nothing else
|
||||
let work = run_dir(&std::env::temp_dir()).unwrap();
|
||||
let keep = work.join("lib-browse").join("keep.txt");
|
||||
std::fs::create_dir_all(keep.parent().unwrap()).unwrap();
|
||||
std::fs::write(&keep, b"mine").unwrap();
|
||||
let a = run_dir(&work).unwrap();
|
||||
let b = run_dir(&work).unwrap();
|
||||
assert_ne!(a, b);
|
||||
fresh_dir(&a, "lib-browse").unwrap();
|
||||
assert!(fresh_dir(&a, "lib-browse").is_err(), "an existing folder is never reused");
|
||||
assert_eq!(std::fs::read(&keep).unwrap(), b"mine");
|
||||
let _ = std::fs::remove_dir_all(&work);
|
||||
}
|
||||
|
||||
/// A folder too small for the disjoint samples is measured on the photos it has (CodeRabbit on #3077).
|
||||
#[test]
|
||||
fn a_small_folder_is_sampled_from_its_start() {
|
||||
let v: Vec<u32> = (0..100).collect();
|
||||
assert_eq!(sample_after(&v, 48, 20), &v[48..68]);
|
||||
assert_eq!(sample_after(&v, 90, 20), &v[90..100]);
|
||||
assert_eq!(sample_after(&v[..3], 48, 20), &v[..3]);
|
||||
assert_eq!(sample_after(&v[..48], 48, 5), &v[..5]);
|
||||
assert!(sample_after::<u32>(&[], 48, 20).is_empty());
|
||||
}
|
||||
|
||||
/// A file link at `at` to `target`.
|
||||
#[cfg(unix)]
|
||||
fn link(target: &Path, at: &Path) -> std::io::Result<()> {
|
||||
std::os::unix::fs::symlink(target, at)
|
||||
}
|
||||
|
||||
/// A file link at `at` to `target` (Windows: needs Developer Mode or the right to make links).
|
||||
#[cfg(windows)]
|
||||
fn link(target: &Path, at: &Path) -> std::io::Result<()> {
|
||||
std::os::windows::fs::symlink_file(target, at)
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn link(_: &Path, _: &Path) -> std::io::Result<()> {
|
||||
Err(std::io::Error::other("no file links here"))
|
||||
}
|
||||
|
||||
/// `--json` naming a link (made elsewhere) to a file in the source folder is refused: writing
|
||||
/// the results would follow it and overwrite a photo (CodeRabbit on #3080). So is a link that
|
||||
/// points to a file not made yet in the source folder, and a loop of links.
|
||||
#[test]
|
||||
fn a_json_link_into_the_source_folder_is_refused() {
|
||||
let work = run_dir(&std::env::temp_dir()).unwrap();
|
||||
let src = fresh_dir(&work, "src").unwrap();
|
||||
let photo = src.join("a.jpg");
|
||||
std::fs::write(&photo, b"photo").unwrap();
|
||||
let to_photo = work.join("report.json");
|
||||
if let Err(e) = link(&photo, &to_photo) {
|
||||
let _ = std::fs::remove_dir_all(&work);
|
||||
lightcraft_testkit::skip!(System, "can't make a file link here ({e}); Windows needs Developer Mode");
|
||||
return;
|
||||
}
|
||||
assert!(inside_source(&to_photo, &src), "a link to a photo in the source folder");
|
||||
let e = check_outputs(&src, Some(&to_photo), &work.join("elsewhere")).unwrap_err();
|
||||
assert!(e.contains("--json"), "{e}");
|
||||
let to_new = work.join("new.json");
|
||||
link(&src.join("not-yet.json"), &to_new).unwrap();
|
||||
assert!(inside_source(&to_new, &src), "a link to a file not made yet in the source folder");
|
||||
let (x, y) = (work.join("x.json"), work.join("y.json"));
|
||||
link(&y, &x).unwrap();
|
||||
link(&x, &y).unwrap();
|
||||
assert!(inside_source(&x, &src), "a loop of links");
|
||||
let to_elsewhere = work.join("fine.json");
|
||||
link(&work.join("out.json"), &to_elsewhere).unwrap();
|
||||
assert!(!inside_source(&to_elsewhere, &src), "a link to a file outside is fine");
|
||||
assert_eq!(std::fs::read(&photo).unwrap(), b"photo");
|
||||
let _ = std::fs::remove_dir_all(&work);
|
||||
}
|
||||
|
||||
/// `--json` naming a link that sits in the source folder and points to a file outside it is
|
||||
/// refused: writing the results would replace the link itself, an entry of the source folder
|
||||
/// (CodeRabbit on #3080). The link stays as it was.
|
||||
#[test]
|
||||
fn a_json_link_in_the_source_folder_to_a_file_outside_is_refused() {
|
||||
let work = run_dir(&std::env::temp_dir()).unwrap();
|
||||
let src = fresh_dir(&work, "src").unwrap();
|
||||
let outside = work.join("out.json");
|
||||
std::fs::write(&outside, b"outside").unwrap();
|
||||
let in_src = src.join("a.jpg");
|
||||
if let Err(e) = link(&outside, &in_src) {
|
||||
let _ = std::fs::remove_dir_all(&work);
|
||||
lightcraft_testkit::skip!(System, "can't make a file link here ({e}); Windows needs Developer Mode");
|
||||
return;
|
||||
}
|
||||
assert!(inside_source(&in_src, &src), "a link in the source folder, wherever it points");
|
||||
let e = check_outputs(&src, Some(&in_src), &work.join("elsewhere")).unwrap_err();
|
||||
assert!(e.contains("--json"), "{e}");
|
||||
assert!(std::fs::symlink_metadata(&in_src).unwrap().file_type().is_symlink(), "the link stays");
|
||||
assert_eq!(std::fs::read_link(&in_src).unwrap(), outside);
|
||||
assert_eq!(std::fs::read(&outside).unwrap(), b"outside");
|
||||
// a new file in the source folder (nothing there yet) is refused too
|
||||
assert!(check_outputs(&src, Some(&src.join("new.json")), &work.join("elsewhere")).is_err());
|
||||
let _ = std::fs::remove_dir_all(&work);
|
||||
}
|
||||
|
||||
/// `--json` naming a hard link (made elsewhere) to a photo in the source folder passes
|
||||
/// [`inside_source`] (a hard link has no target to resolve), so the results replace the link
|
||||
/// instead of writing into it: the photo keeps its bytes (CodeRabbit on #3077).
|
||||
#[test]
|
||||
fn a_json_hard_link_to_a_photo_leaves_the_photo_alone() {
|
||||
let work = run_dir(&std::env::temp_dir()).unwrap();
|
||||
let src = fresh_dir(&work, "src").unwrap();
|
||||
let photo = src.join("a.jpg");
|
||||
std::fs::write(&photo, b"photo").unwrap();
|
||||
let report = work.join("report.json");
|
||||
if let Err(e) = std::fs::hard_link(&photo, &report) {
|
||||
let _ = std::fs::remove_dir_all(&work);
|
||||
lightcraft_testkit::skip!(System, "can't make a hard link here ({e})");
|
||||
return;
|
||||
}
|
||||
finish(json!({"browse": 1}), Some(report.clone())).unwrap();
|
||||
assert_eq!(std::fs::read(&photo).unwrap(), b"photo", "the photo is unchanged");
|
||||
let written: Value = serde_json::from_slice(&std::fs::read(&report).unwrap()).unwrap();
|
||||
assert_eq!(written["browse"], 1);
|
||||
let left: Vec<_> = std::fs::read_dir(&work).unwrap().flatten().map(|e| e.file_name()).collect();
|
||||
assert_eq!(left.len(), 2, "no temporary file is left: {left:?}");
|
||||
// a folder that isn't there: an error, nothing panics
|
||||
assert!(write_replacing(&work.join("missing").join("r.json"), b"x").is_err());
|
||||
let _ = std::fs::remove_dir_all(&work);
|
||||
}
|
||||
|
||||
/// `--json` naming a file in the source folder is refused (CodeRabbit on #3077); elsewhere it is fine.
|
||||
#[test]
|
||||
fn the_results_never_go_in_the_source_folder() {
|
||||
let work = run_dir(&std::env::temp_dir()).unwrap();
|
||||
let src = fresh_dir(&work, "src").unwrap();
|
||||
let sub = fresh_dir(&src, "sub").unwrap();
|
||||
assert!(inside_source(&src.join("a.png"), &src));
|
||||
assert!(inside_source(&sub.join("x.json"), &src));
|
||||
assert!(inside_source(&src.join("sub").join("..").join("a.png"), &src));
|
||||
assert!(!inside_source(&work.join("out.json"), &src));
|
||||
let _ = std::fs::remove_dir_all(&work);
|
||||
}
|
||||
|
||||
/// `--work` in the source folder is refused before anything is made there, also when it doesn't
|
||||
/// exist yet (CodeRabbit on #3077); elsewhere it is fine.
|
||||
#[test]
|
||||
fn the_work_folder_never_goes_in_the_source_folder() {
|
||||
let work = run_dir(&std::env::temp_dir()).unwrap();
|
||||
let src = fresh_dir(&work, "src").unwrap();
|
||||
let inside = src.join("out").join("deeper");
|
||||
let e = check_outputs(&src, None, &inside).unwrap_err();
|
||||
assert!(e.contains("--work"), "{e}");
|
||||
assert!(check_outputs(&src, None, &src).is_err(), "the source folder itself");
|
||||
assert!(!src.join("out").exists(), "nothing was made in the source folder");
|
||||
assert!(check_outputs(&src, None, &work.join("elsewhere")).is_ok());
|
||||
assert!(check_outputs(&src, Some(&src.join("r.json")), &work.join("elsewhere")).is_err());
|
||||
let _ = std::fs::remove_dir_all(&work);
|
||||
}
|
||||
|
||||
/// A `..` in `--json` or `--work` is refused: after a folder the run hasn't made yet it can't be
|
||||
/// resolved now, and would lead into the source folder once the run makes it (CodeRabbit on #3544).
|
||||
#[test]
|
||||
fn a_dot_dot_in_the_outputs_is_refused() {
|
||||
let work = run_dir(&std::env::temp_dir()).unwrap();
|
||||
let src = fresh_dir(&work, "src").unwrap();
|
||||
let sneaky = work.join("new").join("..").join("src").join("r.json");
|
||||
let e = check_outputs(&src, Some(&sneaky), &work.join("elsewhere")).unwrap_err();
|
||||
assert!(e.contains("--json") && e.contains(".."), "{e}");
|
||||
let e = check_outputs(&src, None, &work.join("new").join("..").join("src")).unwrap_err();
|
||||
assert!(e.contains("--work"), "{e}");
|
||||
assert!(!work.join("new").exists(), "nothing was made");
|
||||
let _ = std::fs::remove_dir_all(&work);
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,13 @@
|
||||
//! lightcraft-cli commands [--json]
|
||||
//! lightcraft-cli controls [--json]
|
||||
//! lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
|
||||
//! lightcraft-cli bench-cull FOLDER [--files K] [--n N] [--copy M] [--work DIR] [--json FILE]
|
||||
//! ```
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod alloc_release;
|
||||
mod bench_cull;
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
@@ -110,6 +112,12 @@ USAGE:
|
||||
model, written as <model>.json to DIR (default: the profiles folder LightCraft reads,
|
||||
<config>/camera-profiles, or $LIGHTCRAFT_CAMERA_PROFILES). Raws of a profiled model then
|
||||
take their colour from the profile and only their tone from their own JPEG.
|
||||
lightcraft-cli bench-cull FOLDER [--files K] [--n N] [--copy M] [--work DIR] [--json FILE]
|
||||
Time what culling a shoot costs on its own files: reading the folder (browse), the first
|
||||
screen of grid thumbnails, camera previews at loupe sizes and full size, a loupe render,
|
||||
rating, and importing N files in place (default 2000) and copying M (default 200). Libraries
|
||||
are made in a new folder under DIR (default: the temp folder); nothing is written next to the
|
||||
photos. --files K reads only the first K files by name (a sample of a slow network folder).
|
||||
lightcraft-cli --version | --help
|
||||
";
|
||||
|
||||
@@ -176,6 +184,7 @@ fn main() -> ExitCode {
|
||||
Some("controls") => controls(&args[1..]),
|
||||
Some("denoise-serve") => denoise_serve(&args[1..]),
|
||||
Some("calibrate") => calibrate(&args[1..]),
|
||||
Some("bench-cull") => bench_cull::run(&args[1..]),
|
||||
Some("--version" | "-V" | "version") => {
|
||||
println!("lightcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
|
||||
@@ -101,6 +101,75 @@ fn gradient_png(path: &std::path::Path) {
|
||||
std::fs::write(path, png).unwrap();
|
||||
}
|
||||
|
||||
/// PERF-CULL-5: `bench-cull` times a folder end to end and writes nothing next to the photos.
|
||||
#[test]
|
||||
fn bench_cull_times_a_folder_and_leaves_it_untouched() {
|
||||
let dir = tmp("bench-cull-src");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for (seed, name) in ["a.png", "b.png", "c.png"].iter().enumerate() {
|
||||
// three different pictures (the same bytes would import as one photo)
|
||||
let img = lightcraft_raster::Rgba8::from_fn(120, 80, |x, y| [(x * 2) as u8, (y * 3) as u8, (seed * 80) as u8, 255]);
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &Default::default()).unwrap();
|
||||
std::fs::write(dir.join(name), png).unwrap();
|
||||
}
|
||||
// every file's name and bytes: nothing added, and no photo written into (CodeRabbit on #3577)
|
||||
let listing = |d: &std::path::Path| {
|
||||
let mut v: Vec<(String, Vec<u8>)> = std::fs::read_dir(d)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap())
|
||||
.map(|e| (e.file_name().to_string_lossy().to_string(), std::fs::read(e.path()).unwrap()))
|
||||
.collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let before = listing(&dir);
|
||||
let work = tmp("bench-cull-work");
|
||||
let json_out = tmp("bench-cull.json");
|
||||
let r = Command::new(binary())
|
||||
.arg("bench-cull")
|
||||
.arg(&dir)
|
||||
.args(["--n", "3", "--copy", "1", "--work"])
|
||||
.arg(&work)
|
||||
.arg("--json")
|
||||
.arg(&json_out)
|
||||
.output()
|
||||
.unwrap();
|
||||
let stdout = String::from_utf8_lossy(&r.stdout);
|
||||
assert!(r.status.success(), "{stdout}{}", String::from_utf8_lossy(&r.stderr));
|
||||
assert!(stdout.contains("browse 3 photos") && stdout.contains("ms wall"), "{stdout}");
|
||||
let v: Value = serde_json::from_str(&std::fs::read_to_string(&json_out).unwrap()).unwrap();
|
||||
assert_eq!(v["browse"]["photos"], 3);
|
||||
assert_eq!(v["importAdd"]["photos"], 3);
|
||||
assert_eq!(v["importCopy"]["photos"], 1);
|
||||
assert_eq!(v["rate"]["n"], 3);
|
||||
// a folder smaller than the samples is measured on the photos it has, not reported empty
|
||||
for stage in ["camera1280", "camera2560", "cameraFull", "loupeRender"] {
|
||||
assert_eq!(v[stage]["n"], 3, "{stage}: {}", v[stage]);
|
||||
}
|
||||
assert_eq!(listing(&dir), before, "nothing written next to the photos");
|
||||
// results asked for in the source folder: refused, the photo untouched
|
||||
let photo = std::fs::read(dir.join("a.png")).unwrap();
|
||||
let r = Command::new(binary())
|
||||
.arg("bench-cull")
|
||||
.arg(&dir)
|
||||
.args(["--n", "0", "--work"])
|
||||
.arg(&work)
|
||||
.arg("--json")
|
||||
.arg(dir.join("a.png"))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!r.status.success() && String::from_utf8_lossy(&r.stderr).contains("--json"), "{}", String::from_utf8_lossy(&r.stderr));
|
||||
assert_eq!(std::fs::read(dir.join("a.png")).unwrap(), photo);
|
||||
assert_eq!(listing(&dir), before);
|
||||
// a sample of the folder, no import
|
||||
let r = Command::new(binary()).arg("bench-cull").arg(&dir).args(["--files", "2", "--n", "0", "--work"]).arg(&work).output().unwrap();
|
||||
let stdout = String::from_utf8_lossy(&r.stdout);
|
||||
assert!(r.status.success() && stdout.contains("browse 2 photos (first files)") && !stdout.contains("import"), "{stdout}");
|
||||
// a bad folder is an error, not a panic
|
||||
let r = Command::new(binary()).args(["bench-cull", "/no/such/folder"]).output().unwrap();
|
||||
assert!(!r.status.success() && String::from_utf8_lossy(&r.stderr).contains("not a folder"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibrate_reads_raf_inputs_and_reports_bad_files() {
|
||||
let input = tmp("invalid.RAF");
|
||||
|
||||
@@ -371,6 +371,7 @@ fn main() -> eframe::Result {
|
||||
app.notices.extend(launch_notices);
|
||||
app.notices.extend(window_notice);
|
||||
app.gpu_notice = true;
|
||||
app.disk_space_notice = true;
|
||||
// an empty library opens where Import is offered (a grid, with the sidebar); before
|
||||
// the snapshot below, so this alone isn't written to the settings file
|
||||
if problem.is_none() && files.is_empty() {
|
||||
|
||||
@@ -862,3 +862,210 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`crate::decode_jpeg_srgb8_fit`]: a plain 8-bit sRGB JPEG decoded (DCT-scaled when that still
|
||||
/// covers the box) and area-averaged in linear light straight into 8-bit sRGB, without the
|
||||
/// float image the general path builds (for a 45 MP camera preview fitted to 2560 px that float
|
||||
/// image and its conversions were half the time). `Ok(None)` for anything else.
|
||||
pub(crate) fn srgb8_fit(bytes: &[u8], max_w: u32, max_h: u32) -> Result<Option<(lightcraft_raster::Rgba8, u16)>> {
|
||||
// (a Samsung SEF trailer read as the file it stands for, as the general decode does: CodeRabbit on #3583)
|
||||
let bytes = &*without_sef_trailer(bytes);
|
||||
let Some(m) = parse_markers(bytes) else { return Ok(None) };
|
||||
let plain = matches!(m.components, 1 | 3)
|
||||
&& m.precision == 8
|
||||
&& matches!(m.sof, 0xC0..=0xC2)
|
||||
&& m.icc.is_none()
|
||||
&& !m.icc_invalid
|
||||
&& m.adobe_transform.is_none();
|
||||
let summary = m.exif.as_deref().map(exif::summarize).unwrap_or_default();
|
||||
if !plain || summary.adobe_rgb_hint || max_w == 0 || max_h == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let budget = DecodeOptions::default();
|
||||
check_size(F, m.width as u64, m.height as u64, &budget)?;
|
||||
// as in the general decode: the decoders below walk every APP1 again
|
||||
if m.app1 > MAX_APP1 {
|
||||
return Err(Error::Malformed(F, "too many APP1 markers".into()));
|
||||
}
|
||||
let scale_to = (m.width >= max_w.saturating_mul(2) || m.height >= max_h.saturating_mul(2)).then_some((max_w, max_h));
|
||||
let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components;
|
||||
let raw =
|
||||
if scale_to.is_some() || non_interleaved { decode_jpeg_decoder(bytes, &m, scale_to, &budget)? } else { decode_zune(bytes, &m, &budget)? };
|
||||
let Buf::U8(px) = &raw.buf else { return Ok(None) };
|
||||
let ch = match raw.model {
|
||||
Model::Rgb => 3,
|
||||
Model::Gray => 1,
|
||||
Model::Cmyk => return Ok(None),
|
||||
};
|
||||
let (w, h) = (raw.width, raw.height);
|
||||
if w == 0 || h == 0 || px.len() < w.saturating_mul(h).saturating_mul(ch) {
|
||||
return Err(Error::Malformed(F, "short output".into()));
|
||||
}
|
||||
// the size `lightcraft_raster::resample::fit` gives: within the box, aspect kept, never larger
|
||||
let s = (max_w as f64 / w as f64).min(max_h as f64 / h as f64).min(1.0);
|
||||
let tw = ((w as f64 * s).round() as usize).clamp(1, w);
|
||||
let th = ((h as f64 * s).round() as usize).clamp(1, h);
|
||||
let orientation = summary.orientation.filter(|o| (1..=8).contains(o)).unwrap_or(1);
|
||||
Ok(Some((area_average_srgb8(px, w, h, ch, tw, th), orientation)))
|
||||
}
|
||||
|
||||
/// Source pixels and weights each output pixel along one axis covers: a box of the scale's width
|
||||
/// (`n` source pixels onto `out`), partly covered pixels at its ends weighted by their share.
|
||||
fn box_taps(n: usize, out: usize) -> Vec<(usize, Vec<f32>)> {
|
||||
let scale = n as f64 / out as f64;
|
||||
(0..out)
|
||||
.map(|o| {
|
||||
let (a, b) = (o as f64 * scale, ((o + 1) as f64 * scale).min(n as f64));
|
||||
let first = (a.floor() as usize).min(n - 1);
|
||||
let last = ((b.ceil() as usize).max(first + 1)).min(n);
|
||||
let ws: Vec<f32> =
|
||||
(first..last).map(|j| ((b.min(j as f64 + 1.0) - a.max(j as f64)).max(0.0) / (b - a).max(f64::MIN_POSITIVE)) as f32).collect();
|
||||
(first, ws)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `px` (`w × h`, `ch` = 1 or 3 interleaved 8-bit sRGB samples) averaged over boxes in linear
|
||||
/// light into `tw × th` opaque 8-bit sRGB pixels. A copy when the size doesn't change.
|
||||
fn area_average_srgb8(px: &[u8], w: usize, h: usize, ch: usize, tw: usize, th: usize) -> lightcraft_raster::Rgba8 {
|
||||
use lightcraft_color::transfer::{decode_srgb8, encode_srgb8};
|
||||
let mut out = lightcraft_raster::Rgba8::new(tw, th);
|
||||
let sample = |x: usize, y: usize, c: usize| px.get((y * w + x) * ch + if ch == 3 { c } else { 0 }).copied().unwrap_or(0);
|
||||
if (tw, th) == (w, h) {
|
||||
lightcraft_raster::par_rows(&mut out.data, tw, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
*o = [sample(x, y, 0), sample(x, y, 1), sample(x, y, 2), 255];
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
let lut: [f32; 256] = std::array::from_fn(|i| decode_srgb8(i as u8));
|
||||
let xs = box_taps(w, tw);
|
||||
let ys = box_taps(h, th);
|
||||
// one source row filtered across into linear RGB at the output width
|
||||
let across = |sy: usize, buf: &mut Vec<[f32; 3]>| {
|
||||
buf.clear();
|
||||
let Some(src) = px.get(sy * w * ch..(sy + 1) * w * ch) else {
|
||||
buf.resize(tw, [0.0; 3]);
|
||||
return;
|
||||
};
|
||||
for (x0, wx) in &xs {
|
||||
let mut a = [0f32; 3];
|
||||
if let Some(span) = src.get(x0 * ch..(x0 + wx.len()) * ch) {
|
||||
for (p, &k) in span.chunks_exact(ch).zip(wx) {
|
||||
match p {
|
||||
[r, g, b] => {
|
||||
a[0] += lut[*r as usize] * k;
|
||||
a[1] += lut[*g as usize] * k;
|
||||
a[2] += lut[*b as usize] * k;
|
||||
}
|
||||
[v] => {
|
||||
let l = lut[*v as usize] * k;
|
||||
a = [a[0] + l, a[1] + l, a[2] + l];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
buf.push(a);
|
||||
}
|
||||
};
|
||||
lightcraft_raster::par_rows(&mut out.data, tw, |y, row| {
|
||||
let Some((y0, wy)) = ys.get(y) else { return };
|
||||
let mut acc = vec![[0f32; 3]; tw];
|
||||
let mut line = Vec::with_capacity(tw);
|
||||
for (dy, &k) in wy.iter().enumerate() {
|
||||
across(y0 + dy, &mut line);
|
||||
for (a, l) in acc.iter_mut().zip(&line) {
|
||||
a[0] += l[0] * k;
|
||||
a[1] += l[1] * k;
|
||||
a[2] += l[2] * k;
|
||||
}
|
||||
}
|
||||
for (o, a) in row.iter_mut().zip(&acc) {
|
||||
*o = [encode_srgb8(a[0]), encode_srgb8(a[1]), encode_srgb8(a[2]), 255];
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod srgb8_fit_tests {
|
||||
use super::*;
|
||||
|
||||
fn jpeg(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 3]) -> Vec<u8> {
|
||||
let data: Vec<[u8; 4]> = (0..w * h)
|
||||
.map(|i| {
|
||||
let [r, g, b] = f(i % w, i / w);
|
||||
[r, g, b, 255]
|
||||
})
|
||||
.collect();
|
||||
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
|
||||
crate::encode_jpeg(&crate::EncodeImage::rgba8(&img), 95, crate::ChromaSubsampling::S420, &crate::EncodeMeta::default()).unwrap()
|
||||
}
|
||||
|
||||
/// The fast path shows what the general decode shows for a plain sRGB JPEG, at the same size.
|
||||
#[test]
|
||||
fn matches_the_general_decode_within_a_few_levels() {
|
||||
let b = jpeg(1203, 801, |x, y| [((x * 7) ^ (y * 3)) as u8, (x / 5 + y / 3) as u8, ((x * y) % 251) as u8]);
|
||||
for (mw, mh) in [(300u32, 300u32), (640, 640), (1203, 801), (5000, 5000), (500, 120)] {
|
||||
let (fast, o) = srgb8_fit(&b, mw, mh).unwrap().unwrap();
|
||||
let slow = crate::decode(&b, DecodeOptions::fit(mw, mh)).unwrap().to_srgb8();
|
||||
assert_eq!((fast.width, fast.height), (slow.width, slow.height), "box {mw}×{mh}");
|
||||
assert_eq!(o, 1);
|
||||
let diff: Vec<u32> =
|
||||
fast.data.iter().zip(&slow.data).flat_map(|(a, b)| (0..3).map(move |c| (a[c] as i32 - b[c] as i32).unsigned_abs())).collect();
|
||||
let mean = diff.iter().sum::<u32>() as f64 / diff.len() as f64;
|
||||
assert!(mean < 3.0, "box {mw}×{mh}: mean difference {mean}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Anything but plain 8-bit sRGB goes the general way; junk is an error or `None`, never a panic.
|
||||
#[test]
|
||||
fn declines_what_it_does_not_cover_and_never_panics() {
|
||||
let b = jpeg(64, 48, |x, _| [x as u8, 0, 0]);
|
||||
assert!(srgb8_fit(&b, 0, 10).unwrap().is_none());
|
||||
assert!(srgb8_fit(b"not a jpeg", 10, 10).unwrap().is_none());
|
||||
for cut in [0, 2, 20, b.len() / 2, b.len() - 3] {
|
||||
let _ = srgb8_fit(&b[..cut], 32, 32);
|
||||
}
|
||||
// a 1-pixel-wide image into a box much smaller in one axis
|
||||
let thin = jpeg(1, 300, |_, y| [y as u8, y as u8, y as u8]);
|
||||
let (img, _) = srgb8_fit(&thin, 10, 10).unwrap().unwrap();
|
||||
assert_eq!((img.width, img.height), (1, 10));
|
||||
}
|
||||
|
||||
/// An incomplete ICC_PROFILE sequence (chunk 1 of 2) is "has a profile we can't read", not
|
||||
/// "plain sRGB": the general decode handles it, as the doc says (CodeRabbit on #3577).
|
||||
#[test]
|
||||
fn an_incomplete_icc_sequence_goes_the_general_way() {
|
||||
let b = jpeg(16, 16, |x, y| [x as u8, y as u8, 0]);
|
||||
let mut seg = vec![0xFF, 0xE2, 0, 0];
|
||||
seg.extend_from_slice(b"ICC_PROFILE | ||||