- Light panel: HDR switch, headroom limit and an SDR Rendition flyout
(shown with HDR on); Visualize HDR beside the switch.
- `Overlay::HdrRange` / `view.visualizeHdr`: the SDR render dimmed to grey,
with pixels the HDR rendition puts above SDR white painted in four bands by
stops above it. Rendered on the CPU from the float HDR render.
- Export dialog: "HDR output" for JPEG (gain map) and 32-bit TIFF.
- Parity (Q. HDR 2 ✅ 3 🟡, LR-EDIT-HDR-MODE ✅, LR-EXP-HDR 🟡), README, ROADMAP.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `DevelopSettings.hdr`: enabled, headroom limit (`max_ev`) and SDR-rendition
offsets (brightness, contrast, highlights, shadows, whites, clarity), with
control specs (`hdr.maxEv`, `hdr.sdr*`) and copied with the Light group.
- `ToneMap::hdr` / `display_hdr`: the SDR curve through the midtones, with
highlights lifted up to `2^max_ev`; identical to the SDR tables at peak 1.
- `OutputDepth::F32Hdr`: unclamped linear output up to the peak. The finish
stage carries the peak through highlight desaturation, gamut mapping,
encoding, curves, grain and LUT profiles; with HDR off every branch is the
SDR code path, bit for bit.
- `settings_for`: every non-HDR render (CPU and GPU) uses the SDR rendition.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Auto was a percentile rule that set Exposure about 1.4 EV higher than
Lightroom's Auto on the same photos (mean +1.81 against +0.44 EV) and
barely used Shadows: on 24 Sony raw photos its results were further from
Lightroom's Auto results (median dE76 22.0) than leaving the photo
unedited (7.9), about 18 L* too bright.
Auto is now Lightroom's shape of rule with values chosen for our results:
- Exposure = -0.58 - 0.41 * the scene's log-average luminance (EV from
middle grey, scene-linear, under the photo's white balance, so it does
not depend on the base tone curve), clamped to -2..3 EV. The slope is
Lightroom's: its Auto's chosen Exposure against our log average, r -0.92;
- Highlights -66 and Shadows +52: Lightroom's own average Auto values;
- Contrast +68 and Vibrance +30 (our scales; our base tone is flatter);
Whites, Blacks and Saturation 0.
The exposure offset, Contrast and Vibrance were fitted jointly so our
renders match Lightroom's Auto exports on the 16 photos whose starting
look Sony's DRO doesn't brighten (#568 changes the other 8). Held out one
photo at a time, each fold fitted from its own training photos only:
median dE76 6.38 on those 16 (8.28 unedited, 18.81 with the old rule),
12 of 16 closer than unedited; 7.70 on all 24 (7.92 unedited); on the 8
DRO photos 9.39 (7.00 unedited), their brighter start showing through.
Freeing Highlights and Shadows too (5 fitted values) did no better held
out (6.51) and left them poorly determined across folds.
The log average skips pixels that aren't finite (a damaged decode) through
the proxy's resampling and white balance; with no valid pixel, Exposure 0.
Values are black-box observations of Lightroom (the settings its Auto
chose and its exports), as in #514. Auto only applies when asked for, so
existing edits keep their look; HDR merges with Auto Settings use the
same rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Previews were always sRGB, which wide-gamut monitors show oversaturated.
Settings > Display > Choose Profile... (and the UI command
app.displayProfile) now shows photos through the monitor's ICC profile,
matrix/TRC or LUT-based.
- codecs: DisplayProfile (moxcms): the display's primaries (colorant tags,
or measured through the A2B tables of a LUT-only profile), the proxy ->
device correction (per-channel curve tables for matrix/TRC profiles, a
CMS transform for LUT profiles) and sRGB <-> display transforms.
- pipeline: DisplaySpace / RenderRequest::display: the per-pixel stage
renders into the display's primaries and gamut maps into its gamut
(CPU and GPU: only to_out / out_luma change). Soft proofing's display
gamut warning uses the display's gamut.
- engine: RenderJob::with_display; display::present converts sRGB
results (thumbnails, cached previews, embedded JPEGs). Histograms and
the preview caches stay sRGB; exports are unchanged.
- ui: the loupe, Before, Compare, hover and second window render for the
display; everything else is converted on the render workers. Settings >
Display tab, ui.json setting, file picker opening in the system's ICC
folder.
Tests: profile parsing and transforms (sRGB, P3, gamma 2.2, a synthetic
LUT-only profile, corrupted files), display renders equal 8-bit renders
into the matching output space, sampled histograms, and a headless UI test
of the whole flow. docs/display-profiles.md describes the design.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEuxxPyG8t6KcADbZSEAUj
Sony's Dynamic Range Optimizer (factory default Auto) brightens the darker
regions of the camera JPEG, not the raw. The tone curve fitted to that JPEG
carried the brightening into the raw's starting look: on 8 ILCE-7CR photos
with DRO on, LightCraft rendered +6.4 L* from Lightroom's default (median
dE76 7.5), against -0.2 L* (3.6) for the 16 with DRO off.
- lightcraft_raw::embedded_preview_dynamic_range_optimized reads Sony
maker-note tag 0xb025 (every documented value: 0 off; 1-3, 8-12 and
16-23, i.e. up to Lv8, on). It was present on all 264 Sony raws checked
(98 bodies); 0xb04f only says on/off and is missing on older bodies.
- With DRO on, the look keeps the colour fitted to the JPEG and its tone
curve is lowered, knot by knot, to Sony's curve without DRO wherever that
is darker (assets/camera-tone/sony-dro-off.json, pooled from 12 CC0
raw.pixls.us ARWs of 10 interchangeable-lens bodies; sources and
derivation in the file and assets/ATTRIBUTION.md). The second-last knot
is lowered only as far as keeps the shoulder past the last knot at or
below the fitted curve, so the result is never brighter anywhere.
Compacts that map raw values darker keep their own curve.
- DRO off, unknown and other makers: unchanged (byte-identical renders on
71 + 35 photos). Render cache version 23.
DRO-on photos now land at +0.8 L* from Lightroom (median dE76 3.3), band by
band like the DRO-off ones. Docs: camera-preview-colour.md, parity row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Renders a raw's DNG ProfileGainTableMap (Apple ProRAW's local tone mapping)
when the photo's Profile option "Camera local tone mapping" is on
(`profile.camera_local_tone`, off by default: Lightroom Classic renders
ProRAW without it). Built on #427's parser; supersedes the decode-time
applier of the first version of #273.
- raw/gaintable: GainTableMap::gain / evaluator (GainEval) per DNG 1.7.1:
bilinear between tables with edges replicated, input clamp(weights .
(R,G,B,min,max))^gamma in linear ProPhoto after the baseline exposure,
index input x MapPointsN clamped to the last point. Saturating cell
math; maps whose sizes don't match their gains aren't evaluated.
SourcePlacement: where a developed source sits in the active area.
- pipeline/local_tone: the gain per output pixel in the scene-linear
stage, before the white balance, mapped back through the frame (crop,
user orientation, warp) and the EXIF orientation. lin_needs_cpu routes
the stage to the CPU when it's on (CPU and GPU match); lin_key includes
it. SourceInfo::local_tone carries the map from the decoder.
- develop: Profile::camera_local_tone (omitted from JSON when off, so
existing settings and their hashes are unchanged); Profile row checkbox
when the source has a map; translations.
- RENDER_CACHE_VERSION 17. Docs: parity row and What's New as an opt-in.
Off renders pixel-identical to main on two iPhone 15 Pro ProRAWs (12 and
48 MP). On adds ~60-75 ms to the scene-linear stage at 1536x2048 on a
2-core VM, the same for both files (cost follows output pixels).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFqow2P1EtDxt6nRjjQoTA
A custom white balance on a raw with a colour matrix (DNGs, and other raws
without a file-local look) re-develops the pixels for the chosen white
through the file's own colour model: lightcraft_raw::color::rebalance
re-evaluates the camera transform at the new white and maps the as-shot
development onto it, so a neutral lit by that white renders neutral and
saturated colours move as the camera's matrices say (DNG spec ch. 6, as
Lightroom does). The decoder passes the colour tags and the white the
pixels were developed for in SourceInfo::camera_color; other photos, and
relative white balances, keep the Bradford adaptation. The eyedropper and
Auto invert the same model (color::neutral_white), so a picked grey card
renders neutral.
The Tint sign is main's (#349): unchanged here. Render cache version 15.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Read DNG 1.6 semantic masks (SemanticName, SemanticInstanceID,
MaskSubArea; any coding the tile/strip reader decodes, e.g. ProRAW's
lossy-JPEG matte) with lightcraft_raw::semantic_masks, map them onto the
developed image, and carry Apple's sky / portrait / skin / hair / teeth /
glasses mattes in SourceInfo::mattes (SourceInfo is no longer Copy).
Sky, Subject, Background and People components sample them through
orientation, warp (Warp::frame, as geometry.rs) and crop like the image,
so preview = export, instead of the heuristics; files without mattes
render as before. Malformed mask IFDs are skipped, and only the first
MAX_MASKS mask IFDs are decoded, so a file full of them costs bounded
work. The render cache version is bumped once, in the white-balance commit.
The corpus test reads corpus/raw/dng-apple-iphone12pro-proraw.dng, the
name `cargo xtask corpus` downloads it under.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review findings: window growth for spots was capped silently and then rendered
wrongly; Auto Mask growth took the whole stroke's bounding box; hidden masks grew
windows; the wasm window memory cap was not enforced. window_for_reads_checked
returns None when what a window reads would need more than 8192 px (4096 on
wasm) along an axis, Session::region_job returns None then (the loupe keeps the
whole-frame render), Auto Mask growth is per dab near the request (once, not
chained along the stroke) and ignores hidden masks. Docs say what the limits are.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The GPU renderer renders windows (no more CPU fallback): the vignette gets the
window's offset and the whole output's size (VIG_VIEW), the airlight and noise
reduction scale come from the whole frame, and a window grown for spots or
strokes is cut back after the readback. equivalence.rs renders every settings
case as a window plus a corner vignette and a spot reading from outside, within
the existing 1 LSB. Auto Mask strokes join spots in growing the window (they read
the pixel under each dab). An Auto Mask dab's reference pixel is chosen from its
position at f32 precision, like the GPU kernel: an f64 rounding error that
depended on the window's start could put a boundary dab on either pixel.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Spot removal reads outside the spot and its automatic source depends on the
pixels at hand. A windowed render now fixes automatic sources from a fixed-size
render of the whole frame and grows the window it works on to hold every spot
that touches the requested one and the patch it reads (bounded at 8192 px),
cutting the result back to the request, so photos with spots get sharp windows
too. The wide stages (dehaze, clarity, highlights/shadows) are checked at depth
on hard-edged blocks: the cut kernels cost at most a few levels, so they are not
computed from a whole-frame proxy.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
An adversarial review found local::denoise and Refine Edges scaled with the
window's own size, so their strength changed with pan and zoom. Frame::output_long
gives the whole output's long edge to both; tests on a noisy source cover them.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A zoomed view needs the pixels it shows at the zoom scale, not a whole-frame
texture stretched over them. RenderRequest gains an optional PixelWindow of the
virtual full output that max_w x max_h describes; only the window is
materialized. It is Frame:🪟 the crop rectangle narrows to the window
(flips as drawn), so sampling, masks, spots, red eye and the lens warp need no
change. The post-crop vignette keeps the whole frame's geometry (Frame::view)
and dehaze uses the whole frame's airlight (estimated on a 384 px render), so a
window matches the same pixels of the full render. The GPU renderer declines
windowed requests (CPU for now).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The pipeline honoured a section's eye only where it applies Effects,
Optics, Geometry and Calibration, so turning the eye off on Light, Color
or Detail changed nothing. DevelopSettings::effective() resets the
switched-off sections to their defaults (Light with the tone curve; Color
with white balance, mixers, grading and point colour; Detail), and both the
CPU and GPU renderers render those. Borrowed, so free, when every section
is on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
`tol = 0.04 + 0.16·refine` fully selected anything within 0.06 OkLab of a
sample at the default Refine 50. Through `tonemap_for_select` the report's
teal mid-grey and neutral light grey are only ~0.063 apart (L 0.60 vs 0.72,
a/b ≤ 0.02), so the second patch was 99 % selected and the mask covered the
whole photo; Refine 85 and 100 kept most of it too. Now
`0.015 + 0.06·refine` (full ≤ 0.0225 / gone ≥ 0.045 at Refine 50, gone ≥
0.066 at 85, 0.075 at 100), computed once in
`lightcraft_pipeline::masks::color_range_tolerance` and used by both the CPU
evaluator and the GPU `mask.wgsl` kind-3 parameter so the two keep agreeing.
Test: two flat patches at the report's sRGB values, sampled as
`mask.sampleColor` does; the sampled patch stays fully selected and the other
one is out at Refine 0, 50 and 85 and mostly out at 100.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The RW2 reader reads PanasonicRaw tag 0x0119 (16 LE words) and expresses the
camera's own correction as the equivalent DNG WarpRectilinear in OpcodeList3,
so embedded_lens() turns it into the existing EmbeddedLens/EmbeddedWarp and
"Enable Profile Corrections" applies it like a DNG's warp (no new warp path;
Convert to DNG keeps it). Rule, from our own measurements against the
embedded camera JPEGs (clean-room; ExifTool tag names only):
corrected = rho*(1 + (w8 rho^2 + w4 rho^4 + w11 rho^6)/32768), rho = r/w12
(w12 = active-area half diagonal), output = corrected / (1 + w5/32768),
on when (w7 & 0xf) == 1. Chosen on 38 GX85 photos, held out on 35 photos
from 12 other bodies / 9 other lenses; corrected renders line up with the
camera JPEG within 0.75 px at 1440 px at the corners (up to 13 % of the
half diagonal before). Implausible values leave the photo uncorrected.
The loader now removes lens opcodes before the RW2 camera-look fit (its
binned proxy needs an empty OpcodeList3); Opcode::is_lens_correction()
replaces the duplicated matches. RENDER_CACHE_VERSION 11 -> 12.
Homography::apply, Affine::apply, Warp::to_corrected,
Warp::corrected_to_source and embedded_warp are now generic over
lightcraft_geom::Real (private *_real variants in pipeline, public
apply_real in geom); the f64 API is a thin wrapper, so callers are
unchanged. Real is implemented for f64 (plain IEEE operators) and for
Interval, which rounds every bound one ulp outward and turns division by
a range containing zero, the homography's 1e-300 denominator clamp and
non-finite bounds into "undecided" (NaN). Because the interval runs the
same operations as the f64 path, it bounds the rounded f64 results, not
only the exact ones.
The f64 path keeps the same operations in the same order (only commuted
operands, which IEEE + and x leave bit-identical; no fma). Proof:
warp_f64_results_are_bit_identical_to_the_pre_generic_formulas hashes the
bits of 2000 random warps x 40 points through every formula (plus the
clamp branch, including +-1e-300 exactly) and matches the digest computed
by the same test on thumb-split (419261c) before the change; reassociating
one expression, or `<=` for `<` in the clamp, changes it.
The framing chain (output px -> transformed -> lens-corrected -> green
source, then inside the edges) is written once: Warp::framing, generic.
Warp::frame (f64, returns the corrected and green source points) serves
both the CPU resample (sample_warped) and Warp::covers; block_coverage
evaluates the same chain with intervals over a whole block of output
pixels and compares the bounds against the same edges() inside uses, with
no margin: the bounds enclose every pixel's f64 result, so the decision
equals the per-pixel one; else None.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Engine (crates/engine/src/segment/):
- One worker thread owns the model and the encoded photo; the session
sends jobs (with the render job for the model input) and gets results
over channels, never locking what the model holds. In the app
(Segmenter::background) clicks, descriptions, prepares and detail
passes return at once and Session::segment_poll applies results (one
undo step per settled selection, pending clicks shown on the photo);
the CLI/MCP wait for them. Newer requests for the same selection
supersede queued ones.
- Every job runs under catch_unwind: a candle panic becomes an error and
the model is reloaded next time; busy counters are reset by drop
guards; a dead worker is restarted. The model unloads after 10 min idle.
- segment.model.status / .download {acknowledged: true} / .cancel: the
download runs on its own thread; status via atomics (try_lock only).
Without the model every AI request fails at once with "The SAM 3 model
is not installed…"; renders/exports never need it; `seg` params make
AI masks from stored segmentations without it.
- Hostile documents: at most 4 detail patches per component (capped on
deserialize and in the renderer), at most 64 Object clicks.
UI: Object / Describe without the model open "Download the SAM 3
Model?" (size, folder, SAM License link; Not Now / Download, progress,
cancel, Try Again with the reason, Continue once installed); progress in
the Masking panel when the dialog is closed. Reverted the PR's unrelated
default change (mask overlay off + hover-red) to main's behaviour.
tools/install-sam3.*: --fail so error pages never land in files, 416
restarts instead of looping, the weights are verified before they are
moved into place (a bad file is deleted, never resumed from).
Docs: docs/ai-masks.md (in-app download, mirrors, SAM License summary,
no weights in the repo), NOTICE, parity LR-MASK-OBJECTS, What's New,
ROADMAP.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sunny ILCE-7M4 waterfall shot rendered yellow: sunlit rock came out
cream, +4 b* in the brightest fifth of the picture. Camera JPEGs make
shadows up to 1.4x more colourful and bleach highlights to 0.1-0.3x
(a per-channel tone curve), which a luminance-only curve can't do.
- CameraTone gains an 8-node chroma-by-display-luminance curve, fitted
per file (bright pixels included) and kept only when it lowers the
held-out error; applied after the tone map on CPU and GPU, keeping
luminance. Old smart-preview headers load with an identity curve.
- The hue/saturation table gets an sRGB-encoded value axis: dark yellows
had to turn toward orange while bright yellow-greens turned toward
green. It is applied after the baseline exposure, as it is fitted.
- Matrix-only and matrix+table looks are completed (tone, chroma) and
compared in gamma-encoded values; acceptance gates stay linear.
29 local ARWs vs their camera JPEGs: mean dE 4.23 -> 3.54, every file
closer, saturated areas 7.65 -> 6.41. Render cache v10.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AI masks from Meta's SAM 3, run in LightCraft itself: a new
`lightcraft-segment` crate ports the Hugging Face transformers
implementation (Apache-2.0) to candle, on the GPU through Metal on macOS
(CPU elsewhere), with no C/C++ and no `unsafe`.
- Object: click to include, ⌥-click to leave a part out (SAM 3's
interactive prompt encoder + two-way mask decoder).
- Describe: a text prompt selects every instance ("sky", "the red car";
"car, road" merges several) via the CLIP text encoder and DETR detector.
- Both as new masks and as Add / Subtract / Intersect components; + / −
buttons under the selected mask combine selections.
- The segmentation is stored with the mask (288² quantized logits, ~10-30
KB), so renders, exports and the web build never need the model.
- A zoomed-in detail pass (background, ~5 s on an M4 Pro) re-segments the
area around a selection from a sharper source: 5-10x finer edges on
small objects.
- Per-selection Edge slider (hard ↔ soft); hovering a mask in the list
shows it in red (the always-on overlay is now off by default, O toggles).
- The engine feature `sam` (on in the desktop app) gates the model; the
image is encoded once per photo in the background, clicks take ~35 ms.
- Weights are not bundled (SAM License): tools/install-sam3.sh / .ps1
download and verify facebook/sam3; docs/ai-masks.md.
- Clearer errors (missing file, missing model) and a stderr logger in the
app (LIGHTCRAFT_LOG=info).
candle is pinned at 0.9.2: later releases make candle-core depend on
tokenizers with the Oniguruma C library. Every stage is checked against
the Python reference (tests/reference.rs, tools/sam3_reference.py): on
Metal the backbone matches to 7e-5 relative error, masks to ~5e-5.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C8BkS7JDqa1EMcjW6spDGA
- color: RgbSpace::to_xyz/from_xyz and bradford() no longer expect a
matrix inverse. Degenerate (collinear) primaries, which readers reject
before building a space from a file, leave the primaries unscaled /
give the identity; the constant Bradford and OKLab matrices fall back
to the identity, which can't happen (round trips are tested).
- pipeline geometry: sample() matches (mode, warp) instead of
expect("warp"); a warp plan without a warp (sample_plan never makes
one) samples through the equivalent affine.
- color, geom, raster, develop, scenes, preview and pipeline now #![deny]
unwrap/expect/panic/unimplemented/todo/unreachable; clippy.toml as in
the steering PR.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rust 1.99 (stable since 2026-09-28) adds clippy::chunks_exact_to_as_chunks and widens
clippy::question_mark, which failed `cargo xtask ci` on main. Same behaviour: the
remainder that chunks_exact skipped is the remainder as_chunks returns separately.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soft proofing (S in the loupe; S in grids stays Expand/Collapse Stack):
- pipeline Proof {space, destWarning, displayWarning}: gamut-map into the
proof space, show it in the output space; red/blue gamut warnings
(neutral clipping excluded). CPU only; the GPU path declines proofs.
- RenderJob::with_proof; paper-white surround and Proof Preview label;
Soft Proofing strip under the histogram (profile, warnings, Create
Proof Copy = a named virtual copy). view.softProof for control/MCP.
- photo.virtualCopy takes an optional name.
Mask components: MaskComponent.name; mask.component {component, action:
invert|duplicate|delete|rename|op}; right-click / … menu per component,
double-click rename, Intersect button.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mask.refine (0–100, mask.refine; Masking panel ▸ Refine Edges): after a mask's components are
combined, a guided filter steered by the photo's luminance (window up to ~4 % of the long
edge) snaps soft mask edges to the photo's edges. Rendered on the CPU (the GPU path has no
kernel for it yet).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lightcraft-pipeline dust.rs: sensor-dust detection on a rendered image — pixels darker than
their blurred surroundings by more than 3× the local texture, grouped into small (0.15–2 % of
the long edge), roughly round blobs. spot.findDust {sensitivity?, add?} renders the uncropped
photo, detects and adds a heal spot on each (auto source; one undo step). Remove panel ▸
Find Dust Spots.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lightcraft-pipeline cull.rs: focus (Laplacian energy of the sharpest 16-px blocks at 512 px,
0–100, calibrated on the CC0 corpus), clipping share and an 8×8 look-alike signature.
photo.analyze scores the selection (else the view), groups look-alike shots taken within 10 s
into bursts and marks each burst's sharpest; optionally rejects photos below a focus score and
picks each burst's best (one undo step). Scores live on the photo (Analysis, Op::SetAnalysis):
rule fields Focus and Best of Similar Shots, Info panel line. Photo ▸ Assisted Culling….
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When only vibrance / saturation (and colour grading) are active, chroma is scaled on OkLab
a/b directly — the same result without sin/cos (and atan2 unless vibrance needs the hue).
Bench (loaded machine): finish 1920×1280 + vibrance/saturation 71.8 → 47.7 ms wall on the CPU.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lightcraft-pipeline gains lut.rs: a .cube parser (LUT_3D_SIZE, DOMAIN_MIN/MAX, TITLE), a
trilinear lookup and an in-process registry; the finishing stage applies a `lut:` profile to
the display-encoded colour, blended by the profile amount (0–200 %); LUT profiles render on the
CPU. profile.import {paths} reads files, folders and zips, copies each LUT into the library's
Profiles/ folder, lists them in prefs and registers them when the library opens; the profile
browser shows them under their folder / zip name; profile.deleteImported. File ▸ Import
Profiles & Presets…, the file picker and drag & drop take .cube files.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Color mask tile adds an empty colour range and arms the picker: click the photo to sample
(⇧-click adds, up to five); Pick in the Masking panel re-arms it. mask.sampleColor {x, y, add?}
samples via lightcraft_pipeline::color_range_sample, which reads the exposed scene colour at
that point in the same OkLab space the mask compares in (so a click selects what was clicked).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- develop.autoBwMix (B&W Mixer → Auto): each hue band's colourful pixels are pushed away from
the image's mean lightness (light bands brighter, dark ones darker; bands with almost no
colour untouched), clamped to ±60; switches to B&W; one undo step.
- library.filter {merged: hdr | panorama | hdrPanorama | any}: Photo Merge results recognised
by the "-HDR" / "-Pano" / "-HDR-Pano" names merges write; filter bar type picker lists HDR,
Panoramas, HDR Panoramas.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Resize modes: long edge, short edge, width, height, W x H (either orientation), megapixels,
percent; "don't enlarge" (default on); ppi written to JFIF density / PNG pHYs / TIFF tags.
- Full-size export of a cropped photo now renders the crop at native resolution (it was
upscaled to the uncropped long edge).
- Formats: Original (file copied byte for byte + XMP sidecar with the edits, named after the
output) and DNG (raw photos re-encoded losslessly, edits in the embedded XMP).
- Export dialog: Resize-to dropdown, values, Don't enlarge, Resolution; DNG/Original hide
pixel options.
- lightcraft-cli render goes through the shared exporter (colour space, metadata, ppi) and
takes any app.export option via --opt KEY=VALUE; MCP export schema lists the new params.
- xtask resolves the workspace root at run time (a moved checkout broke ci).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Engine: spot.select / spot.update (move target or source, size, feather, opacity, mode) /
spot.refreshSource (next-best source away from the current one) / spot.delete (selected spot);
spot.add resolves the automatic source (so it has a pin and stays put at every size) and selects
the new spot. Remove tool: pins for targets and the selected spot's source, click to select, drag
to move; Size/Feather/Opacity sliders edit new and selected spots; ⌫ deletes the selected spot
(never the photo), / refreshes its source, [ ] size and Shift+[ ] feather (Masking brush too).
Command tests and headless pointer/keyboard tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Auto Mask strokes weight each dab by similarity (log luminance + chromaticity) to the pixel under
its centre and are refined by a cross guided filter on log luminance (max-combined, never removing
paint). Local Noise smooths small-amplitude luminance detail (negative boosts it) and calms colour
noise; Moiré blends chromaticity towards a blurred plane (appended to the GPU mask buffer);
Defringe desaturates purple/green casts on edges. Pixel tests per slider and for the edge
constraint, CPU/GPU equivalence cases, Auto Mask checkbox and Moiré/Defringe sliders in the panel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RenderRequest gains a sample format (OutputDepth: U8 for previews, U16, F32Linear); deep renders
run the per-pixel stage with the exact sRGB curve and return Rendered::deep alongside the 8-bit
image (GPU stays 8-bit: deep exports render on the CPU). Export encodes deep renders: 16-bit PNG and
TIFF, 32-bit float linear TIFF with a linear ICC profile we generate, 10-bit AVIF (YCbCr planes into
ravif). Output sharpening and watermarks work on deep images. ExportOptions.bitDepth with per-format
defaults (TIFF 16-bit, others 8-bit), export dialog bit depth choice (remembered with the last
export), MCP export schema. Tests: a smooth gradient has 1024 levels in a 16-bit TIFF vs 13 in
8-bit, float TIFF decodes to the same light as 16-bit, per-format depth rules, AVIF 10-bit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>