Under the review that started all of this, someone who builds RAW engines for a living read the highlight complaint, diagnosed it correctly from the outside, and left me a comment that was half prescription and half warning.

The prescription was a pipeline: white balance pre-scale wide open, demosaic with no clamping, highlight reconstruction before the hand-off to a linear RGB texture. The warning was what happens if you do half of it — once you open up the clamps, you have to implement HLR, otherwise you will get banding, fringing, magenta cast, collapse in the near-clipped region. Then a question they couldn't answer themselves, because they work on Windows: hopefully Apple's pipeline is extensible enough to let you achieve this. I don't know.

And at the end, an ask: maybe write the journey up.

This is that post. The short answer to their question is yes, there's a seam, and it is almost exactly where they'd want it. The longer answer is that I never wrote a highlight reconstruction, because by the time I understood what was happening at that seam it turned out one was already running and I had been throwing away its output.

For anyone arriving from the photography side rather than the pipeline side, the thing that shipped is a switch. Photo Developer 1.6.2 adds Recover Highlights, off by default and set per photograph, and on the frame that started this it turns a blown white patch beyond a colonnade back into a building facade — masonry, window openings, corbels with shadow under them. It's a switch rather than a slider because whether a blown region is your subject or is the light source is a judgement no transfer function can make, and the person looking at the picture can make it instantly. An earlier post ended by promising this half and calling it the next release's work. This is that release; what follows is the machinery underneath it.


What I don't get to touch

Photo Developer sits on Apple's RAW decoder. That is a deliberate choice and I'd make it again — it's the same choice Nik Bhatt made for Nitro, and the alternative is the one thing my commenter went out of their way to advise against, having lost a year of their life to it.

The cost of that choice is precisely the stages their prescription is made of. White balance pre-scale, demosaic, reconstruction: all of it happens inside a box I can't open, in an order I don't set, before anything I've written gets a look at the image. That pipeline isn't something I can build. So the question stops being what should the pipeline be and becomes is there anywhere inside it to stand.

The seam

There is, and it's a property called linearSpaceFilter — a filter you hand to the decoder, which it runs inside the decode, in linear space. It had been sitting in my own architecture notes for weeks with nobody having run it.

Three measurements make it the right place, and each one rules out a different way of being wrong.

It costs nothing when idle. An identity filter installed there is byte-identical to no filter at all — 0.000% of pixels differ. So the hook itself isn't a tax you pay for having the feature.

The extended data flows through it. Clamping to [0,1] at that point changes 1.8% of pixels at EV 0 and 10.3% at +2 EV, and drops the final maximum from 2.046 to 1.037. Values well above 1.0 pass through the hook, and destroying them there propagates all the way to the output. Which is the whole ballgame: the hook is upstream of whatever imposes the ceiling.

Apple's tone curve runs after it. This is the one I'd steal if I were reading someone else's post. The obvious way to establish ordering is triangulation, and it's fiddly. The better experiment is to install a filter at the hook whose output is a constant regardless of its input, and then vary a knob you know belongs to the decoder's tone curve. If the same constant renders differently, the curve runs downstream of you. It does: 0.50 at the hook renders 0.7058 at Apple's default boost and 0.5000 with boost at zero. One run, no inference.

That third result is what makes the seam worth having rather than merely available. A rescue written there is shaped by Apple's tone curve on the way out, which means one shoulder rather than two stacked on each other. And it's their "pre Linear RGB Texture handoff" under another name — their prescribed insertion point and my measured one turned out to be the same place, arrived at from opposite directions.

One gift for anyone who goes looking for the same seam: the channels there go negative. Down to −0.41 on one of my frames. Nothing you write for that domain may assume non-negative input.

The ceiling wasn't where I said it was

I had been quoting a figure — a clamp at about 1.13 — as a limit of the decode. It isn't. Turning gamut mapping off takes the working-space maximum from around 1.03 to somewhere between 1.12 and 2.34 depending on the file and the exposure, and it stacks with extended-range decoding.

So the number was real and the mechanism attached to it was wrong, which is a specific kind of error: it survives every check that tests the number. Nobody re-measures a figure that keeps reproducing.

Turning the gamut mapper off is not the fix, incidentally. It fails the same bar extended-range decoding failed — it moves pixels that have nothing wrong with them, on up to 11% of a frame, with excursions of forty-odd code values. It's gentler than the alternative and disqualified for the same reason.

Who answered the warning

Now the warning itself, which is the most useful thing anyone said to me during this work.

The mechanism they describe is right and it isn't in dispute: the three channels don't clip at the same level, so above the clip the ratios between them are wrong, and any tone mapping applied to wrong ratios lands as a colour cast exactly at the boundary where the blown region meets the picture.

My first measurement of whether the platform handled this said it didn't. I toggled Apple's own highlight recovery off and compared: output band means and the fraction of pixels at 254-and-above were identical to the byte. Conclusion, written down at the time — Apple's recovery is a no-op.

