Testing the first version on ILCE-7RM4 photos found a partly clipped sky between
bare branches leaning cyan to teal under #523's edit, where Lightroom shows white.
Where only green clipped, red and blue say such a sky is a pale cyan-blue; the
rebuild kept that colour until halfway to full clipping, and darkening and a camera
profile showed it. Colour borrowed from another surface (bright foliage below the
sky) could also push green up, with a small step where green first clips.
- highlight::reconstruct_with: the colour of a pale partly clipped pixel
(saturation relative to the colour model's white below 0.2, easing off by 0.4)
fades to the clipped neutral from the first clip on instead of from halfway;
brightness and strongly coloured pixels keep the old schedule.
- Borrowed colour counts less and less as both the pixel's own channels and the
rim's colour contradict it (0.15 to 0.4 stop); either alone can be wrong.
- Right past the rim every estimate continues the rim's colour (it used to hold
the clipped channels at their clip, for the neutral estimate only), so the first
clip has no step whatever lent the estimate. The rim is built whenever there is
clipping.
- Nothing jumps where a later channel clips: in the rim distance and the
contradiction, a clipped channel counts as the lower bound it is, and a channel
goes over to that from 0.75 of its clip; a channel about to clip is a floor
capped like the clip floor it becomes.
- Tests: a pale sky above yellow-green foliage with thin dark branches in it (no
step where green clips, no green from the foliage, the same beside the branches,
neutral well past the clip); the fringe test compares the sky beside the branch
with the sky away from it, on its way to neutral; a band whose red clips second
next to a rim of another colour renders without a step, in six layouts.
- docs/parity.md: the Highlights note. Render cache version 28.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A render still running on a worker thread while the process exits crashes in the
driver (SIGSEGV in the NVIDIA Vulkan driver on Linux), where neither the panic
hook nor catch_unwind helps: pool workers were detached, the devices are statics
that are never dropped, and nothing on the way out waited for GPU work.
- gpu: every entry into a device (render, device creation, pool trim, denoise
runner and tiles) is counted as work in flight (exit.rs). begin_shutdown()
lets nothing new in; wait_idle(timeout) waits for what is inside.
- preview: JobPool keeps its workers. shutdown(timeout) and Drop discard the
queued jobs and wait, bounded, for the running ones (2 s on drop); never for
the calling thread, and not twice.
- engine: a render job caught by the shutdown gives up instead of rendering on
the CPU, so quitting doesn't wait for it.
- app: on_exit ends with LightcraftApp::shutdown (2 s for everything), after
settings and library are saved; the macOS menu-bar Quit goes through on_exit.
Not changed: the export, merge, denoise and import threads are still detached;
their GPU work goes through the gate, their CPU work is cut off at exit as before.
Tests: pool drop waits / is bounded / survives a panicked job and its own job
dropping it; the gate; GPU renders refused and the device not created after
begin_shutdown; the app's shutdown leaves no render worker. The crash itself
needs Linux + NVIDIA and was not reproduced here (Windows, DX12): 9 of 9 quits
of the app mid-render exit with code 0 in under 0.8 s.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Testing the first version on ILCE-7RM4 photos found a partly clipped sky between
bare branches leaning cyan to teal under #523's edit, where Lightroom shows white.
Where only green clipped, red and blue say such a sky is a pale cyan-blue; the
rebuild kept that colour until halfway to full clipping, and darkening and a camera
profile showed it. Colour borrowed from another surface (bright foliage below the
sky) could also push green up, with a small step where green first clips.
- highlight::reconstruct_with: the colour of a pale partly clipped pixel
(saturation relative to the colour model's white below 0.2, easing off by 0.4)
fades to the clipped neutral from the first clip on instead of from halfway;
brightness and strongly coloured pixels keep the old schedule.
- Borrowed colour counts less and less as both the pixel's own channels and the
rim's colour contradict it (0.15 to 0.4 stop); either alone can be wrong.
- Right past the rim every estimate continues the rim's colour (it used to hold
the clipped channels at their clip, for the neutral estimate only), so the first
clip has no step whatever lent the estimate. The rim is built whenever there is
clipping.
- Nothing jumps where a later channel clips: in the rim distance and the
contradiction, a clipped channel counts as the lower bound it is, and a channel
goes over to that from 0.75 of its clip; a channel about to clip is a floor
capped like the clip floor it becomes.
- Tests: a pale sky above yellow-green foliage with thin dark branches in it (no
step where green clips, no green from the foliage, the same beside the branches,
neutral well past the clip); the fringe test compares the sky beside the branch
with the sky away from it, on its way to neutral; a band whose red clips second
next to a rim of another colour renders without a step, in six layouts.
- docs/parity.md: the Highlights note. Render cache version 28.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The two new catalogs landed alongside PRs that added ~127 strings; the per-language
tests require them in every catalog.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The loupe read the GPU's texture limit from the frame's raw input
(`i.raw.max_texture_side`). A native host (egui-winit) reports it in the
first frame's input only, so from the second frame on the loupe fell back
to 2048: every window render of a zoomed view was cut to 2048 x 2048 px,
and on a canvas wider than that (HiDPI, 4K, a large window) the strips
beside it showed the magnified whole-frame render. The whole-frame render
and the second-window and compare renders were capped the same way.
Read the limit egui keeps (`InputState::max_texture_side`) instead.
The headless host reported the limit in every frame, which is why no test
saw this: it now reports it once (and again when it changes), as a window
does. Two regression tests: the limit outlives the frame it was reported
in, and at 1:1 on a 2600 px canvas the window covers the canvas.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
`encode_avif_pq`: linear Rec. 2020 with SDR white at 1.0 → PQ (SDR white at
203 cd/m², BT.2408), BT.2020 NCL YCbCr 4:4:4, 10-bit, with CICP nclx
(9/16/9, full range) and a clli box. ravif fixes the sRGB curve, so this
drives the rav1e + avif-serialize it already uses (no new crates).
Export: AVIF with `hdr` renders the HDR edit in Rec. 2020 and writes PQ.
Photos without an HDR edit export as before, in every format. CoreImage
decodes the file as HDR (same 3.1 % of the frame above SDR white as the gain
map JPEG of the same edit).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- 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 <[email protected]>
- `ExportOptions.hdr` (`hdr` param): photos edited in HDR export as ISO
21496-1 gain map JPEGs (the SDR rendition as the base, the gain map measured
against it as written) or 32-bit float TIFF with highlights above 1.
Other formats and SDR edits export as before.
- Output sharpening and watermarks apply to both renditions.
- `develop.hdr {enabled?, maxEv?}` turns HDR editing on/off and sets the
headroom limit.
Checked on macOS: ImageIO reports both the ISO and the Apple gain map, and
CoreImage's HDR decode of a 3 EV edit reaches 2.95 EV.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Sensor-clipped pixels are rebuilt at raw decode. The tone map rolls them off to
white at Neutral, so any colour they carry shows only when Highlights, a negative
Exposure or Whites darkens them. They took colour from the nearest unclipped pixel
(dark foliage, lens fringes along branches, pixels demosaiced from clipped samples),
left two clipped channels at their white-balance-scaled clip levels (red or pink),
and were neutral only in camera RGB, not after the colour model (a look fitted to
the camera's JPEG, a camera profile).
- highlight::reconstruct_with: chromaticity only from bright unclipped content away
from edges and from the clip, through a normalised push-pull pyramid; a neutral
estimate blended in where reliable content is scarce at every scale up to a
quarter of the long edge, applied only as far as the pixel lies past the nearest
near-clip unclipped pixels (so a surface still runs into clipping without a
step); clipped pairs keep the estimate's ratio at their clip floors; partly
clipped pixels fade into the clipped neutral on their way from the first channel
clipping to the last; fully clipped pixels become the colour model's neutral.
Pixels without a clipped channel are left exactly as they were.
- engine: the decode colour step as CameraColourModel, with clipped_white, which
keeps the camera's neutral for nearly singular or implausible models.
- Tests in raw (continuity along a ramp through every clip, also as lines 1 to 16
rows thick with no reliable colour, on canvases 32 to 1024 rows), engine
(pipeline renders at Highlights -100, #523's edit and Exposure -2 through the
first clip, over the same sweep; a CC0 ILCE-7RM4 corpus file added to
RAW_SAMPLES) and the GPU equivalence suite.
- Render cache version 27.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Part of #535 (item 4).
Pre-2017 bodies without Sony crop tags shot in the camera's 16:9 mode now get
the 16:9 crop of their camera JPEG: width from FullImageSize, anchored left as
before (drops the ILCE-7SM2's 32 padding columns); height from FullImageSize
(ILCE-7SM2) or from an Exif image size of the same width (DSLR-A580, whose
FullImageSize still says 3:2); centred vertically at an even offset, as measured
by registering each camera JPEG on its raw. The aspect now matches the JPEG, so
the camera-look fit runs: ILCE-7SM2 dE 15.2 -> 4.8, DSLR-A580 dE 26.7 -> 3.9.
Only these 2 of 140 Sony decodes change; render cache version 26 (23-25 are
taken by #568, #542, #575).
Tests: crop helper (3:2 unchanged, both 16:9 sources, even offsets, hostile
sizes) and a corpus test of the two crops against their camera JPEGs; both
samples added to the corpus list.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
`lightcraft_codecs::gainmap`:
- `compute` measures a gain map (luminance or RGB, log2, block-averaged)
from an SDR and an HDR rendition; `apply` rebuilds the HDR rendition.
- ISO 21496-1 metadata: writer (fixed denominator) and reader (also the
common-denominator form; refuses unknown versions, backward maps and zero
denominators). Adobe `hdrgm` XMP reader and writer, per-channel values as
rdf:Seq; Apple's HDRGainMap/HDRToneMap description of the same map.
- `encode_jpeg`: base with CIPA DC-007 MPF index (gain map typed 0x050000),
the ISO marker and Container XMP merged into the user's packet, followed by
the gain map JPEG. `read_jpeg` / `is_gain_map_jpeg` find and decode the map.
Also: gain map images are no longer picked as embedded thumbnails (an Ultra
HDR JPEG could show its greyscale gain map in the grid). The JPEG APP-segment
building is shared with `encode_jpeg`. Fuzzed with the other decoders.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- `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]>
Uncompressed 16-bit words are read in the other byte order only when the
file's order leaves more than 1% of sampled words at or above
1 << max(bits, 14) and the other order leaves none. Measured on the CC0
DSC-R1 (raw.pixls.us 3221). Crop and white balance (SR2SubIFD) are not
addressed. RENDER_CACHE_VERSION 31 (adjust at merge).
Single-strip ORFs at 12.8 bits per pixel are 16-byte blocks: ten little-endian
12-bit samples in bytes 0..14 plus a zero pad byte. Main routed them through the
XZ-2 12-bit word path (stripes, magenta cast). New branch before it; the exactly
12.0 bpp XZ-2 branch is unchanged. Layout measured on CC0 files (log and scripts in
data/testing/retry-olympus-e300 and tools/retry-olympus-e300).
RENDER_CACHE_VERSION bumped to 30 (open PRs use up to 29; adjust at merge).
- #540's crs::Target replaces the raw flag: #597/#604's folder sync and sidecar_op and #533's
process tests pass the photo's target instead of a bool.
- #568's DRO test fits through #528's fit_look (proxies now return the clipped mask).
- #541 (French) and #564 (Ukrainian) gain the 15 keyword format strings #578-#592 added.
Co-Authored-By: Claude Opus 5.5 <[email protected]>