The pending counter was decremented when the job's guard dropped at the
end of the loop iteration, after the reply was sent, so a waiting caller
(CLI / MCP mask.add) could see the segmenter still busy right after its
answer arrived. Under a loaded test run that made
tests_segment::waiting_requests_with_a_damaged_model_are_errors fail on
assert!(!busy()). The guard is now dropped just before each reply.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
After an undo or redo that changed one photo (its edit, rating, flag,
label, metadata or versions), that photo becomes the active one, as in
Lightroom Classic, so the change is on screen instead of happening to a
photo out of sight. It joins the selection if it isn't in it; steps that
touch several photos, albums or files, and photos outside the current
view, leave the selection alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
#333 passed imported LUT names and folder names through tr(), so a LUT
named like a message ("Vivid") was shown translated; they are now shown
as the file names them (builtin_label / new profile_label, as for user
presets). A LUT folder named like a built-in group ("Film") shared that
group's menu widget id and the browser section's open state; both are
keyed apart now. Restores the "Amount 0 = the photo as it was" assertion
#333 replaced, and adds a What's new line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
#287 made app.export refuse unknown params. Stored settings (a user export
preset, prefs.json lastExport) written by another version are not the
caller's typo, and refusing them would break that preset or Export with
Previous for good after an update: their unknown keys (also inside
watermark) are dropped before the strict check. What the caller passes is
still checked strictly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
lightcraft-cli installed no `log` backend, so every log::warn!/info! record
(the GPU-to-CPU fallback, an unknown LIGHTCRAFT_GPU_BACKEND, the adapter
chosen) was dropped and RUST_LOG, which docs/gpu-pipeline.md documents for
the fallback warning, did nothing. The desktop app had its own logger that
read only LIGHTCRAFT_LOG.
Both now install lightcraft_engine::logging: warnings and errors on stderr
by default; LIGHTCRAFT_LOG or RUST_LOG (off, error, warn, info, debug,
trace) picks another level, LIGHTCRAFT_LOG winning, and RUST_LOG's
per-target directives (`lightcraft=debug,wgpu=warn`) set LightCraft's
level. Below warnings only LightCraft's own records are shown, as the app's
logger did. The app's --help lists the variable.
Tests: level parsing and target filtering (engine logging::tests); the CLI
prints "LIGHTCRAFT_GPU_BACKEND=bogus names no known backend" with
RUST_LOG=warn and nothing with RUST_LOG=off (cli.rs).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3vVft4VXvvy8SpbbUceTW
Review of the onDeleted work found that purging photos one at a time built each
op list against the unchanged catalog, so photos sharing an album or a stack made
Empty Recently Deleted (and Fresh imports) fail with NoStack or leave dangling
album entries. Catalog::delete_photos_permanently_ops builds one list for all of
them; Empty, Delete Permanently and the Fresh purge use it. Photos an import
restores now take part in album/albumName/selection, and undo labels say Restore.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A file that is in Recently Deleted was always skipped as a duplicate with no way
forward (#298). The report now flags such duplicates (existingDeleted); onDeleted
restores the trashed photo with its edits or replaces it with a fresh import; and
Empty Recently Deleted removes every trashed photo in one undoable step.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
crop.drag {handle, from, to, start?} runs drag_crop through the command
layer so the UI, CLI, control channel and MCP agree; a locked crop keeps the
shape of its frame. crop.aspect now rejects non-finite, non-positive and
beyond-20:1 ratios and non-numeric arrays instead of storing a degenerate
lock that produced an empty crop.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- ui: counts stay at the visible edge when the sidebar scrolls sideways
(with the row's colour behind them where a long name runs under); the
smart-album dialog's live count includes the album's folder
- catalog: a folder path is used as given (a name with a space at its edge is
not its neighbour: Remove acted on the wrong folder); a plain POSIX path
is checked against the folder without building a key (about half the cost
on a big library, with a test that it agrees with the careful check)
- engine: editing a smart album's rules keeps the folder the editor cannot
show; a shown folder that loses its last photo gives way to All Photos;
library.state and view.json name the folder only while it is shown; a blank
folder filter is no filter
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- engine: a smart album made from a folder view keeps the folder; only a
folder that shows exactly its own photos can be the source (not the
startup disk, not a folder above disks, not one with no photos); a
folder source and a folder filter never coexist; a folder source with no
folder shows nothing; a saved view whose folder is gone opens on All
Photos; removing a folder above a disk needs `disk: true` even with one
disk mounted; library.folders documents volume/selectable
- ui: deep chains stop indenting after five levels, the grid title gives
way to the photo count (full name on hover), disclosure triangles have
spoken names, the remove dialog says what is included and has a disk
title; test temp folders are cleaned up on failure
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- catalog: a row is exactly one folder (no folding of single-child levels);
the UI starts a folder that holds nothing and leads to one folder open
- engine: a library folder is a source (LibrarySource::LibraryFolder with
Session::library_folder, like Local's browse): library.source
{kind: libraryFolder, path}, kept with the library's view, shown by
library.state, followed through folder.rename / folder.move and undo,
dropped back to All Photos when its folder is removed
- ui: clicking a folder replaces whatever the grid showed (Picks, Recently
Deleted, a Local folder…) instead of narrowing it with a filter chip; the
grid is titled after the folder
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Another way to explore the library next to By Date and Keywords: a tree of
the folders that hold imported photos, with counts (subfolders included),
under the disk or network share each is on. Local stays the way to browse
any folder on disk.
- catalog: Catalog::folder_tree and Filter::library_folder. Each disk is a
volume row (drive letter, Windows share, /Volumes/<share>, /media, /mnt,
gvfs, /net…), found from the stored paths alone, so an offline share costs
nothing. Levels that hold nothing and lead to one folder fold into one row;
depth is bounded. The tree and the filter read paths through one splitter
(separators, `.`, `..`, verbatim UNC prefix, repeated separators), and a
row that would cover other disks' photos is not choosable
- engine: library.folders, library.removeFolder (to Recently Deleted, one
undo step, no file touched; a whole disk or a folder spanning several
needs `disk: true`, the startup disk never), libraryFolder in
library.filter, chip; a chosen folder follows folder.rename / folder.move
and their undo
- ui: collapsible Folders section; the chosen folder's row is always opened
into view; right-click (row or triangle) Rename, Move, Show in Finder,
Remove from Library…; disk rows Remove Disk from Library…; both ask first
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`ExportOptions::from_json` reads what it recognises and defaults the rest, so
`longEdgee=400` exported at 3000 px, `longEdge=banana` at full size, and a
`watermark` of `5` or `{"text":"x","bogus":1}` passed with `ok:true`, while
`size: 3` was silently clamped to 0.5 of the short edge (#181, #183).
`from_json` stays lenient for stored prefs and dialog prefill. New
`ExportOptions::validate(cmd, params)` / `from_params(params)` check every key
against the option and target lists (unknown ones name the nearest known key),
every value's kind and range, and a `watermark` object's keys, kinds and ranges
(`size` 0.005..0.5 and `inset` 0..0.4 of the short edge, `imageWidth` 0.01..1 of
the width, `opacity` 0..1, `color` [r,g,b]), returning
`invalid parameters for \`app.export\`: …` like the other commands. The three
places that run an export use it — the desktop `app.export`, the headless
backend behind `lightcraft-cli run` and the MCP `export` tool, and
`lightcraft-cli render --opt` — and `export.savePreset` validates a preset's
params when it is saved. Built-in presets, the Export dialog's `to_json`,
Export with Previous and `render --opt` all pass unchanged.
The `app.export` param string (`lightcraft-cli commands`, `engine.commands`),
the MCP `export` tool schema and docs/parity.md LR-EXP-WATERMARK now list the
watermark keys, their units and ranges.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`select_photos {"ids": [9999]}` answered `selected: 1` and left the session
with no usable active photo; `develop.get {"id": 9999}` answered too.
`library.select` now checks every id (and `active`) against the catalog
before touching the selection and returns `no such photo N`, so the
previous selection and active photo stay in place; `photo_arg` (develop.get,
photo.inspect, photo.allMetadata, history.list) does the same for an
explicit `id`. Engine and MCP tests cover every select mode.
Fixes#182
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sony A7R II camera JPEGs are distortion-corrected by default while the raw is not. On the public
CC0 ILCE-7RM2 samples the JPEG is up to 15 px of 1440 off, and the misaligned window frames alone
pushed the held-out RMS to 0.108-0.115 (gate 0.10): the photo opened in the grey neutral fallback,
13 L* too dark. The same shot with distortion correction off fits at 0.059.
When the fit on all pixels fails the gates, try it once more, with the same gates, on pixels whose
3x3 neighbourhood in either proxy spans a luminance ratio of at most 3. Fits that already pass are
unchanged (29 other corpus ARW/NEF/RW2 files render pixel-identical). ILCE-7RM2 ΔE76 vs the camera
JPEG (registered) 16.6-16.9 -> 3.7-3.8; corpus GX80 19.1 -> 7.9. RENDER_CACHE_VERSION 11 -> 12.
The RW2 reader reads PanasonicRaw tag 0x0119 (16 LE words) and expresses the
camera's own correction as the equivalent DNG WarpRectilinear in OpcodeList3,
so embedded_lens() turns it into the existing EmbeddedLens/EmbeddedWarp and
"Enable Profile Corrections" applies it like a DNG's warp (no new warp path;
Convert to DNG keeps it). Rule, from our own measurements against the
embedded camera JPEGs (clean-room; ExifTool tag names only):
corrected = rho*(1 + (w8 rho^2 + w4 rho^4 + w11 rho^6)/32768), rho = r/w12
(w12 = active-area half diagonal), output = corrected / (1 + w5/32768),
on when (w7 & 0xf) == 1. Chosen on 38 GX85 photos, held out on 35 photos
from 12 other bodies / 9 other lenses; corrected renders line up with the
camera JPEG within 0.75 px at 1440 px at the corners (up to 13 % of the
half diagonal before). Implausible values leave the photo uncorrected.
The loader now removes lens opcodes before the RW2 camera-look fit (its
binned proxy needs an empty OpcodeList3); Opcode::is_lens_correction()
replaces the duplicated matches. RENDER_CACHE_VERSION 11 -> 12.
Options::token_env (default none) replaces the hard-coded LIGHTCRAFT_SAM3_TOKEN, so a download of one model can
never send another model's token to its host. SAM 3 names its variable through lightcraft_segment::fetch::options().
The decision (non-empty, https, the host the download began at) is a small function with its own test.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
load_bytes_now timed the binned demosaic with std::time::Instant, which
panics on wasm32-unknown-unknown ("time not implemented on this
platform"). Opening a raw in the web build aborted the decode. Use
web_time::Instant, as library.rs already does.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Reopening a library (or opening another one, or set_disk_cache_bytes
falling back to attaching) replaced MediaCache::rendered with a new
PreviewCache, but render jobs still in flight kept the old object.
Each PreviewCache checked only its own generation, so after a clear on
the new cache the old one's generation was still current: a finishing
thumbnail job wrote its pixels through it into the directory the clear
had just emptied. The UI rejected the result, but the JPEG stayed.
attach_disk_cache now retires the cache it replaces before swapping it:
PreviewCache::retire takes the generation write lock (so it waits for a
write in progress, like clear) and marks the object retired; every
get/put/put_deferred through it is then refused and its clear() is a
no-op, as its directory may now belong to the replacement. Files on
disk are left alone, so a plain reopen still finds its thumbnails, and
resizing the budget in place keeps the same cache object as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>