Tell the shader which colour space it is encoding for

The generated shader ended with `encode_srgb` and a clamp, so every
photograph leaving DarkRoom had been through sRGB's gamut whatever the
settings page said. Export refused the other three spaces rather than
tag clipped pixels with a gamut they did not contain — correct, and
not something an encoder could fix.

So the output space becomes a parameter of composition. `compose_for`
emits a constant primaries matrix after the camera matrix and before
the clip, and generates the transfer function to match: the sRGB curve
for sRGB and Display P3, a pure 2.199 gamma for Adobe RGB, 1.8 with a
linear toe for ProPhoto. The ordering the camera matrix depends on is
untouched — operations still run in camera space — and sRGB emits no
conversion at all, so the shader compiled on nearly every frame is
byte-for-byte what it was.

The numbers live in dr-types, derived from four chromaticity pairs per
space rather than tabulated. That is not tidiness: the shader encodes
the pixels and the ICC profile describes them, and a file whose profile
disagrees with its own contents is worse than one with no profile. One
derivation makes them agree by construction, and can be checked against
the values the specifications publish.

Profiles are generated here too — minimal v2 matrix/TRC, about 2 KB,
pure Rust, no lcms to satisfy under the NDK. A JPEG carries it in APP2,
a PNG in iCCP, a TIFF in tag 34675. sRGB gets one as well, because
untagged does not mean sRGB, it means guess.

The refusal survives in a sharper form. A `Frame` now carries the space
it was rendered in, and export refuses to label it anything else. The
develop session still composes for sRGB, so a P3 export from the
interface fails with an accurate error instead of producing a file that
lies — the frontend half is a separate change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 09:04:04 +02:00
co-authored by Claude Opus 5
parent 2330ed25e9
commit 914d14ec0d
11 changed files with 1672 additions and 66 deletions
+16 -5
View File
@@ -23,12 +23,23 @@ pub enum ExportError {
#[error("{} export is not supported yet", .0.label())]
FormatUnsupported(ExportFormat),
/// Asked for a colour space the pipeline does not render to.
/// TRACES: FR-EXP-2
/// The frame was rendered into one colour space and asked to be labelled
/// another.
///
/// See the note in `export`: the shader clips to sRGB before this crate
/// sees a pixel, so a wider space could only be a mislabelling.
#[error("{} export needs a pipeline that renders to it", .0.label())]
ColourSpaceUnsupported(ColourSpace),
/// Not a limitation of the encoders — all four spaces embed a correct
/// profile. It is that the conversion happens in the shader, before the
/// clip to 0..1, so a frame is in exactly one space by the time it gets
/// here. The caller composes with `EditGraph::compose_for` to change which.
#[error(
"the frame was rendered in {} but a {} file was asked for",
.rendered.label(),
.requested.label()
)]
ColourSpaceMismatch {
rendered: ColourSpace,
requested: ColourSpace,
},
#[error("encoding failed: {0}")]
Encode(String),