Files
lightcraft/docs/process-versions.md
T
Brain WangandClaude Opus 5.5 f14d166fdf Issue #3272: AI masks are enlarged along the photo's edges, no outline under a raised Exposure
An Object / Describe mask is stored as a model's logit grid (288² over the whole photo,
~20 px cells on a 24 MP export) and was enlarged by bilinear sampling alone, so its edge
spilled 1–2 px onto the background; raising the mask's Exposure turned that strip into a
bright outline in previews and exports (CPU and GPU: the GPU renderer evaluates these
shapes with the same CPU `shape_alpha`).

`sample_seg` now guided-filters the sampled logits by the photo's log luminance where the
grid is coarser than the output: the fast (subsampled) guided filter, a window of 1.5 grid
cells, so a preview and an export put the edge in the same place. Filtering logits, not
coverage, lets the confident side win (the spill goes to ≈ 0, not to a mean of ≈ 0.1).
Pixels whose window is all inside or all outside, and edges the photo has no contrast
across, keep the plain sample exactly. Refine Edges still runs afterwards on the combined
mask and finds the edges in place: no second halo, no tightening.

One implementation: `guided_cross` (Refine Edges, Auto Mask) and the new
`guided_cross_fast` share `guided_coeffs`; the subsampled form uses
`local::guided_fast_step` and a box `resize`, like `local::guided_fast`. `guided_cross`'s
output is unchanged.

Existing AI masks become 1–2 px tighter at a subject's edges, on every process; their
inside, outside and contrast-less edges are unchanged. This lands in place under the
bug-fix rule of docs/process-versions.md (output that is in neither the photo nor the
edit), noted there.

Cost per AI selection (M2 Pro, 12 cores; one shape_alpha, before → after):
24 MP 25.7 → 48.2 ms, heap peak 96.5 → 108.8 MB; 60 MP 64.1 → 109.9 ms, 239.9 → 256.7 MB;
2.7 MP preview 3.1 → 10.6 ms, 11.2 → 26.5 MB.

Tests (a dark disc on a light background, its mask on a 32² grid): ≈ 0 one and two px
outside and ≈ 1 inside, all around (1 px outside was 0.66), bit-identical away from the
edge; with Refine Edges 50 / 100 (was 0.29 outside) and the edge within 1 px of the
unrefined one; the same edge position at 256, 512 and 1024 px; a Prompt mask's window
matches the whole render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:13:04 +08:00

6.3 KiB
Raw Blame History

Process versions

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 (the latest) 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.

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 this build doesn't know (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.