A GPU render no longer hands back an image it cannot vouch for:
- set the wgpu device-lost callback (wgpu never reports a lost device as an
error, so a driver reset went unnoticed); readback waits with a timeout and
checks the poll result and map callback instead of blocking forever
- each render runs in OutOfMemory/Validation/Internal error scopes; errors
fail that render (OOM and limit failures for that render only, device
errors for the process) and it is redone on the CPU
- every buffer is checked against the device limit when created (no
validation error, no process-wide disable); LIGHTCRAFT_GPU_LIMITS=webgpu|<MiB>
creates the device with lower limits to reproduce small adapters
- the output buffer is cleared and the per-pixel kernel writes alpha 255:
any unwritten pixel (work silently dropped, the issue's all-black export)
fails the render; an all-black result is re-rendered on the CPU
- renders submit per stage and every 16 M invocations, the per-pixel stage
runs in ~4 MP bands: no long submission for a slow iGPU's watchdog to reset
- ui.inspect perf.gpuReason / perf.gpuFallback, app.gpu reason/lastFallback,
System Info and Settings say why the GPU is off (e.g. llvmpipe skipped) and
why the last render fell back
Tests: crates/gpu/tests/fallback.rs (limit, mid-render limit, validation
error, dropped work, black photo), engine export falls back to the CPU image,
banded 3000x2000 GPU/CPU equivalence.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Local browse records can be most of a large catalog (85k photos, 90 %
Local: 313.5 MB snapshot). Untouched Local records of folders whose last
browse is older than library.preferences forgetLocalDays (default 30,
0 = never) are now removed from the catalog when the library opens (and by
library.forgetLocal {dryRun?, days?}), as journaled, non-undoable ops, then
compacted in the background. Files and XMP sidecars are never touched;
browsing the folder again catalogues them afresh.
Untouched = a Local file record, not deleted, not a virtual copy, whose
state still hashes to the fingerprint taken when the browse catalogued it
(any change keeps it); records from before fingerprints need every
user-editable field empty/default. Kept too: records in albums (or as
cover), stacks, sources of virtual copies, visible/selected/edited ones,
ones referenced by undo/redo, ones with a smart preview.
Per-folder last-browsed times live in the catalog (Op::SetBrowsed,
normalised with folder_key; re-stamped at most hourly). Old catalogs load
unchanged; folders without a time are stamped now, so nothing is forgotten
on the first open after the upgrade.
persist_bench (85k photos, 76.5k Local, every 7th folder recent, 1 in 50
rated): 63.8k forgotten, plan 162 ms, snapshot 313.5 MB -> 94.9 MB.
Settings -> Performance -> Local folders.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A command whose journal append fails now returns EngineError::NotSaved
("saved in memory but not written to disk: <reason>; LightCraft will retry")
instead of OK. The change stays applied and undoable, its ops stay queued and
are written by the next save (any command, or the frame loop with a 2 s
backoff). Queries and commands that change nothing are unaffected; a failed
compaction is reported in lastError, not as a failed command (and a failed
synchronous snapshot now backs off like a background one).
Session::unsaved(), library.info unsavedOps/unsavedError, ui.inspect unsaved;
the desktop UI toasts the failure and shows a warning on the top bar's cloud
icon until a save succeeds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An unchanged grid frame used to copy every visible id, hash them twice
(date-run and layout cache keys), clone the date runs, build a full
Vec<Cell>, rebuild the stack index, loop over every cell for the
viewport test and, in Local folder views, look up every photo to count
the browsed ones for "Add N to My Photos".
- engine: Session keeps the visible list as Arc<[PhotoId]> with a
process-unique generation that changes whenever the list is
recomputed; `visible_shared()` hands both out without copying.
- grid: date runs, layout, stack index and the Local count are cached
by that generation (GridCache; replaces the id-hashing date_runs and
grid_layout caches). The layout gains a row index (GridRow), so the
visible + prefetch rows and the date headers are found by binary
search; the sticky header too.
- GridStats counters (frames, cells visited, view/layout/stack-index
builds, last frame time) for the bench and tests.
Measured with the new `cargo run --release -p lightcraft-ui-egui
--example grid_bench` (synthetic library, 1400x900, thumbs 160, missing
files so no decoding is timed, scrolled to the middle, 120 unchanged
frames; machine at load ~200-250, so medians of the whole app frame,
before/after interleaved in the same session):
photos layout source median frame ms allocs/frame cells visited
before -> after before -> after before -> after
200 photo all 1.29 -> 1.28 4747 -> 4729 200 -> 93
200 square all 1.09 -> 1.09 4771 -> 4753 200 -> 74
10 000 photo all 1.52 -> 1.41 5425 -> 4911 10000 -> 100
10 000 photo local 1.90 -> 1.42 5475 -> 4948 10000 -> 100
10 000 square local 1.62 -> 1.07 5365 -> 4838 10000 -> 76
85 000 photo all 2.41 -> 1.29 8996 -> 4729 85000 -> 93
85 000 square all 2.29 -> 1.08 9020 -> 4753 85000 -> 74
85 000 photo local 17.6-34.0 -> 1.32 9049 -> 4766 85000 -> 93
85 000 square local 16.2-25.0 -> 1.10 9073 -> 4790 85000 -> 74
Frame cost no longer depends on the number of results; what is left
(~1 ms, ~4.7k allocs) is drawing the ~75-100 cells near the screen.
Headless snapshots of the demo grid (justified with a sticky header,
after keyboard navigation, square) are byte-identical before/after.
Tests: layout rows cover every cell in order and the binary search
matches a linear scan (square + justified, groups); a 3000-photo
headless library checks that unchanged frames build nothing and visit
< 300 cells, plus date headers, scroll-to-selection, stack badges,
keyboard navigation and a rotation re-laying the grid.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
library.import mode `move` (UI: Transfer ▸ Move, MCP import tool): reuses
Copy's destination, folder organisation (incl. custom templates) and rename
template. Each photo and its XMP sidecars (both namings) are placed without
touching the source (hard link on the same volume, else a new file that is
copied, synced and compared byte for byte; taken names get -1, -2…, never
overwritten). Sources are removed only after the catalog records are
committed and saved, and only when the destination is still intact; failed,
duplicate, unchecked and in-library files keep their sources; sources that
can't be removed are kept and reported (`kept`). Undo removes the catalog
records and leaves the moved files at the destination.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GPX 1.0/1.1 reader in lightcraft-meta (timed track points per segment; interpolation only within a
segment, nearest point within maxGap otherwise). Engine command photo.autoTagTracklog (path or gpx
text, camera time-zone offset, maxGap, replace, dryRun; one undo step) and Photo ▸ Auto-Tag from
Tracklog… with a time-zone prompt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- import / merge polling: let-else on the task instead of expect("task").
- the merge worker catches a panic and reports "the merge failed
unexpectedly" instead of dropping its channel, which left "Merging…"
up forever.
- web: window() returns Option; its ten callers handle a missing window.
- lightcraft-ui-egui and lightcraft-web now #![deny] unwrap/expect/panic/
unimplemented/todo/unreachable; clippy.toml as in the steering PR.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rust 1.99 (stable since 2026-09-28) adds clippy::chunks_exact_to_as_chunks and widens
clippy::question_mark, which failed `cargo xtask ci` on main. Same behaviour: the
remainder that chunks_exact skipped is the remainder as_chunks returns separately.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
File now opens with Import Photos… (Cmd+Shift+I), Import from Folder… and
Import from Device (command ids file.addPhotos / file.addFolder /
file.addFromDevice unchanged; library.import's label is Import Photos).
The review dialog is titled Import Photos, names its source (a scanned
folder is not added to Local; Local -> Browse Folder... is the persistent
location), labels the transfer mode and explains Add in place vs Copy, and
its button reads Import N Photos.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Selecting a mask added the Add/Subtract/Intersect/Invert/Delete row in
one non-wrapping horizontal layout (~273 pt in a ~220 pt column), and the
Create New Mask grid needed 226 pt: the panel's contents grew wider than
the panel (a 250 pt panel came out 275 pt wide) and were drawn
shifted left, clipped by the photo area. The action row now wraps, the
tile grid sizes its tiles and columns to the available width (four tiles
of 48–52 pt, else three), and a component's name is truncated before its
options button. A headless test checks that every widget stays inside the
right panel at 250, 270, 330 and 520 pt, with and without a selected mask
and with the brush settings open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
library.import organize now also takes a folder template, e.g.
{date:%Y}/{date:%Y%m%d} -> 2026/20260114 (Organize::Template). The
template's own / (or \) make the folder levels; each level is expanded
with the rename tokens and sanitized, so metadata can never add a level or
climb out of the destination; absolute paths, drive letters, ~ and . / ..
levels are refused. Dated by capture time, else the import time; a level
whose metadata is missing becomes 'unknown'. The import dialog's Folders
menu gains 'Custom template...' with a template field, tag help, live
validation and an example full destination for every mode; the dialog
stays open (with a toast) when the import is refused. The MCP import
helper passes mode/destination/organize/rename through (docs/mcp.md).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both side panels had an exact width. They are now resizable egui panels
whose width lives in UiState (leftWidth / rightWidth, serde, sanitized on
load) so it survives panel switches, view changes and restarts; egui's
own remembered size is dropped each frame and only a drag of the edge
writes back (measured from the fixed edge to the pointer, so content
wider than the panel can't creep it). Limits: left 200–480 pt, right
250–520 pt, and the photo area keeps at least 360 pt; a narrower window
shrinks the panels without forgetting the chosen width. The bottom bar,
which sits between the panels, now drops Copy/Paste Edit Settings and
then the rating pill when its centre has no room instead of overlapping
the zoom controls. The unused left_w theme token is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Browsing a folder inside a listed Local location (built-in or kept) no
longer adds it as a root of its own: it is highlighted inside that
location's tree, which opens down to it once per browsed folder, so its
ancestors and siblings stay reachable. A folder outside every listed
location gets a session-only row that stays while browsing below it and
offers Keep in Local; subfolder rows offer Add to Local. New UI command
local.addRoot keeps a folder in Local (saved with ui.json, so it
survives restarts; it un-hides it too); Browse Folder... uses it.
Removal stays sidebar-only (local.hide).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rename::TOKENS / DATE_DIRECTIVES / TEMPLATE_NOTES are now the one list of
file-name template tags (a test checks expand_tokens knows each, and that
the rename/import command descriptions mention them all). A Tags toggle
beside the import Rename field (also Rename Photos and export File name)
lists every tag with its meaning and a live example, the {date:...}
directives and the rules (extension appended, blank keeps names, missing
date/metadata, unknown tags stay literal); clicking a tag inserts it at
the text cursor. Unknown tags are flagged under the field. New query
command photo.renameTokens returns the same help as JSON. The import
preview now uses the probe's metadata and the import time for undated
photos, as the import itself does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Local compared folder paths as raw strings, so a built-in location built
as "{home}/{sub}" and the same folder returned by library.browse with
native separators could be listed twice. Folder identity now goes
through catalog::query::folder_key (both separators, trailing/doubled
separators, ./.., verbatim and UNC prefixes, drive-letter case; whole
path case-folded on Windows): the sidebar's root list, the highlighted
and expanded-on-the-way folder, saved Browse Folder roots and
local.hide / local.restoreHidden all compare by it. Built-in paths are
joined with the platform separator.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Catalog Meta gets copyright_status (unknown / copyrighted / publicDomain), usage_terms and
copyright_url, read and written as XMP Rights Management fields (xmpRights:Marked, UsageTerms,
WebStatement) on import, sidecars and exports (kept by "copyright only"). photo.setMeta,
copy/paste metadata, metadata presets, a Copyright Status smart-album rule and the Info panel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The album rows read their counts from Caches::album_counts, recomputed
only when the catalog revision changes (photos, metadata and album rules
all bump it) and, while a smart album uses an "in the last..." rule,
when the session clock enters a new minute. Catalog::album_count counts
smart-album matches in place instead of building the id list for its
length. RuleSet/Filter::depends_on_now tell clock-relative rules apart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Menus are rebuilt every frame, and File → Add from Device called
devices(), which scanned every mount (D:..Z: on Windows) while holding
the cache mutex once its 2 s cache expired. devices() now returns the
last scan at once and starts at most one background rescan when it is
stale; the scan runs without the lock. A changed result calls an
on_change hook (the UI requests a repaint), so hot-plugged cards still
appear. library.devices and Add from Device without a path scan
synchronously (devices_now). Volume roots are probed for DCIM directly
instead of listing them (Windows never lists a root). No threads or
clock on wasm.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Missing Photos (sidebar count, the view, library.missing, Find Missing)
now shares one scope rule, cmd::missing::checked_path: non-deleted
library photos backed by a file. Local browse records, which can
outnumber the library by orders of magnitude, are never stat'ed. The
Missing view checks only the photos its query kept instead of every
catalog record.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Registered File commands the explicit layout doesn't list were appended
after Quit LightCraft. Items in LAST (app.quit) are now held back and
placed after the fallback entries, in their own separator group. The
macOS native menu filters Quit into the app menu and is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The headings are frameless buttons, which egui renders without padding, so
the gap now comes from the layout's item spacing (24 px). The width check
that falls back to the single "Menu" button uses the same gap.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Smart previews always lived in <library>/Smart Previews, so a large set of proxies
filled the library's drive. The folder is now a per-library setting (prefs.json):
- library.smartPreviewsLocation shows the effective folder (path, count, size,
availability) or changes it; changing it needs an explicit choice for the
previews already built (move, leave or discard), and reset returns to the
library's own folder.
- The new folder must accept writes before anything moves. A folder whose parent
is missing (an unplugged drive) is an error and is never created on the current
drive; building also refuses while a chosen folder is unavailable.
- Building, discarding, status and offline rendering all use the chosen folder.
- Settings -> Performance -> Smart previews shows the path before building, what
it holds, the existing-previews choice, Choose Folder... and Use Library Folder.
The thumbnail cache stays in the library.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Right-clicking a top-level Local location (built-in or the browsed folder) offers
Remove from Local. It only hides the shortcut: the folder, its photos and catalog
membership are untouched. The hidden list is saved with the UI state. While any
location is hidden, "Show N hidden locations" under Browse Folder… restores them,
and choosing a hidden folder with Browse Folder… puts it back. Both actions are
commands (local.hide, local.restoreHidden).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A filter set from the sidebar (By Date), the search field or the filter bar
narrows the current source, but nothing said so: a folder with a hidden date
filter looked empty.
- Chips under the grid header, one per active filter, each with an X that
clears only that filter; "+N more" lists the ones that do not fit; Clear all.
- The header counts "N of M photos" (matching of the source's total) while a
filter is on; the total is cached per source/catalog revision.
- A badge on the top-bar filter icon counts the active filters, filter bar
open or not.
- An empty result says no photos match the active filters, and how many the
source holds, instead of implying the folder is empty.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>