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>
- A photo listed twice in the selection counted twice, and an id the
library doesn't have counted in "of": the chips, Will Export and the
Keyword List's tick boxes now count the distinct selected photos.
- The chips looked each keyword's spelling up by scanning every photo
(178 ms with 20,000 photos and 2,000 keywords selected, at every
change while selected): they now take the keyword list's spelling
(`Catalog::listed_path`) or the first one seen, 26 ms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The parity row said Person shapes exported files: it is stored only.
It now also covers the list without a selection, the default parent's
reach (typed keywords too) and keywords' hierarchy on import. The
synonyms' doc no longer says search finds them, which it doesn't yet.
What's New follows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lightroom Classic puts new keywords typed for photos inside the keyword
marked Put New Keywords Inside This Keyword; only + did here. The
Keywords panel's box now does too (`Catalog::typed_keyword`): a new
name goes inside the default parent, while a keyword the library has,
or a path typed whole, stays as typed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Suggestions came from the photos' keywords only, so a keyword created
in the Keyword List was never offered while typing. Those only the list
has are suggested too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Editing a keyword without changing it listed it (when it wasn't) and
made an undo step. The attributes are now set only when they change
something: a keyword only photos have, renamed with the default
attributes, stays unlisted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Creating "TRAVEL|Rome" next to "travel|italy" listed it as typed, and
the tree, which takes a level's spelling from the list first, showed
"TRAVEL". A new keyword now takes the library's spelling of each level
it shares with a keyword there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Exports now write names to `dc:subject` and paths to
`lr:hierarchicalSubject`, as Lightroom Classic does, but import and
sidecars read only `dc:subject`: an exported `travel|Italy|Rome` came
back as three keywords, Rome, Italy and travel. They now take the
paths of `lr:hierarchicalSubject` and the names of `dc:subject` that
aren't levels of those paths (`keywords::from_file`); without paths,
the names as they are, so LightCraft's own sidecars read as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Put New Keywords Inside This Keyword" was a session preference that
followed renames by hand: undo and redo never moved it back, so after
undoing a rename it named a keyword that no longer existed and the
next new keyword silently made that parent again. It is now a mark on
the keyword in the catalog's keyword list (`new_keywords_inside`, one
keyword at most): it moves with its keyword (into a merge too), goes
with it, comes back with undo and redo, and is kept with the library.
Setting it is one undo step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merging `a|b` and `a|c` into `a` moved the listings one at a time: the
`a|b|c` listing landed on `a|c` before that one left for `a`, merged
into it, and its attributes were lost. Every moving listing now leaves
first, then each lands. Each keyword also moves by the closest keyword
merged (`a|b` before `a`), so the order they are given in doesn't
change the result; the keyword filter follows the same way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tree and the counts leave out photos in Recently Deleted (and those
only browsed), but `keyword_path`, `has_keyword` and purging read every
photo: a keyword only a deleted photo had couldn't be created again,
wasn't purged, and could lend its spelling. They now read library
photos only, as the tree does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Events" of a listed "Events|Weddings" is in the tree, but
`has_keyword` and `keyword_path` only looked at photos and the listing
itself: it couldn't be renamed, moved or made the default parent. They
now find a keyword through the listed keywords below it as well.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`is_under` and `dedupe` compared names by lower-casing them, but other
checks used `eq_ignore_ascii_case`: changing only the case of "Ärzte"
was refused as a clash with itself, and moving a keyword where it is
wasn't seen as staying put. Every comparison now uses
`keywords::same`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a keyword was renamed or merged, the keyword filter followed it by
cutting the old name off by bytes: names match whatever their case, and
where a letter's case changes its length (ẞ / ß) the cut could land
inside a letter and panic (`ẞẞA` → `Road` with the filter on `ßßa|é`).
It now uses the catalog's level-based `reparent`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Catalog::export_keywords` applies Lightroom Classic's keyword tag
options: a keyword left out of export goes nowhere; otherwise its name
and the keywords containing it that are exported themselves (unless
"export containing keywords" is off) and its synonyms (unless "export
synonyms" is off) go out flat, for `dc:subject`, and its full path for
`lr:hierarchicalSubject`. Each name once, whatever its case.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The keyword list's own actions, each one undo step:
- create a keyword with its attributes, and tag photos with it at once;
one that exists already (listed or on a photo) is refused;
- move a keyword (with those below it) inside another or to the top
level; never inside itself or its children, and onto a name that is
taken only when merging is allowed (`CatalogError::KeywordExists`
otherwise, so the UI can ask);
- edit: rename its last level and set its attributes together; a taken
name is refused, merging being its own action;
- purge: take off the list the keywords no photo has.
Typed in any case, keywords keep the spelling the library has
(`keyword_path`).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Renaming a keyword cut the keywords below it at the byte length of the
name as typed. Names match whatever their case, but some letters change
length with it (ẞ is three bytes, ß two): `STRAẞE` → `Road` turned
`straße|nord` into `Roadnord`, and a cut inside a letter would panic.
The levels below are now kept whole.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Renaming or moving a keyword takes its listing and its listed children
along, photos or not; a keyword only the list holds can be renamed too.
Merging keeps the target's attributes and gathers the synonyms (a
target not listed yet takes the merged keyword's attributes). Deleting
takes the keyword and those below it off the list. Each is still one
undo step: the list's changes join the photos' in the batch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keywords existed only as `a|b|c` strings on photos: one couldn't be
created before a photo had it, nor a parent made to organize under.
The catalog now has a keyword list (`Op::SetKeyword`, format version
4): a keyword listed on its own is in the tree with no photos, its
parents too, and carries Lightroom Classic's keyword tag options
(`KeywordInfo`: synonyms, include on export, export containing
keywords, export synonyms, person), exported by default. Undo restores
what was listed, as it was written. Version 3 libraries open and are
rewritten as 4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A catalog written before the lens data was read at import (RW2/RWL from
v0.4.0) has none stored, and the lens correction stays off. Reload now
stores the fresh probe's lens data (new journaled, undoable
Op::SetEmbeddedLens; catalog format 4) and, for photos still at import
settings, switches the correction on as import does. Edited photos and
photos with a default-preset look keep their settings.
A Custom white balance from a Lightroom catalog or XMP sidecar was taken as absolute Kelvin for every
raw. Raws LightCraft develops relative to their as-shot look (ARW, NEF, RW2, RAF, CR3, CR2, PEF; 6500 K
means as shot) then read a small tweak as a large blue shift: ΔE 22 against Lightroom's export on the
photos of issue #510.
The crs mapper now takes a `Target` (preset, rendered, raw with a measured illuminant, relative-WB raw)
instead of a bool. For relative-WB raws, and rendered files with only `Temperature`, a Kelvin value is
converted to the same mired shift from the packet's `AsShotTemperature` / `AsShotTint`. Without that
reference (Lightroom catalogs keep none for Custom photos) the photo stays As Shot and the import
report lists `Temperature, Tint` as not carried over. Import, sidecar read and catalog import decide
the target the same way through `lightcraft_catalog::relative_wb_format`.
Tests: shift from the issue's numbers (3650 → 3578 K lands at ~6275 K), the As Shot fallback with its
report entry, target selection per file, and the catalog mapper's Custom-WB case.
Each photo's develop settings record the rendering process they are
interpreted by (`DevelopSettings::process`). There is one process, V1, the
pipeline as it is; nothing renders differently (180 CPU 8/16-bit and GPU
renders of procedural scenes, and the saved settings JSON and hash64, are
byte-identical to main).
Settings saved without the field load as V1 (a field-level serde default),
new settings get the latest (`Default`), and V1 is left out when saving, so
files and preview-cache keys stay as they were. A damaged value (anything
but a whole number up to 2^32 - 1) reads as V1 too, so it never costs the
rest of the settings (an unparsable `lc:settings` sidecar is ignored). Reset moves a photo to the
latest process, section and slider resets don't; virtual copies inherit
it; copy / paste / sync / Auto Sync / presets don't carry it; a number from
a newer LightCraft is kept and renders with the newest known process. The
process is not an edit (Edited badges, import-look checks). The base tone
map is chosen per process in one place (`finish::base_tone`, shared by CPU
and GPU). Photo > Update to Current Process (`develop.updateProcess`) moves
selected photos on an older process to the latest as one undo step. Its
menu enablement follows the selection; a call that names photos (`ids` /
`id`) is validated on those instead, through a new opt-in
`CommandSpec::explicit_targets` (execute checks enablement before the
handler sees `ids`). `develop.get` reports the process. The rule and how to add V2:
docs/process-versions.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From final review: the problem-message example ("rule 2.1: rating is
0–5, not 9") predated the rating check's rewording ("no rating 0–5 is
9"); two doc comments still said Album rules take a plain album; and
What's New described the Album rule's old flat list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From final review: I had refused a time zone or fractions of a second
in rule dates on the premise that capture times are stored local and to
the second. They aren't. Imports write DateTime::to_iso, which carries
EXIF SubSecTime and OffsetTime ("2026-08-14T10:00:00.250+02:00"), so a
rule like "is 2026-08-14T10:00:00.250" did match those photos and was
wrongly refused; saved albums holding such dates would have shown ⚠
too.
rule_date now accepts, after whole seconds, a fraction (1–9 digits)
and a zone (Z or ±hh:mm). Anywhere else ("2026-08-14Z") they are no
date. Issue::DateWithZone and its translations are gone; such values
are NeedsDate. The test matches a photo captured with a fraction and a
zone, and the docs no longer say zones are refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From final review: loop detection only followed Album rules. A smart
album's own album field (Filter.album, as set by the filter bar or
album.setRules {rules: {album}}) was ignored, so
"album.setRules {id: A, rules: {album: A}}" was accepted and A became
silently empty. On main it would have recursed to a stack overflow; the
guard already stops that.
Filter::albums_tested covers both, and album_reaches follows it.
Catalog::album_filter_problem reports a loop through the album field;
smart_album_problems (sidebar ⚠, albums.list) and album.setRules
(including fromView) use it. A problem with no rule position displays
without the "rule …:" / "#…" prefix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From final review: the loop guard bounded the depth of smart albums
testing smart albums, but not repeated work. Each album tested by the
next one twice ("all of: album is X(k-1), album is X(k-1)") doubled the
cost per level, so 25 levels meant 2^25 evaluations per photo, enough
to freeze the sidebar's counts. Any agent or catalog file could build
such a chain.
rules::smart_album_holds remembers each smart album's answer for each
photo during one outermost question, and forgets them when it ends (a
drop guard, however it ends). A regression test counts a 26-deep
doubled chain in well under its 2 s limit; before the fix it ran for
minutes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A file-path rule test and a sidebar long-name test used a real person's
name as their example. They now use "Lake Como Wedding-Day" and keep
what they check: a multi-word folder name, word order that must not
match, and a hyphen a space must not match.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review: the walk through smart-album references kept its seen-set
in a Vec, which is quadratic over long chains. The loop test's comment
now says what a saved loop does: inside it, the album being evaluated
counts as holding no photos.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summaries showed an Album rule's id ("album isn't 9"): the sidebar
tooltip, the "Rules:" filter chip and albums.list's rulesText for
agents.
RuleSet::describe_with(&Catalog) and Filter::describe_with name the
album ("album isn't “Excluded Photos”"). One that is gone shows as #9
(rules::album_name). The engine's rulesText and Rules chip use them, and
i18n::rules_label takes the catalog for the translated summaries.
Tests at all three levels: catalog (nested groups, a missing album),
engine (rulesText), UI (German summary).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An Album rule may now name a smart album: "Keywords contain travel"
and "Album isn't Excluded Photos" (red or rejected) leaves out whatever
Excluded Photos matches, as its rules change. Smart albums used to be
refused for this.
Loops:
- RuleSet::check_for(cat, owner) refuses an Album rule that would make
the owner include itself, directly or through other smart albums
(Issue::AlbumLoop). Catalog::album_reaches walks the references with
a seen-set; RuleSet::albums_tested lists them. album.setRules checks
for the album being edited, and smart_album_problems reports a loop
saved anyway.
- Matching never recurses without end: album_contains evaluates a smart
album's rules through rules::evaluating, a thread-local stack of
albums being evaluated with a 32-deep cap. An album already on it (a
saved loop) holds nothing, and the stack is restored by a drop guard
however evaluation ends. A test builds A → B → C → A and checks that
every album returns and reports the loop.
The issue "can't test a smart album" is replaced by the loop issue in
all seven locales. The sidebar counts already refresh while any smart
album uses "in the last…", so an album testing one is covered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review: "2026-08-14Z" (a zone after a date with no time) was
reported as "needs a date" rather than as the time-zone issue it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some date values passed the check but never matched. "2026-10-01 10:00"
(a space, not T) and "…T10:00:00+02:00" (a zone) were accepted, while
the matcher compared them as written against capture times stored as
"2026-10-01T10:00:00".
rules::rule_date is now the one reading for the check and the matcher:
2026, 2026-08, 2026-08-14, then T10 / T10:00 / T10:00:00, with a space
working as the T, and a real date and time (25 o'clock and minute 61 are
refused too). A time zone or fractions of a second can never match a
local, whole-second capture time. They get their own issue,
DateWithZone, translated in all seven locales, instead of matching
nothing silently.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review:
- Keywords and People say "are empty" / "aren't empty", but their
empty-text hint said "is empty". New issues NeedsNamesOrEmpty /
NeedsNamesOrNotEmpty quote their operators, translated in all seven
locales with each locale's own label (sind leer, están vacíos, が空…).
- Russian had none of the 20 smart-album operator labels, so its
editor showed every operator in English and the new hints named
«пусто», which wasn't on screen. All 20 are translated now, and a
test requires every operator label in every language.
- Japanese and Chinese join a field and its problem with the full-width
colon their catalogs use ("タイトル:…").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Problem carried only an English sentence with the field's id
("`title` needs something to look for…"), which the editor showed
as-is in every language.
Problem now also has `field` (None for a group) and `issue`, an Issue
kind with a short English text for translation catalogs (Issue::text,
Issue::ALL). The English `message` and the `rule 2.1: …` Display stay
as they were for agents and logs. A test covers the field and issue of
each kind of problem and that each issue reads differently.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>