ci: linux xkbcommon-x11, yaml-cpp cstdint patch, hwaccel test skips on VT-less hosts

- Linux: libxkbcommon-x11-dev for the gpui X11 client link
- Windows: patch <cstdint> into the vendored yaml-cpp (a cached cmake
  configure ignores CXXFLAGS; the patch is idempotent and runs after
  cargo fetch)
- macOS: the hw-decode test skips its VideoToolbox engagement
  assertions on hosts where VT cannot initialize (headless/virtualized
  runners) instead of failing
- display color management: the display ICC (system or custom) is
  applied to viewer frames at present time (F32 in place, or in place
  on the BGRA staging copy with the R/B swizzle baked into the OCIO
  chain); preferences get a Color section (mode + custom ICC file); on
  macOS the Metal layer is tagged with the display colorspace when
  self-managing so ColorSync passes pixels through (no double
  correction); frame caches track the transform generation so a mode
  or profile change drops stale pixels
This commit is contained in:
2026-08-21 02:16:39 +08:00
parent fa7d8153bd
commit eff845a5cd
17 changed files with 1079 additions and 58 deletions
+143 -2
View File
@@ -39,6 +39,21 @@ pub struct ColorProcessor {
cpu: Option<ocio_rs::CPUProcessor>,
}
/// The R/B channel swap as a 4x4 matrix transform (baked around the
/// display chain so BGRA buffers can be transformed through OCIO's RGBA
/// entry points).
fn rb_swap_matrix() -> Option<ocio_rs::transform::MatrixTransform> {
let m = ocio_rs::transform::MatrixTransform::create().ok()?;
m.set_matrix(&[
0.0, 0.0, 1.0, 0.0, //
0.0, 1.0, 0.0, 0.0, //
1.0, 0.0, 0.0, 0.0, //
0.0, 0.0, 0.0, 1.0,
])
.ok()?;
Some(m)
}
/// Processor direction.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Direction {
@@ -156,6 +171,75 @@ impl ColorProcessor {
})
}
/// Create the display-output processor from a display ICC profile
/// (macOS ColorSync / Windows ICM / Linux colord or `_ICC_PROFILE`).
///
/// OCIO's ICC reader (FileFormatICC, display-class profiles) builds the
/// FileTransform's forward direction as "CIE XYZ (D65-adapted PCS) →
/// device code values", so the chain is `<src_space> → linear Rec.709 →
/// CIE XYZ D65 → ICC forward`. `src_space` is a colorspace or role of
/// the default config (the pipeline reference, e.g. "scene_linear").
pub fn create_display_icc(src_space: &str, icc_path: &str) -> Option<Self> {
Self::create_display_icc_impl(src_space, icc_path, false)
}
/// Create the display-output processor for BGRA8 buffers (the viewer's
/// wire format): the [`create_display_icc`](Self::create_display_icc)
/// chain with R/B-swapping matrices baked around it, so BGRA bytes are
/// transformed in place through OCIO's RGBA entry points (swap∘chain∘swap
/// is the identity-wrapped chain evaluated on swapped channels).
pub fn create_display_icc_bgra8(src_space: &str, icc_path: &str) -> Option<Self> {
Self::create_display_icc_impl(src_space, icc_path, true)
}
/// Shared builder: `bgra` wraps the chain in R/B swap matrices.
fn create_display_icc_impl(src_space: &str, icc_path: &str, bgra: bool) -> Option<Self> {
let config = default_config()?;
// Leg 1: pipeline space -> linear Rec.709 (sRGB primaries, D65).
let to_lin709 = ocio_rs::transform::ColorSpaceTransform::create().ok()?;
to_lin709.set_src(src_space).ok()?;
to_lin709.set_dst("Linear Rec.709 (sRGB)").ok()?;
// Leg 2: linear Rec.709 -> CIE XYZ D65 (the ICC connection space as
// OCIO's ICC reader adapts it, D50->D65 Bradford baked in). The
// builtin configs carry no XYZ colorspace, so the conversion is an
// explicit matrix (sRGB/Rec.709 primaries -> XYZ D65).
let to_xyz = ocio_rs::transform::MatrixTransform::create().ok()?;
to_xyz
.set_matrix(&[
0.4123908, 0.3575843, 0.1804808, 0.0, //
0.2126390, 0.7151687, 0.0721923, 0.0, //
0.0193308, 0.1191948, 0.9505322, 0.0, //
0.0, 0.0, 0.0, 1.0,
])
.ok()?;
// Leg 3: XYZ D65 -> device, per the ICC profile (OCIO's
// FileFormatICC forward direction).
let icc = ocio_rs::transform::FileTransform::create().ok()?;
icc.set_src(icc_path).ok()?;
icc.set_interpolation(ocio_rs::Interpolation::Linear);
icc.set_direction(ocio_rs::TransformDirection::Forward);
let group = ocio_rs::transform::GroupTransform::create().ok()?;
if bgra {
group.append_transform(&rb_swap_matrix()?).ok()?;
}
group.append_transform(&to_lin709).ok()?;
group.append_transform(&to_xyz).ok()?;
group.append_transform(&icc).ok()?;
if bgra {
group.append_transform(&rb_swap_matrix()?).ok()?;
}
let processor = config
.processor_from_transform(&group, ocio_rs::TransformDirection::Forward)
.ok();
let cpu = processor
.as_ref()
.and_then(|p| p.default_cpu_processor().ok());
Some(Self {
inner: processor,
cpu,
})
}
/// Create from an explicit OCIO processor (C++
/// `ColorProcessor::create(ConstProcessorRcPtr)`).
pub fn from_processor(processor: ocio_rs::Processor) -> Self {
@@ -197,8 +281,7 @@ impl ColorProcessor {
}
/// Convert a whole F32 frame in place (row-major RGBA).
pub fn convert_frame(&self, frame: &mut Frame) -> Result<()> {
let Some(cpu) = &self.cpu else {
pub fn convert_frame(&self, frame: &mut Frame) -> Result<()> { let Some(cpu) = &self.cpu else {
return Ok(()); // pass-through
};
if frame.format != PixelFormat::F32 {
@@ -227,6 +310,29 @@ impl ColorProcessor {
None => String::new(),
}
}
/// Convert a packed BGRA8 buffer in place (the viewer wire format).
/// The processor must have been built with
/// [`create_display_icc_bgra8`](Self::create_display_icc_bgra8) — the
/// R/B swizzle is baked into the chain, so the bytes go through OCIO's
/// RGBA entry point unchanged. A pass-through processor is a no-op.
pub fn convert_bgra8(&self, data: &mut [u8], pixels: i64) -> Result<()> {
let Some(cpu) = &self.cpu else {
return Ok(());
};
cpu.try_apply_rgba_packed_bit_depth(data, ocio_rs::BitDepth::Uint8, pixels, 4)
.map_err(|e| Error::Failed(format!("OCIO packed-u8 apply: {e}")))
}
/// Convert an F32 RGBA buffer in place (tightly packed, 4 floats per
/// pixel). A pass-through processor is a no-op.
pub fn convert_f32_rgba(&self, samples: &mut [f32], pixels: i64) -> Result<()> {
let Some(cpu) = &self.cpu else {
return Ok(());
};
cpu.try_apply_rgba_pixels(samples, pixels, 4)
.map_err(|e| Error::Failed(format!("OCIO f32 apply: {e}")))
}
}
/// Grading-primary transform style (mirrors the C++ enum values: LIN=0,
@@ -594,6 +700,40 @@ mod tests {
assert!((out[3] - 1.0).abs() < 1e-5, "alpha preserved");
}
#[test]
fn display_icc_processor_applies_srgb_profile() {
let _lock = config_lock();
if set_up_default_config().is_err() {
return;
}
// A display-class ICC is required; the macOS system profiles always
// have one, CI Linux/Windows runners may not — skip then.
let icc = [
"/System/Library/ColorSync/Profiles/sRGB Profile.icc",
"/System/Library/ColorSync/Profiles/Display P3.icc",
]
.into_iter()
.find(|p| std::path::Path::new(p).exists());
let Some(icc) = icc else {
eprintln!("no system ICC profile; skipping");
return;
};
let p = ColorProcessor::create_display_icc("scene_linear", icc)
.expect("handle always returned");
assert!(p.is_valid(), "ICC processor builds from {icc}");
// 0.18 scene-linear grey -> ~0.5 sRGB device grey (the sRGB system
// profile's device space is sRGB-encoded).
let out = p.convert_color([0.18, 0.18, 0.18, 1.0]);
assert!(
(out[0] - 0.5).abs() < 0.08,
"0.18 linear grey should land near 0.5 sRGB (got {})",
out[0]
);
assert!((out[0] - out[1]).abs() < 1e-3 && (out[1] - out[2]).abs() < 1e-3,
"grey stays grey: {out:?}");
assert!((out[3] - 1.0).abs() < 1e-5, "alpha preserved");
}
#[test]
fn inverse_direction_reverses() {
let _lock = config_lock();
@@ -670,3 +810,4 @@ mod tests {
assert!(!p.is_valid(), "unreadable LUT → pass-through processor");
}
}