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:
@@ -13,7 +13,7 @@ use std::path::PathBuf;
|
||||
use dr_export::{export, Frame, NameContext};
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
|
||||
use dr_pipeline::EditGraph;
|
||||
use dr_types::{ExportFormat, ExportSettings, OutputSharpening, SizingMode};
|
||||
use dr_types::{ColourSpace, ExportFormat, ExportSettings, OutputSharpening, SizingMode};
|
||||
|
||||
fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info,wgpu=warn"))
|
||||
@@ -72,17 +72,24 @@ fn main() {
|
||||
let (fw, fh) = graph.output_size(sw, sh);
|
||||
println!("source {sw}×{sh}, framed {fw}×{fh}");
|
||||
|
||||
// The output space is chosen *here*, before the render, because that is
|
||||
// where it takes effect: the primaries conversion and the encode are the
|
||||
// last two lines of the generated shader (FR-EXP-2). Asking for it at the
|
||||
// encoder would be too late — the pixels would already be clipped.
|
||||
let space = ColourSpace::DisplayP3;
|
||||
|
||||
let mut adjust = AdjustPass::new(&ctx);
|
||||
let shader = graph.compose();
|
||||
let shader = graph.compose_for(space);
|
||||
let t = std::time::Instant::now();
|
||||
adjust.render(&source, &shader, fw, fh).expect("render");
|
||||
let (pixels, w, h) = adjust.export_pixels().expect("read back");
|
||||
println!(
|
||||
"rendered {w}×{h} in {:.0} ms",
|
||||
t.elapsed().as_secs_f32() * 1000.0
|
||||
"rendered {w}×{h} in {:.0} ms as {}",
|
||||
t.elapsed().as_secs_f32() * 1000.0,
|
||||
space.label()
|
||||
);
|
||||
|
||||
let frame = Frame::new(w, h, pixels).expect("well-formed frame");
|
||||
let frame = Frame::in_space(w, h, pixels, space).expect("well-formed frame");
|
||||
|
||||
let stem = input
|
||||
.file_stem()
|
||||
@@ -121,6 +128,7 @@ fn main() {
|
||||
format,
|
||||
sizing,
|
||||
sharpening,
|
||||
colour_space: space,
|
||||
filename_template: "{name}-{dimensions}".into(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
+225
-19
@@ -1,4 +1,4 @@
|
||||
//! TRACES: FR-EXP-1 | FR-EXP-8
|
||||
//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-8
|
||||
//! The encoders.
|
||||
//!
|
||||
//! All four write RGB, not RGBA. The pipeline produces an opaque frame — no
|
||||
@@ -7,6 +7,18 @@
|
||||
//! the same value in every pixel, and a PNG that some tools then treat as
|
||||
//! having meaningful transparency.
|
||||
//!
|
||||
//! # The colour profile
|
||||
//!
|
||||
//! All four embed one, in the place their container puts it: a JPEG APP2
|
||||
//! segment, a PNG `iCCP` chunk, TIFF tag 34675. Encoding correctly and
|
||||
//! labelling correctly are separate jobs and both are required — pixels in
|
||||
//! Display P3 with no profile are read as sRGB and come out desaturated,
|
||||
//! which is a worse outcome than not offering the space at all.
|
||||
//!
|
||||
//! sRGB gets one too, rather than relying on it being everyone's default.
|
||||
//! Untagged is not the same as tagged sRGB: it means "guess", and the guess
|
||||
//! differs between a browser, a phone gallery and a print shop.
|
||||
//!
|
||||
//! # Metadata
|
||||
//!
|
||||
//! Nothing is written. `strip_location` defaults to on (FR-EXP-8) and this
|
||||
@@ -22,20 +34,25 @@
|
||||
|
||||
use dr_types::{ExportFormat, ExportSettings};
|
||||
|
||||
use crate::ExportError;
|
||||
use crate::{icc, ExportError};
|
||||
|
||||
/// Encode a resized, sharpened RGBA buffer to the requested format.
|
||||
///
|
||||
/// The buffer is already encoded into `settings.colour_space` — that happened
|
||||
/// in the shader, at the only point where the unclipped colour still existed.
|
||||
/// All that is left here is to say so.
|
||||
pub fn encode(
|
||||
rgba: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
settings: &ExportSettings,
|
||||
) -> Result<Vec<u8>, ExportError> {
|
||||
let profile = icc::profile(settings.colour_space);
|
||||
match settings.format {
|
||||
ExportFormat::Jpeg => jpeg(rgba, width, height, settings.quality),
|
||||
ExportFormat::Png => png(rgba, width, height),
|
||||
ExportFormat::Tiff8 => tiff8(rgba, width, height),
|
||||
ExportFormat::Tiff16 => tiff16(rgba, width, height),
|
||||
ExportFormat::Jpeg => jpeg(rgba, width, height, settings.quality, &profile),
|
||||
ExportFormat::Png => png(rgba, width, height, &profile),
|
||||
ExportFormat::Tiff8 => tiff8(rgba, width, height, &profile),
|
||||
ExportFormat::Tiff16 => tiff16(rgba, width, height, &profile),
|
||||
other => Err(ExportError::FormatUnsupported(other)),
|
||||
}
|
||||
}
|
||||
@@ -49,9 +66,20 @@ fn rgb(rgba: &[u8]) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
fn jpeg(rgba: &[u8], width: u32, height: u32, quality: u8) -> Result<Vec<u8>, ExportError> {
|
||||
fn jpeg(
|
||||
rgba: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
quality: u8,
|
||||
profile: &[u8],
|
||||
) -> Result<Vec<u8>, ExportError> {
|
||||
let mut bytes = Vec::new();
|
||||
let encoder = jpeg_encoder::Encoder::new(&mut bytes, quality);
|
||||
let mut encoder = jpeg_encoder::Encoder::new(&mut bytes, quality);
|
||||
// Splits across APP2 segments itself if it has to. The profiles this crate
|
||||
// generates fit in one, but the branch is the encoder's rather than ours.
|
||||
encoder
|
||||
.add_icc_profile(profile)
|
||||
.map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||
encoder
|
||||
.encode(
|
||||
&rgb(rgba),
|
||||
@@ -63,12 +91,21 @@ fn jpeg(rgba: &[u8], width: u32, height: u32, quality: u8) -> Result<Vec<u8>, Ex
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn png(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ExportError> {
|
||||
fn png(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result<Vec<u8>, ExportError> {
|
||||
let mut bytes = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut bytes, width, height);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
// Built through `Info` rather than the setters, because the profile is
|
||||
// the one field `png::Encoder` has no setter for. The `sRGB` chunk is
|
||||
// deliberately left unset: the crate writes `iCCP` only in its
|
||||
// absence, and an sRGB chunk beside a P3 profile is a contradiction a
|
||||
// reader has to pick a side of.
|
||||
let mut info = png::Info::with_size(width, height);
|
||||
info.color_type = png::ColorType::Rgb;
|
||||
info.bit_depth = png::BitDepth::Eight;
|
||||
info.icc_profile = Some(std::borrow::Cow::Borrowed(profile));
|
||||
|
||||
let encoder = png::Encoder::with_info(&mut bytes, info)
|
||||
.map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||
let mut writer = encoder
|
||||
.write_header()
|
||||
.map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||
@@ -82,18 +119,63 @@ fn png(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ExportError> {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn tiff8(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ExportError> {
|
||||
/// The ICC profile as a TIFF tag value.
|
||||
///
|
||||
/// A newtype only because the tag's field type is `UNDEFINED` (7) and the
|
||||
/// `tiff` crate maps a plain `&[u8]` to `BYTE` (1). Both are single bytes and
|
||||
/// most readers do not look, but libtiff declares `TIFFTAG_ICCPROFILE` as
|
||||
/// undefined and a strict reader is entitled to agree with it.
|
||||
struct IccTag<'a>(&'a [u8]);
|
||||
|
||||
impl tiff::encoder::TiffValue for IccTag<'_> {
|
||||
const BYTE_LEN: u8 = 1;
|
||||
const FIELD_TYPE: tiff::tags::Type = tiff::tags::Type::UNDEFINED;
|
||||
|
||||
fn count(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
fn data(&self) -> std::borrow::Cow<'_, [u8]> {
|
||||
std::borrow::Cow::Borrowed(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tag 34675, `InterColourProfile`. Not in the `tiff` crate's `Tag` enum.
|
||||
const TAG_ICC_PROFILE: u16 = 34675;
|
||||
|
||||
fn tiff8(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result<Vec<u8>, ExportError> {
|
||||
use tiff::encoder::{colortype, TiffEncoder};
|
||||
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
let mut encoder =
|
||||
TiffEncoder::new(&mut bytes).map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||
encoder
|
||||
.write_image::<colortype::RGB8>(width, height, &rgb(rgba))
|
||||
let mut image = encoder
|
||||
.new_image::<colortype::RGB8>(width, height)
|
||||
.map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||
tag_profile(image.encoder(), profile)?;
|
||||
image
|
||||
.write_data(&rgb(rgba))
|
||||
.map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||
Ok(bytes.into_inner())
|
||||
}
|
||||
|
||||
/// Add the profile tag to a directory being built.
|
||||
///
|
||||
/// Shared by both TIFF widths, and separate from them because `write_image`
|
||||
/// cannot be used once there is a tag to add — the directory has to be opened,
|
||||
/// written into, and closed by hand.
|
||||
fn tag_profile<W, K>(
|
||||
dir: &mut tiff::encoder::DirectoryEncoder<'_, W, K>,
|
||||
profile: &[u8],
|
||||
) -> Result<(), ExportError>
|
||||
where
|
||||
W: std::io::Write + std::io::Seek,
|
||||
K: tiff::encoder::TiffKind,
|
||||
{
|
||||
dir.write_tag(tiff::tags::Tag::Unknown(TAG_ICC_PROFILE), IccTag(profile))
|
||||
.map_err(|e| ExportError::Encode(e.to_string()))
|
||||
}
|
||||
|
||||
/// 16-bit TIFF, for work continuing in another editor.
|
||||
///
|
||||
/// **Honest about what it carries.** The adjust pass renders to an 8-bit
|
||||
@@ -108,7 +190,7 @@ fn tiff8(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ExportError> {
|
||||
/// one: the composer has to be told what format to write, and export has to
|
||||
/// ask for the wide one (FR-EXP-9). Until then this is a container promotion,
|
||||
/// which is still the right thing to hand an editor that works in 16-bit.
|
||||
fn tiff16(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ExportError> {
|
||||
fn tiff16(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result<Vec<u8>, ExportError> {
|
||||
use tiff::encoder::{colortype, TiffEncoder};
|
||||
|
||||
// `x * 257` rather than `x << 8`: it maps 255 to 65535 exactly, where the
|
||||
@@ -118,8 +200,12 @@ fn tiff16(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ExportError>
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
let mut encoder =
|
||||
TiffEncoder::new(&mut bytes).map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||
encoder
|
||||
.write_image::<colortype::RGB16>(width, height, &wide)
|
||||
let mut image = encoder
|
||||
.new_image::<colortype::RGB16>(width, height)
|
||||
.map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||
tag_profile(image.encoder(), profile)?;
|
||||
image
|
||||
.write_data(&wide)
|
||||
.map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||
Ok(bytes.into_inner())
|
||||
}
|
||||
@@ -127,6 +213,7 @@ fn tiff16(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ExportError>
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
#[test]
|
||||
fn alpha_is_dropped_before_encoding() {
|
||||
@@ -155,7 +242,7 @@ mod tests {
|
||||
0, 0, 255, 255, // blue
|
||||
10, 20, 30, 255,
|
||||
];
|
||||
let bytes = png(&rgba, 2, 2).unwrap();
|
||||
let bytes = png(&rgba, 2, 2, &icc::profile(ColourSpace::Srgb)).unwrap();
|
||||
|
||||
let decoder = png::Decoder::new(std::io::Cursor::new(&bytes));
|
||||
let mut reader = decoder.read_info().unwrap();
|
||||
@@ -166,4 +253,123 @@ mod tests {
|
||||
assert_eq!(info.color_type, png::ColorType::Rgb);
|
||||
assert_eq!(&out[..12], &[255, 0, 0, 0, 255, 0, 0, 0, 255, 10, 20, 30]);
|
||||
}
|
||||
|
||||
/// A flat frame, for the tests that care only about what surrounds the
|
||||
/// pixels.
|
||||
fn flat(w: u32, h: u32) -> Vec<u8> {
|
||||
vec![128; (w * h * 4) as usize]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_png_carries_a_profile_a_decoder_gets_back_intact() {
|
||||
// Read out through the PNG decoder, so this exercises the iCCP
|
||||
// chunk's deflate round-trip rather than asserting the encoder was
|
||||
// called. A truncated or mis-deflated profile is one a reader
|
||||
// discards silently, leaving the file to be guessed at as sRGB.
|
||||
for space in ColourSpace::ALL {
|
||||
let want = icc::profile(space);
|
||||
let bytes = png(&flat(4, 4), 4, 4, &want).unwrap();
|
||||
|
||||
let decoder = png::Decoder::new(std::io::Cursor::new(&bytes));
|
||||
let reader = decoder.read_info().unwrap();
|
||||
let got = reader
|
||||
.info()
|
||||
.icc_profile
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("{space:?} PNG carries no profile"));
|
||||
assert_eq!(got.as_ref(), want.as_slice(), "{space:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jpeg_carries_its_profile_in_a_well_formed_app2_segment() {
|
||||
// The APP2 form is exacting: the marker, a length, the string
|
||||
// "ICC_PROFILE\0", then a chunk index and count before the payload.
|
||||
// A reader that does not find that header ignores the segment, so
|
||||
// walking it here is the only way to know the file is really tagged.
|
||||
for space in ColourSpace::ALL {
|
||||
let want = icc::profile(space);
|
||||
let bytes = jpeg(&flat(4, 4), 4, 4, 90, &want).unwrap();
|
||||
let got = jpeg_icc(&bytes)
|
||||
.unwrap_or_else(|| panic!("{space:?} JPEG has no ICC_PROFILE segment"));
|
||||
assert_eq!(got, want, "{space:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk a JPEG's marker segments and reassemble the ICC profile.
|
||||
///
|
||||
/// Hand-rolled because `zune-jpeg` decodes pixels and this is about what
|
||||
/// travels beside them.
|
||||
fn jpeg_icc(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
const TAG: &[u8] = b"ICC_PROFILE\0";
|
||||
let mut out = Vec::new();
|
||||
let mut i = 2; // past the SOI
|
||||
while i + 4 <= bytes.len() {
|
||||
if bytes[i] != 0xFF {
|
||||
return None;
|
||||
}
|
||||
let marker = bytes[i + 1];
|
||||
// Start of scan: entropy-coded data follows and there are no more
|
||||
// parseable segments.
|
||||
if marker == 0xDA {
|
||||
break;
|
||||
}
|
||||
let len = usize::from(u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]));
|
||||
let payload = &bytes[i + 4..i + 2 + len];
|
||||
if marker == 0xE2 && payload.starts_with(TAG) {
|
||||
// Two bytes of chunk index and count follow the tag.
|
||||
out.extend_from_slice(&payload[TAG.len() + 2..]);
|
||||
}
|
||||
i += 2 + len;
|
||||
}
|
||||
(!out.is_empty()).then_some(out)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_tiff_widths_carry_the_profile_in_tag_34675() {
|
||||
// Read back through the `tiff` decoder's own tag lookup. Writing the
|
||||
// tag with the wrong field type or a stale offset produces a file that
|
||||
// still opens — with no profile, and therefore the wrong colours.
|
||||
use tiff::decoder::Decoder;
|
||||
use tiff::tags::Tag;
|
||||
|
||||
for space in ColourSpace::ALL {
|
||||
let want = icc::profile(space);
|
||||
for (label, bytes) in [
|
||||
("8-bit", tiff8(&flat(4, 4), 4, 4, &want).unwrap()),
|
||||
("16-bit", tiff16(&flat(4, 4), 4, 4, &want).unwrap()),
|
||||
] {
|
||||
let mut d = Decoder::new(std::io::Cursor::new(&bytes)).expect("decode");
|
||||
let got = d
|
||||
.get_tag_u8_vec(Tag::Unknown(TAG_ICC_PROFILE))
|
||||
.unwrap_or_else(|e| panic!("{space:?} {label} TIFF: {e}"));
|
||||
assert_eq!(got, want, "{space:?} {label}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tiff_still_decodes_to_its_pixels_with_the_extra_tag_present() {
|
||||
// Adding a tag means opening the directory by hand instead of using
|
||||
// `write_image`, which is the sort of change that produces a valid
|
||||
// header over unreadable strips.
|
||||
use tiff::decoder::{Decoder, DecodingResult};
|
||||
|
||||
let rgba: Vec<u8> = vec![
|
||||
255, 0, 0, 255, // red
|
||||
0, 255, 0, 255, // green
|
||||
0, 0, 255, 255, // blue
|
||||
10, 20, 30, 255,
|
||||
];
|
||||
let bytes = tiff8(&rgba, 2, 2, &icc::profile(ColourSpace::Srgb)).unwrap();
|
||||
let mut d = Decoder::new(std::io::Cursor::new(&bytes)).expect("decode");
|
||||
assert_eq!(d.dimensions().expect("dimensions"), (2, 2));
|
||||
let DecodingResult::U8(pixels) = d.read_image().expect("read") else {
|
||||
panic!("expected 8-bit samples");
|
||||
};
|
||||
assert_eq!(
|
||||
&pixels[..12],
|
||||
&[255, 0, 0, 0, 255, 0, 0, 0, 255, 10, 20, 30]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
//! TRACES: FR-EXP-2
|
||||
//! Minimal ICC v2 matrix/TRC profiles, generated.
|
||||
//!
|
||||
//! # Why generated rather than shipped
|
||||
//!
|
||||
//! A profile is a description of what the pixels in a file mean, and the
|
||||
//! pixels here were produced by [`dr_types::colour`]'s matrices. Embedding a
|
||||
//! profile downloaded from elsewhere would mean two independent statements
|
||||
//! about the same space, agreeing until one of them was revised. Deriving both
|
||||
//! from the same primaries makes agreement structural.
|
||||
//!
|
||||
//! It is also the only pure-Rust route. Little-CMS is the obvious library and
|
||||
//! it is C, which the whole workspace avoids so it cross-compiles under the
|
||||
//! Android NDK — the same reasoning behind rustls, bundled SQLite and the
|
||||
//! Lensfun port.
|
||||
//!
|
||||
//! # What "minimal" leaves out
|
||||
//!
|
||||
//! A matrix/TRC display profile and nothing else: three colorants, three tone
|
||||
//! curves, a white point and the chromatic adaptation that got it there. No
|
||||
//! A2B/B2A lookup tables, no gamut tag, no named colours. That is the whole of
|
||||
//! what an RGB working space *is*, and it is what every reader — a browser, an
|
||||
//! operating system compositor, Photoshop — takes from a profile like this
|
||||
//! one. The tags omitted describe device behaviour these spaces do not have.
|
||||
//!
|
||||
//! Profiles come out around 2 KB, which matters more than it sounds: a JPEG
|
||||
//! carries the profile in APP2 segments capped at 64 KB each, and one that fits
|
||||
//! in a single segment avoids the chunked form that some older readers
|
||||
//! mishandle.
|
||||
|
||||
use dr_types::{ColourSpace, Transfer};
|
||||
|
||||
/// The ICC profile describing `space`, ready to embed.
|
||||
///
|
||||
/// Deterministic — the same space always produces the same bytes. Two exports
|
||||
/// of the same frame must be byte-identical files, which a creation timestamp
|
||||
/// read from the clock would quietly break, along with any deduplication
|
||||
/// downstream of it.
|
||||
pub fn profile(space: ColourSpace) -> Vec<u8> {
|
||||
let colorants = space.to_pcs_xyz();
|
||||
let trc = trc_curve(space.transfer());
|
||||
|
||||
// Sorted by signature, as the specification asks a tag table to be. Some
|
||||
// readers binary-search it.
|
||||
let mut tags: Vec<(&[u8; 4], Vec<u8>)> = vec![
|
||||
(b"bTRC", trc.clone()),
|
||||
// Columns, not rows: a colorant tag is where one primary lands in XYZ.
|
||||
(b"bXYZ", xyz_type(colorants[2], colorants[5], colorants[8])),
|
||||
(b"cprt", text_type(COPYRIGHT)),
|
||||
(b"desc", description_type(&description(space))),
|
||||
(b"gTRC", trc.clone()),
|
||||
(b"gXYZ", xyz_type(colorants[1], colorants[4], colorants[7])),
|
||||
(b"rTRC", trc),
|
||||
(b"rXYZ", xyz_type(colorants[0], colorants[3], colorants[6])),
|
||||
// The PCS illuminant itself, not the space's own white. The space's
|
||||
// white is recoverable from this and `chad`, and a profile that put
|
||||
// its native white here would have every reader adapt it twice.
|
||||
(b"wtpt", xyz_type(PCS_D50[0], PCS_D50[1], PCS_D50[2])),
|
||||
];
|
||||
|
||||
// Only where there is an adaptation to declare. ProPhoto is a D50 space
|
||||
// already, and an identity `chad` is a tag saying nothing.
|
||||
let adaptation = space.adaptation_to_pcs();
|
||||
if !is_identity(&adaptation) {
|
||||
tags.push((b"chad", sf32_type(&adaptation)));
|
||||
}
|
||||
tags.sort_by_key(|(sig, _)| **sig);
|
||||
|
||||
assemble(&tags)
|
||||
}
|
||||
|
||||
/// What a colour-management dialogue will show this profile as.
|
||||
///
|
||||
/// Deliberately not the canonical names. "sRGB IEC61966-2.1" is the reference
|
||||
/// profile, and this is not it — it is a profile derived from the same
|
||||
/// primaries, which is a different and weaker claim. "Adobe RGB (1998)" is
|
||||
/// additionally a name belonging to someone else. A distinct name also tells a
|
||||
/// user opening the file where the profile came from, which is the question
|
||||
/// they are asking when they look.
|
||||
fn description(space: ColourSpace) -> String {
|
||||
format!("DarkRoom {}", space.label())
|
||||
}
|
||||
|
||||
/// The copyright tag, which ICC requires a profile to carry.
|
||||
///
|
||||
/// A set of chromaticity coordinates from a published specification is not
|
||||
/// something to claim rights over, and a profile nobody may redistribute would
|
||||
/// make the files carrying it awkward to share — which is the whole purpose of
|
||||
/// an export.
|
||||
const COPYRIGHT: &str = "Generated by DarkRoom. No rights reserved.";
|
||||
|
||||
/// The profile connection space illuminant, as s15Fixed16 exactly.
|
||||
const PCS_D50: [f32; 3] = [0.9642, 1.0, 0.8249];
|
||||
|
||||
/// Samples in a tabulated tone curve.
|
||||
///
|
||||
/// 1024 is what the reference sRGB profiles use. The curve is interpolated
|
||||
/// linearly between samples, so this is far finer than the 8-bit values it
|
||||
/// describes; halving it would still be adequate and would save a kilobyte
|
||||
/// nobody is counting.
|
||||
const TRC_SAMPLES: usize = 1024;
|
||||
|
||||
/// A tone reproduction curve for the space's transfer function.
|
||||
///
|
||||
/// ICC curves run *towards* the connection space — device value to linear —
|
||||
/// which is the opposite direction from the shader's final encode. Getting it
|
||||
/// backwards produces a file that looks washed out or crushed by exactly the
|
||||
/// amount the curve bends.
|
||||
fn trc_curve(transfer: Transfer) -> Vec<u8> {
|
||||
// A pure power curve has an exact representation: a single u8Fixed8
|
||||
// gamma. Adobe RGB's 563/256 lands on it precisely, where a 1024-entry
|
||||
// table would be an approximation of a number the format can hold.
|
||||
if let Transfer::Gamma(g) = transfer {
|
||||
let mut out = tag_header(b"curv");
|
||||
out.extend_from_slice(&1u32.to_be_bytes());
|
||||
out.extend_from_slice(&((g * 256.0).round() as u16).to_be_bytes());
|
||||
return out;
|
||||
}
|
||||
|
||||
let mut out = tag_header(b"curv");
|
||||
out.extend_from_slice(&(TRC_SAMPLES as u32).to_be_bytes());
|
||||
for i in 0..TRC_SAMPLES {
|
||||
let device = i as f32 / (TRC_SAMPLES - 1) as f32;
|
||||
let linear = transfer.decode(device);
|
||||
out.extend_from_slice(&((linear * 65535.0).round() as u16).to_be_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An `XYZType` tag: one colour in the connection space.
|
||||
fn xyz_type(x: f32, y: f32, z: f32) -> Vec<u8> {
|
||||
let mut out = tag_header(b"XYZ ");
|
||||
for v in [x, y, z] {
|
||||
out.extend_from_slice(&s15_fixed16(v).to_be_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An `s15Fixed16ArrayType` tag, which is how `chad` is stored.
|
||||
fn sf32_type(m: &[f32; 9]) -> Vec<u8> {
|
||||
let mut out = tag_header(b"sf32");
|
||||
for v in m {
|
||||
out.extend_from_slice(&s15_fixed16(*v).to_be_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A `textType` tag: ASCII with a terminating NUL.
|
||||
fn text_type(s: &str) -> Vec<u8> {
|
||||
let mut out = tag_header(b"text");
|
||||
out.extend_from_slice(s.as_bytes());
|
||||
out.push(0);
|
||||
out
|
||||
}
|
||||
|
||||
/// A `textDescriptionType` tag — the v2 profile's name field.
|
||||
///
|
||||
/// Baroque, and not optional: v2 has no plain `mluc`, and the ASCII string is
|
||||
/// followed by empty Unicode and ScriptCode blocks that a reader will walk
|
||||
/// whether or not they hold anything. The 67-byte Macintosh field is fixed
|
||||
/// width by specification, so it is written out zeroed rather than omitted.
|
||||
fn description_type(s: &str) -> Vec<u8> {
|
||||
let ascii = s.as_bytes();
|
||||
let mut out = tag_header(b"desc");
|
||||
out.extend_from_slice(&(ascii.len() as u32 + 1).to_be_bytes());
|
||||
out.extend_from_slice(ascii);
|
||||
out.push(0);
|
||||
// Unicode language code, then Unicode character count: none of either.
|
||||
out.extend_from_slice(&[0; 8]);
|
||||
// ScriptCode code (u16), length (u8), and the fixed 67-byte field.
|
||||
out.extend_from_slice(&[0; 3]);
|
||||
out.extend_from_slice(&[0; 67]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Every tag element opens with its type signature and four reserved bytes.
|
||||
fn tag_header(sig: &[u8; 4]) -> Vec<u8> {
|
||||
let mut out = Vec::from(*sig);
|
||||
out.extend_from_slice(&[0; 4]);
|
||||
out
|
||||
}
|
||||
|
||||
/// ICC's fixed-point number: 16 integer bits, 16 fractional.
|
||||
fn s15_fixed16(v: f32) -> i32 {
|
||||
(f64::from(v) * 65536.0).round() as i32
|
||||
}
|
||||
|
||||
fn is_identity(m: &[f32; 9]) -> bool {
|
||||
const IDENTITY: [f32; 9] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
|
||||
// One step of s15Fixed16, the format the matrix would be stored in. Below
|
||||
// that it *is* the identity — ProPhoto's own white and the PCS illuminant
|
||||
// differ in the sixth decimal place, and a `chad` recording that would be
|
||||
// nine copies of 1.0000 and 0.0000 dressed up as information.
|
||||
const STEP: f32 = 1.0 / 65536.0;
|
||||
m.iter().zip(IDENTITY).all(|(a, b)| (a - b).abs() < STEP)
|
||||
}
|
||||
|
||||
/// Header, tag table, and the tag data, with the size written back in.
|
||||
fn assemble(tags: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
|
||||
let mut out = header();
|
||||
|
||||
out.extend_from_slice(&(tags.len() as u32).to_be_bytes());
|
||||
let table_at = out.len();
|
||||
out.resize(table_at + tags.len() * 12, 0);
|
||||
|
||||
for (i, (sig, data)) in tags.iter().enumerate() {
|
||||
// Identical elements share one copy, which the specification allows
|
||||
// explicitly. The three tone curves of a grey-balanced space are the
|
||||
// same 2 KB table, so this is two thirds of the profile.
|
||||
let offset = find(&out, data).unwrap_or_else(|| {
|
||||
let at = out.len();
|
||||
out.extend_from_slice(data);
|
||||
// Every element starts on a four-byte boundary.
|
||||
while !out.len().is_multiple_of(4) {
|
||||
out.push(0);
|
||||
}
|
||||
at
|
||||
});
|
||||
|
||||
let entry = table_at + i * 12;
|
||||
out[entry..entry + 4].copy_from_slice(*sig);
|
||||
out[entry + 4..entry + 8].copy_from_slice(&(offset as u32).to_be_bytes());
|
||||
out[entry + 8..entry + 12].copy_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
}
|
||||
|
||||
let size = out.len() as u32;
|
||||
out[0..4].copy_from_slice(&size.to_be_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// Where `needle` already sits in `haystack`, if it does.
|
||||
///
|
||||
/// Only ever called with tag elements, which begin on four-byte boundaries and
|
||||
/// start with a type signature — so a match cannot be a coincidental overlap
|
||||
/// of two other tags' bytes.
|
||||
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.position(|w| w == needle)
|
||||
.filter(|at| at.is_multiple_of(4))
|
||||
}
|
||||
|
||||
/// The fixed 128-byte profile header.
|
||||
fn header() -> Vec<u8> {
|
||||
let mut h = Vec::with_capacity(128);
|
||||
// Size, filled in once the profile is complete.
|
||||
h.extend_from_slice(&[0; 4]);
|
||||
// Preferred CMM: no preference.
|
||||
h.extend_from_slice(&[0; 4]);
|
||||
// Version 2.1.0. v2 rather than v4 because it is what every reader
|
||||
// handles, and because nothing here needs a v4 tag type.
|
||||
h.extend_from_slice(&[0x02, 0x10, 0x00, 0x00]);
|
||||
h.extend_from_slice(b"mntr");
|
||||
h.extend_from_slice(b"RGB ");
|
||||
h.extend_from_slice(b"XYZ ");
|
||||
// Creation date. Fixed, for the determinism the module docs describe.
|
||||
for field in [2025u16, 1, 1, 0, 0, 0] {
|
||||
h.extend_from_slice(&field.to_be_bytes());
|
||||
}
|
||||
h.extend_from_slice(b"acsp");
|
||||
// Primary platform, flags, manufacturer, model, attributes: unspecified.
|
||||
h.extend_from_slice(&[0; 24]);
|
||||
// Rendering intent: perceptual, as the reference RGB working-space
|
||||
// profiles declare. For a matrix/TRC profile the field is advisory —
|
||||
// there is only one transform in here to apply.
|
||||
h.extend_from_slice(&[0; 4]);
|
||||
for v in PCS_D50 {
|
||||
h.extend_from_slice(&s15_fixed16(v).to_be_bytes());
|
||||
}
|
||||
// Creator, profile ID, and the reserved tail.
|
||||
h.extend_from_slice(&[0; 4]);
|
||||
h.extend_from_slice(&[0; 16]);
|
||||
h.extend_from_slice(&[0; 28]);
|
||||
debug_assert_eq!(h.len(), 128);
|
||||
h
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A tag's element data, located through the profile's own tag table —
|
||||
/// so these tests read the profile the way a colour engine would rather
|
||||
/// than the way it was written.
|
||||
fn tag<'a>(profile: &'a [u8], want: &[u8; 4]) -> Option<&'a [u8]> {
|
||||
let count = u32::from_be_bytes(profile[128..132].try_into().unwrap()) as usize;
|
||||
for i in 0..count {
|
||||
let at = 132 + i * 12;
|
||||
if &profile[at..at + 4] == want {
|
||||
let off = u32::from_be_bytes(profile[at + 4..at + 8].try_into().unwrap()) as usize;
|
||||
let len = u32::from_be_bytes(profile[at + 8..at + 12].try_into().unwrap()) as usize;
|
||||
return Some(&profile[off..off + len]);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn xyz(data: &[u8]) -> [f32; 3] {
|
||||
let read =
|
||||
|at: usize| i32::from_be_bytes(data[at..at + 4].try_into().unwrap()) as f32 / 65536.0;
|
||||
[read(8), read(12), read(16)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_declares_its_own_length() {
|
||||
// The first field a reader trusts. A profile whose header says it is
|
||||
// longer than the buffer is one a strict parser rejects outright and a
|
||||
// lax one reads past the end of.
|
||||
for space in ColourSpace::ALL {
|
||||
let p = profile(space);
|
||||
let declared = u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize;
|
||||
assert_eq!(declared, p.len(), "{space:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_carries_the_signature_that_identifies_it_as_one() {
|
||||
// `acsp` at offset 36 is how every reader recognises an ICC profile.
|
||||
for space in ColourSpace::ALL {
|
||||
assert_eq!(&profile(space)[36..40], b"acsp", "{space:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_tag_lies_inside_the_profile_and_on_a_boundary() {
|
||||
// A tag table is offsets and lengths, and nothing checks them for us.
|
||||
// An off-by-four here produces a profile that parses as far as the
|
||||
// tag a reader happens to want.
|
||||
for space in ColourSpace::ALL {
|
||||
let p = profile(space);
|
||||
let count = u32::from_be_bytes(p[128..132].try_into().unwrap()) as usize;
|
||||
for i in 0..count {
|
||||
let at = 132 + i * 12;
|
||||
let off = u32::from_be_bytes(p[at + 4..at + 8].try_into().unwrap()) as usize;
|
||||
let len = u32::from_be_bytes(p[at + 8..at + 12].try_into().unwrap()) as usize;
|
||||
assert!(off.is_multiple_of(4), "{space:?} tag {i} starts at {off}");
|
||||
assert!(off + len <= p.len(), "{space:?} tag {i} runs off the end");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_profile_carries_the_tags_a_matrix_trc_profile_requires() {
|
||||
// The ICC v2 required set for a display profile. A reader missing any
|
||||
// one of these falls back to assuming sRGB, which is the silent
|
||||
// failure this whole feature exists to prevent.
|
||||
for space in ColourSpace::ALL {
|
||||
let p = profile(space);
|
||||
for required in [
|
||||
b"desc", b"cprt", b"wtpt", b"rXYZ", b"gXYZ", b"bXYZ", b"rTRC", b"gTRC", b"bTRC",
|
||||
] {
|
||||
assert!(tag(&p, required).is_some(), "{space:?} has no {required:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_colorants_are_the_ones_the_shader_encoded_with() {
|
||||
// The property the file's honesty rests on. The composer converts the
|
||||
// pixels with `to_pcs_xyz`'s primaries; if the profile described any
|
||||
// others the file would be a precise, confident lie.
|
||||
for space in ColourSpace::ALL {
|
||||
let p = profile(space);
|
||||
let want = space.to_pcs_xyz();
|
||||
for (i, sig) in [b"rXYZ", b"gXYZ", b"bXYZ"].into_iter().enumerate() {
|
||||
let got = xyz(tag(&p, sig).expect("colorant"));
|
||||
for (row, g) in got.iter().enumerate() {
|
||||
let expected = want[row * 3 + i];
|
||||
assert!(
|
||||
(g - expected).abs() < 1e-4,
|
||||
"{space:?} {sig:?} row {row}: profile says {g}, shader used {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_white_point_is_the_connection_space_illuminant() {
|
||||
// Not the space's own white. ProPhoto's is D50 anyway, but P3's is
|
||||
// D65, and a profile advertising D65 as its media white would have
|
||||
// every neutral adapted a second time.
|
||||
for space in ColourSpace::ALL {
|
||||
let got = xyz(tag(&profile(space), b"wtpt").expect("wtpt"));
|
||||
for (i, want) in PCS_D50.iter().enumerate() {
|
||||
assert!((got[i] - want).abs() < 1e-4, "{space:?} white {i}: {got:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tabulated_curve_reproduces_the_transfer_function_it_came_from() {
|
||||
// Read back out of the profile and compared against the function the
|
||||
// shader encodes with. The curve runs device-to-linear, and writing it
|
||||
// the other way round would still produce a monotonic curve of the
|
||||
// right length — this is what catches the direction.
|
||||
for space in [ColourSpace::Srgb, ColourSpace::ProPhoto] {
|
||||
let p = profile(space);
|
||||
let curve = tag(&p, b"rTRC").expect("rTRC");
|
||||
let count = u32::from_be_bytes(curve[8..12].try_into().unwrap()) as usize;
|
||||
assert_eq!(count, TRC_SAMPLES, "{space:?}");
|
||||
|
||||
let transfer = space.transfer();
|
||||
for i in [0, 1, count / 4, count / 2, count - 1] {
|
||||
let at = 12 + i * 2;
|
||||
let got =
|
||||
f32::from(u16::from_be_bytes(curve[at..at + 2].try_into().unwrap())) / 65535.0;
|
||||
let want = transfer.decode(i as f32 / (count - 1) as f32);
|
||||
assert!(
|
||||
(got - want).abs() < 1e-4,
|
||||
"{space:?} sample {i}: profile {got}, transfer {want}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adobe_rgb_stores_its_gamma_exactly_rather_than_sampling_it() {
|
||||
// 563/256 is representable in a u8Fixed8, so the curve is one number.
|
||||
// A 1024-entry table would approximate a value the format can hold
|
||||
// exactly, and would round-trip through other software as 2.2.
|
||||
let p = profile(ColourSpace::AdobeRgb);
|
||||
let curve = tag(&p, b"rTRC").expect("rTRC");
|
||||
assert_eq!(u32::from_be_bytes(curve[8..12].try_into().unwrap()), 1);
|
||||
assert_eq!(u16::from_be_bytes(curve[12..14].try_into().unwrap()), 563);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_three_tone_curves_share_one_copy() {
|
||||
// Not a size optimisation for its own sake: it keeps the profile under
|
||||
// the 64 KB a single JPEG APP2 segment holds, so the chunked form that
|
||||
// older readers mishandle is never needed.
|
||||
let p = profile(ColourSpace::Srgb);
|
||||
let count = u32::from_be_bytes(p[128..132].try_into().unwrap()) as usize;
|
||||
let offsets: Vec<u32> = ["rTRC", "gTRC", "bTRC"]
|
||||
.iter()
|
||||
.map(|sig| {
|
||||
(0..count)
|
||||
.map(|i| 132 + i * 12)
|
||||
.find(|at| &p[*at..at + 4] == sig.as_bytes())
|
||||
.map(|at| u32::from_be_bytes(p[at + 4..at + 8].try_into().unwrap()))
|
||||
.expect("curve present")
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(offsets[0], offsets[1]);
|
||||
assert_eq!(offsets[1], offsets[2]);
|
||||
assert!(p.len() < 8 * 1024, "{} bytes is too large", p.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_d65_space_declares_its_adaptation_and_a_d50_space_does_not() {
|
||||
// `chad` is what lets a reader recover the space's native white from
|
||||
// colorants that have already been adapted. Without it, D65 primaries
|
||||
// adapted to D50 and genuine D50 primaries are the same nine numbers.
|
||||
assert!(tag(&profile(ColourSpace::DisplayP3), b"chad").is_some());
|
||||
assert!(
|
||||
tag(&profile(ColourSpace::ProPhoto), b"chad").is_none(),
|
||||
"ProPhoto is a D50 space; an identity chad says nothing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_space_always_produces_the_same_bytes() {
|
||||
// Two exports of one frame must be identical files. A creation
|
||||
// timestamp from the clock is the obvious way to lose that.
|
||||
for space in ColourSpace::ALL {
|
||||
assert_eq!(profile(space), profile(space), "{space:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_space_is_described_by_its_own_name() {
|
||||
// A file whose profile says "sRGB" while carrying P3 pixels is exactly
|
||||
// as misleading as no profile at all, and harder to notice.
|
||||
for space in ColourSpace::ALL {
|
||||
let p = profile(space);
|
||||
let desc = tag(&p, b"desc").expect("desc");
|
||||
let len = u32::from_be_bytes(desc[8..12].try_into().unwrap()) as usize;
|
||||
let name = std::str::from_utf8(&desc[12..12 + len - 1]).expect("ascii");
|
||||
assert_eq!(name, format!("DarkRoom {}", space.label()));
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
-15
@@ -1,4 +1,4 @@
|
||||
//! TRACES: FR-EXP-1 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-9
|
||||
//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-9
|
||||
//! Turning a rendered frame into a file's worth of bytes.
|
||||
//!
|
||||
//! # What this crate is, and is not
|
||||
@@ -26,6 +26,7 @@ use dr_types::{ColourSpace, ExportFormat, ExportSettings};
|
||||
|
||||
mod encode;
|
||||
mod error;
|
||||
pub mod icc;
|
||||
mod name;
|
||||
mod sharpen;
|
||||
mod size;
|
||||
@@ -36,7 +37,7 @@ pub use size::target_size;
|
||||
|
||||
/// A rendered frame, as the adjust pass produced it.
|
||||
///
|
||||
/// 8-bit RGBA, display-encoded sRGB — the format
|
||||
/// 8-bit RGBA, display-encoded in [`Self::space`] — the format
|
||||
/// [`dr_gpu::AdjustPass`](../dr_gpu/struct.AdjustPass.html) writes. Alpha is
|
||||
/// carried but never meaningful: the pipeline writes 1.0 everywhere, and no
|
||||
/// operation produces transparency.
|
||||
@@ -46,10 +47,42 @@ pub struct Frame {
|
||||
pub height: u32,
|
||||
/// Tightly packed RGBA8, `width * height * 4` bytes.
|
||||
pub rgba: Vec<u8>,
|
||||
/// TRACES: FR-EXP-2
|
||||
/// The space the shader encoded these pixels into.
|
||||
///
|
||||
/// Travels with the pixels rather than being asserted at the point of
|
||||
/// encoding, because it is a fact about them and not a preference. The
|
||||
/// conversion happened in the generated shader, before the clip to 0..1,
|
||||
/// and nothing downstream can undo or redo it — a frame clipped to sRGB
|
||||
/// has already lost whatever a wider space would have carried.
|
||||
///
|
||||
/// Making it a field is what lets [`export`] refuse to label a frame as
|
||||
/// something it is not, rather than trusting a caller to have rendered
|
||||
/// what it asked for.
|
||||
pub space: ColourSpace,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// A frame the pipeline rendered in sRGB — what
|
||||
/// [`EditGraph::compose`](../dr_pipeline/struct.EditGraph.html#method.compose)
|
||||
/// produces, and so what the display path hands over.
|
||||
///
|
||||
/// An export in a wider space must render its own frame with
|
||||
/// `compose_for` and declare it through [`Self::in_space`]. Defaulting
|
||||
/// here rather than demanding the space at every call site keeps the
|
||||
/// common case honest by construction: a caller that has not thought
|
||||
/// about colour is describing sRGB, and sRGB is what it rendered.
|
||||
pub fn new(width: u32, height: u32, rgba: Vec<u8>) -> Result<Self, ExportError> {
|
||||
Self::in_space(width, height, rgba, ColourSpace::Srgb)
|
||||
}
|
||||
|
||||
/// A frame rendered into a stated colour space.
|
||||
pub fn in_space(
|
||||
width: u32,
|
||||
height: u32,
|
||||
rgba: Vec<u8>,
|
||||
space: ColourSpace,
|
||||
) -> Result<Self, ExportError> {
|
||||
let expected = width as usize * height as usize * 4;
|
||||
if rgba.len() != expected {
|
||||
return Err(ExportError::FrameSize {
|
||||
@@ -64,6 +97,7 @@ impl Frame {
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
space,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -100,17 +134,22 @@ pub fn export(
|
||||
settings: &ExportSettings,
|
||||
name: String,
|
||||
) -> Result<Encoded, ExportError> {
|
||||
// Refused rather than mislabelled. The pipeline's final stage encodes to
|
||||
// sRGB and clamps to its gamut (see `encode_srgb` in the generated
|
||||
// shader), so the pixels arriving here have already lost anything a wider
|
||||
// space could have carried. Tagging them Display P3 would produce a file
|
||||
// that claims a gamut it does not contain — worse than not offering it,
|
||||
// because the claim survives into everything downstream.
|
||||
// TRACES: FR-EXP-2
|
||||
// Refused rather than mislabelled. Every space the settings page offers
|
||||
// now works, but only if the *frame* was rendered into it: the conversion
|
||||
// and the clip both happen in the generated shader, so pixels that arrive
|
||||
// clipped to sRGB have already lost whatever a wider space would have
|
||||
// carried, and no amount of profile-writing here brings it back.
|
||||
//
|
||||
// Honouring the other spaces is a pipeline change, not an encoder one:
|
||||
// the shader has to be told what to encode to (FR-EXP-2).
|
||||
if settings.colour_space != ColourSpace::Srgb {
|
||||
return Err(ExportError::ColourSpaceUnsupported(settings.colour_space));
|
||||
// The caller's fix is to compose with `EditGraph::compose_for(space)`
|
||||
// before rendering. Until it does, this is an accurate error where the
|
||||
// alternative would be a file that claims a gamut it does not contain —
|
||||
// and that claim survives into everything downstream.
|
||||
if frame.space != settings.colour_space {
|
||||
return Err(ExportError::ColourSpaceMismatch {
|
||||
rendered: frame.space,
|
||||
requested: settings.colour_space,
|
||||
});
|
||||
}
|
||||
|
||||
if matches!(settings.format, ExportFormat::Avif | ExportFormat::JpegXl) {
|
||||
@@ -247,17 +286,41 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_colour_space_the_pipeline_cannot_produce_is_refused_not_mislabelled() {
|
||||
fn a_frame_rendered_in_one_space_is_not_labelled_another() {
|
||||
// A file tagged Display P3 carrying sRGB-clipped pixels is a lie that
|
||||
// survives into everything downstream. Better to fail loudly.
|
||||
// survives into everything downstream. The frame carries the space it
|
||||
// was rendered in precisely so this cannot be waved through.
|
||||
let mut s = settings(ExportFormat::Jpeg);
|
||||
s.colour_space = ColourSpace::DisplayP3;
|
||||
assert!(matches!(
|
||||
export(&frame(8, 8), &s, "a".into()),
|
||||
Err(ExportError::ColourSpaceUnsupported(_))
|
||||
Err(ExportError::ColourSpaceMismatch { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_colour_space_exports_when_the_frame_was_rendered_in_it() {
|
||||
// The other side of the refusal above, and what FR-EXP-2 actually
|
||||
// asks for: a frame the pipeline encoded into a wide space reaches a
|
||||
// file, in every format that has an encoder.
|
||||
for space in ColourSpace::ALL {
|
||||
for format in [
|
||||
ExportFormat::Jpeg,
|
||||
ExportFormat::Png,
|
||||
ExportFormat::Tiff8,
|
||||
ExportFormat::Tiff16,
|
||||
] {
|
||||
let mut s = settings(format);
|
||||
s.colour_space = space;
|
||||
let mut f = frame(8, 8);
|
||||
f.space = space;
|
||||
let out = export(&f, &s, "a".into())
|
||||
.unwrap_or_else(|e| panic!("{space:?} as {format:?}: {e}"));
|
||||
assert!(!out.bytes.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_formats_without_an_encoder_say_so() {
|
||||
for format in [ExportFormat::Avif, ExportFormat::JpegXl] {
|
||||
|
||||
Reference in New Issue
Block a user