Files
lightcraft/docs/process-versions.md
Alex Chambers baf079f659 LR-BEHAV-RENDER-FIDELITY: process V5 rolls raw tone curves off into white in stops, so raising Exposure keeps highlights (#1632) (#3783)
* LR-BEHAV-RENDER-FIDELITY: V3 camera tone curve rolls off in stops past its last knot (#1632)

A raw's camera tone curve is fitted only to camera JPEG tones below 0.85, so its last knot sits
1 to 3 stops below the sensor's white. Past it, CameraTone::apply closes on white exponentially in
linear light: the sensor's white renders at 8-bit 246-254 at Exposure 0 and 254-255 from +1, and
raising Exposure turns the top stop the sensor recorded white.

Process V3 extends the curve with CameraTone::apply_rolloff: same value and slope at the knot,
then gap / (1 + c (2^t - 1)) at t stops past it, the gap halving every stop in the end. On 11 CC0
corpus files with a fitted look, unclipped pixels rendering white drop from up to 22.5% to 0 at
+1 EV and from up to 35% to 0 at +2 EV; Exposure 0 renders change by at most 1 code on average.
V1 and V2, the fit itself, JPEGs and raws without a fitted look are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-BEHAV-RENDER-FIDELITY: V3 generic raw curve rolls off in stops too; DRO-off, DNG and GPU checks (#1632)

Raws without a camera curve take ToneMap::hdr, which reached white about 1/4 stop past the
sensor's white and clipped there (o.min(1.0)). Process V3 now uses ToneMap::rolloff_hdr: from half
a stop below the white point (tone::GENERIC_KNEE_STOPS) the extended Reinhard curve hands over to
the same stop-based shoulder as the camera curve (tone::rolloff_past, shared with
CameraTone::apply_rolloff), with the same value and slope at the join. The sensor's white renders
at about 235 / 248 / 252 at Exposure 0 / +1 / +2 where V1 and V2 render 239 / 255 / 255. On 6 CC0
DNGs on the generic curve, unclipped pixels rendering white drop from up to 8.4% to 0 at +1 EV and
from up to 17.2% to 0 at +2 EV.

The DRO-off gate (look_fit::at_most) also checks the V3 shoulder; the rate bound already implies it,
so no fit changes and LOOK_VERSION stays. Tests: the generic shoulder (join, slope, monotone, below
white, no kink), a regression test that fails with the clipping curve, a DNG ProfileToneCurve under
V3, the DRO-off property tests on the V3 shoulder, and a per-process camera-curve GPU check.
Docs: process-versions, parity checklist (LR-BEHAV-RENDER-FIDELITY, LRC-DEV-PROCESS), gap 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-BEHAV-RENDER-FIDELITY: what's new: process 3 keeps raw highlights when Exposure is raised (#1632)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-BEHAV-RENDER-FIDELITY: a flat last camera-curve segment joins the V3 shoulder with its own slope (#1632)

The V3 camera shoulder took the last segment's slope with a floor of 0.1, so a flat or shallow
segment (valid for a fitted, DNG or saved curve) joined with a kink: slope 0 on the curve, about
0.1 just past it. The shoulder now keeps the segment's own slope at the knot. Below 0.1 a quadratic
term in 2^t - 1 joins the denominator: it adds no slope at the knot and makes up the 0.1 pace
further up, so a flat curve still closes on white. Segments at 0.1 or steeper are byte-identical,
and the generic curve passes no floor. Comments now say the shoulder approaches white in real
numbers and can round to it in f32. Found in an independent review of the branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-BEHAV-RENDER-FIDELITY: V4's GPU test pins V4; the corpus DRO photos keep their knots under the V5 bound (#1632)

The GPU light-operator test assumed the default process is V4; it now sets V4, since V5 is the
default. The corpus DRO test checks that dro_off's V5 bound leaves the second-last knot where
the rate bound puts it on the Sony DRO samples, so their V1 to V4 renders don't change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-BEHAV-RENDER-FIDELITY: the DRO-off curve is main's again, so V1 to V4 keep it (#1632)

CodeRabbit found that the V5 bound in `dro_off` could move the second-last knot of a DRO
photo whose fitted last segment is flat, and V1 to V4 read that curve too. `dro_off` and
`at_most` are back to main's, so the camera-look fit is unchanged and LOOK_VERSION stays.
The DRO tests check the V5 shoulder where the lowered segment's slope over its squared gap
is at most the fitted one's (then it stays below), and on the Sony DRO corpus samples.
Also: `rolloff_hdr`'s doc names the HDR lift, and the release note names DNGs whose curve
already reaches white.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:50:13 +01:00

9.6 KiB
Raw Permalink Blame History

Process versions

Changes: revision history

Every photo's develop settings record the rendering process they are interpreted by (DevelopSettings::process, a ProcessVersion in crates/develop/src/settings.rs). Lightroom calls this the process version. It lets the look of LightCraft improve (a new default tone curve, issue #146; stronger Light sliders, issue #196) without changing a single photo someone has already edited.

Process What it changes
V1 LightCraft's rendering from before process versions existed. Frozen.
V2 Highlights and Shadows keep fine detail (issue #632): their base layer (local::tone_base) is the edge-aware filter of a separable median of log luminance (radius 0.15 % of the long edge, at most 24 px, local::BASE_DETAIL) instead of log luminance itself, so twigs, lines and texture take their surroundings' shift and keep their contrast; region edges stay sharp. CPU (local::prepare) and GPU prepare branch on local::tone_base_detail (0 = no median under V1); the base plane is cached per process (local::BaseKey). Renders without Highlights or Shadows (global or local) are the same as V1's. Tone map unchanged.
V3 Pixel-scale sharpening (issue #757): Radius uses the original photo’s pixels, Detail controls halo damping, and downsized sources retain a response-matched sharpening band. V1 and V2 keep texture-band sharpening exactly. The sharpen plane is keyed by radius and process and invalidated after noise-reduction changes. The Highlights/Shadows base and tone map are unchanged from V2.
V4 V3 plus calibrated global Contrast (per channel) and Blacks (per-channel/luminance mix) on raws with a camera tone curve. Whites behaves exactly as V2, both signs; its calibration is a planned follow-up. Other sources and local adjustments retain V2 operators. See calibrated Light operators.
V5 (the latest) V4 plus: a raw's tone curve rolls off into white in stops instead of reaching it within about a stop of the sensor's white, so raising Exposure keeps the highlights the sensor recorded (issue #1632). Camera curve (the look fitted to the embedded JPEG, or a DNG's ProfileToneCurve; CameraTone): past its last knot CameraTone::apply_rolloff closes the gap to white by gap / (1 + c·(2^t − 1)) at t stops past the knot, with c matching the curve's slope there and the gap halving every stop in the end (tone::ROLLOFF_STOPS); ToneMap::camera_rolloff_hdr builds the table from it. Under V1 to V4 the curve past its last knot (fitted only to camera JPEG tones below 0.85, so 1 to 3 stops below the sensor's white on most photos) closes on white exponentially in linear light: the sensor's white renders at 8-bit 246 to 254 at Exposure 0 and 254 to 255 from Exposure +1. On the 11 CC0 corpus files with a fitted look (8 Sony, 2 Nikon, 1 Fujifilm) it now renders at 239 to 250 at Exposure 0 and 247 to 253 at +1. A DNG curve that reaches white at the sensor's white renders as before. Generic curve (raws without a camera curve, ToneMap::rolloff_hdr): from half a stop below its white point (tone::GENERIC_KNEE_STOPS) the same shoulder takes over, with the extended Reinhard curve's value and slope at the join, where V1 to V4 clip at the white point (scene 1.18 at default settings, ¼ stop above the sensor's white). The sensor's white renders at about 235, 248 and 252 at Exposure 0, +1 and +2 (V1 to V4: 239, 255, 255; the CC0 Canon R8 file and 6 CC0 DNGs without a camera curve). Content past the old clip point, such as a clipped sky, now renders up to 15 codes below white at Exposure 0 and reaches 255 about 5 stops past the sensor's white. Below the last knot or the join, JPEGs and other display-referred sources, and the camera fit itself are unchanged; CPU and GPU share the tone LUT.

The rule

A change that alters how existing settings render ships as a new process. V1 is frozen.

  • A deliberate change of look (tone model, slider response, default curve, how a stage interprets a value) goes into a new process. Photos stay on the process they have until someone runs Update to Current Process (or Reset).
  • Bug fixes that make output correct where it was broken (a crash, NaN or black output, a decoder error, CPU and GPU disagreeing) are not looks and can land in place, as before.
  • Changes to how a file is decoded or which camera colour it gets change the source, not how settings are interpreted; the process doesn't reach that stage today.

Neutral display tone map (JPEG, PNG and other display-referred sources with Contrast, Whites and Blacks at 0) is the identity up to white: the SDR shoulder no longer pulls 1.0 down to 0.9875 (8-bit 255 to 254, issue #1945). It landed in place as a bug fix, since the tone map promised identity at neutral settings; edited curves keep the shoulder and their V1 look.

AI mask edges (Object and Describe selections stored as a model's grid) follow the photo's edges when they are enlarged to the output (issue #3272): the plain enlargement spilled 1–2 px onto the background, which a raised Exposure turned into a bright outline around the subject in previews and exports. Existing AI masks become 1–2 px tighter at a subject's edges, on every process; their inside, their outside and edges without contrast in the photo are unchanged. It landed in place as a bug fix: the outline is output that isn't in the photo nor in the edit, not a look anyone chose.

Adding a process

  1. Add a variant to Process (V2), append it to Process::ALL (oldest first), give it its number in Process::version and make it Process::LATEST.
  2. The compiler then points at every match on Process. The base tone map is chosen in one place, lightcraft_pipeline::finish::base_tone (both the CPU and the GPU renderer take it from there): give V2 its own arm and leave V1's alone. A process that changes another stage branches there the same way, on s.process.process(); if that stage is cached (Plan::lin_key, the planes in local.rs), add the process to its key.
  3. Keep settings_saved_before_process_versions_load_as_v1 (crates/develop/src/tests_process.rs) passing: settings saved without the field must stay V1, not become the new latest. That is the field's #[serde(default = "ProcessVersion::legacy")]; the struct-level Default gives new settings the latest instead.
  4. Render a few procedural scenes on both processes and compare, and update docs/parity-checklist.md.

What gets which process

Process
A newly imported, merged or Lightroom-imported photo the latest
Settings saved without the field (catalogs and their snapshots, versions and history, lc:settings in XMP sidecars, browser libraries: everything from before process versions), or with a damaged value V1
Reset (develop.reset) the latest
Reset of a section or a slider unchanged
Update to Current Process (develop.updateProcess) the latest, for photos on an older one; one undo step. The menu item follows the selection; a call with ids / id acts on those photos whatever is selected
Virtual copy, duplicate the source's
Copy / paste / sync / Auto Sync / presets unchanged (see below)
crs: edits (XMP from other raw developers, Lightroom catalogs, imported presets) unchanged; a new photo has the latest
Restoring a version or history step the one it had

Copy, paste, sync and presets never carry the process: it is in no settings group, so the photos they change keep their own and interpret the pasted values with it. (Lightroom offers the process version as a separate checkbox when syncing; LightCraft's groups are coarser and keep it out.) Partial settings JSON that names process itself (develop.merge, a hand-written preset) sets it.

Lightroom edits are re-interpreted by LightCraft's mapper (crates/engine/src/crs.rs), which is written against the current rendering. crs:ProcessVersion numbers Adobe's renderer, not ours, and is ignored; a photo imported with Lightroom edits is on LightCraft's latest process, and an existing LightCraft photo that receives them keeps its own.

A number above this build's latest (a library or sidecar written by a newer LightCraft) loads, is kept when saved, and renders with the newest process this build has. Update to Current Process never moves a photo to an older process; Reset does move it to this build's latest.

A damaged value (anything but a whole number from 0 to 2^32 - 1; 2.0 counts as 2) reads as V1, like a missing field, and costs nothing else: the rest of the settings load from the catalog, a snapshot, the op log or an lc:settings sidecar as usual. The damaged value isn't kept: the photo is saved as V1.

Storage

The process is saved as "process": <number> in the settings JSON, except V1, which is left out. Settings made before process versions existed are therefore saved exactly as before, and their preview-cache keys (DevelopSettings::hash64) don't change. develop.get (MCP get_develop) always states the process.

From process V3 on, persistent thumbnail, view-render and variant keys also include the resolved rendering process. A future number can keep the same settings hash across builds while resolving to different operators, so those keys must differ. V1 and V2 omit this extra field and retain their existing cached renders. No cache-version bump is needed.

Revision history

Date Change Summary
2026-11-05 minor V5: a raw's tone curve rolls off into white in stops, so raising Exposure keeps recorded highlights (#1632)
2026-10-12 minor Calibrated Contrast and Blacks, V2 Whites, resolved-process cache keys and number coordination