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>
proxies() fitted the decoded camera JPEG into the sensor proxy's box,
which keeps the JPEG's own aspect and can round a pixel short: an
ILCE-7RM4's 1616x1080 preview, decoded at 384x257, fits a 192x128
profile proxy as 191x128, and collect_pairs rejected the mismatch. So
`lightcraft-cli calibrate` skipped every file from this camera and no
profile could be written.
Resize the JPEG to exactly the sensor proxy's size instead; proxies()
has already rejected references whose aspect differs by more than 2%.
Where fitting already landed on the proxy's size the result is
identical (fit is resize at a rounded size), so working renders don't
change: 34 real ILCE-7RM4 ARWs render bit-identically. On 923 files
from one trip calibrate goes from 0 to 843 accepted.
Refs #515
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From final review: photo.setCaptureTime quietly skipped photos whose
shifted date would leave years 0000–9999. It reported success, with
the photo missing from `captured`. Such a shift is now refused for the
whole selection with how many photos it concerns, and nothing changes.
A date that doesn't read at all is still skipped, as before.
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: library.filter and album.setRules (without replace)
merged a change onto the current rules and re-checked all of them.
Once a rule stopped checking later (the album it tests was deleted, a
custom label renamed), every later change was refused: the search box,
keyword clicks and the filter bar silently stopped working, since the
UI doesn't show those errors, and partial edits of the album were
refused.
Only rules a change sets ("ruleSet" present) are checked now. Rules
that went stale still show in the dialog and the sidebar's ⚠ until
they're fixed. A regression test deletes the album a filter rule tests,
then changes the rating, edits the album, and still gets refused for a
bad new rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review: album.setRules with fromView saved the current view
unchecked. Showing smart album A ("Album isn't B") and updating B from
that view made B test itself, which the rule check is meant to refuse.
The view is still saved as it is, but a loop for the album being
updated is refused (Issue::AlbumLoop), and the sidebar's menu item
shows the reason instead of ignoring the result.
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>
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>
The rule editor renamed an album (album.rename) and then set its rules
(album.setRules) as two commits. If the second failed (refused rules
before the dialog checked them, or a save error), the album kept the new
name without the rules, and undo needed two steps.
album.setRules takes an optional `name` (blank keeps the album's) and
commits the rename and the rules as one Op::Batch: both or neither, one
undo step. The dialog edits through it.
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: view_rules merged the filter bar's settings onto a smart
album's rules through merge_rules, which now also checks the rules, and
fell back to the bare album rules on any problem. A smart album testing
an album that was later deleted therefore lost the filter bar (e.g.
Rating ≥ 3) from Create Smart Album from Filter and the dialog's count,
without a word.
merge_filter does the merge alone; merge_rules is merge_filter plus the
check, for album.createSmart / setRules. view_rules uses merge_filter.
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. photo.setCaptureTime turned `shift` / `hours` into seconds
with a saturating cast and added them with plain `+`. A shift of 1e30
seconds overflowed. Only the last-resort panic guard caught it,
reporting "failed unexpectedly: attempt to add with overflow". A large
but not overflowing shift reached the catalog as a date like
-114079456599-08-23.
A shift that isn't a number or is more than 10,000 years either way is
now refused up front ("the shift is more than 10,000 years") and
nothing changes. The regression test requires that message, so a
caught panic no longer passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review: album.createSmart and setRules refuse an unknown field, a
missing operator or a yes/no value that is neither yes nor no, but
library.filter applied such a rule set and showed an empty grid. It now
returns the same problems as an error and leaves the current filter in
place.
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>
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>
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>
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>
Lossless JPEG scans whose SOS gives predictor selection value 8 (which T.81 leaves undefined) are read as Hasselblad codes them (ljpeg::decode_hasselblad): 32-bit little-endian words read from their most significant bit, no byte stuffing; each row in pairs of samples, both Huffman categories before both differences; category 16 is followed by 16 ignored bits and means -32768; each sample is the previous sample of its column parity in the row plus the difference, modulo 2^16, both starting at 32768 on every row. Only the layout measured is accepted (one component, 16 bits, no point transform or restarts, even width); other layouts and selection values above 8 stay Unsupported. The rule was measured on 14 CC0 files with the ljpeg-explorer tool. RENDER_CACHE_VERSION 23.
A clipped raw pixel no longer records the scene's colour (one channel held at
the clip level turns a white sky magenta or blue through the matrix while the
camera JPEG keeps it white). As training pairs such pixels pulled the fitted
colour matrix toward grey and made the chroma curve remove colour at their
display luminance: on the CC0 Z 6II forest sample every chroma sample above
display luminance 0.36 was clipped, and the curve cut colourfulness there to
13-19%.
The proxies now carry a per-pixel clip mask (any covered sensor sample at or
above 0.99, aligned with the CR3 framing). The search on all pixels still
decides whether a photo gets a look and which attempt gives it; only that
attempt is refitted without the clipped pixels, and the look on all pixels is
kept when the refit misses the gates, so no photo loses its look or switches
attempt. Photos without a look get one when the search without the clipped
pixels finds it. Camera-profile pairs leave clipped pixels out too.
Render cache version 20 -> 22.
A batch ran its photos strictly one after another: each render is
row-parallel inside, but a photo's decode, its PNG/TIFF/WebP encode and
the wait on a GPU render are mostly serial, so a 32-core machine averaged
~9.5 busy cores on the CPU path and 1-2 with the GPU.
run_batch now keeps export_parallelism() photos in flight (cores / 4,
1..=4; one on wasm32) on scoped lane threads plus the calling thread.
Each holds an estimate of its working memory from a new export_gate
(twice the memory budget, separate from the decode gate it nests over),
so full-size 24 MP exports (~1.2 GB each) run two at a time by default.
Lanes run at most `lanes` photos past the last one placed, which also
bounds the finished files waiting for their turn. Paths, conflict
handling, original protection, writes, results and progress stay in
batch order (Placer), so the files are the same as one at a time; a
panic in a lane becomes that photo's error.
render_bench's batch scenario now goes through export_batch. 8 x 24 MP
JPEGs, 32 cores + RTX 4080 Laptop: CPU 3.87 s -> 2.35 s, GPU 4.21 s ->
1.82 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With a CropAngle, Lightroom's CropLeft/CropTop and CropRight/CropBottom
are the top-left and bottom-right corners of the tilted crop on the
stored image, and its angle turns the other way to LightCraft's. The
import treated the edges as an axis-aligned rectangle in the rotated
frame and kept the angle's sign, so straightened crops came out the
wrong shape and size.
Carry the two corners through the orientation and then LightCraft's
straighten rotation (angle = -CropAngle, mirrored orientations flip it
back), using the stored image's aspect from fileWidth/fileHeight. On a
real catalog every one of 223 straightened crops now matches the frame
of Lightroom's export, and the content difference falls to that of
unstraightened crops.
Refs #483
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Quantile matching fits the held-out pixels slightly worse than the conditional-median fit it replaced. On 7 of 1023 archive files (compacts, bridge cameras, early Micro Four Thirds) the look passed the acceptance gates only with the old curve, and the quantile curve lost it. The search now runs with the quantile fit first (all pixels, away from edges, with and without the camera profile) and, only when no look passes, runs again with the earlier conditional-median plus isotonic fit, restored as fit_tone_conditional. The gates are unchanged, so no file loses its look relative to the previous fit, and the quantile curve is used wherever it passes. Tests: the flat-step test stays; a synthetic photo whose JPEG carries per-pixel noise misses the gates with the quantile fit and passes with the fallback, and the order of the two is checked.
crs crop edges are normalized to the stored (unrotated) image, while
LightCraft's crop rectangle lives in the oriented frame. Catalog import
applied them unchanged, so crops on portrait photos (EXIF 6 or 8) landed
along the wrong axes.
Read Lightroom's orientation code (Adobe_images.orientation: AB, BC, CD,
DA and the mirrored BA, CB, DC, AD) and map the rectangle into the
oriented frame with Orientation::map_norm_rect; a mirrored orientation
also mirrors the straighten angle. The code comes from the catalog, so
this works while the originals are still missing at import time.
Refs #483
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>