That measurement was true and useless. It was taken in the output, where every pixel in the population under discussion renders 255 whether or not the recovery ran. Measured at the hook instead — the side of the pipeline where the data still exists — it's a 32% lift on the green channel.

Sunrise, EV 0RGB
Apple's recovery off1.9051.2331.568
Apple's recovery on1.7951.6241.624

The top row is an un-reconstructed clip: strongly warm, green lowest, exactly the artefact they predicted. The bottom row is near-neutral, with green and blue lifted to a common level. Apple is doing per-channel reconstruction there, and the direction it moves in is the collapse-toward-white that luminance recovery — the algorithm they suggested as a starting point — converges on by construction.

So: you have to implement HLR is answered by the platform. I didn't implement one. What I did was stop compressing its output into invisibility, which is a much smaller piece of work and a much less impressive sentence.

Two things follow. The first is that the property had never been queried in my source — the default being on was luck rather than a decision, and it's now pinned deliberately, because a setting that is inert in today's pipeline is not inert once you compress that range back into view. The second is that the property arrived in macOS 26, so none of this is a general statement about Apple's decoder. It's a statement about a recent one.

What the warning changed anyway

The platform answering the mechanical half didn't make the comment less useful, because the warning did something to the design that I hadn't managed on my own.

I had a non-goal on file that read, roughly, no synthesised highlight colour — recovery dominates invention. That warning turns it into a contradiction rather than a principle. Above the clip point, recovering real data and inventing plausible data are not alternatives: some channels carry genuine overage while others are pinned, so any reconstruction is constrained invention of the pinned channels, anchored to the ones that still carry signal. That's not a loophole, it's what reconstruction means.

So the rule got scoped instead of defended. Where real data exists, recovery still strictly dominates — and it turns out that's essentially all of what renders blown, since on five fixtures zero percent of blown pixels sit below 0.85 at the hook. Above the clip, synthesis is permitted when it's anchored to surviving channels, and stays forbidden when it would come from spatial context instead. A comment on a YouTube review made me write a better version of my own rule.

The other thing still owed to them is the boundary itself. Measured, the news is good and the sign is the opposite of the prediction: saturation across the near-clip transition moves by +0.1% to +4.3% relative, same direction on every fixture and every exposure, which is a near-clip pixel escaping the gamut mapper's compression and holding slightly more of its own colour rather than less. But a metric that says the failure class isn't there is not the same as having looked, and every frame judged so far was chosen for peak severity — the biggest plateau, the brightest disc — while the artefact they named lives one population over.

And using the switch on my own photographs rather than on fixtures, I have seen it: bright sun on water, recovery on, a fringe at the edge. Not on every frame of that kind, and not on the frames where the recovery does its best work — which is most of the argument for shipping it as something you turn on per photograph rather than something I turn on for you. But their prediction has a hit against it, my measurement said otherwise, and the measurement was drawn from frames chosen for a different reason. That is the fourth time in this project the instrument was fine and the sample was the blind spot, and I would like to say I saw it coming.

Where it leaves the algorithm they suggested

Luminance recovery is not implemented, and the honest reason is that Apple's reconstruction is already doing that family's job on the pinned channels. What survives is a bounded remainder: under 5% of blown pixels have two of three channels at their ceiling, where the ratio is an artefact of clipping rather than a fact about the light. That's the population a chroma term at the reconstruction stage would exist for, and it's the next thing in this area — after locality, which is what one sunrise frame keeps arguing for: recovery in the water reflection and not on the sun's disc, in a single photograph, which no global number delivers and the app's existing masking machinery already half-supplies.

There's a verdict I have to keep stage-scoped to avoid poisoning it. Collapsing colour toward white as values approach the ceiling is wrong for the negative pull that shipped in 1.6 — it re-introduces the desaturation the review complained about in the first place — and right above the clip, where it's what keeps a blown core white instead of pink. Same idea, opposite sign, different stage. I've written it down in the source twice for that reason.

What the journey actually was

Three of the readings in this post were wrong the first time, and all three were wrong the same way: they measured the thing through the very process that was destroying it. There's a whole post about that, and it's the one I'd point a colleague at first.

The rest of it is smaller than it sounds. I didn't build a highlight reconstruction. I found somewhere to stand inside someone else's decoder, measured what was already running there, discovered I'd been discarding it, and stopped. The interesting engineering was other people's, in Cupertino and in a comment thread; my share was the measuring, and half of that had to be done twice.

They also advised, plainly, against writing a demosaicing engine from scratch — been there, done that, a year of my life lost. I don't need convincing. But it's worth saying why that advice and this post are the same advice: the reason the seam was worth finding is that everything on either side of it was better than anything I would have written. Knowing exactly where your own work should start is most of what platform choice buys you, and I only found the boundary by trying to cross it.