mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-13 01:14:21 -06:00
* 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>
422 lines
49 KiB
Markdown
422 lines
49 KiB
Markdown
# Control protocol
|
||
|
||
`lightcraft --control 7980` (or `LIGHTCRAFT_CONTROL_PORT=7980`) starts a JSON-lines server on
|
||
`127.0.0.1:7980` (loopback only). One request per line, one reply per line, in order:
|
||
|
||
```text
|
||
→ {"id": 1, "method": "engine.execute", "params": {"command": "photo.rate", "params": {"rating": 4}}}
|
||
← {"id": 1, "ok": true, "result": null}
|
||
← {"id": 2, "ok": false, "error": "unknown command `nope`"}
|
||
```
|
||
|
||
Requests are answered on the UI thread between frames (timeout 60 s). A request is given up
|
||
if the app has not started it and draws no frame for 5 s. Synthetic pointer input is fed one event
|
||
per frame; the same 5 s limit is a delivery stall, restarted whenever the app consumes a batch, so a
|
||
many-step drag on a slow frame is not cut off while an app that stops drawing still fails. The reply is then `{"ok": false, "error": "the app drew no frame for 5 s, …"}`,
|
||
and the app never runs that request later, so a retry doesn't do it twice. This happens on
|
||
Wayland when the window is on a hidden workspace or minimized: the compositor sends no frame
|
||
callbacks, so the app's loop is paused. Keep the window on screen while you drive it, or use
|
||
headless snapshots or `lightcraft-cli mcp` without `--connect`. A request that has already started
|
||
(a long command blocks frames) still gets the full 60 s.
|
||
|
||
Connections close after five minutes without incoming bytes while the server is waiting for
|
||
input, including in the middle of an unfinished request line. This allows pauses between
|
||
automation commands while releasing abandoned connection slots. An idle read timeout gets one
|
||
reply without an `id`, then the connection closes; incomplete input is never dispatched:
|
||
|
||
```text
|
||
← {"ok": false, "error": "control connection idle read timed out; closing the connection", "closed": "idle"}
|
||
```
|
||
|
||
This is a socket read inactivity limit, not a total connection lifetime: a peer that keeps
|
||
sending bytes can keep it open. Waiting for an already-dispatched command's reply does not
|
||
count as idle reading and retains the request timeout above.
|
||
|
||
Reconnect before sending another request after an idle close. A request sent just as the
|
||
connection expires crosses the notice: the client reads the notice where it expects the reply.
|
||
The server reads nothing after its idle timeout, so that request never ran, and `"closed": "idle"`
|
||
(only on this notice) says it is safe to send it again, once, on a new connection. Apps from
|
||
before the marker send the same notice without it; match its exact `error` text for them. MCP
|
||
connect mode does both: it detects closed connections before sending and reconnects, and
|
||
resends a request that crossed the idle notice once. Any other lost connection after sending a
|
||
request leaves its outcome unknown, and the client must not blindly replay it.
|
||
|
||
**Only requests are read.** Every line must be a JSON object with a string `method` (`id` and `params` are
|
||
optional; blank lines are skipped). Anything else — text that isn't JSON, a JSON array or number, an object without
|
||
`method`, invalid UTF-8, or a line longer than 4 MiB — gets one error reply
|
||
(`{"ok": false, "error": "… closing the connection"}`) and the server **closes the connection**; nothing sent after
|
||
it on that connection runs. This keeps an HTTP request (for example a web page's cross-origin `fetch` to
|
||
`127.0.0.1:<port>`) from smuggling a command in its body: its request line is rejected first. Junk never reaches the
|
||
UI thread, and at most 16 connections are served at once (further ones get an error line and are closed). Clients
|
||
that hit an error reply should reconnect. The port has no authentication, so only enable it when you need it. The MCP server's connect
|
||
mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation:
|
||
`crates/ui-egui/src/control.rs` (methods) and `apps/lightcraft/src/control_server.rs` (transport).
|
||
|
||
## Isolated automation profiles
|
||
|
||
Set `LIGHTCRAFT_CONFIG_DIR` to a fresh directory to isolate UI preferences, app logs, the emergency
|
||
panic log, the GPU initialization marker, camera profiles and default model folders. The nonempty
|
||
value is used as the complete settings directory. Unset retains the normal platform paths; an explicit
|
||
empty value disables the settings directory, while the emergency panic log retains its system-temp path.
|
||
Explicit model-directory environment variables still take precedence over their defaults.
|
||
Also pass `--library DIR` with a separate scratch library: the configuration override does not change
|
||
the default photo-library location. Do not use `--memory` or `LIGHTCRAFT_NO_PREFS` when testing persistence.
|
||
|
||
A native acceptance run must reject the stderr message `rendering headlessly`: `ui.screenshot`
|
||
automatically uses the CPU fallback after two seconds without a presented frame. That fallback is useful
|
||
for headless scripts, but cannot establish native-window acceptance. Control input injects egui events;
|
||
it does not establish operating-system input delivery or screen-reader acceptance.
|
||
|
||
On macOS, the native menu owns its keyboard accelerators. `ui.key` injects egui key events and
|
||
does not synthesize the Cocoa menu events for those shortcuts. Use `ui.menu.invoke` with the
|
||
command ID (for example `edit.undo`, `edit.redo`, `view.filmstrip` or `view.leftPanel`) to test
|
||
their command behavior. That API invokes the app command dispatcher directly; it does not traverse
|
||
Cocoa menu dispatch. Native menu activation and keyboard accelerator delivery require a separate test.
|
||
|
||
Before capturing a finished Detail view, poll `ui.inspect` until the expected `loupe.photo` is active,
|
||
`loupe.source` is `render` or `cached`, `loupe.pending` is false, and the render queues are empty — or until
|
||
`loupe.failed` is set: that photo's render failed, and what is on screen is all there will be.
|
||
For the loupe, `cached` is a prior view render keyed by the current photo and develop settings;
|
||
`thumb`, `small` and `embedded` are separate fallback sources. A presented frame can otherwise
|
||
contain an in-progress rendering placeholder. Require loaded thumbnail textures only when the
|
||
filmstrip is visible.
|
||
|
||
## Methods
|
||
|
||
| Method | Params | Result |
|
||
|---|---|---|
|
||
| `engine.execute` (alias `ui.menu.invoke`) | `{command, params?}` | Run any engine or UI command (see `engine.commands`) |
|
||
| `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled |
|
||
| `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) |
|
||
| `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus |
|
||
| `ui.inspect` | — | UI state, window, `loupe` (`source`, `pending`, `failed` = why its render failed, else `null`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why, `gpuWindowDevice` / `screenTextures` / `mainOnGpu` = renders run on the window's device / can be drawn from the GPU / the loupe shows one now — see `docs/gpu-pipeline.md`, `loupeSteps` = how the loupe showed each photo it was stepped to, measured on the frames it drew: `{steps: [{photo, anyMs, anySource, fullMs, fullSource, sharpMs, dwellMs}], total, current}` — ms from the step to the first frame with anything of the photo, with a full-size picture (`embedded` camera JPEG, `cached` or `render`), and with LightCraft's own render; `dwellMs` is how long it stayed, the latest 256 steps; `total` counts every step finished since launch, a cursor that keeps rising when old steps leave the list; `thumbsMade` = photos given a grid thumbnail since launch (never lowered when old thumbnails are evicted, unlike `thumbTextures`); `previewCache` = the rendered-preview cache: `memEntries` / `memBytes` in memory, the disk cache's `diskHits`, `diskMisses`, `diskWrites` and `diskBudget` (bytes; all `null` without a library on disk), and `loupeEdge` (px, the loupe's longest edge, the size standard previews are built at)), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file, or once per session the drive holding the preview cache running low; OK = `button:noticeOk`, and the low-drive notice's `button:noticeSettings` opens Settings ▸ Performance), `quitPrompt` (why quitting was stopped: tasks that would be cut short are running — `button:quitAnyway` cancels them and quits, `button:quitCancel` keeps working — or unsaved changes: `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:<id>`, ✕ = `activity:cancel:<id>`, `activity:more` expands past 3 rows), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) |
|
||
| `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) |
|
||
| `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, button?, fx?, fy?}` (`button: "right"` right-clicks; `count` is capped at 16) / `{id, toX?, toY?, dx?, dy?, steps?}` (steps are capped at 1000) / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews). `fx`/`fy` are fractions of the registered rect and may lie outside `[0,1]`; the resulting point must still be inside egui's clipped interaction rect, and a non-finite fraction or resolved point is rejected before any input is queued. Geometry comes from the latest completed layout; if the widget moves before the first event or before a press, or its latest clipped rect no longer contains the point, the request fails with an error instead of clicking another widget or retargeting. A target below an ordinary modal dialog may succeed as a no-op or a backdrop click, because egui routes that input to the dialog; a target covered by a non-modal overlay fails. The blocking library-problem/launch-list window is explicit: its dim takes the modal layer, but a background target fails instead of reporting a no-op success, and only that window's own controls (`button:library…`) pass. Its creation frame is shielded and redrawn with empty input like a prompt's, so background text/pointer input cannot act while the new modal areas settle. A quit prompt or settings notice is a real egui modal scope with a foreground backdrop. Only its own buttons (`button:quit…`, `button:noticeOk`) accept a `ui.clickWidget`; background targets fail, and background keystrokes, text/clipboard input, wheel/zoom input and AccessKit actions are dropped before panels see them. The prompt itself takes Tab/Shift+Tab, Enter/Space on the focused button, and Escape/Enter for the safe default Cancel; AccessKit Focus/Click works when targeted at a prompt button. Its first creation frame (including a Tasks→Unsaved or notice→next-notice transition) is shielded and redrawn with empty input, so a press/release in that frame, or a held press from before it, cannot act underneath it; a synthetic drag already in progress is canceled. Once settled, raw `ui.click`/`ui.drag`/`ui.pointer` and backdrop clicks are still delivered to the prompt backdrop as no-ops, and the prompt's own button clicks work normally. The area/layer check cannot see a different widget covering the point in the same layer. A `ui.clickWidget` whose widget disappears before its release also fails, after canceling the pointer press without a click or drag (the same rules apply when a click spans several host frames) |
|
||
| `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points (the same click and step caps apply). In every pointer and scroll gesture a number other than `count` and `steps` must lie between −1,000,000 and 1,000,000, or the request is refused and queues nothing |
|
||
| `ui.pointer` | `{events: [{kind: down\|drag\|up, x, y}], alt?, shift?, cmd?}` | Gesture in normalized image coordinates (Detail view) |
|
||
| `ui.key` | `{key, action?: press\|down\|up, cmd?, shift?, alt?, ctrl?}` | Key press (default), or the key going `down` and staying held until an `up` (Space held over a `ui.pointer` drag); while a prompt is up only its Tab/Shift+Tab, Enter, Space and Escape are delivered, every other key is dropped |
|
||
| `ui.text` | `{text}` | Text input |
|
||
| `ui.clipboard` | `{action: cut\|copy\|paste, text?}` | Cut, copy or paste in the focused text field, as the system does for ⌘X / ⌘C / ⌘V; `paste` pastes `text`, or the system clipboard without it. Refused when no text field has the focus (outside one, ⌘C / ⌘V are the commands `develop.copy` / `develop.paste`). In the desktop app cut and copy write the system clipboard. Refused while a prompt or the blocking library window owns input, including a window in its creation frame. A `paste` without `text` queues egui's `RequestPaste` viewport command for the host: egui 0.37 gives that command no completion/correlation id, and egui-winit emits a later `Event::Paste` only for non-empty text (or a `PasteImage` fallback; asynchronous clipboard reads finish later), so the reply confirms the request was queued in egui, not that the host dispatched it or that it was delivered; a barrier created after the request can drop that later event. `paste` with `text` is synthetic and is canceled like other injected input. `ui.inspect` → `copied` is the text the UI last put on the clipboard |
|
||
| `ui.scroll` | `{dx, dy, source?, unit?, phase?, cmd?, ctrl?, shift?, alt?}` | Wheel / two-finger scroll at the current pointer; pans over the image, modifier-scroll zooms. `source` is `unknown` (default), `wheel`, `trackpad` or `momentum`; `unit` is `point` (default), `line` or `page`; `phase` is `move` (default), `start`, `end` or `cancel`. Wheel/line events drive the grid/filmstrip one-third wheel step and the loupe's notch zoom; trackpad/momentum phases drive kinetic scrolling and rubber-band boundaries. Invalid values are refused and queue nothing |
|
||
| `ui.zoom` | `{factor}` | Pinch zoom at the current pointer (positive scale multiplier; 1 = unchanged). Position it first with `ui.move` or `ui.hoverWidget` |
|
||
| `ui.set` | partial UI state, e.g. `{"view": "detail"}` | Resulting UI state |
|
||
| `ui.dialog.confirm` / `ui.dialog.cancel` | — | Close the open dialog (confirm runs its action; Synchronize Folder on a folder that is up to date answers `{"upToDate": true}` and starts nothing) |
|
||
| `ui.resize` | `{width, height}` | Resize the window |
|
||
| `ui.dropFiles` | `{paths: [..], hover?}` | Files dragged from the file manager. `hover: true` makes every following frame report `paths` as hovered over the window (the drop card appears, `ui.widgets` lists `dropOverlay`) until another `ui.dropFiles` replaces them; `{paths: [], hover: true}` ends the hover. Without `hover` the files are dropped: the hover ends and the next frame receives them as dropped files, exactly as an OS drop arrives (photos and folders open the import review, or import at once with Settings ▸ Import ▸ Import dropped files without review). An empty path stands for a file whose path the platform doesn't tell |
|
||
| `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) |
|
||
| `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. A `path` that did not exist when the request was admitted is written with create-new semantics and is never replaced if a file (including a newly imported original) appears there while the capture is queued; an existing ordinary file may be refreshed. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill; `loupe:renderFailed` is the pill saying a render failed behind a stand-in; Compare and Reference panes have their own (`compare:rendering:<pane>`, `compare:renderFailed:<pane>`) |
|
||
| `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background`; `destinationMode: "sameAsOriginal"` writes each photo into its original's folder (+ `subfolder`) instead of `dir` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` |
|
||
| `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) |
|
||
| `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error |
|
||
| `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` |
|
||
| `engine.execute {command: "slideshow.layout"}` | `{margins?, marginLeft?, …, zoomToFill?, strokeBorder?, castShadow?, backdropColor?, colorWash?, backgroundImage?, …}` (see `engine.commands`) | The slideshow's slide layout (Layout, Options, Backdrop), saved with the library; omitted keys keep their value, `{}` reads it; out-of-range numbers are clamped, a wrong type or unknown key is refused and nothing changes |
|
||
| `engine.execute {command: "slideshow.overlays"}` | `{identityPlate?: {enabled?, text?, image?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, behind?}, ratingStars?: {enabled?, color?, size?, anchor?, …}, texts?: [{text?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, shadow?, shadowOpacity?, shadowOffset?, shadowRadius?, shadowAngle?}], addText?, removeText?, showWatermark?, watermark?}` (see `engine.commands`) | The overlays drawn on every slide, saved with the library: sizes and offsets are fractions of the slide's shorter side, anchors `topLeft`…`bottomRight` of the `slide` or the `photo`; text overlays expand tokens (`{caption}`, `{exposure}`, `{date}`, the file-naming tokens…; unknown tags show as typed); `watermark` takes the export watermark's keys. Replies with the settings and `warnings` |
|
||
| `engine.execute {command: "slideshow.titles"}` | `{intro?: screen, ending?: screen}`, a screen being `{enabled?, color?, content?: "none"\|"identityPlate"\|"text", text?, overrideColor?, textColor?, scale?, duration?: seconds\|null}` | The intro and ending screens, saved with the library: a background colour and, centred, the Overlays identity plate or the screen's own text, in the plate's colour or `textColor` when `overrideColor` is on, `scale` = its height as a fraction of the slide's shorter side; `duration` null = the slide duration. The intro plays once before the first photo, the ending after the last when repeat is off. Replies with the settings and `warnings` |
|
||
| `engine.execute {command: "slideshow.preview"}` | `{id?, title?, template?, width?, height?, path?}` | One slide composed with the layout and overlays, as the slideshow plays it (with `template: id`, with that template's settings instead, leaving the library's alone: the Template Browser's hover preview) (with `title: "intro"\|"ending"`, that title screen instead → `{title, enabled, width, height, plate}`): a PNG written to `path`, or returned base64 in `png` without one; `{id, width, height, cell, photo, overlays}` (`cell` and `photo` are `[x0, y0, x1, y1]` in slide pixels; `overlays` lists `{kind, box}` for each drawn overlay). Sides 16–4096; one side given takes the other from the aspect preview |
|
||
| `engine.execute {command: "slideshow.templates.list"}` | `{}` | The Template Browser: `{templates: [{id, name, builtin, group, description?, path?}], previous, folder, problems}` — built-ins (`builtin:…`, our own designs) then user templates (`user:<file>`, files in `<settings>/slideshow-templates`, at most 1000); `problems` lists files skipped and why |
|
||
| `engine.execute {command: "slideshow.templates.apply"}` | `{id}` or `{name}`; `{id: "previous"}` | Replaces the playback (prepare previews kept), layout, overlays and titles settings with the template's, saved with the library. No undo: the replaced settings become `previous` for the session → `{applied, name, warnings, previous, settings}` (`warnings`: a background photo not in this library, an unreadable plate or watermark image…) |
|
||
| `engine.execute {command: "slideshow.templates.save"}` / `update` / `rename` / `delete` | `{name, replace?}` / `{id}` / `{id, name}` / `{id}` | Save the current settings as a user template (`<name>.json`; path separators and reserved characters become `_`, a taken or built-in name is refused unless `replace`), update one with the current settings, rename (its file follows), delete. Built-ins can't change |
|
||
| `engine.execute {command: "slideshow.templates.import"}` / `export` | `{path}` / `{id, path}` | Import a template `.json` (at most 256 KB; format, version and every setting checked; a taken name gets " (2)") → `{id, name, renamed, warnings}`; export any template as `.json` |
|
||
| `engine.execute {command: "slideshow.export"}` | `{format: "pdf"\|"jpeg"\|"video", path, size?, quality?, fps?, container?: "mp4"\|"webm", fullScreen?, speed?, name?, ids?, wait?}` | The slideshow (the photos in view, or `ids`, as composed slides between the intro and ending screens that are on) as a PDF (one page per slide; `size` = long side 64–8192 px, default 1920), a JPEG sequence (`path` = the folder, files `<name>-001.jpg`…) or AV1 video in MP4 / WebM (`size` = frame height 64–2160 or `"720p"` / `"1080p"`, `fps` 24 / 25 / 30 / 60; fades rendered as frames; no audio; at most 2 hours). Runs in the background → `{taskId, background, slides, seconds, total, width, height, warnings}`; its row is in `activity.list` (`kind: "export"`, label "Exporting slideshow"), `activity.cancel {id: taskId}` stops it and leaves no file (outputs are written as `.part` and renamed when complete). `wait: true` runs it in the call and replies with the result. One at a time |
|
||
| `engine.execute {command: "slideshow.exportStatus"}` | — | The last slideshow export: `{taskId, format, path, running, done, total, cancelled, result?, error?}`; `result` has `pages` (PDF), `files` (JPEG) or `frames`, `samples`, `keyFrames`, `seconds`, `fps`, `container`, `codec: "av1"`, `audio: false` (video), and `skipped` / `warnings` for photos that could not be rendered |
|
||
| `engine.execute {command: "dialog.slideshowExport"}` / `app.slideshowExport` | `{format?}` / `{format, size?, quality?, fps?, container?, fullScreen?, path?}` | View ▸ Export Slideshow…: opens the dialog; `app.slideshowExport` asks for the path with a save dialog when none is given, then starts `slideshow.export` |
|
||
| `app.quit` | — | Close the app |
|
||
|
||
White balance (`develop.set` with `wb.temp` / `wb.tint`, `develop.wb`, `develop.wbPick`) is in Kelvin and tint on
|
||
raws with colour matrices — in the file (DNG) or measured for the camera model (`lightcraft_raw::spectral`; issue
|
||
#730) — and on raws whose white-balance presets give the scale (`lightcraft_raw::kelvin_presets`), where As Shot
|
||
reads the file's as-shot white; rendered files and raws without matrices use a relative scale
|
||
whose 6500 K / 0 is the as-shot look (the Edit panel shows it as −100..+100). The stored `wb` of such a Kelvin raw
|
||
says `"scale": "kelvin"`; a partial settings JSON given to `develop.merge`, to a preset's `wb`, or to
|
||
`lightcraft-cli render --settings` and which sets `temp` / `tint` **without** `scale` keeps the relative meaning it
|
||
always had (6500 = as shot) on every raw, so agents that mean Kelvin on a Kelvin raw pass `"scale": "kelvin"` or use
|
||
`develop.set` / `develop.wb`. A partial that names only one of `temp` / `tint` is resolved against the photo
|
||
(issue #2910): the photo's white balance is first brought onto the partial's scale with the photo's own as-shot
|
||
white, so the field the partial does not name keeps the look it had instead of being re-read on the partial's scale,
|
||
and the stored result is back on the scale the photo is edited on (`"scale": "kelvin"` on a Kelvin raw). The same
|
||
holds for `preset.apply` when the preset names only one of the two numbers; a preset written by the app, or imported
|
||
from crs / XMP, names `mode`, `temp`, `tint` and `scale` together and behaves exactly as before. A photo whose white
|
||
balance mode answers its own white — `As Shot`, or a named preset on a raw edited in Kelvin — has that white written
|
||
out as `Custom` values first, because a number merged into such a mode would otherwise be stored where no render
|
||
reads it (the panel would show 5800 while the photo stayed at as shot); a partial that names a `mode` itself stays
|
||
authoritative. Partials naming both fields mean the same as before.
|
||
|
||
Image navigation is also available directly as the UI command `view.navigate`, with
|
||
`{zoom?: "fit" | "fill" | {"percent": number}, pan?: [x, y]}`. Percentage zoom accepts fractional
|
||
values greater than 0 and at most 800; pan is the normalized image centre, with coordinates from 0 to 1.
|
||
Pinching keeps the image point under the pointer steady and zooms between Fit and 800%; two-finger
|
||
scrolling pans in both axes and respects the operating system's scrolling direction and momentum.
|
||
These gestures work in Detail (including editing tools and full-screen preview), Compare and Reference
|
||
views, and only apply over their image areas. Panning stops at the image edges.
|
||
|
||
The camera's focus point ([focus-points.md](focus-points.md)) is `meta.focus` in `photo.inspect`: `{rect: {x0, y0,
|
||
x1, y1}, mode?, area?, tracking?}`, normalized to the upright photo (absent when the file records none).
|
||
`view.focusPoint {on?}` shows it over the photo (no params toggles; replies `{on}`), and `view.zoomToFocus {on?}`
|
||
(on by default) makes `view.zoomToggle` zoom in centred on it, replying `{focus: [x, y]}` (the `pan` it set).
|
||
`photo.readFocusPoints {ids?, wait?}` reads the point for photos imported before it was read, from a few KB of each
|
||
file, on a background job (activity kind `focus`); `wait: true` returns the run's counts when done. The app calls
|
||
`focus.pump {pace}` every frame to drive it.
|
||
|
||
With several photos selected, the Info panel's Show Metadata For is `view.infoSelectedPhotos {on?}` (no `on`, or
|
||
`on: null`, toggles; any other value that is not true or false is refused; replies `{on}`;
|
||
`settings.infoSelectedPhotos`): off (Target Photo) shows the active photo's values and saves edits to it alone, on (Selected Photos, the default)
|
||
shows the values the selected photos share, leaves each text field and GPS where they differ empty with a Mixed hint,
|
||
and shows Mixed as the Copyright Status when the statuses differ.
|
||
|
||
The interface theme is `view.appearance {mode?: "auto" | "light" | "dark"}` (no mode: the next one,
|
||
Auto → Light → Dark → Auto, like the top bar's `icon:appearance` button) and `view.theme {theme:
|
||
"charcoal" | "midnight" | "silver" | "paper"}`, which picks a theme and fixes the mode to its family.
|
||
Both reply `{mode, darkTheme, lightTheme}`; the same values are `settings.appearanceMode`,
|
||
`settings.darkTheme` and `settings.lightTheme` in `ui.set`. Auto follows the system (on Linux the
|
||
XDG desktop portal, watched for changes rather than polled) and is dark when the system says nothing.
|
||
Mask brushing uses the UI command `tool.brush` through `engine.execute`. With `{}` it activates
|
||
painting on the current mask. `{"new": true}` creates a separate brush mask; `{"op": "add"}` or
|
||
`{"op": "subtract"}` appends a brush component to the selected mask. Creating either starts in
|
||
paint mode with the overlay visible. Brush Erase (or Alt while painting) removes strokes' coverage
|
||
within that brush component; it is distinct from subtracting the component from the whole mask.
|
||
|
||
### When the library can't be saved
|
||
|
||
With a persistent library, every command that changes something is written to the catalog journal (fsynced) before
|
||
it replies. If that write fails (disk full, volume gone, permissions), the command replies `ok: false` with
|
||
`"saved in memory but not written to disk: <reason>; LightCraft will retry"`. The change itself **is** applied (and
|
||
undoable) and stays queued: the next command, and the app's frame loop every couple of seconds, retry the write, so
|
||
nothing is lost once the disk is writable again — unless the app quits first. Meanwhile `ui.inspect` → `unsaved`
|
||
is `{ops, error}` (else `null`), `library.info` reports `unsavedOps` / `unsavedError`, and the top bar's cloud icon
|
||
shows a warning (widget `indicator:unsaved`). Queries and commands that change nothing still succeed. A failed
|
||
compaction (snapshot) is not a failed command — the log is kept whole — and only shows in `library.info` →
|
||
`lastError`.
|
||
|
||
### When the library can't be opened
|
||
|
||
If the desktop app can't open its library at launch (another program has it open, an unreadable or newer-format
|
||
catalog, a missing drive), the session starts empty and in memory — never with demo photos — and a window asks what
|
||
to do: `ui.inspect` → `libraryProblem` is `{path, error, temporarySession, pendingImport}` (else `null`); its buttons
|
||
are `button:libraryRetry`, `button:libraryChoose`, `button:libraryTemporary` (Continue Without Saving) and
|
||
`button:libraryQuit`. A temporary session shows a banner (`indicator:temporarySession`, `button:libraryReopen`) and
|
||
writes nothing. `app.openLibrary` opening a library ends it. `pendingImport` counts the files waiting for a
|
||
library: those given on the command line, and photos dropped on the window while it (or the launch list below) is
|
||
up (desktop app); they are imported into the library that opens, or into the temporary session. Presets dropped
|
||
then are refused with a toast.
|
||
|
||
### Choosing the library at launch
|
||
|
||
With Settings ▸ General ▸ At launch set to Ask me (`ui.set {"settings": {"atLaunch": "ask"}}`; `"last"`, the
|
||
default, opens the last library), or Alt/Option held during the first 2 s after the window appears, the desktop app
|
||
shows a window listing the recent libraries (issue #1290). `--library` and `LIGHTCRAFT_LIBRARY` skip it, and so does
|
||
`--memory`; `lightcraft-cli` never shows it. `ui.inspect` → `libraryProblem.chooser` is `{libraries: [{path,
|
||
lastOpened, available, checking, reason}], selected, libraryOpen}` (else `null`; `libraryOpen: true` when it was opened
|
||
over the last library with Alt). Each library is checked read-only on a worker thread of its own, never on the UI
|
||
thread (the folder and its catalog file are looked at, never locked or written, so a library another program has
|
||
open shows as available and Open reports who has it in `libraryProblem.error`). `checking: true` until its check
|
||
answers (the row says *Checking…*, then that the drive isn't answering after 5 s); `available` is `false` only
|
||
once a check found a `reason`, so a row still checking can be opened. The selection starts on the last library and
|
||
moves to the first one that can be opened once every check has answered, unless it was moved meanwhile. Each row is the widget `libraryRecent:<index>`; the buttons are `button:libraryOpen`,
|
||
`button:libraryOpenAnother` (the folder dialog of `app.openLibrary`), `button:libraryNew` (`app.newLibrary`),
|
||
`button:libraryForget` (Remove from List) and `button:libraryQuit`; `ui.key` ArrowUp / ArrowDown move the selection,
|
||
Enter opens it and Escape quits (over an open library: closes the window and keeps it). `app.openLibrary {path}`
|
||
opening a library ends it. While it shows, the settings are saved as usual (a removal is kept).
|
||
|
||
`app.recentLibraries` returns `[{path, name, exists, checking, open, lastOpened}]`. `exists` is what the last
|
||
background check found (`false`: moved, on a disconnected drive, not a folder or no longer a library; `null`: not
|
||
checked yet, or the check couldn't tell, e.g. a folder that can't be listed, an unreadable catalog or a lookup that
|
||
failed) and `checking` whether one
|
||
is running: the command never looks at a folder itself. A failed open never marks a library missing (another program
|
||
may have it). Recent libraries are checked in the
|
||
background when the app starts (the first 8) and when the launch window opens; one not found is looked for again every
|
||
30 s (a drive plugged back in), the others only when the window comes back to the front (a timer would wake external
|
||
drives and shares). File ▸ Open Recent Library greys out only an entry a check found missing (not there, not a folder,
|
||
no library in it); an unreadable catalog, a folder that refuses to be listed, a failed open or a lookup that failed
|
||
(a share that timed out) doesn't grey it out. `lastOpened`: ISO 8601, `null` when not known; kept in `ui.json` →
|
||
`settings.recentLibrariesOpened`. `{remove: path}` forgets one entry (an error if the list
|
||
doesn't hold it) and `{clear: true}` (or `app.clearRecentLibraries`) all of them; nothing on disk is touched.
|
||
|
||
### Open Library on a Lightroom catalog
|
||
|
||
`app.openLibrary` never makes a LightCraft library inside a Lightroom Classic catalog (issue #891): given the catalog
|
||
folder (it holds a `*.lrcat`), one of its `*.lrdata` / `*.lrcat-data` folders or `Backups/`, the `.lrcat` itself or
|
||
its `-wal` / `-shm` / `.lock` files, it keeps the library open now and returns `{opened: false, lightroomCatalog:
|
||
<the .lrcat found, or null>, path}`; the window shows a dialog whose `button:dialogOk` (Import Lightroom Catalog…, or
|
||
`ui.dialog.confirm`) runs `file.importLightroom` with that catalog (no catalog found: it asks for one), which then
|
||
asks where the catalog goes when `settings.lightroomImport` is `"ask"` (next section), and `button:dialogCancel` (`ui.dialog.cancel`) does nothing. A folder that is a LightCraft library (`catalog.snap` / `catalog.log`) always
|
||
opens as one. With no library open (the library problem window) it is an error instead. The engine refuses the same
|
||
places (`EngineError::LightroomCatalog`, so `lightcraft-cli --library` and `mcp --library` too). `library.importLightroom
|
||
{status: true}` reports the catalog's `path` after the task ends as well as while it runs.
|
||
|
||
### Import Lightroom Catalog: where the catalog goes
|
||
|
||
`file.importLightroom {path?, into?, name?, location?}` (File ▸ Import Lightroom Catalog…, issue #2672). With `into`
|
||
it acts without asking: `"open"` imports into the library open now (`library.importLightroom`); `"new"` creates the
|
||
library `name` (default: the catalog's file name without `.lrcat`, or `Travel 2`… when taken) in the folder
|
||
`location` (a full path; default: the open library's parent folder) under `app.newLibrary`'s rules, opens it and
|
||
imports there, returning the import's start with `library: {path, name, photos}`. A catalog file that isn't there is
|
||
refused before anything is made; so is a new library while an import runs. Without `into`, a library on disk with
|
||
`settings.lightroomImport` `"ask"` (the default) opens a window and returns `{asking: true, path, into}` (`into`: the
|
||
choice it opens with, `"open"` when the library is empty, else `"new"`). Its widgets:
|
||
`radio:lightroomDestination-open`, `radio:lightroomDestination-new`, `field:lightroomLibraryName`,
|
||
`field:lightroomLibraryLocation`, `button:lightroomLibraryChoose` (where there is a folder dialog), `check:lightroomDontAsk` (with Merge only; sets
|
||
`"alwaysMerge"`), `button:dialogOk` / `ui.dialog.confirm` (which stays open on an error such as a folder that isn't
|
||
empty) and `button:dialogCancel`. With `"alwaysMerge"` (`ui.set {"settings": {"lightroomImport": "alwaysMerge"}}`,
|
||
or Settings ▸ General `button:settingsLightroomImport-0|1`), or in a session in memory, it imports into the open
|
||
library without asking.
|
||
|
||
## External controllers
|
||
|
||
External apps can use this channel to send commands and poll the running desktop app for feedback
|
||
(e.g. knob values or LEDs). Start the app with `--control PORT`, then keep a JSON-lines connection to
|
||
`127.0.0.1:PORT`. Discover command ids, parameters and current enablement with `engine.commands`;
|
||
use control ids and ranges from `develop.controls` rather than translated labels or widget positions.
|
||
|
||
| Task | Command through `engine.execute` | Result / effect |
|
||
|---|---|---|
|
||
| Read selection and active photo | `library.state {}` | `selection: {ids, active}`; `active` is `null` when no photo is active |
|
||
| Read photo identity and settings together | `photo.inspect {}` | Active photo's `id`, `develop`, rating, flag and history in one reply |
|
||
| Read a specific photo without selecting it | `photo.inspect {id}` / `develop.get {id}` | Photo details / full develop JSON; omitting `id` reads the active photo |
|
||
| Read global slider metadata and values | `develop.controls {section?}` | Array of `{id, label, section, min, max, default, step, decimals, track, value}` for the active photo |
|
||
| Set absolute slider values | `develop.set {values: {"light.exposure": 0.7}}` | Undoable edit of the active photo; also accepts `{control, value}` |
|
||
| Move a relative control | `develop.adjust {control: "light.exposure", delta: 0.1}` | Adds to the current value, clamped to the control's range |
|
||
| Change photo | `library.select {ids: [photoId]}` / `library.next {}` / `library.previous {}` | Changes the active photo; next/previous follow the current view's order |
|
||
| Undo / redo | `edit.undo {}` / `edit.redo {}` | Restore settings; poll again for feedback |
|
||
|
||
With Auto Sync enabled, `develop.set` and `develop.adjust` can also change the other selected photos.
|
||
Only changed settings propagate; spot removal, red eye and the rendering process stay per photo.
|
||
During an interaction, propagation occurs when it ends. Re-read affected photos after the edit.
|
||
`develop.autoSync {}` toggles the mode; it is not a state query.
|
||
|
||
For example, send these requests one at a time and read each reply before sending the next:
|
||
|
||
```json
|
||
{"id":1,"method":"engine.commands"}
|
||
{"id":2,"method":"engine.execute","params":{"command":"photo.inspect"}}
|
||
{"id":3,"method":"engine.execute","params":{"command":"develop.controls","params":{"section":"light"}}}
|
||
{"id":4,"method":"engine.execute","params":{"command":"develop.set","params":{"values":{"light.exposure":0.7}}}}
|
||
{"id":5,"method":"engine.execute","params":{"command":"photo.inspect"}}
|
||
```
|
||
|
||
Replies use `{id, ok, result}` or `{id, ok: false, error}`. In the photo snapshot, exposure is
|
||
`result.develop.light.exposure`; the controls array's matching `light.exposure` entry has `value`,
|
||
`min: -5`, `max: 5` and `step: 0.01`. `wb.temp` is Kelvin on raws with colour matrices and relative
|
||
(6500 = as shot) elsewhere, as described above. Re-read values after edits to reflect the stored result.
|
||
Local mask adjustments are in the photo snapshot's `develop.masks`; use the mask commands from the
|
||
registry (e.g. `mask.adjust`) for those, rather than treating global control values as mask values.
|
||
|
||
Feedback uses polling: the channel sends replies to requests, with no unsolicited change events.
|
||
Compare successive `photo.inspect` snapshots for photo-id or develop-setting changes, including GUI
|
||
edits, photo navigation, presets and undo/redo. GUI slider updates are visible while a drag is in
|
||
progress; ending a changed interaction commits it as one undo step (unchanged drags add none).
|
||
A controller can group `develop.set` / `develop.adjust` updates between
|
||
`develop.beginInteraction {label}` and `develop.endInteraction {}`. The GUI and controllers share
|
||
one interaction: another `develop.beginInteraction` or `library.select` ends the current one.
|
||
Update controller feedback from reads without sending the same values back as new edits.
|
||
|
||
Each request is a separate snapshot. `photo.inspect` keeps identity and settings together; a
|
||
`develop.controls` reply has no photo id, so do not associate it with an earlier cached selection
|
||
when the user is changing photos. Refresh the selection and retry those reads after navigation.
|
||
With no active photo, `develop.controls` returns default values and `photo.inspect` / `develop.get`
|
||
without `id` return an error; check `library.state` before treating defaults as photo feedback.
|
||
Photo ids are local to the current library/session. After reconnecting or opening another library,
|
||
discard cached photo mappings and re-read state; matching photo ids alone do not identify the library.
|
||
For persistent libraries, poll `library.info` for the current `path` as well. Polling observes the state
|
||
at each read, not every intermediate change.
|
||
|
||
The same queries and edits are available through the connected CLI:
|
||
|
||
```sh
|
||
lightcraft-cli run --connect 127.0.0.1:7980 library.state photo.inspect develop.controls section=light
|
||
lightcraft-cli run --connect 127.0.0.1:7980 develop.adjust control=light.exposure delta=0.1 photo.inspect
|
||
```
|
||
|
||
For MCP, use `lightcraft-cli mcp --connect 127.0.0.1:7980`: `run_command` reaches the commands above,
|
||
`list_controls` / `set_develop` provide slider helpers, and `resources/read` polls
|
||
`lightcraft://library`, `lightcraft://photo/active`, `lightcraft://develop/active` and
|
||
`lightcraft://controls` ([mcp.md](mcp.md#resources)). Headless CLI/MCP mode owns a separate engine
|
||
session; use connect mode for feedback from edits in the desktop app.
|
||
|
||
The import review's **Thumbnail size** slider is `slider:importThumbSize` in `ui.widgets`.
|
||
`engine.execute {command: "view.importThumbSize", params: {size: 240}}` sets its size in points
|
||
(80–320, clamped); omitting `size` reads it. Both return `{size}`. Non-numeric or non-finite
|
||
values are refused. The preference is `importThumbSize` in the UI state, independent of the
|
||
library grid's `thumbSize`; resizing keeps the import checkboxes and duplicate markers.
|
||
The shared slider also exposes `sliderValue:importThumbSize` for exact entry and
|
||
`sliderLabel:importThumbSize` for double-click reset to 116 pt. Up/Down nudges the selected
|
||
slider by 1 pt; Return commits typed values and Escape cancels them. Slider and command
|
||
labels, including edit/reset feedback, are localized in every shipped catalog. Previews use bounded physical-pixel buckets through 1024 px. A smaller source is tried once per bucket; scrolling releases offscreen textures, queued work and request identities. A renderable raw uses a render when its camera JPEG cannot cover the tile; preview-only raws, unavailable renders and failed renders retain the available camera JPEG.
|
||
|
||
## Headless rendering (no window, no GPU)
|
||
|
||
The egui UI can be rasterized on the CPU (`crates/ui-egui/src/softpaint.rs`, driven by
|
||
`crates/ui-egui/src/headless.rs`): same tessellation, gamma-space premultiplied blending and
|
||
scissor clipping as egui's GPU backends, so the image matches the window (minus GPU dithering).
|
||
|
||
- **In the running app:** `{"method": "ui.screenshot", "params": {"path": "a.png", "headless": true}}`.
|
||
The UI is drawn into an offscreen context from the app's logic tick, which keeps running when the
|
||
window is occluded or the display sleeps/locks. Windowed screenshots fall back to this after 2 s.
|
||
- **Without any app window:** `lightcraft-cli snapshot` runs a whole app session headlessly.
|
||
Photo development also defaults to the CPU, so the initial frames do not discover a GPU adapter
|
||
or load a graphics driver. `LIGHTCRAFT_GPU=1` does not opt snapshots back into GPU rendering.
|
||
This applies only to the snapshot session: even `--library DIR` leaves the desktop app's saved
|
||
`ui.json` preferences untouched. It answers the same control requests (same handler) from a JSON-lines script:
|
||
|
||
```text
|
||
lightcraft-cli snapshot --demo -o grid.png --size 1600x1000 [--scale 2]
|
||
lightcraft-cli snapshot --library DIR --script tour.jsonl -o shot.png
|
||
```
|
||
|
||
Headless screenshot dimensions must be finite and at least one logical point, with a finite,
|
||
positive scale. Rounded output must be at least one pixel per edge, at most 16,384 pixels
|
||
per edge and 33,554,432 pixels in total (128 MiB of RGBA, the per-image screenshot
|
||
budget). `snapshot` and `ui.resize` reject requests outside these limits before layout or
|
||
rasterization; supplied resize dimensions must be numbers.
|
||
An interface zoom (egui's zoom factor; the keyboard never changes it — ⌘= / ⌘− / ⌘0 zoom the
|
||
photo, issue #566) changes text and control sizes while preserving the snapshot viewport's requested
|
||
physical pixel dimensions. Later `ui.resize` requests use current egui points; validation
|
||
and rasterization both convert them to native viewport points before rounding the output
|
||
pixels. A native edge below one point after zooming out is valid if it rounds to at least
|
||
one pixel.
|
||
|
||
`tour.jsonl` holds one request per line (`#` comments allowed), e.g.
|
||
`{"method": "ui.set", "params": {"view": "detail", "right": "edit", "openSections": ["optics"]}}`
|
||
then `{"method": "ui.screenshot"}`. Replies are printed to stdout. A failed request
|
||
(`"ok": false`) does not stop the script — the remaining lines and the final screenshot still
|
||
run — but the exit status is non-zero when any request failed, as with `run --keep-going`, so
|
||
CI and nightly runs can judge a snapshot by its exit status. A `ui.screenshot` without
|
||
`path` writes `-o` (then `OUT-2.png`, `OUT-3.png`, …); `ui.settle {timeoutMs?}` waits until no
|
||
renders are in flight. Each request runs frames until it is answered and its injected input
|
||
(clicks, keys, drags) has played out. Widget ids for `ui.clickWidget` come from `ui.widgets`
|
||
(e.g. `button:upright-auto`). A 1600×1000 demo snapshot takes ~1–2 s (debug build).
|
||
|
||
## Import album picker
|
||
|
||
In the Import Photos review, `albumPicker:import-album` opens the destination picker.
|
||
`albumPickerFolder:<folder-id>:import-album` expands or folds a folder; it never chooses a destination.
|
||
`albumPickerItem:<album-id>:import-album` selects a regular album. The picker follows sidebar order,
|
||
opens the current choice's ancestors and shows its full path on the closed button. Smart albums
|
||
are not import destinations. Type into `albumPickerSearch:import-album` to search folder/album paths;
|
||
↑ / ↓ and Enter pick a search result, and Escape closes the picker while keeping the review open.
|
||
If the chosen album is deleted while the review is open, the destination returns to None before
|
||
confirmation instead of submitting the deleted ID.
|
||
|
||
`importAlbum:none` clears the destination and any pending new album name. `importAlbum:new` keeps
|
||
or initializes that name, edited through `field:importAlbumName`. Choosing an existing album clears
|
||
the pending name. `ui.dialog.confirm` starts the same import into the selected album ID; engine
|
||
`library.import` parameters `album` and `albumName` are unchanged.
|
||
|
||
## Panel docking
|
||
|
||
Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences as a craft-ui docking workspace (`docking`: `version`, `layout`, `hidden`, `saved`, `locked`); settings written before workspaces had a `version` load as version 1. These operations do not edit document contents.
|
||
|
||
Panel IDs: `library`, `tools`, `presets`. The protected center is `canvas`.
|
||
|
||
`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|tab|left|right|top|bottom|above|below`; `Center`, `Left`, `Right`, `Top` and `Bottom` also parse, while `tab`, `above` and `below` are lowercase only). Center and tab group with the target; side zones split beside it; above and below put the panel in a new group above or below the target's group. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Both commands return `{"docking": workspace}`.
|
||
|
||
Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tools","rect":[100,100,320,440]}}`.
|
||
|
||
Advanced `ui.dock` operations use the same validated transaction as pointer gestures:
|
||
`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and
|
||
`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`),
|
||
`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null.
|
||
A center `move` can include `before` (panel ID or null to append) for tab order.
|
||
The equivalent `{"action": ...}` form accepts a serialized shared docking action;
|
||
this is also the form emitted by the renderer. Protected-canvas and app-specific panel
|
||
rules apply equally to both forms.
|