import_reads_files_off_the_ui_thread_and_can_be_cancelled: the probe was
a 150 ms sleep judged by a 300 ms wall-clock frame budget, which the
loaded FreeBSD QEMU VM can miss. It now waits on the sleeping-NAS gate of
tests_offline, which can also let a counted number of reads through:
frames must go on while every read is held (a watchdog records a frame
that waited), one batch is let through and joins the catalog, then
Cancel and wake: the files being read finish and nothing new starts.
compare_at_one_to_one_gets_a_sharp_window_per_pane ("both originals stay
decoded"): the media cache takes its budget from the process-wide memory
budget, which settings_dialog_by_keyboard_and_clicks sets to 1 GB for a
moment (and left there when it failed on FreeBSD). That leaves a 512 MiB
share, too small for two 24 MP originals (288 MB each), so one pane's
was evicted. compare_at now gives its session the default 768 MiB share;
LIGHTCRAFT_MEMORY_MB=1024 reproduces the failure without this.
dropping_the_lock_frees_the_library_while_another_thread_spawns_processes:
"the spawning thread never got a child off the ground" when a spawn took
longer than the 2 s of lock cycles. The cycles now go on until at least
5 children were spawned during them (at most a minute).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Photo ▸ Photo Merge ▸ HDR for Each Bracket Set (`merge.hdrBatch`) merges many brackets in one go with the last HDR
options, as Lightroom merges a selection of stacks: one HDR per stack among the photos, then per bracket among the
unstacked ones (`Catalog::bracket_sets`: photos taken less than about a second after the previous one's shutter
closed, at exposures at least ½ EV apart; bursts at one exposure and earlier merge results are left out). The app runs
the merges one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); the
command runs them inline for the CLI and MCP (`lightcraft-cli merge hdr-batch FILES…`) and reports merged and failed
sets.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Catalog format v9 adds Photo.preview_checked and Op::SetContent.preview_checked:
the app version (CARGO_PKG_VERSION) whose decoder set preview_only, empty when
it isn't set. Import and content_op (Reload, Relink) stamp it; undo swaps it
back. Catalogs from before v9 read an empty stamp, so their preview-only photos
count as stale (Photo::preview_only_stale) and will be rechecked once.
The version guard test now also accepts a format version that only adds fields.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Held-out and library-wide calibrate runs on real CR3s (calibrate's CR3 /
CR2 input is #1073): profiles change little (R7 2.52 -> 2.57, R6 Mark II
6.12 -> 6.09 mean dE), so none is bundled; notes the CRX adaptive QP
172/175 files that stay preview-only. The catalog test pins that an EOS
R6 Mark II (Exif "Canon EOS R6m2") edits in Kelvin through its measured
matrices and an EOS R7 stays relative.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzWyUn77ZWLe3q3qMiQbVt
A photo browsed in a Local folder is flagged deleted by photo.delete, but
Filter::matches_in dropped every browsed photo from views without a folder,
so it vanished from the folder and never reached Recently Deleted: it could
not be restored and sat in the catalog invisibly (a deleted record is also
never forgotten as an untouched Local one).
Deleted browsed photos now match the Recently Deleted filter, so they can be
restored; the library view still excludes browsed photos.
Sony ARWs of cameras without measured colour matrices were edited on the
relative scale (6500 K / 0 = as shot). Their SR2SubIFD stores the
multipliers of the camera's presets at 2500, 3200, 4500, 6000 and 8500 K;
lightcraft_raw::kelvin_presets reads the as-shot multipliers on the curve
through them and they take the spectral matrices' place in the #730
design (as-shot Kelvin and tint only; look, Bradford adaptation and the
pixel-identical legacy upgrade unchanged).
Covered: ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S.
Kelvin-dial shots read back 3400 -> 3396, 5200 -> 5193, 4700 -> 4708,
5600 -> 5594 K; Shade 7493 K. Off-curve constants (isotherm tilt 0.74,
tint spacing 1) measured from the 14 Sony bodies with both spectral
matrices and presets.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The spawning thread ran /bin/true, which is /usr/bin/true on macOS, so
it never spawned a child and the test failed there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Go to History Step (and new Previous/Next History Step commands) loads the
step's settings and moves a per-photo cursor (`Photo.history_cursor`,
`Op::SetHistoryCursor`, catalog format v8; older catalogs open at the latest
step) instead of appending "History: …". Later steps stay listed, greyed in
the History panel, and can be returned to; a new edit made behind the end
drops them first. Each move is one undo step; undoing an edit restores the
dropped steps and the cursor. `history.list` returns `cursor`.
Also: spots.rs uses as_chunks (clippy chunks_exact_to_as_chunks).
Sources: own design (Lightroom Classic / Photoshop History panel behaviour, observed).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review follow-up on the keyword sidebar fix:
- The skip is a shared helper, `out_of_sight`, that Keywords and Folders
call before a row's work; a row out of sight still takes its room, is
listed for the control channel (agents and tests find it as before) and
asks for its measured width, so the content no longer widens and
narrows as rows scroll by.
- The scroll to a keyword made from the header is a per-frame request
for any row id (`SCROLL_TO_ROW`), set before the sections are drawn and
cleared after: no frame stamp, nothing left behind.
- keyword_rows/folder_rows are skip-or-draw then recurse (the drawn row
is keyword_row/folder_row); leaves no longer look up an open state.
- keyword_tree builds children lists as levels are made and sorts by the
lower-case name it already computed; empty_hint uses ROW_HEIGHT; the
test reuses tests_panels::scroll_sidebar.
58,000 photos / 4,000 keywords, release: 1.6 ms per unchanged frame
(7.4 ms before the fix), keyword_tree 14 ms (156 ms).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cause: the sidebar's Keywords section drew every keyword row on every
frame (layout, text measuring, widget registration, hover text, context
menu), even the thousands out of sight, and every catalog edit rebuilt
the keyword tree from all photos with several String allocations per
keyword level. On a 58,000-photo library with 4,000 keywords an
unchanged frame took 7.4 ms in the sidebar alone and the frame after a
rating 220 ms (156 ms of it the tree), release build on Apple Silicon.
Fix:
- Keywords rows out of sight only take their height (open children are
still walked, so the layout is unchanged), as the grid already does
(#35). A keyword made from the header's + is always drawn and the
sidebar scrolls to it, so it is in sight even at the end of a long list.
- Catalog::keyword_tree works out each distinct keyword string's levels
once and then only counts (a per-level "last photo" stamp replaces
the per-photo HashSet of Strings).
Now: 0.55 ms per unchanged frame (7 rows drawn instead of 4,000),
keyword_tree 17 ms, the frame after a rating 44 ms.
Test: tests_keyword_sidebar::keywords_section_draws_only_the_rows_near_
the_screen (failed before: "2000 of 2000 keyword rows drawn"; now the
rows near the screen, and scrolling reaches the last keyword), an
ignored timing probe at the report's library size, a catalog test for a
keyword repeated on one photo, and the_keywords_header_creates_a_keyword
now checks the new keyword is in sight.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sources: TIFF 6.0 and own black-box analysis of the three CC0 samples;
white-balance estimate from the embedded preview: own design.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
desktop_exec treats a freedesktop Exec path as absolute when it starts
with '/' on every platform (Path::is_absolute wants a drive letter on
Windows); fill_box uses as_chunks (clippy::chunks_exact_to_as_chunks);
LibraryLock::descriptor_copy is cfg(all(test, unix)) like its only test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Photos that tie on the sort key (equal ratings, one import batch, a
burst within a second) now sort by file name before id, in
Catalog::query, Recently Added and auto-stack. Split out of #641.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Raws without matrices in the file (ARW, NEF, RW2, RAF, CR2, CR3, PEF, SRW,
ORF) were all edited on the relative white-balance scale (6500 K / 0 = as
shot), even when lightcraft_raw::spectral has measured matrices for the
camera. Those 52 bodies (ILCE-7M3, ILCE-7M4, D850, EOS 5D Mark III...) now
edit in Kelvin like a DNG and like Lightroom.
Design:
- One decision on both sides: spectral::find_camera("Make Model") backs
catalog::relative_wb_camera (Photo::relative_wb); files::WbScaleOf decides
the same from the decoded file. A corpus test asserts they agree.
Photo::fitted_look keeps the old predicate for smart previews.
- The matrices only convert: As Shot is the file's multipliers read through
the model's ColorMatrix1/2 (spectral::as_shot_white_through). The look
(JPEG fit / profile / spectral fallback) is unchanged, and the slider
adapts from that white with Bradford. The camera-space rebalance stays
reserved for files with their own matrices.
- Presets resolve to their Kelvin values (WbMode::preset, sources cited).
- The Color panel shows the white balance in effect (effective_wb).
- XMP: crs::Target::for_file takes the camera; matrix cameras read
crs:Temperature as Kelvin (marked "scale": "kelvin"), others keep the
#510 as-shot shift.
Migration (no look changes):
- WhiteBalance.scale: WbScale { Legacy (absent in JSON), Kelvin }. A
white balance without a scale keeps its old meaning. On a spectral-Kelvin
raw (SourceInfo.legacy_relative_wb) the pipeline composes the old
adaptation (6500 -> value) with the new one (as shot -> ...) in Bradford
cone space (local::legacy_white). Von Kries scalings compose exactly, so
the matrix matches to f32 and the pixels are identical
(pipeline legacy_relative_white_balance_renders_as_before_on_a_kelvin_raw,
media old_relative_white_balance_on_a_kelvin_camera_keeps_its_look...).
- Every white-balance writer (develop.set/adjust/quickAdjust/wb/wbPick/
reset) first runs local::normalize_wb: a legacy value becomes the Custom
Kelvin value with the same look and is marked Kelvin. Partials without a
scale (old presets, develop.merge) stay legacy.
- Catalog format 7: Op::SetAsShotWb replaces the 6500/0 as-shot recorded
by older imports when the file is next decoded or reloaded (system op,
not an undo step). Format-version test updated.
- The web wire carries legacy_relative_wb.
Docs: parity (LR-EDIT-COLOR-TEMP), camera-preview-colour, xmp-interop,
lightroom-catalog-import, control-protocol, mcp, whats-new.
LibraryLock freed the library by letting its File close. On Unix the
flock lock belongs to the open file description and lasts while any
descriptor on it is open. When another thread spawns a process,
posix_spawn copies the whole descriptor table into the child and
O_CLOEXEC only closes the copies at execve, so a LibraryLock dropped in
that window stayed locked until the child exec'd. Re-acquiring the
library then failed with LockError::InUse(None) ("already open in
another LightCraft program") although nobody had it open. That is the
flake in tests_lock::second_opener_is_refused_until_the_first_lets_go,
whose neighbour lock_held_by_another_process_is_released_when_it_dies
spawns the test binary on a parallel libtest thread.
Drop now removes the owner note while still holding the lock (so it
can't delete the next holder's), then calls File::unlock(): LOCK_UN
through our descriptor frees the lock whatever copies a child still
holds. An unlock error is logged, never a panic; the handle still closes
right after, which releases the lock once every copy is gone, as before.
Tests: the_library_is_free_once_dropped_even_if_a_child_holds_a_copy
holds a try_clone of the lock's descriptor across the drop (the same
open file description a child gets between fork and exec) and
re-acquires; dropping_the_lock_frees_the_library_while_another_thread_
spawns_processes runs a thread spawning /bin/true against two seconds of
acquire/drop cycles. Both fail every run without the fix (the first
deterministically) and pass with it; the flaky pair went from 3/100 to
0/100 failed runs here. Both are cfg(unix): Windows handles aren't
inherited by CreateProcess.
tests_lock::second_opener_is_refused_until_the_first_lets_go failed now and
then on macOS with InUse(None): the child process that
lock_held_by_another_process_is_released_when_it_dies spawns briefly holds
copies of the test process's file descriptors, lock files included, until its
exec closes them, so a lock dropped in that moment stays held. The three tests
that hold a lock in the test process now take one mutex. Under parallel load:
6 of 400 runs failed before, 0 of 400 after. Test-only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ORF decoder supplies the as-shot white balance (ImageProcessing
WB_RBLevels) and no colour matrix, like ARW, NEF, RW2, RAF, CR2/CR3, PEF
and SRW, but ORF was left out of camera_preview::file_local_look and the
catalog's relative_wb, so it rendered with the neutral fallback matrix:
flat, grey and dark. It now gets the same guarded fit to its camera JPEG
and white balance relative to the as-shot look. Corpus (held-out RMS
against the fallback, all accepted): E-1 0.126 -> 0.014, E-400 0.087 ->
0.022, XZ-2 0.061 -> 0.023; compressed ORFs still open from their preview.
Against Lightroom Classic (CC0 E-1 kit, 2000 px, ΔE00): unedited render
11.25 -> 8.30; mean over 86 variants abs 12.10 -> 9.48, effect 3.44 ->
2.80. Every other kit's unedited render is byte-identical.
RENDER_CACHE_VERSION 23.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>