New `inpaint` crate (L1): multi-scale patch-based completion with a PatchMatch
nearest-neighbour field, seeded by a membrane fill. Deterministic, no panics on
hostile sizes, cancellable. Ported from PhotoCraft (MIT OR Apache-2.0).
Remove tool: a fourth spot mode, `fill` ("Content-Aware"), rebuilds the spot
from patches of its surroundings instead of one source; coarse-to-fine, so the
structure is chosen at a scale set by the spot size and preview and export agree
(tested). spot.add leaves its source unset.
Sources: C. Barnes et al., PatchMatch (SIGGRAPH 2009); Y. Wexler, E. Shechtman,
M. Irani, Space-Time Completion of Video (TPAMI 2007); P. Perez et al., Poisson
Image Editing (SIGGRAPH 2003) for the membrane fill; code ported from PhotoCraft
crates/algo/src/inpaint.rs + poisson.rs (a680ed22).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The highlights/shadows base (fast guided filter of log luminance, eps 0.35)
followed thin high-contrast detail, so the per-pixel shift flattened twigs,
lines and texture in bright areas (and dark ones under Shadows); Exposure
kept them.
This changes how every existing edit with Highlights or Shadows renders, so
it ships as a new rendering process (docs/process-versions.md):
- Process::V2 (version 2) is added and made LATEST. New photos and Reset get
it; existing edits stay on V1 until Update to Current Process. Settings
saved without the field still load as V1.
- From V2 the base is the same guided filter of a separable median of log
luminance (radius 0.15 % of the long edge, <= 24 px): fine detail takes its
surroundings' shift and keeps its contrast, region steps stay put (no new
halos). V1's base is untouched: local::tone_base_detail is 0 under V1 and
tone_base then is exactly guided_fast(l, sigma, BASE_EPS), as before.
- CPU (local::prepare) and GPU prepare (median_h / median_v kernels, bit for
bit with the CPU median) both branch on it via PlaneSigmas::base_detail.
- The base plane's cache key is now (sigma, process, median radius)
(local::BaseKey), so a warm cache never serves one process the other's
base. plane_at is shared with the GPU crate.
- finish::base_tone: V2 uses V1's tone map.
Tests: V2 keeps twig/local contrast (>= 0.9 of Exposure's) with no halo; V1
plane sigmas have no median and its base equals guided_fast bit for bit; V1
and V2 render alike without Highlights/Shadows; warm-cache renders across
V1/V2 switches equal fresh ones (fails if the key drops the process); GPU
matches CPU on both processes at 720 and 2400 px, cached and not.
Pentax 645D 2401 at 4000 px: main and V1 renders bit-identical (plain,
Highlights -100, Shadows +100). Highlights -100 bright rel. detail
0.095 (V1) -> 0.147 (V2), Exposure -1: 0.124; Shadows +100 dark rel. detail
0.126 -> 0.148.
RENDER_CACHE_VERSION 54 (adjust at merge).
Raws without matrices in the file (ARW, NEF, RW2, RAF, CR2, CR3, PEF, SRW,
ORF) were all edited on the relative white-balance scale (6500 K / 0 = as
shot), even when lightcraft_raw::spectral has measured matrices for the
camera. Those 52 bodies (ILCE-7M3, ILCE-7M4, D850, EOS 5D Mark III...) now
edit in Kelvin like a DNG and like Lightroom.
Design:
- One decision on both sides: spectral::find_camera("Make Model") backs
catalog::relative_wb_camera (Photo::relative_wb); files::WbScaleOf decides
the same from the decoded file. A corpus test asserts they agree.
Photo::fitted_look keeps the old predicate for smart previews.
- The matrices only convert: As Shot is the file's multipliers read through
the model's ColorMatrix1/2 (spectral::as_shot_white_through). The look
(JPEG fit / profile / spectral fallback) is unchanged, and the slider
adapts from that white with Bradford. The camera-space rebalance stays
reserved for files with their own matrices.
- Presets resolve to their Kelvin values (WbMode::preset, sources cited).
- The Color panel shows the white balance in effect (effective_wb).
- XMP: crs::Target::for_file takes the camera; matrix cameras read
crs:Temperature as Kelvin (marked "scale": "kelvin"), others keep the
#510 as-shot shift.
Migration (no look changes):
- WhiteBalance.scale: WbScale { Legacy (absent in JSON), Kelvin }. A
white balance without a scale keeps its old meaning. On a spectral-Kelvin
raw (SourceInfo.legacy_relative_wb) the pipeline composes the old
adaptation (6500 -> value) with the new one (as shot -> ...) in Bradford
cone space (local::legacy_white). Von Kries scalings compose exactly, so
the matrix matches to f32 and the pixels are identical
(pipeline legacy_relative_white_balance_renders_as_before_on_a_kelvin_raw,
media old_relative_white_balance_on_a_kelvin_camera_keeps_its_look...).
- Every white-balance writer (develop.set/adjust/quickAdjust/wb/wbPick/
reset) first runs local::normalize_wb: a legacy value becomes the Custom
Kelvin value with the same look and is marked Kelvin. Partials without a
scale (old presets, develop.merge) stay legacy.
- Catalog format 7: Op::SetAsShotWb replaces the 6500/0 as-shot recorded
by older imports when the file is next decoded or reloaded (system op,
not an undo step). Format-version test updated.
- The web wire carries legacy_relative_wb.
Docs: parity (LR-EDIT-COLOR-TEMP), camera-preview-colour, xmp-interop,
lightroom-catalog-import, control-protocol, mcp, whats-new.
- `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 <[email protected]>
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 <[email protected]>
scale_patch interpolated every JSON number as a float, including the
integer grain seed; the fractional seed failed to deserialize, merged()
failed and apply_partial returned the photo unchanged, so a preset with
the Grain group (in the default copy groups) did nothing at any amount
other than 100 %. Integer fields are now discrete like strings and
booleans; float settings and integer literals in preset files still
interpolate.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VFqow2P1EtDxt6nRjjQoTA
Recover the unpublished denoise work against current upstream. Preserve later opt-in model setup, shared Rust HTTPS downloads, pending-photo resume and an Amount-independent switch. Keep original RAWs unchanged and share the current color finish between ordinary and denoised sources.
Bound public tiling inputs, refuse corrupt-cache exports, and retain synthetic/real-model coverage. Include two Windows path spelling fixes in existing tests. No model weights, face code, media or Tract runtime are included.
Validation: cargo xtask ci (all seven gates); installed RawNIND CPU/GPU agreement 1.38e-6; native CPU preview/export on a public 24 MP Sony RAW, unchanged original SHA-256.
Co-authored-by: Codex <[email protected]>
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Generated with Codex.
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
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.
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 <[email protected]>
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) <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
Local corrections stored as crs: structures (MaskGroupBasedCorrections and the older
gradient / circular gradient / paint containers) become our masks: linear and radial
gradients, brushes (dab runs → strokes), Subject / Sky, luminance and depth ranges, with
add / subtract / intersect and invert, and all local sliders. lightcraft-meta's XmpData gains
`values`, a structured view of every property (arrays of structs survive); .lrtemplate Lua
tables convert into the same shape.
Applying a preset adds its masks to the photo's own (once; Amount scales them); sidecar masks
replace them and radial masks are fitted to the photo's aspect. Unsupported components
(colour range, other AI selections) are reported as "Mask: <kind>".
Co-Authored-By: Claude Opus 5.5 <[email protected]>
pipeline::redeye: inside the user's ellipse the pupil is found on the source image (a fixed
grid in normalized coordinates, so previews and exports agree): weighted centroid and area of
red-dominant pixels (or, for pet eyes, of the brightest ones). Pupil Size scales the detected
disc; rendering in scene-linear light after white balance neutralizes the red where it is red
(iris and catchlights stay) and darkens it. CPU (lin stage) and GPU kernel redeye_k with an
equivalence test (both the GPU and the CPU scene-linear paths). Indexed controls
redEye.<i>.pupilSize/darken, redeye.add/delete commands, the Red Eye panel (selected eye's
sliders, delete) and the loupe tool (drag an ellipse, click to select).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Up to 8 OkLCh samples (pointColor.pick samples the colour after the mixer on a proxy render);
per sample hue/saturation/luminance shift, variance (compress/expand the spread around the
sample) and an overall + per-axis range with soft edges, applied after the colour mixer in
ColorOps and its WGSL twin (equivalence case). Indexed control specs
(pointColor.<i>.hueShift …) so develop.set/controls/MCP address each sample. Visualize range
is a render overlay (RenderRequest::overlay) drawn after either renderer: the sample's own
edit is left out and colours outside the range turn grey. Point Color flyout in the Color
section with swatches, eyedropper, sliders, visualize and delete.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
curve.refineSaturation (0..100, default 100 = the curve's own saturation): after the tone
curves, the chroma around luma is rescaled so saturation moves from the curve's towards the
pre-curve value, keeping the curve's tone. CPU (finish::refine_saturation) and GPU twin,
equivalence case, slider under the curve editor, crs:CurveRefineSaturation import.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
White-preserving primaries matrix (OkLCh hue rotation / chroma scale of each Rec.2020
primary) plus a luminance-preserving green/magenta shadows tint, applied in scene-linear
light before the tone map. CPU (colorops::calibrate) and GPU (finish.wgsl) with an
equivalence case; seven control specs (calibration.*), a settings group, crs: import
(ShadowTint, Red/Green/BlueHue/Saturation) and a Calibration section at the bottom of the
Edit panel.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- pipeline::upright: own gradient-based line-segment detector (level-line region growing seeded from the
strongest gradients, weighted principal-axis fit, elongation test); vanishing points by RANSAC over
segment pairs + least-squares refinement (smallest eigenvector of Σ w·l·lᵀ)
- corrections as a virtual camera rotation K·R·K⁻¹ (vertical VP → y axis; Full also horizontal VP → x axis
with focal length from VP orthogonality when plausible); Level = rotation; Auto = Full/Vertical when
moderate, else Level; all re-centred
- Guided: guides stored in normalized lens-corrected coordinates; 2+ vertical/horizontal guides define the
vanishing points, a single guide levels
- the analysis runs on the lens-corrected image at 1024 px and the result is stored in
geometry.upright_transform (identical preview/export); settings naming a mode without a transform
(XMP, CLI) are resolved at render time
- commands geometry.upright {mode} (Update = run again) and geometry.guides {guides, add}
- UI: Upright mode buttons, Update, Draw/Clear Guides in the crop panel; on-canvas guide drawing that
follows the warp
- tests: segment detector on known lines; Vertical/Auto/Full fix converging verticals; Level fixes a tilted
horizon; Full fixes both families; guided from two guides; analysis resolution independence; lazy vs
stored transform; engine commands
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- pipeline::optics::Warp: one inverse map (lens-corrected → source) inside the single geometry resample:
manual radial distortion, embedded DNG WarpRectilinear (per plane), per-channel lateral CA scale,
radial vignetting gain (manual + embedded FixVignetteRadial); all in normalized radii
- automatic lateral CA estimate: radial edge profiles matched sub-pixel per plane, robust LSQ fit of
displacement = α·r (cached per source)
- defringe: purple/green hue ranges (OkLab hue) desaturated next to high-contrast edges, width relative
to the image long edge
- "Enable Lens Corrections" applies only lens data embedded in DNGs (opcodes are no longer baked at decode;
stored on the photo record, on by default at import when present). No LCP data anywhere.
- new controls: optics.caRed/caBlue, defringe hue ranges; Optics panel: Manual sub-section, Defringe flyout
- tests: barrel grid straightened, CA removal (auto + manual), embedded corrections toggle/scale, preview vs
export resolution independence, CA estimator accuracy, orientation of embedded lens data
Co-Authored-By: Claude Opus 5.5 <[email protected]>