Rules are checked when they are made, but a saved smart album can go
bad later. The usual case is a rule testing an album that is then
deleted; it silently matches nothing.
Catalog::smart_album_problems(id) checks a saved album's rules as they
are now (RuleSet::upgrade, then RuleSet::check). It is empty for a sound
smart album, a plain album or no album. albums.list reports `problems`
for every smart album, e.g. ["rule 1: no album 8"].
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review:
- rules::album_rule_id reads an Album rule's id as a whole number,
written as 3, 3.0 or "3". The check, the matcher and the editor now
agree; the picker showed a valid "1" as unset.
- A folder holds albums, not photos, so an Album rule naming one matched
nothing. The check now refuses it ("is a folder; choose an album in
it").
- Smart albums saved while Album was a number field could use ≥, ≤ or
between, which the matcher always read as "isn't". Since Album became
is / isn't only, editing or filtering with them was refused.
RuleSet::upgrade rewrites them to the "isn't" they meant; merge_filter
(album.createSmart / setRules) and library.filter apply it before
checking.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dialog showed only the first problem, in place of the match count,
without saying which rule it was about. OK stayed clickable, and an
edit renamed the album before its rules were refused.
- Each rule with a problem gets the message right under its row, in the
caution colour; inside groups too, and on an empty group's frame. The
rules_editor::Env carries the problems from RuleSet::check, and each
row finds its own by path. Markers register `ruleProblem:<salt>`.
- The summary reads "Fix the marked rules to save this album." and OK is
disabled while any problem remains.
- confirm_dialog refuses problems before anything changes (not even the
name), and the control channel's ui.dialog.confirm keeps the editor
open on that error, as the button does.
- Album is its own field kind: is / isn't only (it was a number field
that offered ≥ and between). It picks its album from a list of the
plain albums (`ruleAlbum:` / `ruleAlbumItem:<id>:`) and starts with
none chosen ("choose an album"). album.ruleFields reports kind "album".
- Found while testing: the number editor's spinner clamped a stored
value outside its range as soon as it was drawn, so "Rating is 9"
quietly became "Rating is 5". It now uses
clamp_existing_to_range(false): the range limits dragging, and a
stored value stays as it is, marked by the check.
New labels (Choose an album…, No albums yet, Fix the marked rules…) are
translated in all seven locales.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review of the value checks (false rejections and one silent
mismatch):
- In-the-last units: one table (unit_secs) for check and matcher. Any
case, singular or plural; no unit means days. An unknown unit is
refused and now matches nothing, where the matcher used to read it as
days, so "week" meant one day.
- Rating: refused only when no rating 0–5 could meet the rule, tested
with the matcher's own comparison ("is 9", "≥ 7", "between 6 and 9").
"< 6" is broad but valid. Message: "no rating 0–5 is ≥ 7".
- Catalog::label_from_name compares with Unicode lowercase, as rules
do, so a custom label "Été" is found as "été".
- Empty text hints at the operator that fits: "isn't empty" for
isn't / doesn't contain, "is empty" otherwise.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RuleSet::problems only checked that a field and operator exist (and,
lately, yes/no values). Any other value was accepted and silently
matched nothing or everything: "Rating ≥ abc", a date of "banana" or
"2026-13", "Color Label isn't pink" (everything), "Title contains ''"
(everything), "in the last 0 days", an album that is gone.
RuleSet::check(&Catalog) -> Vec<Problem> replaces it. Each Problem has
the rule's position (path [1, 0]; shown as "rule 2.1: …"), so the
editor can point at the row. It checks:
- numbers (rating 0–5), shutter speeds (1/250, 2)
- dates: 2026, 2026-08, 2026-08-14 or a full time; real dates only
- between: two bounds; dates in order (2026-08-14 … 2026-08 is fine)
- in the last: N > 0 and a known unit
- choices: the ids, the flag's picked / rejected, a colour label's
custom name (from the catalog), copyright-status spellings
- text and keyword / people rules: something to look for, or "is empty"
- album: exists and is a plain album
- no empty groups (an empty rule list at the top stays "all photos")
album.createSmart, album.setRules and library.filter report every
problem. The editor's date defaults move from "" (which, as "between",
matched every dated photo) to the current year, and its test now
requires every default to be valid, except text and album rules, which
wait for input.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Crash fix. A huge or negative "in the last N" from an agent or a saved
file ({"n": -1e30}) panicked with "attempt to negate with overflow" in
debug builds. Release builds wrapped instead, and the rule matched the
wrong photos.
- last_secs: a zero, negative, NaN or unreadable N is None (the rule
matches nothing, either way round). Spans are capped at 10,000 years,
which means "ever": every dated photo is in it and none is outside it.
- dates::shift_iso: checked_add, and a result outside years 0000–9999
(which iso_seconds can't read back) is None. It never overflows,
whatever the seconds.
Regression tests cover both and panicked before the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Megapixels used the original width × height, while Long Edge, Short
Edge and Aspect Ratio measure the photo as shown (oriented and
cropped). One rule set could disagree with itself: a 24 MP photo
cropped to a 4000 × 4000 square had a long edge of 4000 but still
counted as 24 MP. Megapixels now uses Photo::shown_size too, so that
crop is 16 MP. Rotation doesn't change it.
Behaviour change: a smart album with a Megapixels rule can now leave
out heavily cropped photos it used to include.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review: an agent sending a float (1.0) was refused while 1 was
accepted. Any number equal to 1 or 0 now reads as yes or no; other
numbers (2, 0.5, -1) are still refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Yes/no fields (Has Edits, Cropped, Has GPS, Virtual Copy, In a Stack,
Best of Similar Shots) showed "true" / "false" in the editor and
summaries, and Russian had no translation for either. Matching read the
value with as_bool().unwrap_or(true), so an agent's "false" (a string)
or any other non-boolean counted as yes, while the summary said
"false".
- catalog: bool_value is the one reading. true / "yes" / "true" / 1 /
no value mean yes; false / "no" / "false" / 0 mean no (strings in any
case). Anything else matches nothing, and RuleSet::problems reports it
("`edited` is yes or no, not \"maybe\""), so album.createSmart and
setRules refuse it. bool_label gives "Yes" / "No"; describe() reads
"has edits is yes".
- ui: i18n::bool_text for the editor's dropdown and translated
summaries (Ja / Nein…, existing keys in all locales). The editor
stores the value back as a plain boolean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review: the treatment choice "Color" reused the shared "Color" key,
which zh-hans and ru translate as the colour noun (颜色, Цвет) for the
Color panel, giving "Treatment is colour". The colour-label choice
"None" was masculine in Spanish (Ninguno) next to the feminine
"etiqueta". They are now "In Color" and "No Label", translated in all
seven locales (彩色, Цветное, Sin etiqueta…).
Also moves display_label_gaps_are_reported's doc comment back onto it;
the new choice test had been inserted between them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Choice values showed their rule ids in English ("Copyright Status is
publicDomain", "Treatment is monochrome"), and Russian had no
translation for any of them. Kind::Choice now carries (id, label) pairs
like the operators: rules still store and accept the ids, and people
see the labels.
- catalog: choice_label(field, id); RuleSet::describe uses the label
("copyright status is public domain").
- engine: album.ruleFields keeps `choices` as ids and adds
`choiceLabels` {id: label}. A test pins the ids, since the new tuple
type would otherwise serialize as pairs.
- ui: i18n::choice_text is the one path for the editor's value dropdown
and translated summaries. Aspect labels are "Landscape (wide)" /
"Portrait (tall)", because the existing "Landscape" / "Portrait" keys
mean scenery and people. Seven locales gain Video, Copyrighted, Public
Domain, Unknown, the aspect labels and Square (ru also Raw). A test
requires every choice label to be translated in every language.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review: `.hidden` reported the extension "hidden", so it matched
"File Extension is hidden". A name whose only dot leads has no extension
now. Photo::is_cropped uses the existing CropGeometry::is_identity
instead of comparing against a default crop.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The any-text rule already searched the city and country but skipped the
state / province. It now also searches the alt text, the people on faces
and a virtual copy's name, so it agrees with the dedicated fields.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New smart-album fields, each in its menu group:
- Source: Copy Name, In a Stack
- File: File Extension (case and leading dot ignored), Video Duration
- Description: Alt Text
- Location: City, State / Province, Country (Location still matches all)
- Size: Long Edge, Short Edge, Aspect Ratio (landscape / portrait /
square within 1 %), measured on the photo as shown (oriented and
cropped)
- Develop: Cropped (crop or straighten), Treatment (color / monochrome)
Photo::extension, Photo::shown_size and Photo::is_cropped hold the
domain rules. A crop rectangle that is NaN or outside the frame is
clamped, so hostile settings can't produce NaN sizes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Camera Info gains Shutter Speed, compared in seconds and written as the
camera shows it: "≤ 1/60" finds faster exposures, "between 1/1000 and
1/125" works, and a value that isn't a time matches nothing. The editor
takes it as text (1/250, 0.5, 2").
The shutter parser moves from engine export into the catalog
(parse_shutter_seconds) so export and the rule share it. A new
rules-editor test checks that every field × operator default value
makes a valid rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Smart albums can test how many keywords a photo has ("2 or more", "2 or
fewer", "exactly 2" via the number operators), who is in it (People: the
names on face regions, with the keyword operators) and how many people.
Photo::keyword_count counts a hierarchical keyword once and ignores case
and blank entries; Photo::people is the domain rule for "people in a
photo" (named face regions, trimmed, case-insensitive, pets and unnamed
faces excluded), now shared by the existing person filter. Keyword and
people rules share one name-list matcher.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FIELDS is now listed in field-menu order, with TOP_LEVEL_FIELDS (rating,
pick flag, colour label, any searchable text) followed by FIELD_GROUPS
(Source, File, Date, Keywords & People, Description, Camera Info,
Location, Size, Develop, Assisted Culling). field_group / field_label
look a field up. album.ruleFields reports each field's group so agents
see the same structure as the editor. Rule ids are unchanged, so saved
smart albums keep working.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Album.order and Op::SetAlbumOrder; album_children lists folders first, then
by place, then by name. Moving an album drops its place and undo restores it.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
CR2 and PEF decode without a camera colour matrix, like ARW, NEF, RW2,
RAF and CR3, but camera_preview::file_local_look (and the catalog's
relative white balance) left them out, so they rendered with the neutral
fallback matrix: the camera preview showed first, then a flat,
desaturated render replaced it. They now use the same guarded fit and
relative WB; a fit that fails the gates keeps the fallback, so a file can
only stay as it was or move closer to its camera JPEG. Not measured on
the corpus here (raw.pixls.us is blocked from this environment).
RENDER_CACHE_VERSION 15 re-renders cached CR2/PEF thumbnails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
follow_library_folder rebuilt the chosen subfolder from the lower-cased
folder_key on Windows and joined it with '/', so after a move the folder
shown in the sidebar came back as c:\archive\trip/day1 with its names in
lower case. Add folder_rest (compares by folder_key, returns the names as
spelled) and join them with the platform's separator. Tests compare
folders by identity where they only mean the same folder, and a new test
fails on Windows without the fix.
Decode lossless CRX and version 0x100/0x200 C-RAW in portable Rust,
including sensor geometry, timed white balance and black/white levels.
Verify all sensor samples against six independent M50/R100/R8 references.
Any CR3 decode error falls back to the embedded JPEG, as CR3 files did
before this decoder. The full sensor buffer is allocated only after the
first plane decodes, and plane buffers grow row by row. Tile planes
decode in parallel with a windowed bit reader (24 MP: 424 -> 89 ms
lossless, 519 -> 138 ms C-RAW on an M4).
Add guarded CR3 camera-JPEG colour fitting with independent framing
validation, relative white balance and cache invalidation.
Validation: cargo xtask ci.
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>
- 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>
- 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>
Adds a "File Path" text rule so a smart album can match on where a photo
lives, e.g. contains "/Aliah Ira Polanco-Grylls/". "contains" /
"doesn't contain" match the whole string (spaces included) rather than
word by word; the other text operators are the shared ones. `\` and `/`
both separate and matching ignores case, so catalogs moved between
platforms still match. Demo photos have no path.
The rule editor, CLI and MCP pick the field up from FIELDS. parity.md's
smart-collection rows now list it (and the stale field count is fixed).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
SortKey::Random orders photos by a splitmix64 rank of (Sort.seed, id): same seed gives
the same order, and added/edited photos never reshuffle the others. library.sort takes
an optional seed; library.shuffle picks a new one (below 2^53 for JSON clients). Menus
gain Random and Reshuffle; Ascending/Descending are disabled for Random. No effect in
Recently Added (always newest import first).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
RW2 / RWL / RAW join ARW and NEF: the starting look (matrix, hue/saturation table, tone and chroma curves) is
fitted to the file's own embedded JPEG behind the same gates, with white balance relative to the as-shot look.
Panasonic previews always show the whole active area while the default crop is the in-camera aspect ratio, so when
the preview matches the active area instead of the crop, the reference is cropped to the default crop before
fitting (corpus_rw2_with_an_in_camera_crop_gets_a_camera_look).
On 174 CC0 samples with a preview 140 fits are accepted (93 of the 114 bodies with a preview), median ΔE against
the camera JPEG 5.0 with matching lightness; most of the others are compacts and kit zooms whose camera JPEG is
distortion-corrected (tag 0x0119 isn't used yet). `lightcraft-cli calibrate` keeps collecting ARW and NEF only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtQmZ6peZXGn3wiqeowV2v
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>