Resize::apply rounds the size, then the render fit the frame into that box
again: on a frame a hair under 3:2 the refit gave 1333 x 1.4999999 = 1999.49
and lost a pixel of the long edge. Frame::fit now keeps a box that already has
the frame's aspect up to rounding, and output_size rounds the native size to
whole pixels first so float noise can't decide a half-pixel tie.
An uncompressed Leaf Aptus 22 MOS keeps its 16-bit mosaic in IFD0
(compression 1, BlackIsZero, one sample per pixel) with no Make tag and
no CFA photometric, so `probe` returned None and the file imported as a
greyscale image of the mosaic. Leaf's data block, tag 0x8606 (LeafData
in the ExifTool EXIF tag-name documentation), now marks such an IFD0 as
a raw: OtherTiff, preview only with the reason, as the lossless-JPEG MOS
already is. A rendered RGB image that kept the block stays an image.
The Hasselblad Lunar is a Sony NEX-7 that writes an ARW under Make
HASSELBLAD; only SONY files reached the ARW reader, so it opened as its
preview. A HASSELBLAD file with Sony's raw coding (an IFD with
compression 32767 or the tone-curve tag 0x7010, which no 3FR / FFF has)
now goes there and decodes like a NEX-7: black 512, the SR2SubIFD white
balance. The Tag2010 white-balance fallback, the one table arw.rs keys
by model, takes the body from the maker note's SonyModelID (0xb001)
when the file is not Sony-branded.
Docs: the Lunar is verified in docs/raw/evidence.tsv (236 verified),
raw-parity.md, parity-checklist.md, gaps.md and whats-new.md.
RENDER_CACHE_VERSION 61 -> 62: a Lunar's thumbnails were rendered from
its preview.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For a DNG with its own colour matrix, an identity or absent ProfileToneCurve, no
LookTable and no ProfileGainTableMap, fit only the tone and chroma curves to the
camera JPEG, with the file's colour model untouched (profile tables and embedded
vignetting applied to the sensor proxy as the loader renders it). The fit is used
only when the preview is at least 90% of the raw's long edge, shows the same
picture (structure gate), passes the existing held-out acceptance, and does not
put more of the photo near white than the plain render (highlight gate, at 2x
proxy luminance). Otherwise the render is unchanged. Other formats and
catalogued photos are untouched. Render cache version 62.
library.up / library.down (Up / Down) go to the cell above / below in the
grids (nearest by position in justified rows; the top / bottom row stays
put), and step one photo in the filmstrip.
Sources: own design.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Go to History Step (and new Previous/Next History Step commands) loads the
step's settings and moves a per-photo cursor (`Photo.history_cursor`,
`Op::SetHistoryCursor`, catalog format v8; older catalogs open at the latest
step) instead of appending "History: …". Later steps stay listed, greyed in
the History panel, and can be returned to; a new edit made behind the end
drops them first. Each move is one undo step; undoing an edit restores the
dropped steps and the cursor. `history.list` returns `cursor`.
Also: spots.rs uses as_chunks (clippy chunks_exact_to_as_chunks).
Sources: own design (Lightroom Classic / Photoshop History panel behaviour, observed).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A decoded 64 MP original (Sony A7R VI: 9984×6656 linear float, 797 MB) is bigger than the
default cache share (half of 1.5 GiB). MediaCache::enforce_budget evicted it as soon as the
render handed it over, so every pan of a 1:1 view and every slider move while zoomed decoded
the raw again (and lost the shared GPU copy, keyed by the CPU Arc).
The frontend now tells the cache which photo Detail shows (MediaCache::set_on_screen, set per
frame in panels::compare::prepare); that photo's original is not evicted for the budget and is
let go as soon as another photo or view is shown. Keying it on "no newer preview of another
photo" instead does not work: the neighbour prefetch stores previews after the original.
The decoders keep the alpha plane, but to_working converted only the
colour, so transparent pixels kept the black most encoders store under
them: renders, previews, exports and contact sheets showed it. The
working image is now flattened onto white, in linear light like the
rest of the pipeline.
- Files with no capture date in their metadata or sidecar (e.g. exports saved without
metadata) take the file's modification time, as Lightroom does, instead of all landing
under "Unknown Date" (the loupe overlay showed the import date for them). The import
review and its folder/rename templates use the same date.
- The import review counted files with the same bytes as another file in the source as
"already in the library" (8 on a brand-new, empty library); they are now shown apart as
"identical copies".
- View > Grid Info captions are drawn on Square Grid only; choosing one from Photo Grid now
switches to Square Grid instead of seeming to do nothing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
print.exportPdf now converts each composed page to the job's colour target and writes it into a
print-ready PDF:
- PrintJob gains `color` (PrintColorSettings): Managed by Printer (sRGB / Adobe RGB, profile
embedded, no output intent) or a printer / paper .icc / .icm converted here and named as the
document's output intent; perceptual or relative colorimetric with black point compensation;
Print Adjustment brightness / contrast; print sharpening Low / Standard / High for Matte or
Glossy; 16 or 8 bits per component. print.set / print.get and print templates carry it;
out-of-range values are refused when set.
- Each photo is sharpened for the paper right after it renders at its print size.
- print/output.rs converts and Flate-compresses one page at a time (memory holds one page's device
samples) into ICC-tagged images through engine::pdf; the PDF is 1.6. The 256 MiB cap counts the
compressed pages.
- A printer profile that can't be used (missing, too large, not a profile) fails the print before
anything renders, naming the file.
- contact_sheet's PDF writer goes back to private: only the contact sheet uses it.
Tests: a printer profile reaches the PDF as ICCBased images plus an output intent, a junk profile
fails naming the file, colour settings validate with the job, sharpening and media round-trip
through print.set / print.get, a missing profile fails before rendering with no activity row;
the existing print and CLI tests follow the PDF 1.6 output. Docs: LRC-PRINT-JOB and gap 15.
engine::print_color turns composed pages into a print-ready PDF:
- Profile: Managed by Printer sends the page in sRGB or Adobe RGB with that profile embedded and
no output intent (the driver converts); a printer / paper .icc / .icm converts the page to the
device's values here and becomes the document's output intent (named by the profile's
description), so the driver must not convert again. Profiles over the size cap, unreadable or
invalid files are errors that name the file.
- Intent (perceptual / relative colorimetric), black point compensation and print adjustment
brightness / contrast, through codecs::print.
- Print sharpening Off / Low / Standard / High for Matte or Glossy paper, per photo at its print
size, with the export sharpener.
- 16 bits per component by default, 8 for smaller files.
PrintColorSettings is serde (camelCase, defaults for missing fields) so print templates can save
it. Page sizes are checked with overflow-safe arithmetic. Not wired to the print layout yet.
engine::pdf writes print-ready documents: pages of any size in points with raster images placed
on them, each image tagged with its ICC profile as an ICCBased colour space (RGB, CMYK or grey),
stored losslessly with Flate at 8 or 16 bits per component (16-bit samples big-endian), or copied
as a JPEG stream where file size matters more. A profile shared by several images is written once.
An optional output intent names the printer / paper profile the file was made for (PDF/X-style
/OutputIntents in the catalog); the Info dictionary carries the title, as UTF-16 when it isn't
plain ASCII.
PDF 1.6 (16-bit images need 1.5), classic cross-reference table, deterministic bytes. Pages, images
per page, page and image sizes are capped; bad sizes, placements, sample counts and component
counts are errors, never panics. Tests read the files back through the xref table: image samples
and profiles inflate to what went in, placements reach the content streams, one profile object is
shared. Not wired to the print job yet.
File → Print… (⌘P) opens the Print module as a dialog: settings on the left,
the page drawn to scale on the right with the photos as they will print (fit
or Zoom to Fill, turned by Rotate to Fit, Photo Border, Inner Stroke, page
background).
Settings column: templates (save, apply, delete), layout style, image
settings, grid / package cell controls (standard and custom cell sizes, Auto
Layout, New Page, Clear Layout), rulers and guides (units in/cm/mm/pt, snap
off/cells/grid, grid size, margins, bleed, page grid, image cells,
dimensions), page setup (paper, orientation, margins), page background and
the print job.
Preview: rulers in the chosen unit, dashed margins and bleed, page grid,
dimension labels, page navigation. In the package styles cells are selected,
dragged (print.cell.move) and resized from a corner handle
(print.cell.resize) with the guides' snapping, arranged from the inspector
row or a context menu, and given a photo (Custom Package). Every change is a
print.* command; the drag is measured from where the pointer went down so
the click-versus-drag slop is not lost.
Print to PDF… picks a file and renders on a background thread with an
activity-stack row, cancellation and original protection, like the contact
sheet, which keeps working unchanged.
Headless tests drive the dialog with real pointer input through the control
channel (print:cell:N, print:resize:N, button:print*): styles and checkboxes,
add / drag / resize / arrange / delete, snapping to a neighbour, Auto Layout
and pages, templates, the PDF, the activity row, and that the dialog and its
buttons fit the window.
The Print module's state lives in the session (Session::print), so the app,
CLI, control channel and MCP share one print: layout style (Single Image,
Contact Sheet, Picture Package, Custom Package), page setup, grid, image
settings, page background, guides and job. 18 commands, none journaled
(nothing here touches the catalog):
print.get / set / reset set merges a partial patch, validates the whole
result and changes nothing on error; a new
paper or margin pulls cells back inside
print.cell.add|move|resize|delete|stack|photo|page
print.layout.autoLayout|newPage|clear
print.exportPdf {path, ids?} composes and writes the PDF with an activity
row, refuses to replace an original
print.template.list|save|apply|delete
Templates keep layout, image settings, page and job (not the photos assigned
to cells, not the guides) and are saved in the library's prefs.json next to
the export presets. They are stored as JSON and checked when applied, so a
template from another version or a hostile one is an error on use and never
makes the preferences file unreadable.
Tests drive every command through the dispatcher (hostile numbers, unknown
keys, caps, persistence across reopening a library) and a CLI end-to-end test
chains set / add cells / Auto Layout / PDF in one run.
print/compose.rs turns a layout and its photos into PageRasters: 16-bit
interleaved RGB encoded in Rec. 2020 at the job's pixel density. Each cell's
photo is rendered with the export pipeline at exactly the size the cell needs,
then fitted (whole photo, centred) or zoomed to fill (centre crop), turned a
quarter clockwise when Rotate to Fit makes it print larger (or crop less),
framed by the Photo Border (package styles only) and finished with the Inner
Stroke drawn inside the photo edge. The page background, border and stroke
colours are given in sRGB and converted to the page encoding.
Photos go to cells by layout style: Single Image and Contact Sheet paginate
the selection, Picture Package repeats the first photo, Custom Package prints
each cell's own photo. The job is prepared on the session and run off it with
progress and cancellation, like the contact sheet; page and job pixel counts
and page counts are capped so a hostile resolution or paper cannot exhaust
memory.
print/output.rs holds the interim PDF writer (Rec. 2020 to 8-bit sRGB JPEG
pages sized to the paper in points) through the contact sheet's Pdf, now
pub(crate). PrintJob carries the print resolution (72 to 720 ppi, 240 default).
Tests draw on synthetic images for exact pixels (borders, strokes, crop,
rotation direction, resampling) and on the demo library for whole pages.
crates/engine/src/print/layout.rs is the pure page model the Print module
builds on: ten named papers plus custom, orientation, margins and the
printable area; Single Image, Contact Sheet (grid with spacing and square
cells), Picture Package and Custom Package styles; package cells with a
stacking order (send to back / backward / forward / bring to front); a
deterministic MaxRects packer behind Auto Layout (largest first, earlier
pages filled before a new one opens, a cell turned a quarter only when it
fits nowhere as given); snap-to-cells and snap-to-grid helpers; ruler units
and guide options.
All lengths are inches in page space, after orientation. Every number from
outside is checked (NaN, infinity, negative, oversized, cell and page caps)
and Layout::validate guards saved layouts.
No command or UI reaches it yet, so the parity rows keep their status.
With XMP auto-write on, photo.readMetadataFromFile committed what it read
and the command handler then wrote the sidecar back, adding lc:settings to
a sidecar that had only another app's crs: settings. Later reads prefer
lc:settings, so LRTimelapse's next crs: edits were ignored. The read now
sets the existing one-command skip_auto_write switch (as photo.removeRegion
does); edits made afterwards are still auto-written.
Tests: a read leaves the sidecar byte for byte and a later develop edit is
still written; the issue's crs:Exposure2012 0.5 -> 1.2 round trip is read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A manual album shown in its own order sorted with position() per photo
(O(n^2): 2.0 s for 20k photos, now 18 ms). album.addPhotos, removePhotos
and toggleTarget test membership with sets. The 100k scale test measures
all three.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
libtest has no skipped status and hides the output of passing tests, so a
test that returns early without a GPU, a corpus file, a craft-fonts face or
a model passed silently. `lightcraft-testkit` (the testkit slot the layering
table already reserves, dev-dependency only, std only) adds
`skip!(Need, "reason")`: it prints the note as before and, when
LIGHTCRAFT_SKIP_LOG names a file, appends one JSON line (need, reason, test,
binary, file:line). Plain `cargo test` is unchanged. The 61 existing skip
sites use it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EngineError gains Cancelled and ModelMissing { model: AiModel } and a
kind() -> ErrorKind, so frontends decide on what an error is rather than
on its words. The face detector, SAM 3 model checks and the Lightroom
job produce them at the source; the error text is unchanged, so control
channel and MCP clients see the same `error` strings.
Background results that cross the wire as JSON get an additive flag
instead: SAM 3 download status `cancelled`, Apple Photos import outcome
`cancelled: true` next to `skipped`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A panic inside RenderJob::run or QuickJob::run (a raw decoder or pipeline bug) unwound the
job pool's worker without sending a result. The slot stayed pending and its key was never
asked for again, so the loupe or the thumbnail rendered forever; and the pool never
replaces a dead worker, so after as many such panics as it has workers (2–6) no photo
rendered until LightCraft was restarted.
Both now catch the panic (guard::catch) and return it as the job's failure, answering the
request it came from, so the renderer records it like any failed render: no retry until
the catalog changes, and the workers live on. run() is the one place every render goes
through (the pool, export, Build Previews, contact sheets, face and AI-mask workers);
callers that had their own guard keep working, and the watermark preview's, now
redundant, is gone. The web build aborts on panic, so nothing changes there.
Co-Authored-By: Claude <noreply@anthropic.com>
A lensfun profile is scaled by the crop factor of the recorded frame. `lookup` took the body's from the
database whenever the camera was in it, so a full-frame body shooting a crop of its sensor (APS-C /
Super 35 mode, or switched there by an APS-C lens) had the profile applied as if the crop were the full
frame: the crop's corners got the full frame's corner distortion and vignetting. On a Sony ILCE-1M2 in
APS-C mode with the FE 100-400mm F4.5-5.6 GM OSS at 400 mm, the warp's r³ coefficient was the full
frame's 0.0215 instead of 0.0096.
The 35 mm equivalent focal length shows the crop: when it implies a crop factor more than 10 % above the
body's, that wins (`frame_crop_factor`); within 10 % the difference is the tag's whole-millimetre
rounding and the body's value stands. Without the body in the database the 35 mm equivalent is used, as
before.
Tests: `in_camera_crop_mode_uses_the_frames_crop_factor` (fails on main: in its profile the APS-C frame's
vignetting gain is 0.40 instead of 0.18) covers APS-C mode, full frame, a 35 mm equivalent rounded by a
millimetre, no body in the database and a hostile focal length. Checked on 19 private ILCE-1M2 ARWs
(FE 100-400mm GM, Sigma 24-70mm F2.8 DG DN | Art, full frame and APS-C, firmware 2.01-4.00): every
APS-C coefficient is the full-frame one scaled by 1/1.5².
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Photo Merge decoded each raw itself and wrote the reference frame's own colour
tags into the merged DNG. A CR2 (and NEF, ARW and the other raws whose colour is
fitted from the camera JPEG) has none, so an HDR merge of CR2s rendered with no
camera colour: green and about 16 L* dark.
`lightcraft_merge::load_frame_with` takes the raw's colour tags from the caller,
and the engine passes `starting_colour_tags`: the colour the raw opens with
(`starting_colour`) as DNG tags. That is one forward matrix with a colour matrix
and AsShotNeutral that agree with it at the as-shot white, the look's hue/sat
table as ProfileHueSatMap and its tone curve as ProfileToneCurve. Raws with
their own matrices (DNG) keep their tags. The look's chroma curve has no DNG
tag and is left out.
On two CC0 CR2s (EOS 550D, 40D) an HDR merge of three copies against the photo
alone: mean CIE76 dE 26.15 -> 1.07 and 22.89 -> 0.47.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts in crates/ui-egui/src/lightroom_import.rs: main moved the
Lightroom import's progress from its own window to the shared activity
stack, and this branch had added a second bar to that window.
Resolved by keeping main's activity row and feeding it this branch's
step-by-step progress (LightroomProgress): the row counts the step under
way (the catalog, reading the photos, adding them) and names it, so it
moves while the photos are read. The progress window stays removed; the
whole-import fraction is still in the task's status.
The engine half of this branch merged without conflict and is what main
still lacks: reading the photos is counted, and Cancel stops it.
main's two activity tests now drive the task through LightroomProgress.
"Reading photos…" and "Adding photos…" are added to the language
catalogs, cut from the existing "… N of M" translations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sources: TIFF 6.0 and own black-box analysis of the three CC0 samples;
white-balance estimate from the embedded preview: own design.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
New export format `rendereddng` (export dialog: Format DNG, "DNG of: Rendered
photo"; `app.export`; MCP export tool): the rendered photo, with the edit,
crop, size, output sharpening and watermark baked in, written by the new
`lightcraft_raw::write_rendered_dng` as a LinearRaw DNG of 16-bit float Deflate
tiles in the export colour space. ColorMatrix1 (XYZ -> space), ForwardMatrix1
(space -> XYZ D50), a neutral AsShotNeutral and an identity ProfileToneCurve
make raw converters show it as rendered. HDR edits keep highlights above white.
Bad input (wrong sample count, zero or huge sizes, NaN/inf, out of half-float
range) gives an error or is clamped, never a panic.
Also fixes the Rust 1.99 clippy lint chunks_exact_to_as_chunks in
pipeline/spots.rs that fails `cargo xtask ci` on main.
Sources: Adobe DNG Specification 1.4+ (LinearRaw, ColorMatrix/ForwardMatrix,
ProfileToneCurve); own design.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The \0MRM block container: PRD sensor size, storage and Bayer cell; WBG
as-shot gains in cell order; TTW TIFF for Exif and orientation. Black 0,
white from the saturation plateau. 4 CC0 samples pinned in RAW_SAMPLES;
DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D verified. Also fixes
clippy::chunks_exact_to_as_chunks in pipeline/spots.rs.
Sources: public prose description of the MRW layout (Dalibor Jelinek's
MRW notes) and own black-box analysis of the CC0 samples.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brush strokes record a pen pressure (0..1) per point from egui touch
force while Brush > Pen Pressure is on (default). Each dab's radius goes
from a tenth to all of the brush, and its flow is proportional to the
pressure, in the committed stroke and the live preview alike. A mouse
stroke stores no pressure, so documents and renders are unchanged. The
GPU mask kernel leaves pressure strokes to the CPU. mask.brushStroke takes
`pressure`; bad lists are refused. Also satisfies clippy 1.99's
chunks_exact_to_as_chunks in spots.rs.
Sources: own design.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The header-only path added in #755 answers for a Fuji RAF, whose preview pointer sits in the first
bytes. A Canon CR3 keeps its preview in a JPEG track, and a TIFF raw (CR2, ARW, DNG, RW2, 3FR …) can
keep its preview tag in an IFD the prefix cuts short: both fell back to `std::fs::read` of the whole
file — tens of megabytes for a JPEG of a few, on the format of the largest bucket in gaps.md (Canon,
33 % of photographers).
`preview_window` now asks `lightcraft_raw::sparse::preview_plan` — the module #961 added for the
import path, which had no production caller — which ranges `embedded_preview` reads, and reads
exactly those: `planned_preview` fills a buffer of the file's length, reads the ranges the plan names
and asks again until it says `Ready`. The buffer is the file's length, which the memory gate counted
already, and its untouched pages stay zero, so what this path costs is the bytes read — on macOS a
freed buffer of that size may sit in the allocator's cache rather than go back to the OS (measured on
a 38k-photo import, #832). The loop gives up past 60 % of the file, the bound step 2's partial read
uses, and a plan that does not fit, a file past 1 GiB or a window that does not decode all end in the
whole-file fallback.
The quick path hands the JPEG to the decoder alone, so two container facts the whole-file path applies
have to be visible in the bytes it is handed, or the file declines and the whole file keeps the
preview:
- a preview colour space of Adobe RGB (`decode_raw_preview` applies it; a JPEG-only decode has the
JPEG's own space, else sRGB). A container that says sRGB does not decline — that is what the decode
assumes anyway, and declining it took the fast path away from a real Sony ARW;
- the file's own EXIF orientation, when the preview JPEG carries none of its own: the plan's header is
a bounded 64 KiB (the header route grows its prefix to the preview's declared extent instead), and
an orientation tag it cannot see would show the preview unrotated.
Measured over the whole CC0 corpus (111 raws): 52 files take this route, reading a median 7.8 % of
their bytes (0.9-27.7 %) — RW2 3.3 %, Leica RWL 4.1 %, ARW 7.5 %, CR3 8.4 %, DNG 9.7 %, CR2 14.3 % —
while the 21 RAFs stay on the header route (median 4.5 %). The other 38 read whole: NEF, NRW, ORF, PEF
and SRW because the planner does not describe them (an SRW plans to the whole file, which the 60 %
budget then declines), plus the ARWs, CR2s, CR3s and DNGs that declare Adobe RGB or whose plan wants
more than that budget — the bytes they read are the ones the plan would have read anyway. Across all
111 files the plan handed on the same preview as the whole file, with the skipped bytes zeroed and
again 0xFF. `LIGHTCRAFT_PROFILE=1` prints
[profile] preview …/arw-sony-a7rm4a-compressed.arw: read 688128 of 61832192 bytes (plan, window 483441)
Tests: a synthetic TIFF/DNG whose preview only its tags locate (identical pixels to the whole-file
path, the CFA strip unread), the same structure without the tag that makes it a raw, a file whose
orientation only the whole file shows, and a file that states Adobe RGB against one that states sRGB;
the opt-in real-file test reports which path answered.
Co-Authored-By: DeepSeek V3.2 <noreply@deepseek.com>
Soft proof is per-pixel, so the After window carries the proof like the
whole-frame render under it (the proof key is part of the tile's look).
New tests: a 1:1 window of a 24 MP photo equals the full-size export to
within one level; soft proofing at 1:1 is a canvas-sized frame plus a
native window. profile_slider_drag gains a soft-proof case. Gap 4 closed,
checklist row to done with measured Linux latencies.
Sources: own design.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Mask effects gain groups, stars, partial presets (only some sliders, with or
without Amount), optional brush settings and the settings an imported preset
carries that we can't apply (kept for export). New commands: mask.updateEffect,
renameEffect, duplicateEffect, favoriteEffect, setEffectGroup,
renameEffectGroup, toolEffect; applyEffect takes several masks, all masks, or
the same-named masks on the selected photos in one undo step. A new mask starts
with the effect last applied to a mask of its kind (kept with the library).
Import (mask.importEffects and preset.import): Lightroom Classic local
.lrtemplate, Camera Raw local-correction .xmp, .lceffect and zips of these;
folders and LrC divider presets ("10 ----- DETAIL ----- 10") become groups;
grain / Point Color / toningLuminance reported and kept. Malformed files are
errors, never panics.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Named sets of local adjustment sliders, picked from a new Effect menu
above the mask sliders in the Masking panel (apply sets every slider and
keeps Amount; save current; import; right-click delete; the menu names
the matching effect, else Custom). Saved with the library preferences.
Commands: mask.effects, mask.applyEffect, mask.saveEffect,
mask.deleteEffect, mask.importEffects, mask.exportEffects (.lceffect).
Lightroom local adjustment presets (.lrtemplate with type
LocalizedAdjustmentPreset) are read by mask.importEffects and routed
there by preset.import / File > Import Profiles & Presets, which used to
reject them as having no develop settings. Their lower-camel Local* keys
map through the same table as XMP mask fields (crs_masks::local_values);
sliders without a counterpart (toningLuminance, grain) are reported.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Lightroom collection imported whole (11 113 presets, a 42 MB presets.json)
made every interaction take seconds:
- Session::persist ran after every command and serialized the whole preset
list to compare it with the last write: 609 ms per slider tick (release,
the real collection). Session::presets is now a PresetList whose generation
changes on mutable access; persist serializes only when it (or the profile
lists) changed, and skips the write when the bytes hash the same.
develop.set: 609 ms -> 2.3 ms; preset.apply: 598 ms -> 3.1 ms.
- The Presets panel deep-cloned every preset into a BTreeMap, built a row,
widget id and (with thumbnails) a variant render job for each of them every
frame. Grouping is cached per generation and only rows in view are laid
out: 5041 -> ~20 rows a frame, 2.3 ms with 5000 presets.
- The preset pickers (Import dialog, Settings > Import) build only the rows
in view.
- add_presets and opening a library compared every preset with every other
(quadratic); both use hash indexes now. Opening no longer serializes the
list once just to remember it.
Tests: develop commands don't re-serialize unchanged presets (and a change
is still saved), generation semantics, a 20 000-preset import, and the
panel building only visible rows (fails on the old code: 5041 rows).
An ignored bench (presets_scale_bench, LIGHTCRAFT_BENCH_PRESETS) times a
real presets.json.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
20 engine tests failed on Windows on clean main. Three real bugs on
case-insensitive volumes (Windows, default macOS APFS) caused most of them:
- Renaming a photo probed `a.XMP` before `a.xmp`. On a case-insensitive
volume the upper-case spelling "exists" when only `a.xmp` is there, so the
sidecar was moved as `Trip.XMP` (and its retained-packet record was taken
under the wrong spelling). Upper-case candidates are now used only when the
folder lists them exactly (new `MoveFs::listed`).
- `RealFs::same_file` on Windows (no file id in std) depended on argument
order: `same_file("Trip.XMP", "Trip.xmp")` was false when `Trip.xmp` is the
real entry, so saving after a partial rename reported a phantom collision.
It is now symmetric, and also requires the names to differ only by case
(two different files with equal size and mtime were "the same").
- Saving metadata picked the sidecar spelling by `is_file()`, lower case
first, so the atomic write renamed an existing `Trip.XMP` to `Trip.xmp`.
The spelling the folder lists now wins.
The rest were fixtures: FakeFs fault paths, catalog paths and expected
paths spelled with `/` while the code joins with the host separator.
Regression tests: case_insensitive_renames_keep_each_sidecars_spelling
(FakeFs, every platform; fails without the fix) and
same_file_spellings_are_symmetric_on_windows. Linux behaviour unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AVIF HDR export takes hdrTransfer = pq | hlg (export dialog: HDR curve;
MCP export schema lists it). HLG writes CICP transfer 18 and no clli box.
The window logs whether it shows HDR; today always SDR (eframe configures
an 8-bit surface), with the platform / surface format / headroom / opt-out
rules unit-tested. Also satisfies clippy 1.99's chunks_exact_to_as_chunks
in spots.rs.
Part of #868.
Sources: ITU-R BT.2100 (HLG OETF, inverse OOTF with system gamma 1.2),
ITU-R BT.2408 (203 cd/m2 reference white at 75 % HLG); own design for
the display decision.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Preserve page-backed large-catalog import while integrating upstream catalog and documentation changes. Adapt only the sparse test fixture to the shared upstream fixture helper.
Use the equivalent complete RGB-triple iterator required by Rust 1.99 Clippy; the same lint failure was reproduced on the exact upstream baseline.
Validation: 47 catalog tests, nine spot tests, exact sparse regression, all eight CI gates, and an original synthetic headless import/selection check passed.
Generated with Codex.
DNG export gains a "Lossy" compression: the raw data is demosaiced to linear
camera RGB (OpcodeList3 and the default crop applied), scaled to the resize long
edge when one is set, and stored as 8-bit baseline JPEG tiles at the export
quality. The codes follow a 1/2.2 power curve that a LinearizationTable maps
back to linear 16-bit values; colour matrices, white balance and the profile
look are kept. Available in the export dialog, `app.export`
(`dngCompression: "lossy"`) and the MCP export tool.
Also fixes a new clippy lint (chunks_exact_to_as_chunks) in pipeline/spots.rs
that fails `cargo xtask ci` on Rust 1.99.
Sources: Adobe DNG Specification 1.4+ (Compression 34892, LinearizationTable);
own design for the code curve.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
app.export takes destinationMode (specificFolder | sameAsOriginal); the
Export dialog shows it as "Export to". In sameAsOriginal mode each photo
is written into its original's folder plus the optional subfolder, with
the usual conflict policy and original guard per folder. A photo with no
folder on disk or whose folder is missing gets its own error. Saved in
presets and Export with Previous like the other options.
Fixes#956
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tests were written for a case-sensitive disk. rename.rs sidecars() offers both
.XMP and .xmp per naming and keeps those that exist; on case-insensitive APFS
a.XMP "exists" for a file stored as a.xmp, so the code renames a.XMP while the
fault injectors (Faults twice, PartialRename) compared exact paths with the
lowercase one: the injected failure never fired and the rename the tests expect
to be refused succeeded.
Fault injectors now match by same_path_or_file (equal path, or the same file by
identity); two assertions that compared an exact spelling use same_path_for_volume
(equal, or equal ignoring ASCII case on a case-insensitive volume), and one that
required the lowercase packet to be absent is skipped there, where both spellings
are one file. Helpers live in tests_xmp.rs. Production code is not touched.
Run with rustc 1.99.0 on macOS: case-insensitive APFS, the ten failed before and
pass now; case-sensitive APFS (a disk image), the original tests pass and so do
these. All 13 sidecar::tests_repair tests pass on both. cargo fmt is clean and
clippy over lightcraft-engine with --all-targets is clean. Not touched: the
.xmp -> .XMP spelling change on rename, and the Lightroom folder test (its own PR).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Photos that tie on the sort key (equal ratings, one import batch, a
burst within a second) now sort by file name before id, in
Catalog::query, Recently Added and auto-stack. Split out of #641.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
APFS refuses names that are not valid UTF-8 (EILSEQ, os error 92), so the test's
create_dir_all(...).unwrap() panicked on macOS. The test now probes once with a
\xff folder; where that fails, the non-UTF-8 folder case and the non-UTF-8 entry
are skipped and the catalog-folder and symlink cases still run on a plain-named
folder. Where the probe succeeds (Linux, FreeBSD) the test is unchanged.
Run on macOS (case-insensitive APFS, rustc 1.99.0): the five tests in
tests_lightroom_folder pass, cargo fmt is clean, and clippy over lightcraft-engine
with --all-targets is clean. The ten sidecar tests of item 3 are not touched.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>