Merge branch 'colour-managed-export'
This commit is contained in:
@@ -13,7 +13,7 @@ use std::path::PathBuf;
|
|||||||
use dr_export::{export, Frame, NameContext};
|
use dr_export::{export, Frame, NameContext};
|
||||||
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
|
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
|
||||||
use dr_pipeline::EditGraph;
|
use dr_pipeline::EditGraph;
|
||||||
use dr_types::{ExportFormat, ExportSettings, OutputSharpening, SizingMode};
|
use dr_types::{ColourSpace, ExportFormat, ExportSettings, OutputSharpening, SizingMode};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info,wgpu=warn"))
|
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);
|
let (fw, fh) = graph.output_size(sw, sh);
|
||||||
println!("source {sw}×{sh}, framed {fw}×{fh}");
|
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 mut adjust = AdjustPass::new(&ctx);
|
||||||
let shader = graph.compose();
|
let shader = graph.compose_for(space);
|
||||||
let t = std::time::Instant::now();
|
let t = std::time::Instant::now();
|
||||||
adjust.render(&source, &shader, fw, fh).expect("render");
|
adjust.render(&source, &shader, fw, fh).expect("render");
|
||||||
let (pixels, w, h) = adjust.export_pixels().expect("read back");
|
let (pixels, w, h) = adjust.export_pixels().expect("read back");
|
||||||
println!(
|
println!(
|
||||||
"rendered {w}×{h} in {:.0} ms",
|
"rendered {w}×{h} in {:.0} ms as {}",
|
||||||
t.elapsed().as_secs_f32() * 1000.0
|
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
|
let stem = input
|
||||||
.file_stem()
|
.file_stem()
|
||||||
@@ -121,6 +128,7 @@ fn main() {
|
|||||||
format,
|
format,
|
||||||
sizing,
|
sizing,
|
||||||
sharpening,
|
sharpening,
|
||||||
|
colour_space: space,
|
||||||
filename_template: "{name}-{dimensions}".into(),
|
filename_template: "{name}-{dimensions}".into(),
|
||||||
..Default::default()
|
..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.
|
//! The encoders.
|
||||||
//!
|
//!
|
||||||
//! All four write RGB, not RGBA. The pipeline produces an opaque frame — no
|
//! 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
|
//! the same value in every pixel, and a PNG that some tools then treat as
|
||||||
//! having meaningful transparency.
|
//! 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
|
//! # Metadata
|
||||||
//!
|
//!
|
||||||
//! Nothing is written. `strip_location` defaults to on (FR-EXP-8) and this
|
//! Nothing is written. `strip_location` defaults to on (FR-EXP-8) and this
|
||||||
@@ -22,20 +34,25 @@
|
|||||||
|
|
||||||
use dr_types::{ExportFormat, ExportSettings};
|
use dr_types::{ExportFormat, ExportSettings};
|
||||||
|
|
||||||
use crate::ExportError;
|
use crate::{icc, ExportError};
|
||||||
|
|
||||||
/// Encode a resized, sharpened RGBA buffer to the requested format.
|
/// 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(
|
pub fn encode(
|
||||||
rgba: &[u8],
|
rgba: &[u8],
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
settings: &ExportSettings,
|
settings: &ExportSettings,
|
||||||
) -> Result<Vec<u8>, ExportError> {
|
) -> Result<Vec<u8>, ExportError> {
|
||||||
|
let profile = icc::profile(settings.colour_space);
|
||||||
match settings.format {
|
match settings.format {
|
||||||
ExportFormat::Jpeg => jpeg(rgba, width, height, settings.quality),
|
ExportFormat::Jpeg => jpeg(rgba, width, height, settings.quality, &profile),
|
||||||
ExportFormat::Png => png(rgba, width, height),
|
ExportFormat::Png => png(rgba, width, height, &profile),
|
||||||
ExportFormat::Tiff8 => tiff8(rgba, width, height),
|
ExportFormat::Tiff8 => tiff8(rgba, width, height, &profile),
|
||||||
ExportFormat::Tiff16 => tiff16(rgba, width, height),
|
ExportFormat::Tiff16 => tiff16(rgba, width, height, &profile),
|
||||||
other => Err(ExportError::FormatUnsupported(other)),
|
other => Err(ExportError::FormatUnsupported(other)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,9 +66,20 @@ fn rgb(rgba: &[u8]) -> Vec<u8> {
|
|||||||
out
|
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 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
|
encoder
|
||||||
.encode(
|
.encode(
|
||||||
&rgb(rgba),
|
&rgb(rgba),
|
||||||
@@ -63,12 +91,21 @@ fn jpeg(rgba: &[u8], width: u32, height: u32, quality: u8) -> Result<Vec<u8>, Ex
|
|||||||
Ok(bytes)
|
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 bytes = Vec::new();
|
||||||
{
|
{
|
||||||
let mut encoder = png::Encoder::new(&mut bytes, width, height);
|
// Built through `Info` rather than the setters, because the profile is
|
||||||
encoder.set_color(png::ColorType::Rgb);
|
// the one field `png::Encoder` has no setter for. The `sRGB` chunk is
|
||||||
encoder.set_depth(png::BitDepth::Eight);
|
// 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
|
let mut writer = encoder
|
||||||
.write_header()
|
.write_header()
|
||||||
.map_err(|e| ExportError::Encode(e.to_string()))?;
|
.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)
|
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};
|
use tiff::encoder::{colortype, TiffEncoder};
|
||||||
|
|
||||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||||
let mut encoder =
|
let mut encoder =
|
||||||
TiffEncoder::new(&mut bytes).map_err(|e| ExportError::Encode(e.to_string()))?;
|
TiffEncoder::new(&mut bytes).map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||||
encoder
|
let mut image = encoder
|
||||||
.write_image::<colortype::RGB8>(width, height, &rgb(rgba))
|
.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()))?;
|
.map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||||
Ok(bytes.into_inner())
|
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.
|
/// 16-bit TIFF, for work continuing in another editor.
|
||||||
///
|
///
|
||||||
/// **Honest about what it carries.** The adjust pass renders to an 8-bit
|
/// **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
|
/// 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,
|
/// 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.
|
/// 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};
|
use tiff::encoder::{colortype, TiffEncoder};
|
||||||
|
|
||||||
// `x * 257` rather than `x << 8`: it maps 255 to 65535 exactly, where the
|
// `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 bytes = std::io::Cursor::new(Vec::new());
|
||||||
let mut encoder =
|
let mut encoder =
|
||||||
TiffEncoder::new(&mut bytes).map_err(|e| ExportError::Encode(e.to_string()))?;
|
TiffEncoder::new(&mut bytes).map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||||
encoder
|
let mut image = encoder
|
||||||
.write_image::<colortype::RGB16>(width, height, &wide)
|
.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()))?;
|
.map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||||
Ok(bytes.into_inner())
|
Ok(bytes.into_inner())
|
||||||
}
|
}
|
||||||
@@ -127,6 +213,7 @@ fn tiff16(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, ExportError>
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use dr_types::ColourSpace;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn alpha_is_dropped_before_encoding() {
|
fn alpha_is_dropped_before_encoding() {
|
||||||
@@ -155,7 +242,7 @@ mod tests {
|
|||||||
0, 0, 255, 255, // blue
|
0, 0, 255, 255, // blue
|
||||||
10, 20, 30, 255,
|
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 decoder = png::Decoder::new(std::io::Cursor::new(&bytes));
|
||||||
let mut reader = decoder.read_info().unwrap();
|
let mut reader = decoder.read_info().unwrap();
|
||||||
@@ -166,4 +253,123 @@ mod tests {
|
|||||||
assert_eq!(info.color_type, png::ColorType::Rgb);
|
assert_eq!(info.color_type, png::ColorType::Rgb);
|
||||||
assert_eq!(&out[..12], &[255, 0, 0, 0, 255, 0, 0, 0, 255, 10, 20, 30]);
|
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())]
|
#[error("{} export is not supported yet", .0.label())]
|
||||||
FormatUnsupported(ExportFormat),
|
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
|
/// Not a limitation of the encoders — all four spaces embed a correct
|
||||||
/// sees a pixel, so a wider space could only be a mislabelling.
|
/// profile. It is that the conversion happens in the shader, before the
|
||||||
#[error("{} export needs a pipeline that renders to it", .0.label())]
|
/// clip to 0..1, so a frame is in exactly one space by the time it gets
|
||||||
ColourSpaceUnsupported(ColourSpace),
|
/// 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}")]
|
#[error("encoding failed: {0}")]
|
||||||
Encode(String),
|
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.
|
//! Turning a rendered frame into a file's worth of bytes.
|
||||||
//!
|
//!
|
||||||
//! # What this crate is, and is not
|
//! # What this crate is, and is not
|
||||||
@@ -26,6 +26,7 @@ use dr_types::{ColourSpace, ExportFormat, ExportSettings};
|
|||||||
|
|
||||||
mod encode;
|
mod encode;
|
||||||
mod error;
|
mod error;
|
||||||
|
pub mod icc;
|
||||||
mod name;
|
mod name;
|
||||||
mod sharpen;
|
mod sharpen;
|
||||||
mod size;
|
mod size;
|
||||||
@@ -36,7 +37,7 @@ pub use size::target_size;
|
|||||||
|
|
||||||
/// A rendered frame, as the adjust pass produced it.
|
/// 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
|
/// [`dr_gpu::AdjustPass`](../dr_gpu/struct.AdjustPass.html) writes. Alpha is
|
||||||
/// carried but never meaningful: the pipeline writes 1.0 everywhere, and no
|
/// carried but never meaningful: the pipeline writes 1.0 everywhere, and no
|
||||||
/// operation produces transparency.
|
/// operation produces transparency.
|
||||||
@@ -46,10 +47,42 @@ pub struct Frame {
|
|||||||
pub height: u32,
|
pub height: u32,
|
||||||
/// Tightly packed RGBA8, `width * height * 4` bytes.
|
/// Tightly packed RGBA8, `width * height * 4` bytes.
|
||||||
pub rgba: Vec<u8>,
|
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 {
|
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> {
|
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;
|
let expected = width as usize * height as usize * 4;
|
||||||
if rgba.len() != expected {
|
if rgba.len() != expected {
|
||||||
return Err(ExportError::FrameSize {
|
return Err(ExportError::FrameSize {
|
||||||
@@ -64,6 +97,7 @@ impl Frame {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
rgba,
|
rgba,
|
||||||
|
space,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,17 +134,22 @@ pub fn export(
|
|||||||
settings: &ExportSettings,
|
settings: &ExportSettings,
|
||||||
name: String,
|
name: String,
|
||||||
) -> Result<Encoded, ExportError> {
|
) -> Result<Encoded, ExportError> {
|
||||||
// Refused rather than mislabelled. The pipeline's final stage encodes to
|
// TRACES: FR-EXP-2
|
||||||
// sRGB and clamps to its gamut (see `encode_srgb` in the generated
|
// Refused rather than mislabelled. Every space the settings page offers
|
||||||
// shader), so the pixels arriving here have already lost anything a wider
|
// now works, but only if the *frame* was rendered into it: the conversion
|
||||||
// space could have carried. Tagging them Display P3 would produce a file
|
// and the clip both happen in the generated shader, so pixels that arrive
|
||||||
// that claims a gamut it does not contain — worse than not offering it,
|
// clipped to sRGB have already lost whatever a wider space would have
|
||||||
// because the claim survives into everything downstream.
|
// carried, and no amount of profile-writing here brings it back.
|
||||||
//
|
//
|
||||||
// Honouring the other spaces is a pipeline change, not an encoder one:
|
// The caller's fix is to compose with `EditGraph::compose_for(space)`
|
||||||
// the shader has to be told what to encode to (FR-EXP-2).
|
// before rendering. Until it does, this is an accurate error where the
|
||||||
if settings.colour_space != ColourSpace::Srgb {
|
// alternative would be a file that claims a gamut it does not contain —
|
||||||
return Err(ExportError::ColourSpaceUnsupported(settings.colour_space));
|
// 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) {
|
if matches!(settings.format, ExportFormat::Avif | ExportFormat::JpegXl) {
|
||||||
@@ -247,17 +286,41 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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
|
// 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);
|
let mut s = settings(ExportFormat::Jpeg);
|
||||||
s.colour_space = ColourSpace::DisplayP3;
|
s.colour_space = ColourSpace::DisplayP3;
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
export(&frame(8, 8), &s, "a".into()),
|
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]
|
#[test]
|
||||||
fn the_formats_without_an_encoder_say_so() {
|
fn the_formats_without_an_encoder_say_so() {
|
||||||
for format in [ExportFormat::Avif, ExportFormat::JpegXl] {
|
for format in [ExportFormat::Avif, ExportFormat::JpegXl] {
|
||||||
|
|||||||
@@ -1260,6 +1260,84 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_output_colour_space_renders_what_the_colorimetry_predicts() {
|
||||||
|
// TRACES: FR-EXP-2
|
||||||
|
// The shader carries constants generated from `dr_types::colour`; this
|
||||||
|
// recomputes the same conversion on the CPU and demands the GPU agree.
|
||||||
|
// A transposed matrix, a transfer function applied before the
|
||||||
|
// primaries, or a clip in the wrong place all compile perfectly and
|
||||||
|
// simply produce the wrong colour — none of which a "did it compile"
|
||||||
|
// test would notice.
|
||||||
|
//
|
||||||
|
// A saturated patch, deliberately: every one of these spaces maps a
|
||||||
|
// neutral to itself, so a grey would agree with all four.
|
||||||
|
let Some(ctx) = ctx() else { return };
|
||||||
|
let mut pass = AdjustPass::new(&ctx);
|
||||||
|
let source = [200u8, 90, 40];
|
||||||
|
let img = jpeg_image(&ctx, source);
|
||||||
|
|
||||||
|
// The JPEG path linearises with the sRGB curve, so this is the value
|
||||||
|
// reaching the output stage.
|
||||||
|
let linear: Vec<f32> = source
|
||||||
|
.iter()
|
||||||
|
.map(|&v| dr_types::Transfer::Srgb.decode(f32::from(v) / 255.0))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for space in dr_types::ColourSpace::ALL {
|
||||||
|
let shader = EditGraph::default_chain().compose_for(space);
|
||||||
|
let t = pass.render(&img, &shader, 16, 16).expect("render");
|
||||||
|
let got = read_centre(&ctx, t);
|
||||||
|
|
||||||
|
let m = space.from_linear_srgb();
|
||||||
|
for channel in 0..3 {
|
||||||
|
let converted = m[channel * 3] * linear[0]
|
||||||
|
+ m[channel * 3 + 1] * linear[1]
|
||||||
|
+ m[channel * 3 + 2] * linear[2];
|
||||||
|
let want = space.transfer().encode(converted.clamp(0.0, 1.0)) * 255.0;
|
||||||
|
let delta = (f32::from(got[channel]) - want).abs();
|
||||||
|
// Two levels: the pipeline stores its intermediate in f16 and
|
||||||
|
// the source itself came from an 8-bit texel, so exactness is
|
||||||
|
// not on offer. A wrong matrix is out by tens.
|
||||||
|
assert!(
|
||||||
|
delta <= 2.0,
|
||||||
|
"{space:?} channel {channel}: rendered {} against a predicted {want:.1} \
|
||||||
|
(whole pixel {got:?})",
|
||||||
|
got[channel]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_wide_gamut_render_differs_from_an_srgb_one() {
|
||||||
|
// The companion to the test above, and the one that would fail if the
|
||||||
|
// output space were accepted and then ignored: predicted values that
|
||||||
|
// happened to match sRGB's would prove nothing. A saturated red is
|
||||||
|
// several tens of levels apart in P3.
|
||||||
|
let Some(ctx) = ctx() else { return };
|
||||||
|
let mut pass = AdjustPass::new(&ctx);
|
||||||
|
let img = jpeg_image(&ctx, [230, 30, 20]);
|
||||||
|
|
||||||
|
let srgb = {
|
||||||
|
let shader = EditGraph::default_chain().compose_for(dr_types::ColourSpace::Srgb);
|
||||||
|
let t = pass.render(&img, &shader, 16, 16).expect("render");
|
||||||
|
read_centre(&ctx, t)
|
||||||
|
};
|
||||||
|
let p3 = {
|
||||||
|
let shader = EditGraph::default_chain().compose_for(dr_types::ColourSpace::DisplayP3);
|
||||||
|
let t = pass.render(&img, &shader, 16, 16).expect("render");
|
||||||
|
read_centre(&ctx, t)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Less red and more green: the same colour expressed against wider
|
||||||
|
// primaries needs smaller numbers to reach it.
|
||||||
|
assert!(
|
||||||
|
p3[0] < srgb[0] && p3[1] > srgb[1],
|
||||||
|
"sRGB rendered {srgb:?} and Display P3 {p3:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_jpeg_and_sensor_data_agree_on_the_same_scene_value() {
|
fn a_jpeg_and_sensor_data_agree_on_the_same_scene_value() {
|
||||||
// The two producers must be interchangeable. A mid-grey that is
|
// The two producers must be interchangeable. A mid-grey that is
|
||||||
|
|||||||
@@ -285,9 +285,24 @@ impl EditGraph {
|
|||||||
!self.ops.iter().any(|o| o.is_active()) && !self.framing.edits_image()
|
!self.ops.iter().any(|o| o.is_active()) && !self.framing.edits_image()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate the fused shader for the current state.
|
/// Generate the fused shader for the current state, encoded to sRGB.
|
||||||
|
///
|
||||||
|
/// What the display path wants. An export that has been asked for a wider
|
||||||
|
/// space wants [`Self::compose_for`] instead, and must say so: the space
|
||||||
|
/// is baked into the shader, so a frame rendered by this one is sRGB and
|
||||||
|
/// nothing downstream can make it anything else.
|
||||||
pub fn compose(&self) -> ComposedShader {
|
pub fn compose(&self) -> ComposedShader {
|
||||||
compose_with_framing(&self.ops, &self.framing)
|
self.compose_for(dr_types::ColourSpace::Srgb)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-EXP-2
|
||||||
|
/// Generate the fused shader, encoded to a chosen output space.
|
||||||
|
///
|
||||||
|
/// Not stored on the graph, because it is not part of the edit: the same
|
||||||
|
/// graph renders to the screen and to a file in the same breath, and the
|
||||||
|
/// two want different answers.
|
||||||
|
pub fn compose_for(&self, output: dr_types::ColourSpace) -> ComposedShader {
|
||||||
|
compose_with_framing(&self.ops, &self.framing, output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
|
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
use dr_types::{ColourSpace, Transfer};
|
||||||
|
|
||||||
use crate::descriptor::{OpDescriptor, ParamId, Presentation};
|
use crate::descriptor::{OpDescriptor, ParamId, Presentation};
|
||||||
use crate::framing::{Framing, FRAMING_UNIFORM_FIELDS};
|
use crate::framing::{Framing, FRAMING_UNIFORM_FIELDS};
|
||||||
|
|
||||||
@@ -152,23 +154,41 @@ const BASE_UNIFORM_FIELDS: usize = 16;
|
|||||||
/// operation's uniforms the moment either block changes size.
|
/// operation's uniforms the moment either block changes size.
|
||||||
pub const RESERVED_UNIFORM_FIELDS: usize = BASE_UNIFORM_FIELDS + FRAMING_UNIFORM_FIELDS;
|
pub const RESERVED_UNIFORM_FIELDS: usize = BASE_UNIFORM_FIELDS + FRAMING_UNIFORM_FIELDS;
|
||||||
|
|
||||||
/// Compose enabled operations into a single compute shader.
|
/// Compose enabled operations into a single compute shader, for the display.
|
||||||
///
|
///
|
||||||
/// Inactive operations are skipped entirely — they contribute no code, no
|
/// Inactive operations are skipped entirely — they contribute no code, no
|
||||||
/// uniforms, and nothing to the structure hash.
|
/// uniforms, and nothing to the structure hash.
|
||||||
///
|
///
|
||||||
/// Equivalent to [`compose_with_framing`] with neutral framing.
|
/// Equivalent to [`compose_with_framing`] with neutral framing and an sRGB
|
||||||
|
/// output.
|
||||||
pub fn compose(ops: &[Box<dyn Operation>]) -> ComposedShader {
|
pub fn compose(ops: &[Box<dyn Operation>]) -> ComposedShader {
|
||||||
compose_with_framing(ops, &Framing::new())
|
compose_with_framing(ops, &Framing::new(), ColourSpace::Srgb)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-EXP-2 | FR-DSP-6
|
||||||
/// Compose operations and framing into a single compute shader.
|
/// Compose operations and framing into a single compute shader.
|
||||||
///
|
///
|
||||||
/// Framing generates the shader's **prologue** — the map from an output pixel
|
/// Framing generates the shader's **prologue** — the map from an output pixel
|
||||||
/// back to a source position — where [`compose`] would emit a fixed identity
|
/// back to a source position — where [`compose`] would emit a fixed identity
|
||||||
/// scale. The fused-dispatch property is unaffected: a cropped, straightened
|
/// scale. The fused-dispatch property is unaffected: a cropped, straightened
|
||||||
/// edit with three adjustments is still one dispatch, one read, one write.
|
/// edit with three adjustments is still one dispatch, one read, one write.
|
||||||
pub fn compose_with_framing(ops: &[Box<dyn Operation>], framing: &Framing) -> ComposedShader {
|
///
|
||||||
|
/// # The output space is a parameter, not a constant
|
||||||
|
///
|
||||||
|
/// `output` decides the primaries and transfer function the last two lines of
|
||||||
|
/// the shader encode into. It is passed per composition rather than held
|
||||||
|
/// anywhere because it is a property of *this render*: the same edit goes to
|
||||||
|
/// the screen in the display's space and to a file in whatever the export asks
|
||||||
|
/// for, and neither is more authoritative than the other.
|
||||||
|
///
|
||||||
|
/// It also enters the structure hash, so the two do not collide in the
|
||||||
|
/// pipeline cache — a screen render and a Display P3 export are different
|
||||||
|
/// shaders, however identical their sliders.
|
||||||
|
pub fn compose_with_framing(
|
||||||
|
ops: &[Box<dyn Operation>],
|
||||||
|
framing: &Framing,
|
||||||
|
output: ColourSpace,
|
||||||
|
) -> ComposedShader {
|
||||||
let active: Vec<&dyn Operation> = ops
|
let active: Vec<&dyn Operation> = ops
|
||||||
.iter()
|
.iter()
|
||||||
.map(|o| o.as_ref())
|
.map(|o| o.as_ref())
|
||||||
@@ -271,6 +291,9 @@ pub fn compose_with_framing(ops: &[Box<dyn Operation>], framing: &Framing) -> Co
|
|||||||
""
|
""
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let to_output = primaries_conversion(output);
|
||||||
|
let encode_output = encode_output_fn(output);
|
||||||
|
|
||||||
let source = format!(
|
let source = format!(
|
||||||
"// GENERATED — do not edit.
|
"// GENERATED — do not edit.
|
||||||
//
|
//
|
||||||
@@ -286,17 +309,8 @@ struct Params {{
|
|||||||
@group(0) @binding(1) var<uniform> u: Params;
|
@group(0) @binding(1) var<uniform> u: Params;
|
||||||
@group(0) @binding(2) var output: texture_storage_2d<rgba8unorm, write>;
|
@group(0) @binding(2) var output: texture_storage_2d<rgba8unorm, write>;
|
||||||
|
|
||||||
{sampler_helper}{helper_src}// Linear sRGB to the display transfer function.
|
{sampler_helper}{helper_src}{encode_output}
|
||||||
//
|
// Display-encoded sRGB back to linear, for sources that arrive that way.
|
||||||
// The one place quantisation happens: everything above runs in linear f16,
|
|
||||||
// and this is the final encode (ARCH §5.2).
|
|
||||||
fn encode_srgb(c: vec3<f32>) -> vec3<f32> {{
|
|
||||||
let lo = c * 12.92;
|
|
||||||
let hi = 1.055 * pow(max(c, vec3<f32>(0.0031308)), vec3<f32>(1.0 / 2.4)) - 0.055;
|
|
||||||
return select(hi, lo, c <= vec3<f32>(0.0031308));
|
|
||||||
}}
|
|
||||||
|
|
||||||
// The inverse, for sources that arrive already display-encoded.
|
|
||||||
//
|
//
|
||||||
// A JPEG is uploaded with its bytes untouched, so its values are gamma-encoded
|
// A JPEG is uploaded with its bytes untouched, so its values are gamma-encoded
|
||||||
// where the demosaicer's are linear. Every operation below assumes linear
|
// where the demosaicer's are linear. Every operation below assumes linear
|
||||||
@@ -345,10 +359,12 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
|||||||
dot(u.cam_to_srgb_1.rgb, c),
|
dot(u.cam_to_srgb_1.rgb, c),
|
||||||
dot(u.cam_to_srgb_2.rgb, c),
|
dot(u.cam_to_srgb_2.rgb, c),
|
||||||
);
|
);
|
||||||
|
{to_output}
|
||||||
// Clip to the display gamut and encode.
|
// Clip to the output gamut and encode. The clip is last for the reason the
|
||||||
|
// matrix above is: a colour outside sRGB is still inside a wider space, and
|
||||||
|
// clipping before the conversion would throw it away for no one's benefit.
|
||||||
c = clamp(c, vec3<f32>(0.0), vec3<f32>(1.0));
|
c = clamp(c, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||||
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(encode_srgb(c), 1.0));
|
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(encode_output(c), 1.0));
|
||||||
}}
|
}}
|
||||||
",
|
",
|
||||||
active.len()
|
active.len()
|
||||||
@@ -357,7 +373,15 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
|||||||
// Framing enters the hash by structure only — which branches its prologue
|
// Framing enters the hash by structure only — which branches its prologue
|
||||||
// generated, never how far a slider moved. Dragging the crop handles must
|
// generated, never how far a slider moved. Dragging the crop handles must
|
||||||
// reuse the compiled pipeline and re-upload uniforms.
|
// reuse the compiled pipeline and re-upload uniforms.
|
||||||
let structure_hash = mix(hash_structure(&active), framing.structure_key());
|
//
|
||||||
|
// The output space enters it too, and must: it changes the source, so two
|
||||||
|
// spaces sharing a hash would have the second silently rendered with the
|
||||||
|
// first's shader — a Display P3 export that came out sRGB and said
|
||||||
|
// otherwise.
|
||||||
|
let structure_hash = mix(
|
||||||
|
mix(hash_structure(&active), framing.structure_key()),
|
||||||
|
output as u64,
|
||||||
|
);
|
||||||
|
|
||||||
ComposedShader {
|
ComposedShader {
|
||||||
source,
|
source,
|
||||||
@@ -366,6 +390,87 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The WGSL converting linear sRGB into the output space's primaries.
|
||||||
|
///
|
||||||
|
/// A constant matrix rather than a uniform: the space is chosen when the
|
||||||
|
/// shader is composed, so the numbers are known at generation time and the
|
||||||
|
/// driver can fold them into the surrounding arithmetic.
|
||||||
|
///
|
||||||
|
/// Empty for sRGB, which is the space the pipeline already works in — the
|
||||||
|
/// camera matrix converts into it, which is what `cam_to_srgb` is named for.
|
||||||
|
/// Emitting an identity there would put nine constants and three dot products
|
||||||
|
/// into the display path's shader, the one compiled most often, to compute the
|
||||||
|
/// value it already had. The identity is detected rather than special-cased by
|
||||||
|
/// name, so a space that happens to share sRGB's primaries would be spared
|
||||||
|
/// too.
|
||||||
|
fn primaries_conversion(output: ColourSpace) -> String {
|
||||||
|
let m = output.from_linear_srgb();
|
||||||
|
const IDENTITY: [f32; 9] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
|
||||||
|
// A tolerance rather than equality: the matrix is an inverse multiplied by
|
||||||
|
// a product, so sRGB's own comes back a few ULP off the identity. A
|
||||||
|
// millionth of a channel is four decimal places below an 8-bit step.
|
||||||
|
if m.iter().zip(IDENTITY).all(|(a, b)| (a - b).abs() < 1e-6) {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = format!(
|
||||||
|
"\n // Linear sRGB -> linear {}. The last colour transform before the\n\
|
||||||
|
\x20 // encode, and the reason a colour sRGB could not hold survives\n\
|
||||||
|
\x20 // this far: it is still inside this gamut.\n\
|
||||||
|
\x20 c = vec3<f32>(\n",
|
||||||
|
output.label()
|
||||||
|
);
|
||||||
|
// Entries below the printed precision are zero as far as the shader is
|
||||||
|
// concerned, and a shared primary produces one every time. Snapping them
|
||||||
|
// avoids emitting `-0.000000`, which reads as a sign error to whoever is
|
||||||
|
// debugging a shader at the time.
|
||||||
|
let show = |v: f32| if v.abs() < 5e-7 { 0.0 } else { v };
|
||||||
|
for row in 0..3 {
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" dot(vec3<f32>({:.6}, {:.6}, {:.6}), c),",
|
||||||
|
show(m[row * 3]),
|
||||||
|
show(m[row * 3 + 1]),
|
||||||
|
show(m[row * 3 + 2])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out.push_str(" );\n");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The WGSL of the output space's transfer function.
|
||||||
|
///
|
||||||
|
/// Named `encode_output` whatever the space, so the call site at the end of
|
||||||
|
/// `main` does not have to know which one it got.
|
||||||
|
fn encode_output_fn(output: ColourSpace) -> String {
|
||||||
|
let body = match output.transfer() {
|
||||||
|
Transfer::Srgb => " let lo = c * 12.92;
|
||||||
|
let hi = 1.055 * pow(max(c, vec3<f32>(0.0031308)), vec3<f32>(1.0 / 2.4)) - 0.055;
|
||||||
|
return select(hi, lo, c <= vec3<f32>(0.0031308));"
|
||||||
|
.to_string(),
|
||||||
|
// No linear segment at all, so no `select`: Adobe RGB (1998) is a
|
||||||
|
// pure power curve, and inventing a toe for it would be a different
|
||||||
|
// space wearing its name.
|
||||||
|
Transfer::Gamma(g) => format!(" return pow(c, vec3<f32>(1.0 / {g:.8}));"),
|
||||||
|
Transfer::Prophoto => " let lo = c * 16.0;
|
||||||
|
let hi = pow(max(c, vec3<f32>(0.001953125)), vec3<f32>(1.0 / 1.8));
|
||||||
|
return select(hi, lo, c < vec3<f32>(0.001953125));"
|
||||||
|
.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"// Linear {} to its transfer function.
|
||||||
|
//
|
||||||
|
// The one place quantisation happens: everything above runs in linear f16,
|
||||||
|
// and this is the final encode (ARCH §5.2).
|
||||||
|
fn encode_output(c: vec3<f32>) -> vec3<f32> {{
|
||||||
|
{body}
|
||||||
|
}}
|
||||||
|
",
|
||||||
|
output.label()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// The WGSL turning the framed source position `p` into the colour `c`.
|
/// The WGSL turning the framed source position `p` into the colour `c`.
|
||||||
///
|
///
|
||||||
/// Split out because it is the join between the coordinate stage and the
|
/// Split out because it is the join between the coordinate stage and the
|
||||||
@@ -766,6 +871,90 @@ mod tests {
|
|||||||
assert!(op < matrix, "the camera matrix must come after operations");
|
assert!(op < matrix, "the camera matrix must come after operations");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compose with neutral framing into a chosen output space.
|
||||||
|
fn compose_to(ops: &[Box<dyn Operation>], output: ColourSpace) -> ComposedShader {
|
||||||
|
compose_with_framing(ops, &Framing::new(), output)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_srgb_render_is_byte_for_byte_what_it_was_before_output_spaces_existed() {
|
||||||
|
// The display path is the shader compiled on nearly every frame, and
|
||||||
|
// it must not pick up an identity matrix multiply for the sake of
|
||||||
|
// generality. Asserted against the source rather than against timing,
|
||||||
|
// which would not fail reliably.
|
||||||
|
let ops = vec![fake(&DESC_A, 1.0, false)];
|
||||||
|
let srgb = compose_to(&ops, ColourSpace::Srgb).source;
|
||||||
|
assert!(
|
||||||
|
!srgb.contains("Linear sRGB -> linear sRGB"),
|
||||||
|
"sRGB in, sRGB out must emit no conversion:\n{srgb}"
|
||||||
|
);
|
||||||
|
assert_eq!(srgb, compose(&ops).source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_wide_output_space_converts_after_the_camera_matrix_and_before_the_clip() {
|
||||||
|
// The whole point of the ordering. The camera matrix lands the colour
|
||||||
|
// in linear sRGB, the primaries conversion carries it into the wider
|
||||||
|
// space, and only then is it clipped — clipping first would discard
|
||||||
|
// exactly the colours the wider space was chosen to keep.
|
||||||
|
let source = compose_to(&[fake(&DESC_A, 1.0, false)], ColourSpace::DisplayP3).source;
|
||||||
|
let camera = source.find("u.cam_to_srgb_0").expect("camera matrix");
|
||||||
|
let convert = source
|
||||||
|
.find("Linear sRGB -> linear Display P3")
|
||||||
|
.expect("primaries conversion");
|
||||||
|
let clip = source.find("c = clamp(c,").expect("clip");
|
||||||
|
assert!(camera < convert, "the camera matrix must come first");
|
||||||
|
assert!(convert < clip, "the clip must come after the conversion");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_generated_matrix_is_the_one_the_profile_writer_will_use() {
|
||||||
|
// The shader encodes the pixels and `dr-export` describes them, from
|
||||||
|
// the same table in `dr-types`. If the composer ever grew its own copy
|
||||||
|
// of these numbers the file would be labelled with primaries it does
|
||||||
|
// not contain, which is the failure the whole feature exists to avoid.
|
||||||
|
let m = ColourSpace::DisplayP3.from_linear_srgb();
|
||||||
|
let first_row = format!("dot(vec3<f32>({:.6}, {:.6}, {:.6}), c)", m[0], m[1], m[2]);
|
||||||
|
let source = compose_to(&[], ColourSpace::DisplayP3).source;
|
||||||
|
assert!(
|
||||||
|
source.contains(&first_row),
|
||||||
|
"expected {first_row} in:\n{source}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_output_space_encodes_with_its_own_transfer_function() {
|
||||||
|
// Adobe RGB's pure 2.199 gamma and ProPhoto's 1.8-with-a-toe are not
|
||||||
|
// the sRGB curve, and a file encoded with the wrong one is wrong in a
|
||||||
|
// way no amount of correct primaries repairs.
|
||||||
|
let marks = [
|
||||||
|
(ColourSpace::Srgb, "1.0 / 2.4"),
|
||||||
|
(ColourSpace::DisplayP3, "1.0 / 2.4"),
|
||||||
|
(ColourSpace::AdobeRgb, "1.0 / 2.19921875"),
|
||||||
|
(ColourSpace::ProPhoto, "1.0 / 1.8"),
|
||||||
|
];
|
||||||
|
for (space, mark) in marks {
|
||||||
|
let source = compose_to(&[], space).source;
|
||||||
|
assert!(
|
||||||
|
source.contains(mark),
|
||||||
|
"{space:?} should encode with {mark}:\n{source}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_output_space_changes_the_structure_hash() {
|
||||||
|
// The pipeline cache is keyed on this hash. Two spaces sharing one
|
||||||
|
// would have the second rendered with the first's compiled shader —
|
||||||
|
// an export that came out sRGB and claimed to be Display P3.
|
||||||
|
let mut seen: Vec<u64> = Vec::new();
|
||||||
|
for space in ColourSpace::ALL {
|
||||||
|
let h = compose_to(&[fake(&DESC_A, 1.0, false)], space).structure_hash;
|
||||||
|
assert!(!seen.contains(&h), "{space:?} collides with another space");
|
||||||
|
seen.push(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generated_source_carries_a_do_not_edit_banner() {
|
fn generated_source_carries_a_do_not_edit_banner() {
|
||||||
// Someone will eventually find this in a debugger and try to fix it
|
// Someone will eventually find this in a debugger and try to fix it
|
||||||
|
|||||||
@@ -0,0 +1,545 @@
|
|||||||
|
//! TRACES: FR-EXP-2 | FR-DSP-6
|
||||||
|
//! The numbers behind an output colour space.
|
||||||
|
//!
|
||||||
|
//! # Why this lives in the types crate
|
||||||
|
//!
|
||||||
|
//! Two crates need these numbers and they must never disagree. `dr-pipeline`
|
||||||
|
//! bakes the primaries into the generated shader, so the pixels are *encoded*
|
||||||
|
//! into the space; `dr-export` writes the same primaries into an ICC profile,
|
||||||
|
//! so the file is *described* as being in it. A drift between the two produces
|
||||||
|
//! a file whose profile lies about its own contents — precisely the failure the
|
||||||
|
//! export path refused to risk before any of this existed. Neither crate
|
||||||
|
//! depends on the other, so one shared home is the only way to make that
|
||||||
|
//! disagreement impossible rather than merely unlikely.
|
||||||
|
//!
|
||||||
|
//! # Derived, not tabulated
|
||||||
|
//!
|
||||||
|
//! Everything here comes from four chromaticity pairs per space. A table of
|
||||||
|
//! nine pre-computed matrix entries is a set of numbers nobody can check, and a
|
||||||
|
//! transposed row in one looks exactly like a correct matrix. A derivation can
|
||||||
|
//! be tested against the values the specifications publish, which is what the
|
||||||
|
//! tests at the bottom do.
|
||||||
|
//!
|
||||||
|
//! The arithmetic is `f64` throughout and only narrows on the way out. Two of
|
||||||
|
//! the three steps are matrix inversions, and an inverse amplifies whatever
|
||||||
|
//! rounding it was handed.
|
||||||
|
|
||||||
|
use crate::settings::ColourSpace;
|
||||||
|
|
||||||
|
/// A colour space's primaries and white point, as CIE xy chromaticities.
|
||||||
|
///
|
||||||
|
/// This is the whole definition of a space's *gamut*; the transfer function
|
||||||
|
/// (see [`Transfer`]) is the other, independent half. Display P3 and sRGB
|
||||||
|
/// differ only in the first, ProPhoto and Adobe RGB in both.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Chromaticities {
|
||||||
|
pub red: [f64; 2],
|
||||||
|
pub green: [f64; 2],
|
||||||
|
pub blue: [f64; 2],
|
||||||
|
pub white: [f64; 2],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a space maps linear light onto the numbers stored in a file.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum Transfer {
|
||||||
|
/// The sRGB curve: linear at slope 12.92 below 0.0031308, then gamma 2.4
|
||||||
|
/// offset to meet it. Display P3 uses this same curve — it departs from
|
||||||
|
/// sRGB in its primaries alone.
|
||||||
|
Srgb,
|
||||||
|
/// A pure power curve with no linear segment. Adobe RGB (1998)'s.
|
||||||
|
Gamma(f32),
|
||||||
|
/// ROMM RGB's curve: linear at slope 16 below 1/512, then gamma 1.8.
|
||||||
|
Prophoto,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Transfer {
|
||||||
|
/// Linear light to a stored value. What the final stage of a render does.
|
||||||
|
pub fn encode(self, v: f32) -> f32 {
|
||||||
|
// `powf` of a negative is NaN, and a negative arrives whenever a
|
||||||
|
// colour falls outside the destination gamut. Callers clamp, but a
|
||||||
|
// NaN escaping here would reach a file as a black pixel with no
|
||||||
|
// indication of where it came from.
|
||||||
|
let v = v.max(0.0);
|
||||||
|
match self {
|
||||||
|
Self::Srgb => {
|
||||||
|
if v <= 0.003_130_8 {
|
||||||
|
v * 12.92
|
||||||
|
} else {
|
||||||
|
1.055 * v.powf(1.0 / 2.4) - 0.055
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::Gamma(g) => v.powf(1.0 / g),
|
||||||
|
Self::Prophoto => {
|
||||||
|
if v < 1.0 / 512.0 {
|
||||||
|
v * 16.0
|
||||||
|
} else {
|
||||||
|
v.powf(1.0 / 1.8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stored value back to linear light.
|
||||||
|
///
|
||||||
|
/// The direction an ICC tone reproduction curve is defined in — a `curv`
|
||||||
|
/// tag maps device values towards the profile connection space — which is
|
||||||
|
/// why this exists alongside the encoder the renderer wants.
|
||||||
|
pub fn decode(self, v: f32) -> f32 {
|
||||||
|
let v = v.max(0.0);
|
||||||
|
match self {
|
||||||
|
Self::Srgb => {
|
||||||
|
if v <= 0.040_45 {
|
||||||
|
v / 12.92
|
||||||
|
} else {
|
||||||
|
((v + 0.055) / 1.055).powf(2.4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::Gamma(g) => v.powf(g),
|
||||||
|
Self::Prophoto => {
|
||||||
|
if v < 16.0 / 512.0 {
|
||||||
|
v / 16.0
|
||||||
|
} else {
|
||||||
|
v.powf(1.8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColourSpace {
|
||||||
|
/// The primaries and white point that define this space's gamut.
|
||||||
|
pub fn chromaticities(self) -> Chromaticities {
|
||||||
|
match self {
|
||||||
|
Self::Srgb => Chromaticities {
|
||||||
|
red: [0.6400, 0.3300],
|
||||||
|
green: [0.3000, 0.6000],
|
||||||
|
blue: [0.1500, 0.0600],
|
||||||
|
white: D65,
|
||||||
|
},
|
||||||
|
// Same blue as sRGB, and a far redder red: P3's gamut is the
|
||||||
|
// cinema projector primaries on a D65 white.
|
||||||
|
Self::DisplayP3 => Chromaticities {
|
||||||
|
red: [0.6800, 0.3200],
|
||||||
|
green: [0.2650, 0.6900],
|
||||||
|
blue: [0.1500, 0.0600],
|
||||||
|
white: D65,
|
||||||
|
},
|
||||||
|
// Red and blue are sRGB's exactly. Adobe RGB (1998) widens the
|
||||||
|
// green corner alone, which is why the conversion out of sRGB
|
||||||
|
// leaves the blue channel almost untouched.
|
||||||
|
Self::AdobeRgb => Chromaticities {
|
||||||
|
red: [0.6400, 0.3300],
|
||||||
|
green: [0.2100, 0.7100],
|
||||||
|
blue: [0.1500, 0.0600],
|
||||||
|
white: D65,
|
||||||
|
},
|
||||||
|
// ROMM RGB. Two of its three primaries are imaginary — outside the
|
||||||
|
// spectral locus — which is how it encloses every real surface
|
||||||
|
// colour, and why so much of its cube is unreachable.
|
||||||
|
Self::ProPhoto => Chromaticities {
|
||||||
|
red: [0.734_699, 0.265_301],
|
||||||
|
green: [0.159_597, 0.840_403],
|
||||||
|
blue: [0.036_598, 0.000_105],
|
||||||
|
white: D50,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The transfer function a file in this space carries.
|
||||||
|
pub fn transfer(self) -> Transfer {
|
||||||
|
match self {
|
||||||
|
Self::Srgb | Self::DisplayP3 => Transfer::Srgb,
|
||||||
|
// 563/256, which is what Adobe RGB (1998) specifies and, not by
|
||||||
|
// coincidence, exactly what an ICC `curv` tag's u8Fixed8 gamma can
|
||||||
|
// hold. Writing 2.2 instead would be a different space.
|
||||||
|
Self::AdobeRgb => Transfer::Gamma(563.0 / 256.0),
|
||||||
|
Self::ProPhoto => Transfer::Prophoto,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linear sRGB to this space's linear RGB, row-major.
|
||||||
|
///
|
||||||
|
/// The transform the render's final stage needs. Linear sRGB is the
|
||||||
|
/// pipeline's connection space — the camera matrix converts into it, which
|
||||||
|
/// is what `cam_to_srgb` in the generated shader is named for — so every
|
||||||
|
/// output space is reached from there.
|
||||||
|
///
|
||||||
|
/// Identity for [`ColourSpace::Srgb`], to within the rounding of an
|
||||||
|
/// inversion followed by a multiplication.
|
||||||
|
pub fn from_linear_srgb(self) -> [f32; 9] {
|
||||||
|
let src = ColourSpace::Srgb.chromaticities();
|
||||||
|
let dst = self.chromaticities();
|
||||||
|
// Through XYZ, adapting the white point on the way: ProPhoto is a D50
|
||||||
|
// space, and handing D65 white to it unadapted would tint every export
|
||||||
|
// in it warm.
|
||||||
|
let adapt = adaptation(white_xyz(&src), white_xyz(&dst));
|
||||||
|
narrow(mul(invert(rgb_to_xyz(&dst)), mul(adapt, rgb_to_xyz(&src))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This space's linear RGB to the ICC profile connection space, row-major.
|
||||||
|
///
|
||||||
|
/// The columns are the `rXYZ`, `gXYZ` and `bXYZ` colorant tags of a
|
||||||
|
/// matrix/TRC profile. Chromatically adapted to D50 because the PCS is
|
||||||
|
/// defined at D50 and nowhere else — a profile carrying unadapted D65
|
||||||
|
/// colorants describes a space nobody asked for.
|
||||||
|
pub fn to_pcs_xyz(self) -> [f32; 9] {
|
||||||
|
let c = self.chromaticities();
|
||||||
|
narrow(mul(adaptation(white_xyz(&c), PCS_D50), rgb_to_xyz(&c)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The white-point adaptation folded into [`Self::to_pcs_xyz`], row-major.
|
||||||
|
///
|
||||||
|
/// An ICC profile carries this separately, in its `chad` tag, so that a
|
||||||
|
/// reader can undo the adaptation and recover the space's native white.
|
||||||
|
/// Without it the colorants alone are ambiguous: D65 primaries adapted to
|
||||||
|
/// D50 and genuine D50 primaries are the same nine numbers.
|
||||||
|
pub fn adaptation_to_pcs(self) -> [f32; 9] {
|
||||||
|
narrow(adaptation(white_xyz(&self.chromaticities()), PCS_D50))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The D65 white point, as sRGB, Display P3 and Adobe RGB all define it.
|
||||||
|
const D65: [f64; 2] = [0.3127, 0.3290];
|
||||||
|
|
||||||
|
/// The D50 white point, as ROMM RGB defines it.
|
||||||
|
const D50: [f64; 2] = [0.345_704, 0.358_540];
|
||||||
|
|
||||||
|
/// The profile connection space illuminant, to the precision ICC fixes it at.
|
||||||
|
///
|
||||||
|
/// Written as the values a profile's header actually carries — 0x0000F6D6,
|
||||||
|
/// 0x00010000, 0x0000D32D as s15Fixed16 — rather than derived from a
|
||||||
|
/// chromaticity pair. A media white point that differs from the PCS
|
||||||
|
/// illuminant in the last bit is a profile some validators reject and some
|
||||||
|
/// readers quietly re-adapt.
|
||||||
|
const PCS_D50: [f64; 3] = [0.9642, 1.0, 0.8249];
|
||||||
|
|
||||||
|
/// The Bradford cone response matrix.
|
||||||
|
///
|
||||||
|
/// Bradford rather than the simpler von Kries or XYZ scaling: it is what ICC
|
||||||
|
/// specifies for the `chad` tag, so a profile built with anything else would
|
||||||
|
/// describe colorants that disagree with the adaptation it declares.
|
||||||
|
const BRADFORD: M3 = [
|
||||||
|
[0.8951, 0.2664, -0.1614],
|
||||||
|
[-0.7502, 1.7135, 0.0367],
|
||||||
|
[0.0389, -0.0685, 1.0296],
|
||||||
|
];
|
||||||
|
|
||||||
|
type M3 = [[f64; 3]; 3];
|
||||||
|
|
||||||
|
/// An xy chromaticity as an XYZ triple normalised to Y = 1.
|
||||||
|
fn xyz_from_xy(xy: [f64; 2]) -> [f64; 3] {
|
||||||
|
let [x, y] = xy;
|
||||||
|
[x / y, 1.0, (1.0 - x - y) / y]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn white_xyz(c: &Chromaticities) -> [f64; 3] {
|
||||||
|
xyz_from_xy(c.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linear RGB to XYZ for a set of primaries.
|
||||||
|
///
|
||||||
|
/// The primaries fix the *directions* of the three columns; the white point
|
||||||
|
/// fixes their lengths, by the requirement that (1, 1, 1) render as the white.
|
||||||
|
fn rgb_to_xyz(c: &Chromaticities) -> M3 {
|
||||||
|
let r = xyz_from_xy(c.red);
|
||||||
|
let g = xyz_from_xy(c.green);
|
||||||
|
let b = xyz_from_xy(c.blue);
|
||||||
|
let directions = [[r[0], g[0], b[0]], [r[1], g[1], b[1]], [r[2], g[2], b[2]]];
|
||||||
|
let scale = mul_vec(invert(directions), white_xyz(c));
|
||||||
|
let mut m = directions;
|
||||||
|
for row in &mut m {
|
||||||
|
for (col, s) in row.iter_mut().zip(scale) {
|
||||||
|
*col *= s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bradford chromatic adaptation between two white points, in XYZ.
|
||||||
|
fn adaptation(from: [f64; 3], to: [f64; 3]) -> M3 {
|
||||||
|
let s = mul_vec(BRADFORD, from);
|
||||||
|
let d = mul_vec(BRADFORD, to);
|
||||||
|
let scale = [
|
||||||
|
[d[0] / s[0], 0.0, 0.0],
|
||||||
|
[0.0, d[1] / s[1], 0.0],
|
||||||
|
[0.0, 0.0, d[2] / s[2]],
|
||||||
|
];
|
||||||
|
mul(invert(BRADFORD), mul(scale, BRADFORD))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mul(a: M3, b: M3) -> M3 {
|
||||||
|
let mut out = [[0.0; 3]; 3];
|
||||||
|
for (i, row) in out.iter_mut().enumerate() {
|
||||||
|
for (j, cell) in row.iter_mut().enumerate() {
|
||||||
|
*cell = (0..3).map(|k| a[i][k] * b[k][j]).sum();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mul_vec(m: M3, v: [f64; 3]) -> [f64; 3] {
|
||||||
|
[
|
||||||
|
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
|
||||||
|
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
|
||||||
|
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invert by the adjugate.
|
||||||
|
///
|
||||||
|
/// No singular case to handle: every matrix inverted here is built from a set
|
||||||
|
/// of primaries that span a real gamut, or is the Bradford constant. A
|
||||||
|
/// degenerate one would mean a space with two identical primaries, which is
|
||||||
|
/// not a space.
|
||||||
|
fn invert(m: M3) -> M3 {
|
||||||
|
let cofactor = |a: usize, b: usize, c: usize, d: usize| m[a][b] * m[c][d] - m[a][d] * m[c][b];
|
||||||
|
let a = cofactor(1, 1, 2, 2);
|
||||||
|
let b = -cofactor(1, 0, 2, 2);
|
||||||
|
let c = cofactor(1, 0, 2, 1);
|
||||||
|
let det = m[0][0] * a + m[0][1] * b + m[0][2] * c;
|
||||||
|
let inv = 1.0 / det;
|
||||||
|
[
|
||||||
|
[
|
||||||
|
a * inv,
|
||||||
|
-cofactor(0, 1, 2, 2) * inv,
|
||||||
|
cofactor(0, 1, 1, 2) * inv,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
b * inv,
|
||||||
|
cofactor(0, 0, 2, 2) * inv,
|
||||||
|
-cofactor(0, 0, 1, 2) * inv,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
c * inv,
|
||||||
|
-cofactor(0, 0, 2, 1) * inv,
|
||||||
|
cofactor(0, 0, 1, 1) * inv,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row-major `f64` matrix to the flat `f32` array the rest of the tree passes
|
||||||
|
/// around — the same shape `dr-decode` gives a camera matrix.
|
||||||
|
fn narrow(m: M3) -> [f32; 9] {
|
||||||
|
let mut out = [0.0f32; 9];
|
||||||
|
for (i, row) in m.iter().enumerate() {
|
||||||
|
for (j, v) in row.iter().enumerate() {
|
||||||
|
out[i * 3 + j] = *v as f32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Compare against a published matrix, entry by entry.
|
||||||
|
fn assert_close(got: [f32; 9], want: [f32; 9], tol: f32, what: &str) {
|
||||||
|
for (i, (g, w)) in got.iter().zip(want).enumerate() {
|
||||||
|
assert!(
|
||||||
|
(g - w).abs() <= tol,
|
||||||
|
"{what} entry {i}: got {g}, published {w}\n got {got:?}\n want {want:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn srgb_to_srgb_is_the_identity() {
|
||||||
|
// The property the composer relies on to leave the display path's
|
||||||
|
// shader alone: asking for the space the pipeline already works in
|
||||||
|
// must cost nothing. A matrix that is merely *close* would still be
|
||||||
|
// skipped, but one that is not close means the derivation itself is
|
||||||
|
// wrong in a way every other space would inherit.
|
||||||
|
assert_close(
|
||||||
|
ColourSpace::Srgb.from_linear_srgb(),
|
||||||
|
[1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
|
||||||
|
1e-5,
|
||||||
|
"sRGB -> sRGB",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adobe_rgb_leaves_the_red_and_blue_primaries_where_they_were() {
|
||||||
|
// Adobe RGB (1998) shares sRGB's red and blue chromaticities exactly,
|
||||||
|
// so the conversion can only move green into red and green into blue.
|
||||||
|
// Any other non-zero entry means the primaries were mistyped or the
|
||||||
|
// matrix came out transposed — which a norm-based check would pass.
|
||||||
|
let m = ColourSpace::AdobeRgb.from_linear_srgb();
|
||||||
|
assert_close(
|
||||||
|
m,
|
||||||
|
[0.7152, 0.2848, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0412, 0.9588],
|
||||||
|
1e-3,
|
||||||
|
"sRGB -> Adobe RGB",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_p3_matches_the_published_conversion() {
|
||||||
|
// P3 shares sRGB's blue, so the third column is zero above the
|
||||||
|
// diagonal. The published matrix, to four places.
|
||||||
|
assert_close(
|
||||||
|
ColourSpace::DisplayP3.from_linear_srgb(),
|
||||||
|
[
|
||||||
|
0.8225, 0.1774, 0.0, 0.0332, 0.9669, 0.0, 0.0171, 0.0724, 0.9105,
|
||||||
|
],
|
||||||
|
1e-3,
|
||||||
|
"sRGB -> Display P3",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prophoto_conversion_matches_the_published_bradford_matrix() {
|
||||||
|
// The one space whose conversion includes a white-point adaptation.
|
||||||
|
// Dropping the adaptation changes these by a percent or two — small
|
||||||
|
// enough to look plausible and large enough to tint every export.
|
||||||
|
assert_close(
|
||||||
|
ColourSpace::ProPhoto.from_linear_srgb(),
|
||||||
|
[
|
||||||
|
0.5294, 0.3300, 0.1406, 0.0983, 0.8734, 0.0283, 0.0169, 0.1178, 0.8653,
|
||||||
|
],
|
||||||
|
2e-3,
|
||||||
|
"sRGB -> ProPhoto",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_srgb_colorants_match_the_reference_profile() {
|
||||||
|
// The nine numbers in every sRGB IEC61966-2.1 profile ever shipped.
|
||||||
|
// This is what proves the D50 adaptation runs in the right direction:
|
||||||
|
// reversing it moves each entry by several percent.
|
||||||
|
//
|
||||||
|
// Row-major here, where a profile stores them column by column as
|
||||||
|
// three XYZ tags — so this also pins the orientation.
|
||||||
|
assert_close(
|
||||||
|
ColourSpace::Srgb.to_pcs_xyz(),
|
||||||
|
[
|
||||||
|
0.4360, 0.3851, 0.1431, //
|
||||||
|
0.2225, 0.7169, 0.0606, //
|
||||||
|
0.0139, 0.0971, 0.7141,
|
||||||
|
],
|
||||||
|
1e-3,
|
||||||
|
"sRGB colorants",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_prophoto_colorants_match_the_reference_profile() {
|
||||||
|
// ProPhoto is already D50, so its adaptation is a near-identity and
|
||||||
|
// its colorants are the raw primaries. Its blue Y of 0.0001 is the
|
||||||
|
// giveaway that the numbers are the real ROMM ones.
|
||||||
|
assert_close(
|
||||||
|
ColourSpace::ProPhoto.to_pcs_xyz(),
|
||||||
|
[
|
||||||
|
0.7977, 0.1352, 0.0313, //
|
||||||
|
0.2880, 0.7119, 0.0001, //
|
||||||
|
0.0000, 0.0000, 0.8251,
|
||||||
|
],
|
||||||
|
1e-3,
|
||||||
|
"ProPhoto colorants",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_space_renders_its_white_as_the_pcs_illuminant() {
|
||||||
|
// (1, 1, 1) in any RGB space is that space's white, and a profile's
|
||||||
|
// colorants must sum to D50 or every neutral in the file comes out
|
||||||
|
// tinted. The failure is invisible in a single patch and glaring
|
||||||
|
// across a grey ramp.
|
||||||
|
for space in ColourSpace::ALL {
|
||||||
|
let m = space.to_pcs_xyz();
|
||||||
|
let sum = [m[0] + m[1] + m[2], m[3] + m[4] + m[5], m[6] + m[7] + m[8]];
|
||||||
|
for (i, (got, want)) in sum.iter().zip(PCS_D50).enumerate() {
|
||||||
|
assert!(
|
||||||
|
(f64::from(*got) - want).abs() < 1e-3,
|
||||||
|
"{space:?} white component {i} is {got}, not the PCS {want}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_wide_spaces_contain_the_whole_srgb_cube() {
|
||||||
|
// The claim a wide-gamut export makes: nothing sRGB could express is
|
||||||
|
// lost by encoding into a larger space. Every corner of the sRGB cube
|
||||||
|
// must land inside the unit cube of the destination — and a matrix
|
||||||
|
// derived in the wrong direction sends P3's red corner to 1.2, which
|
||||||
|
// is exactly the clipping this feature exists to avoid.
|
||||||
|
for space in ColourSpace::ALL {
|
||||||
|
let m = space.from_linear_srgb();
|
||||||
|
for corner in 0..8u8 {
|
||||||
|
let v = [
|
||||||
|
f32::from(corner & 1),
|
||||||
|
f32::from((corner >> 1) & 1),
|
||||||
|
f32::from((corner >> 2) & 1),
|
||||||
|
];
|
||||||
|
for row in 0..3 {
|
||||||
|
let out = m[row * 3] * v[0] + m[row * 3 + 1] * v[1] + m[row * 3 + 2] * v[2];
|
||||||
|
assert!(
|
||||||
|
(-1e-4..=1.0 + 1e-4).contains(&out),
|
||||||
|
"{space:?} sends sRGB corner {v:?} channel {row} to {out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_wider_space_leaves_headroom_where_srgb_has_none() {
|
||||||
|
// The other half of the same claim, and the reason the feature is
|
||||||
|
// worth having: saturated sRGB primaries stop short of the wide
|
||||||
|
// space's own primaries, so there is room left for colours sRGB
|
||||||
|
// could not hold. Without this, "wide gamut" would be a relabelling.
|
||||||
|
for space in [
|
||||||
|
ColourSpace::DisplayP3,
|
||||||
|
ColourSpace::AdobeRgb,
|
||||||
|
ColourSpace::ProPhoto,
|
||||||
|
] {
|
||||||
|
let m = space.from_linear_srgb();
|
||||||
|
// sRGB's fully saturated red, in the destination.
|
||||||
|
let red = [m[0], m[3], m[6]];
|
||||||
|
assert!(
|
||||||
|
red[0] < 0.99,
|
||||||
|
"{space:?} maps sRGB red to {red:?}, leaving it no headroom"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_transfer_function_round_trips() {
|
||||||
|
// Encode then decode must be the identity, or a file exported in a
|
||||||
|
// space and reopened in it would drift a little further every pass.
|
||||||
|
// Checked near both ends, where a mismatched breakpoint hides.
|
||||||
|
for space in ColourSpace::ALL {
|
||||||
|
let t = space.transfer();
|
||||||
|
for v in [0.0, 0.0005, 0.002, 0.05, 0.2159, 0.5, 0.9, 1.0] {
|
||||||
|
let back = t.decode(t.encode(v));
|
||||||
|
assert!(
|
||||||
|
(back - v).abs() < 1e-4,
|
||||||
|
"{space:?} at {v}: encode/decode returned {back}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_transfer_function_maps_the_ends_to_the_ends() {
|
||||||
|
// Black must stay black and white must stay white in every space. A
|
||||||
|
// curve that maps 1.0 to 0.998 makes every white in a file slightly
|
||||||
|
// grey, which is the sort of thing nobody notices until a print.
|
||||||
|
for space in ColourSpace::ALL {
|
||||||
|
let t = space.transfer();
|
||||||
|
assert!(t.encode(0.0).abs() < 1e-6, "{space:?} black");
|
||||||
|
assert!((t.encode(1.0) - 1.0).abs() < 1e-6, "{space:?} white");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoding_a_negative_is_black_rather_than_nan() {
|
||||||
|
// Out-of-gamut colours arrive negative. `powf` of a negative is NaN,
|
||||||
|
// and a NaN reaching an 8-bit cast is a black pixel with no clue as
|
||||||
|
// to where it came from.
|
||||||
|
for space in ColourSpace::ALL {
|
||||||
|
assert_eq!(space.transfer().encode(-0.2), 0.0, "{space:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,11 @@ use std::collections::BTreeSet;
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
||||||
|
pub mod colour;
|
||||||
pub mod selector;
|
pub mod selector;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|
||||||
|
pub use colour::{Chromaticities, Transfer};
|
||||||
pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier};
|
pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier};
|
||||||
pub use settings::{
|
pub use settings::{
|
||||||
CacheSettings, CollisionPolicy, ColourSpace, DevelopSettings, ExportFormat, ExportSettings,
|
CacheSettings, CollisionPolicy, ColourSpace, DevelopSettings, ExportFormat, ExportSettings,
|
||||||
|
|||||||
@@ -451,7 +451,13 @@ impl ExportFormat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Output colour space, with its ICC profile embedded on export (FR-EXP-2).
|
/// TRACES: FR-EXP-2
|
||||||
|
/// Output colour space, with its ICC profile embedded on export.
|
||||||
|
///
|
||||||
|
/// The name alone. What each one *is* — its primaries, white point and
|
||||||
|
/// transfer function, and the matrices derived from them — lives in
|
||||||
|
/// [`crate::colour`], where the renderer and the profile writer read the same
|
||||||
|
/// numbers and so cannot describe a file as something it is not.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ColourSpace {
|
pub enum ColourSpace {
|
||||||
|
|||||||
Reference in New Issue
Block a user