LightCraft has no colour matrix for Nikon cameras. Because of this, NEF
files showed colours that were too gray and too green.
- Colour: LightCraft now calculates a colour matrix and a tone curve from
the JPEG preview in each NEF file. Sony ARW files use the same
procedure and the same quality checks. White balance is relative to
the as-shot look, as for ARW files.
- One function, camera_preview::file_local_look, selects the formats for
the fit and for the relative white balance. Photo::relative_wb in the
catalog uses the same formats.
- Black level: Nikon stores the black level (maker note 0x003d) in
14-bit units, also in 12-bit files. The decoder read it as a 12-bit
value. Because of this, 12-bit files from the D750, D780, D850, D7500
and Z 50 were too dark. Now the decoder divides the value by 4 for
12-bit files.
- The corpus gets the D7500 12-bit and 14-bit lossless samples. A test
compares their levels. The render cache version increases to 9.
Results on 13 NEF files that LightCraft can decode: LightCraft accepted
the fit for all 13 files. The mean colour difference from the camera
JPEG (CIE76 dE) decreased from 13-46 to 2.9-7.8. One file with mixed
light improved by a small quantity only (12.7 to 12.0).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The view listed everyone in the library, so with another filter active (a
date, a keyword…) picking a person could end in an empty grid. The list now
covers the photos matching every filter except `person` itself, counts are
within them, and the filter chips show above the cards (removable, Clear all).
`Catalog::people_in(&Filter)`; the cache is keyed on the catalog revision and
the filter.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A dedicated view mode (View > People, and a toolbar button) shows a card per
person named on faces: a close-up of their largest face, the name and how many
photos they are in. A click shows that person's photos in the grid. Only the
cards on screen render their face. The sidebar section is gone (it would get
crowded).
`Catalog::people()` now returns `Person { name, count, photo, face }`, the
photo and face being the person's largest. `Session::face_job` renders a
square close-up through the engine (the photo's own look, crop replaced, the
smallest source level that keeps it sharp, cached like variant thumbnails); it
clamps hostile rectangles, with a test for corners, oversized, degenerate and
non-finite faces.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Read-only and names-only: the left panel gets a People section built from the
named Face regions read from XMP, with photo counts (once per photo, case
insensitive, pets and unnamed faces left out). A click filters the grid via a
new `person` field on `library.filter`, also reachable as a `person:` search
token and shown as a filter chip. The list is cached on the catalog revision.
No face detection, identity or writing regions back yet. Parity rows for the
People list, filter and view are 🟡 with that scope noted.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Threads the Vec<Region> that lightcraft-meta already reads from XMP
(MWG-RS) through to the screen, so it's no longer inert:
- catalog::Meta gains a `regions` field (catalog now depends on
lightcraft-meta, which doesn't depend back on catalog, so no cycle).
- crates/engine/src/files.rs (meta_of) and src/sidecar.rs (parse_sidecar
/ merge_into) populate it for both embedded-XMP and external-sidecar
photos, mirroring how `keywords` is already handled.
- ui-egui's loupe view (panels/detail.rs) draws each region as a
rectangle over the photo via the existing CanvasMap, color-coded by
kind (Face/Pet/other) with its name label if present, right alongside
the existing info overlay. Read-only, no new interaction.
New end-to-end test (regions_from_sidecar_are_read_on_import) covers a
region written to a sidecar reaching Photo.meta.regions through real
import, not just the parser. cargo xtask ci green (fmt, clippy -D warnings, test, parity, layers, assets, wasm).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- safe_file: write policy documented (durable for what can't be recreated, atomic-only for
recreatable outputs); new write_atomic_nosync (temp + rename, temp removed on failure, no
file/folder sync); per-thread sync counter for tests.
- export::write_file (exports, renders, screenshots, merge previews, CLI/MCP) uses the
non-syncing writer; new write_file_durable keeps the sync for Edit a Copy (the TIFF becomes a
library photo). Catalog, sidecars, settings, DNG conversion/merges and smart previews stay
durable; the #93 original-overwrite guard is untouched.
- Import > Copy verifies the synced copy against the probe's content hash (SipHash-1-3/128 of the
whole file) instead of re-reading the source; byte compare when there is no hash. A file changed
since Review Import is now refused instead of imported with stale details. Move keeps its
byte-for-byte check before deleting sources.
- check_write_target: skip building the library-wide guard when nothing is at the target.
- write_new_unique keeps its read-back (DNGs replace raws; cheap next to encoding).
- Tests: no sync on the export path, durable writers still sync, Copy caught by the probe hash
(corrupt copy, stale hash, changed source); ignored timing test write_policy_timing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve SourceRef::load_source against #104's offline fallback: a File
source keeps the decoder's SourceInfo; a smart-preview fallback has none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main added Op::SetBrowsed, Catalog.browsed and Photo.local_baseline while
the snapshot still said version 1, so v0.2.0 read such a library in part
(unknown ops as log damage, unknown fields dropped on its next snapshot).
- journal::VERSION = 2 (now public), with a format table and rules in the
journal docs; a test with an exhaustive match over Op makes adding a
variant fail until it is assigned a version.
- Journal::open reads the snapshot header first and refuses a newer
version with CatalogError::Newer ("written by a newer version of
LightCraft"), even when its catalog wouldn't parse.
- A log record whose CRC matches but whose op doesn't parse is "newer",
not damage or a torn tail: refused before any file is modified.
- Opening an older (or snapshot-less) library writes a snapshot in this
format before anything is appended (LoadReport::upgraded_from); a new
library gets one at once, so v0.2.0 refuses it instead of reading a bare
log.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed append (disk full, dropped share) could leave part of its batch in
catalog.log; the retry (same seqs) then shared a line with the fragment and
the next load treated it as mid-log damage, setting later edits aside.
- Journal::append cuts the log back to the last whole record on error (or,
if that fails too, before the next append).
- FsStore drops a failed append handle so the next attempt reopens the file;
truncate of a never-created file is a no-op.
- The loader recognises a CRC-valid record glued after a fragment (logs
written by older builds) and skips only the fragment.
- log_bytes now counts the newline added to an unterminated last record.
- close_library writes the closing snapshot even when appending fails; the
snapshot absorbs the queued ops (Journal::snapshot_with_unlogged).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two processes on one library each kept their own catalog and seq counter;
the last snapshot silently dropped the other's edits.
- lightcraft_catalog:🔒 LibraryLock holds an exclusive OS lock
(std File::try_lock: flock / LockFileEx) on <library>/catalog.lock for
the session; who holds it goes to catalog.lock.owner (separate file: a
Windows lock also blocks reads). Released on drop and by the OS when the
process dies, so leftover files from a crash never lock anyone out. File
systems without lock support open unprotected with a warning.
- Session::open_library takes the lock before reading anything and fails
with EngineError::LibraryInUse ("this library is already open in
LightCraft 0.x (process N on host)"); reopening the session's own
library hands its lock over.
- lightcraft-cli (mcp/run/snapshot --library) adds how to drive the
running app instead (--control + mcp --connect).
- Tests: in-process, leftover files, a killed child process, two engine
sessions, CLI e2e (mcp --library holds it, run --library is refused).
Existing tests that reopened a library while it was open now drop the
first session.
- docs: parity row LRC-LIB-CATALOG-LOCK, mcp.md, web.md (no guard across
browser tabs yet), whats-new.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- lightcraft_catalog::safe_file: one tested helper for crash-safe writes
(temp file in the same folder, sync, rename, folder sync; temp removed on
failure), write_new/write_new_unique that never replace a file and read
the result back, same_file, and a per-thread fault injection for tests.
- engine::originals::OriginalGuard: refuses a write whose target is a
catalogued photo's original or XMP sidecar (by file identity), whatever
the conflict policy or exact path. Used by run_batch (export, Export with
Previous, control/MCP exact paths), ui.render/ui.screenshot paths, merge
previewPath, MCP render_photo/screenshot paths (new query command
export.checkTarget) and lightcraft-cli render.
- The conflict policy now covers an export's sidecars together with the
file (Unique gives both the same free name, Skip skips when either is
taken).
- export::write_file replaces the plain std::fs::write in the desktop app,
CLI, MCP and engine writers; XMP sidecar writes use the shared helper.
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>
At the 2,000-record threshold the UI thread serialised and fsynced the whole catalog (1-2 s
at 85k photos after streaming). Now `persist()` starts `Journal::snapshot_in_background`:
- the worker writes `catalog.snap` for op N from a clone of the catalog (photos are Arc-shared,
so the clone is cheap) through `Store::background_writer` (a second FsStore handle; MemStore
opt-in; none on wasm/web, which stays synchronous);
- appends go on as before (fsynced before every command returns) and are also kept in memory
as the "tail" (records > N);
- `Journal::poll` (from `persist` and the per-frame `persist_if_dirty`) sees the worker done and
atomically replaces the log with exactly the tail.
Crash points: before the snapshot rename -> old snapshot + whole log; between the rename and the
log rewrite -> new snapshot + whole log (records <= N skipped as stale, existing rule); after ->
new snapshot + tail. A failed snapshot keeps the log whole and retries after another
max_records records (no retry storm). At most one snapshot in flight; a synchronous snapshot
(close, Optimize Library), reopening a library and dropping the Journal first wait for it, so an
older snapshot can never replace a newer one. On-disk format unchanged.
Tests (crates/catalog/src/tests_background.rs, a gated store holds the worker mid-flight and
"crashes" by copying the files): edits during a background snapshot survive crashes at each
point and the trimmed log holds exactly seq N+1.. in order; failure loses nothing and backs
off; sync snapshot waits (no regression); drop waits; real FsStore with a leftover partial
temp file; a proptest over interleavings of edits / start / land / finish / sync snapshot /
crash. Mutations checked: trimming the log to empty, or not waiting in `snapshot`, fail them.
Engine: `threshold_compaction_runs_in_the_background` (crash copy mid-run, reopen without close).
`library.info` -> persistence gains snapshotRunning, failedSnapshots, maxBlockingMs and
lastSnapshot.{background, blockingMs}.
persist_bench, same session (load avg ~160), UI-thread time per threshold compaction:
85k photos: synchronous 0.85-1.7 s (median 1.25 / 1.34 s) -> background: blocking median
45 / 74 ms (clone 1.3-154 ms + log rewrite 9-23 ms); worker 1.2-2.4 s; 230-380
single-op appends during it at median 4-6 ms (same as without)
10k photos: synchronous 72-233 ms -> blocking median 12.5 / 14 ms
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Journal::snapshot serialised the catalog into a String and then copied it into a second
`format!`-ed String before writing (2x the snapshot size in memory, ~620 MB at 85k photos).
It now streams `{"format":…,"seq":N,"catalog":` + serde_json::to_writer + `}\n` through a
1 MiB BufWriter into the store (new `Store::write_atomic_with`; FsStore writes the temp file
directly, then flush + sync_all + rename + dir fsync as before; other stores buffer, as before).
Durability and format are unchanged: same temp + fsync + atomic rename; a failed write leaves
the old snapshot (and now removes the temp file); the bytes are identical to the old format
(test `streamed_snapshot_keeps_the_on_disk_format`, which also loads a legacy snapshot), so
no version bump. Also tested: `failed_streamed_write_keeps_the_old_file`.
persist_bench, 85k photos (310 MB snapshot), compaction at the 2,000-record threshold,
before/after binaries alternated in the same session (load avg ~130-170), building+compacting
only (PERSIST_BENCH_NO_LOAD):
compaction median: before 3.5 s / 4.6 s, after 1.9 s / 2.5 s
peak RSS: before 926 MB / 1263 MB, after 308 MB / 366 MB
10k photos peak RSS: 149-235 MB -> 61-68 MB (times too noisy to compare)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- catalog store: the journal appender is a let-else with an io::Error,
not expect("appender just opened").
- catalog dates: group_label slices with get(), so a non-ASCII key of a
date's length no longer panics mid-character (regression test).
- codecs jpeg_par: rows come from map_while instead of next().unwrap().
- lightcraft-codecs and lightcraft-catalog now #![deny] unwrap/expect/
panic/unimplemented/todo/unreachable (one test let-else uses panic!
instead of unreachable!); clippy.toml as in the steering PR.
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>
Raw variants the decoder can't read yet (e.g. Nikon Huffman-compressed NEF
from a D3200/D5100) silently fell back to the camera's embedded JPEG, so
users edited a JPEG with the camera's picture style (e.g. Monochrome) baked
in without being told.
- catalog: Photo.preview_only (serde default; old catalogs load), set /
cleared by Op::SetContent with an exact inverse; Photo::develops_raw().
- engine: ProbeInfo.preview_only carries the decoder's Unsupported reason
into import, the import review (previewOnly) and catalog.query
(previewOnly); photo.inspect includes it. photo.reload and photo.relink
re-probe and clear/set it when the file decodes (or not) now.
- source_info: a preview-only raw is a rendered source (relative WB scale,
display tone curve) instead of raw:true/5500 K + filmic tone map on a
display-referred JPEG; import uses the JPEG default preset for it; the
Edit panel shows the rendered-file WB sliders.
- UI: "Preview" badge on grid thumbnails, a pill on the loupe, a notice in
Edit and Info (amber caution token, Info icon drawn in code).
- Tests: catalog serde/undo, engine import -> reopen -> reload/relink,
headless UI badge/notice widgets.
Refs #10
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- develop.quickAdjust {control, delta}: adds a step to every target photo's own value (one undo
step); the Edit panel shows Quick Develop rows (exposure ⅓ / 1 stop, contrast, highlights,
shadows, whites, blacks, clarity, vibrance, temperature) in the grid with several selected.
- library.findSimilar {id?, similarity?}: look-alike photos by their culling signature (cached
per content), most similar first, as a view filter (Filter.only); photo menu entry.
- develop.matchExposure: selected photos' Exposure set so shutter × ISO ÷ aperture² + slider
matches the active photo; photos without exposure data are skipped.
- Impromptu slideshow row marked (View ▸ Slideshow).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lightcraft-pipeline cull.rs: focus (Laplacian energy of the sharpest 16-px blocks at 512 px,
0–100, calibrated on the CC0 corpus), clipping share and an 8×8 look-alike signature.
photo.analyze scores the selection (else the view), groups look-alike shots taken within 10 s
into bursts and marks each burst's sharpest; optionally rejects photos below a focus score and
picks each burst's best (one undo step). Scores live on the photo (Analysis, Op::SetAnalysis):
rule fields Focus and Best of Similar Shots, Info panel line. Photo ▸ Assisted Culling….
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A 100k-photo scaling check (ignored test `scale_100k_library_queries`) showed the left
panel rebuilding the keyword tree every frame (323 ms), the grid recomputing date headers
(57 ms) and the Keywords panel its suggestions (44 ms) every frame.
- keyword_tree builds with hash maps instead of linear sibling scans: 323 → 57 ms at 100k.
- LightcraftApp::caches keeps the keyword tree, date runs and keyword suggestions per catalog
revision (and inputs), so each is computed once per change instead of every frame.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Op::SetContent records a photo's file facts (size, dimensions, content hash; undoable).
photo.reload re-probes the photos' files and, for those that changed on disk, updates them and
drops their cached sources; render keys now include the file's content key, so the grid and
loupe re-render. The app reloads this session's external-edit copies whenever its window
regains focus (with a toast).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Auto versions: leaving an edited photo keeps its settings as an Auto version (when they
differ from its latest version; 20 kept); saved with the library, not an undo step.
- View ▸ Slideshow (⌥⌘↩, `interval`): the photos in view full screen, wrapping; Space
pauses, ←/→ step, Esc ends.
- Photo ▸ Convert to DNG (photo.convertToDng): lossless DNG next to the raw with the settings
embedded; the photo and its virtual copies are relinked (Relink gains an optional format),
originals kept, undoable. Import: Copy as DNG (`dng`) for copy imports.
- A fine grid over the photo while a geometry slider is dragged.
- View ▸ Show Photo Counts; Help ▸ System Info… (app.systemInfo, JSON for agents).
- Native menus: modified Enter accelerators.
- Parity rows refreshed (target / quick collection, auto sync, location rules, bring to front).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Albums gain a `quick` flag: the Quick Collection is created the first time B adds to it.
album.toggleTarget adds the selection to the target album (the Quick Collection unless
album.setTarget names another regular album) or removes it when all of it is already there;
album.clearQuick empties it. B in the photo / square grids does this (with a toast); in the
loupe and edit views B stays the masking brush. Album menu: Set as Target Album / Stop Using
as Target Album / Clear Quick Collection; the target is marked + in the list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Filter.labels: a photo matches when its label is any of them. The filter bar's label toggles
add / remove labels from the set instead of replacing the single label; the summary reads
"label red or yellow". The filter already persists across albums (locked, as in the desktop
app).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lightcraft-catalog gains rules.rs: RuleSet {match: all|any|none, rules: [{field, op, value} |
{group}]} with text, keyword, number, date (is / before / after / between / in the last N
hours…years) and choice operators over 26 fields (rating, flag, label, type, edits, keywords,
any text, filename, title, camera, lens, location, dates, ISO, aperture, focal length,
megapixels, GPS, virtual copy, album…). Filter.ruleSet: smart albums and library.filter use it;
"in the last" counts from the session clock. album.ruleFields lists fields and operators for
agents; rules are validated.
UI: File ▸ New Smart Album… / album context menu ▸ Edit Smart Album… open the rule editor
(field / operator / value rows with per-kind editors, + / − / ⌥+ for groups, live match count).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- develop.autoSync (Edit ▸ Auto Sync, ⌥⇧⌘A): an edit of the active photo carries only the
settings that changed to the other selected photos, in one undo step; slider drags sync on
release; spots / red eye and history restores stay per photo. Edit ▸ Sync Settings in the
menu; Edit panel banner while it's on with several photos selected.
- Colour-label sets: built-in Colors / Review sets plus user sets in prefs (label.sets,
applySet, saveSet, deleteSet); listed in Photo ▸ Set Color Label; the names dialog can start
from a set and save one. XMP sidecars and exported originals write the label's name, and
reading resolves custom names back to their colour.
- Crop: ⌘-drag draws a straighten line and keeps the crop tool active.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- develop.autoBwMix (B&W Mixer → Auto): each hue band's colourful pixels are pushed away from
the image's mean lightness (light bands brighter, dark ones darker; bands with almost no
colour untouched), clamped to ±60; switches to B&W; one undo step.
- library.filter {merged: hdr | panorama | hdrPanorama | any}: Photo Merge results recognised
by the "-HDR" / "-Pano" / "-HDR-Pano" names merges write; filter bar type picker lists HDR,
Panoramas, HDR Panoramas.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Export watermark can be a graphic (watermark {image, imageWidth}): a logo with transparency,
scaled premultiplied to a share of the photo's width, converted into the output colour
space, placed with the same anchor / inset / opacity as text; decoded once per file.
Export dialog: Text / Graphic, Choose…, width; size and shadow apply to text only.
- Recently Added: everything imported in the 30 days up to the latest import, newest import
first, grouped by import day (was the latest import day only).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- metadata.savePreset / applyPreset / presets / deletePreset: named sets of copyright,
creator, title, caption, place and keywords (text replaces, keywords add; one undo step),
saved with the library; Photo → Metadata Preset (apply, Save Metadata Preset… from the active
photo's copyright, creator and place); Settings → Import applies one to every import
(library.preferences import.metadataPreset).
- stack.split (this photo and those after it become their own stack; single leftovers are
unstacked), stack.moveUp / moveDown; in the Stack menus.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- library.missing lists photos whose file isn't where the library expects it; photo.relink
points one at its new location; library.findMissing {folder} relinks every missing photo
whose file (same name and size) is somewhere in a folder, in one undoable step. New
Op::Relink: like a rename in the catalog, but undo/redo never move files.
- UI: File → Find Missing Photos…, photo menu → Locate Missing File… (when the file is gone).
- Renderer: a remembered failure is retried once the catalog changes (a relinked file).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- library.browse {path, subfolders}: a folder's photos are read in place as "local" photos
(catalog flag, SetLocal op) that only folder views list — not All Photos, albums, date
groups, keyword counts or stats. photo.addToLibrary promotes them; importing the folder for
real promotes instead of reporting duplicates. The browsed folder persists with the view.
- UI: Local section (Pictures, Desktop, Downloads, Home, the browsed folder, Browse Folder…);
folder views get a breadcrumb header (each part opens that folder), Include subfolders and
"Add N to My Photos".
- Renderer: a job that fails (unreadable / missing file) is remembered by slot + key and not
requested again every frame — the grid re-queued it forever (a render loop per broken
file). The loupe says "This photo can't be opened" with the reason; grid cells show "!".
ui.inspect lists pending render slots.
- Headless app.export (MCP, lightcraft-cli run) exports the selection like the desktop app.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Metadata gains alt text, extended description, sublocation, city, state/province, country:
catalog, photo.setMeta, copy/paste metadata, XMP read/write (Iptc4xmpCore AltText /
ExtDescrAccessibility / Location, photoshop City / State / Country), sidecars and export
(place fields dropped with "remove location").
- Info panel: camera card (camera, lens, size · file size, format badge, focal length,
shutter, aperture, ISO), then title, caption, alt text, extended description, copyright,
creator, file name (rename), file path (reveal), captured (edit), place fields, GPS.
- Text fields keep what is typed across frames and save on focus loss (they were rebound to
the stored value every frame). New pencil icon (drawn in code); dates::display_time.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- By Date: year → month → day rows with counts and disclosure triangles; a row filters by
that date prefix (catalog date_groups now carries days).
- Settings → Import → Metadata: copyright and creator given to imported photos that don't
carry their own (library.preferences {import: {copyright, creator}}), saved with the library.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Op::SetCaptured (validated, journaled), photo.setCaptureTime (the active photo gets the time,
others keep their offsets; or each / shift / hours), Edit Capture Time dialog with preview
(Photo menu, Info panel); engine + headless tests incl. op-log replay.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>