- A full-size draft's time is its render plus the CPU time of the frame
that uploaded it (eframe's cpu_usage, Perf::host_frame_ms): render
times alone said 231 of 234 1:1 drafts fit 16 ms while no complete
update did (PR #1009 benchmarks).
- A drag whose full-size drafts take more than twice the budget drops to
0.6 after three of them instead of at its end (a refined mask ran at
190 ms a step for the whole first drag).
- Under a zoom window the hidden whole-frame draft is the 0.6 one, and it
is not timed (the frame's time there is the window's upload).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A 10 MP zoom window's ColorImage conversion ran on the UI thread on every
slider step. The pool now hands back the render with its texture pixels;
offloaded (browser) results and results presented again on the UI thread
are still converted there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Before window waited for the original only when the After's window job was already pending,
but side by side the Before is asked for first in the frame, so both decoded the 64 MP raw at
once; and a window asked for again while the first was still decoding (a pan, the layout
settling at startup) started another full decode beside it (three decodes seen at startup).
Any window now waits while a window job is pending and the original isn't held, and follows
from the decoded original.
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>
The Print dialog's Print Job panel, under Resolution:
- Profile: Managed by Printer (sRGB), Managed by Printer (Adobe RGB), or Other… to pick a printer /
paper .icc / .icm with the colour-profile file picker the soft proof uses; the picked profile's
name is shown.
- Intent: Perceptual / Relative; Black Point Compensation (enabled for Relative).
- Print Adjustment: Brightness and Contrast, -100..100.
- Sharpening: Off / Low / Standard / High; Media: Matte / Glossy (enabled while sharpening is on).
- 16 bits per channel.
- A summary line: PDF, profile, depth, current edits.
Each control sends print.set {job: {color}}, so the dialog, CLI, control channel, MCP and print
templates share the settings. UI test: every control reaches the job (the Print Job panel is
scrolled into view first, as a person would), Other… picks a profile from the picker, and the PDF
printed with it carries the output intent at 8 bits.
A thumbnail is kept at one size. The page preview asked for each cell's photo at the cell's size
on every frame, and the library grid behind the dialog asked for its own size, so the two replaced
each other's request: while the Print dialog was open a thumbnail render was always in flight and
about two render jobs were rebuilt per frame, keeping a core busy. The preview now uses the
photo's thumbnail when there is one and asks for one only when there is none.
Regression test: with the dialog open in Contact Sheet, Picture Package (the same photo in cells of
different sizes) and Single Image, the dialog settles, no render is in flight and no thumbnail job
is rebuilt over 60 idle frames. Before the fix the job count rose by about 100 every 50 frames.
The Print dialog's UI tests now finish in seconds (they were waiting out each settle timeout).
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.
codecs::print converts a composed print page (16-bit RGB in the Rec2020 output encoding) to the
samples a print-ready file carries:
- PrintTarget::Profile(icc): a printer / paper profile, RGB, CMYK or grey, matrix or LUT based,
converted with moxcms; PrintTarget::Managed(space): the printer manages colour and the page goes
in sRGB or Adobe RGB with that profile embedded.
- Perceptual or relative colorimetric (ProofIntent, as soft proofing).
- Black point compensation for relative colorimetric: the device's darkest is measured by a round
trip of black through the profile and linear values are scaled to land the page's black there
(Y' = Y(1 - Yb) + Yb, the published mapping for a neutral destination black), so shadows below
the ink's black no longer clip to one value. Perceptual tables keep their own black mapping.
- Print adjustment brightness / contrast (-100..100) on the sRGB-encoded values, black and white
held, monotonic at every setting.
Everything before the profile is per channel and folds into one 16-bit table; pages convert in
bounded batches (parallel with the `parallel` feature). Hostile profiles, half pixels and NaN
settings are errors or neutral, never panics. 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.
Italian (`it`) catalogs: 1,992 interface messages and all 246 format strings
(2,201 of 2,398 messages; What's New left in English like most languages).
410 labels reuse the PhotoCraft, VectorCraft, PdfCraft and WordCraft Italian
catalogs where the meaning fits a photo app; 46 were retranslated.
Counts follow a label ("Foto importate: 3") because Italian adjectives agree
with the number; the English plural suffix is consumed with {:.0}.
Language table entry, menu command, three tests mirroring Ukrainian's, the
button-fit test extended to Italian, and the Settings language buttons now
wrap so an eleventh language doesn't widen the dialog past a short window.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Add the Czech catalog (2,056 UI messages, 246 format templates), the
language-menu entry, locale detection (cs, cs-CZ, cs_CZ.UTF-8) and
regression tests for switching, persistence, dates, placeholders and
glyphs. Translate a few face/model, person and HDR/PDF captions through
the existing tr() interface. Settings > Language now wraps its buttons,
since twelve language names no longer fit a narrow window.
What's New prose stays English, like most catalogs. AI-assisted, with
an independent review pass; not yet proofread by a native speaker.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For Overlay::AllMasks, the masks buffer already holds each visible mask's
alpha back-to-back in the same order the single-mask overlay uses
(cx.slice(all, mi*n, n)). Read the whole buffer once with cx.read instead
of one synchronous read_plane per mask, then split it into per-mask Planes
on the CPU. Same bytes, but the pipeline stalls once instead of N times.
The wheels had Shift (keep the hue, change saturation). Lightroom's other
two modifiers are now there too:
- Cmd/Ctrl-drag keeps the puck's saturation and changes only the hue, so it
moves round its circle; the circle is drawn as the guide, as Shift draws
the spoke. Shift wins when both are down (keeping both would freeze the
puck).
- Alt/Option-drag fine-adjusts: the puck moves a tenth as far as the pointer
from where it was grabbed (the slider's fine-adjust factor), counted from
the press so no travel is lost to egui's drag threshold. It combines with
Shift and Cmd/Ctrl.
- Pressing or releasing a modifier mid-drag re-grabs the puck as it is at
that moment (WheelGrab); wheel_pick takes a WheelLock (Hue / Sat) and
returns finite values for any input.
- A puck at the centre has no hue, so a Shift-drag from there now takes the
angle where the pointer pressed, not where egui first reported a drag.
- The wheel has a hover tooltip naming the modifiers.
Shift-drag already kept the puck's own hue whenever its saturation was 1 or
more; shift_drag_keeps_the_pucks_hue_wherever_the_pointer_grabs pins that.
Tests (tests_grading.rs) drive the wheel through ui.dragWidget with each
modifier and check the develop values and one undo step per drag, plus unit
tests of wheel_pick (lock, clamp, centre, NaN/inf) and WheelGrab.
After `lightcraft --control 7980` was running, the natural next thing to type
was `lightcraft-cli run --connect 7980 ui.inspect`. The CLI's argument parser
only consumed the value if it contained a colon (it was looking for a full
`HOST:PORT`), so `7980` fell through and was parsed as a command id, producing
`{"error":"unknown command `7980`","ok":false}`. The default address was used
behind the scenes, but only by coincidence matched `127.0.0.1:7980`.
The parser now also consumes a token that is all digits (a bare port) and
formats it as `127.0.0.1:<port>`. Non-numeric, non-`HOST:PORT`-shaped tokens
(e.g. `ui.inspect` after `--connect`) keep falling through so the existing
`--connect ui.inspect` form (use the default address, then run ui.inspect)
still works.
Two parser sites have the same shape — the global `--connect` in `cli()`
and the per-step `--connect` in `run_step()` — and both are updated.
Test: connect_bare_port_is_treated_as_loopback_port. cargo test -p
lightcraft-cli: 21 passed, 0 failed. cargo clippy -p lightcraft-cli
--no-deps clean.
- view.maskOverlayAll: Cmd+O clashed with library.open; rebind to Cmd+Shift+O
- Masking panel tooltip now derives the key label from the binding (platform
aware: Cmd vs Ctrl) instead of hardcoding Ctrl+O; base string localized
- gpu/render.rs: rewrite the AllMasks alpha gather as a for-loop so it passes
cargo fmt --check (max_width 150)
- docs/parity-checklist.md: add LR-MASK-OVERLAYALL row
- locales: add 'Show All Mask Overlays' and 'Show all overlays' to de, es, fr,
ja, pt-br, ru, uk, zh-hans, zh-hant
Adds the 36 face-recognition entries Spanish lacked, translates 100 that
still held the English text (AI Denoise and its messages), and applies
docs/localization-es.md: infinitive instructions instead of tú, Upright
as «Vertical», Grab bag and Develop corrected.
AI-assisted, native review welcome.
Models: Opus 5.5, Terra 5.6, DeepSeek V4 Flash
Part 1: verified = latest release tag policy in docs/user/AGENTS.md; released-pages sentence in root AGENTS.md. Part 6: blank line in install.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hands each selected photo's original file itself (a raw stays a raw: no
render, no copy, nothing added to the library) to the editor set in
Settings ▸ External Editing ▸ Other editor, e.g. DxO PhotoLab. Also on a
photo's right-click menu (Edit In).
- `photo.openOriginalInExternal {ids?, app?}` (UI command, menus.rs): up to
50 photos; demo photos and missing files are skipped with the reason; when
none has a file it fails saying why (toast + error, no panic). Enabled when
the active photo has a file and the host can start programs (not the web).
- Several photos go to one start of the editor (`Services::open_all_with`,
editors.rs `open_all_with`): a single-window editor such as DxO PhotoLab
drops the files that arrive while it is still starting when started once
per file. The system default ("" editor) still opens them one by one.
- Settings hint, translations of the two new toasts, parity rows
LR-EXP-PSD / MENU-FILE-PHOTOSHOP.
Tested by hand on Windows with DxO PhotoLab 8 (one and several raws).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`cargo xtask ci` failed at clippy on Windows with Rust 1.98.1 before any
feature work:
- crates/pipeline/src/spots.rs: clippy::chunks_exact_to_as_chunks (new lint);
`as_chunks::<3>()` is what crates/codecs already uses, same behaviour, no
indexing.
- crates/catalog/src/lock.rs: `descriptor_copy` is only used by a
`#[cfg(unix)]` test, so it was dead code in Windows test builds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finish the initial UI layout and assert font readiness before timing the existing views. Preserve the 300 ms limit and final probe assertions.
Co-authored-by: Codex <codex@openai.com>
Compare and Reference View are where focus is checked at 1:1, and their panes show the same
stand-ins as the loupe: a thumbnail while the photo renders, the last render after an
edit, the magnified frame under a window not in yet. Each pane now shows the loupe's
"Rendering…" pill once that has lasted half a second with nothing new (widget
`compare:rendering:<pane>`), and its failed pill ("This photo can't be opened", or
"Original offline · no smart preview", the reason on hover) when the render failed behind
a thumbnail (`compare:renderFailed:<pane>`). Survey's small fit tiles don't.
Compare panes have no draft renders, so a slider drag on the photo would leave its pane
saying "Rendering…" for the whole drag: the photo being dragged doesn't count until the
drag ends. Each pane's wait belongs to the photo in it, so one that moves in (swap, Make
Select) starts its own. A retry of a failed render keeps the failed pill, as in the loupe.
Co-Authored-By: Claude <noreply@anthropic.com>
A photo whose render failed while the loupe showed its embedded JPEG or a thumbnail kept
that stand-in up as if it were the photo; "This photo can't be opened" only showed when
nothing at all could be drawn. Now a caution pill at the "Rendering…" spot says so:
"Original offline · no smart preview" when that is why, else "This photo can't be opened",
with the reason on hover and for screen readers (widget `loupe:renderFailed`). Agents see
it as `ui.inspect` → `loupe.failed`, so one waiting for a finished loupe knows when there
won't be one.
A library change retries failed renders; while the retry of the same key runs, the pill
stays and "Rendering…" doesn't take over. The renderer remembers which photo a failure
belongs to (`failure_of`): the centre message no longer shows the previous photo's error
for a moment after a step. Tests share one render offload (`headless::RenderGate`) that
holds renders and can fail chosen slots.
Co-Authored-By: Claude <noreply@anthropic.com>
The DNG CFAPattern describes the top-left of the ActiveArea, but RawImage::cfa is sensor-anchored (the crop to the active area shifts it by the area's offset). The DNG reader now moves the tag's pattern out to the sensor origin, and the DNG writer states the pattern back at the ActiveArea. A DNG whose area starts on an odd row or column (the Canon EOS 60D DNG of #1086, top 51) no longer reads RGGB as GBRG.
Cause: a floating-point CFA DNG is read as stored (no check of its float
samples). AHD and PPG clamp each green estimate between its two neighbouring
greens with `est.clamp(a.min(b), a.max(b))`; when both greens are NaN the
bounds are NaN and `f32::clamp` panics (ppg.rs:45, ahd.rs green pass). Any
float DNG with two adjacent NaN greens crashed decode + develop. Infinities
alone did not panic but turned into NaN in the demosaic arithmetic.
Fix, at two layers:
- demosaic: a `clamp_between` helper replaces the three clamps. With finite
or infinite bounds it is the same `f32::clamp` call (bit-identical
results); with NaN bounds it returns the estimate unclamped, so the panic
is gone whatever puts NaN into the mosaic.
- DNG decode: non-finite float samples are replaced where the white level is
known. NaN and -inf read as black (0), +inf as the white level (a clipped
highlight). No sensor records NaN or infinity, so a valid file is never
touched; finite samples, negative or above white included, stay as stored.
Tests: `demosaic::tests::non_finite_samples_do_not_panic` (NaN block, +/-inf
and an all-NaN mosaic through Bilinear, PPG and AHD; plus `clamp_between`
equal to the old clamp bit for bit over finite, signed-zero and infinite
values) and `non_finite_float_samples_decode_and_develop` (a float CFA DNG,
uncompressed and Deflate, with NaN and +/-inf samples: decoded values
checked, develop with all three methods gives a finite image). Both panicked
before the fix at ppg.rs:45 / ahd.rs.
Co-Authored-By: Claude <noreply@anthropic.com>