color: non-sRGB preview, per-monitor display ICC, pipeline hardening

Preview now follows the project output colorspace end to end: the
display chain derives its content space from the project's OutputColorSpec
instead of a hardcoded sRGB name, self-managed ICC transforms go through
an XYZ D65 interchange stage (OCIO cie_xyz_d65_interchange) for non-sRGB
targets, and the platform layer declares the content colorspace (gpui
submodule bump). macOS defaults to OS-managed (fixes wide-gamut UI
oversaturation); Windows ACM warns once on non-sRGB targets.

Multi-monitor: the display ICC is looked up per the window's current
screen (macOS display id, Windows per-monitor DC, X11 RandR output
profile) with a throttled poll that invalidates frame caches on moves.

Pipeline precision: 10-bit+ sources fall back to YUV444P16LE + a Rust
matrix conversion when swscale lacks F32 output (no more 8-bit
truncation); BT.709/2020 SDR decodes with BT.1886 gamma 2.4 instead of
the sRGB EOTF; working-space compositing no longer clamps RGB to [0,1]
(alpha still clamped); the output node clamps to the target gamut;
frames without colorimetry metadata convert with BT.709 defaults
(warned once) instead of passing through; scopes read the
output-colorspace signal on both F32 paths.

Also: only emit rerun-if-changed for .env when it exists (a missing file
made every build fully dirty).
This commit is contained in:
2026-08-29 00:24:15 +08:00
parent 879ff8da5b
commit fdb5caabd5
42 changed files with 4423 additions and 146 deletions
+144
View File
@@ -193,6 +193,42 @@ impl ColorProcessor {
Self::create_display_icc_impl(src_space, icc_path, true)
}
/// Create the display-output processor for content the caller has already
/// converted to CIE XYZ (D65, unit luminance) — the non-sRGB project
/// output gamut path. [`create_display_icc`](Self::create_display_icc)
/// starts from an OCIO named space (sRGB and friends); P3/BT.2020
/// targets have no named space in the builtin configs, so the caller
/// linearizes and gamut-maps to XYZ itself
/// (`oak_common::colormath::output_spec_to_xyz_d65`) and this chain only
/// needs the ICC half: it runs the existing builder with the config's
/// `cie_xyz_d65_interchange` role as the source space (XYZ → linear
/// Rec.709, whose inverse the builder's leg 2 immediately undoes — a
/// no-op round trip, leaving the ICC FileTransform to map the XYZ PCS
/// to device values).
///
/// Returns `None` (not a pass-through) when the chain cannot build — e.g.
/// the config rejects the interchange role — so callers can fall back to
/// the sRGB chain instead of silently mapping wrong content.
pub fn create_display_icc_xyz(icc_path: &str) -> Option<Self> {
Self::create_display_icc_xyz_impl(icc_path, false)
}
/// The [`create_display_icc_xyz`](Self::create_display_icc_xyz) chain
/// with R/B-swapping matrices baked around it for BGRA8 buffers (see
/// [`create_display_icc_bgra8`](Self::create_display_icc_bgra8)).
pub fn create_display_icc_xyz_bgra8(icc_path: &str) -> Option<Self> {
Self::create_display_icc_xyz_impl(icc_path, true)
}
fn create_display_icc_xyz_impl(icc_path: &str, bgra: bool) -> Option<Self> {
let p = Self::create_display_icc_impl("cie_xyz_d65_interchange", icc_path, bgra)?;
// A failed OCIO lookup yields a pass-through processor (the
// non-fatal convention above); for the XYZ chain that must surface as
// `None` so the caller can fall back to the sRGB degradation instead
// of feeding XYZ values through a no-op.
p.is_valid().then_some(p)
}
/// 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()?;
@@ -384,6 +420,48 @@ fn bytemuck_f32_slice(data: &mut [u8]) -> Option<&mut [f32]> {
// ---- process-wide default config (C++ ColorManager statics) ----------------
/// The process-wide pipeline color settings (the project properties that
/// drive the ACEScg + F32 pipeline): the working colorspace and the
/// output/delivery spec. Defaults to ACEScg working + sRGB output; the app
/// updates this from the open project's properties (and again on every
/// project-properties commit). Render and export paths read it — a single
/// project is open at a time, so a process global is the same shape as the
/// OCIO default config above.
static PIPELINE_COLOR: LazyLock<Mutex<(oak_common::colormath::WorkingColorSpace, oak_common::colormath::OutputColorSpec)>> =
LazyLock::new(|| Mutex::new((
oak_common::colormath::WorkingColorSpace::default(),
oak_common::colormath::OutputColorSpec::default(),
)));
/// Set the pipeline color settings (working space + output spec).
pub fn set_pipeline_color_settings(
working: oak_common::colormath::WorkingColorSpace,
output: oak_common::colormath::OutputColorSpec,
) {
*PIPELINE_COLOR.lock().unwrap_or_else(|e| e.into_inner()) = (working, output);
}
/// The pipeline working colorspace.
pub fn pipeline_working_space() -> oak_common::colormath::WorkingColorSpace {
PIPELINE_COLOR.lock().unwrap_or_else(|e| e.into_inner()).0
}
/// The pipeline output/delivery spec.
pub fn pipeline_output_spec() -> oak_common::colormath::OutputColorSpec {
PIPELINE_COLOR.lock().unwrap_or_else(|e| e.into_inner()).1
}
/// The pipeline working colorspace as an OFX colorspace name (the value
/// written to `kOfxImageClipPropColourspace` so plugins are told the
/// true space of the pixels they receive — ACEScg in the default pipeline,
/// sRGB in the legacy pass-through mode).
pub fn pipeline_working_ofx_name() -> &'static str {
match pipeline_working_space() {
oak_common::colormath::WorkingColorSpace::AcesCg => "ACEScg",
oak_common::colormath::WorkingColorSpace::SrgbLegacy => "sRGB",
}
}
/// Send+Sync wrapper around `ocio_rs::Config` (a `NonNull`-based handle;
/// the underlying OCIO config is a shared pointer safe for concurrent
/// reads).
@@ -829,6 +907,72 @@ mod tests {
assert!((out[3] - 1.0).abs() < 1e-5, "alpha preserved");
}
/// The non-sRGB project output gamut display path: content converted to
/// CIE XYZ (D65, unit luminance) by
/// `oak_common::colormath::output_spec_to_xyz_d65` must flow through the
/// display ICC. Builds by running the classic builder with the config's
/// `cie_xyz_d65_interchange` role as the source space — the feasibility
/// question this test answers is whether OCIO accepts the role name as a
/// `ColorSpaceTransform` source (it is a role, not a bare colorspace, in
/// the OCIO 2.2+ builtin configs).
#[test]
fn display_icc_xyz_accepts_interchange_role() {
let _lock = config_lock();
if set_up_default_config().is_err() {
return;
}
// Any display-class ICC; probe the usual macOS + Linux system profile
// locations (CI runners may have none — skip then).
let icc = [
"/System/Library/ColorSync/Profiles/sRGB Profile.icc",
"/System/Library/ColorSync/Profiles/Display P3.icc",
"/usr/share/color/icc/colord/sRGB.icc",
"/usr/share/color/icc/ghostscript/srgb.icc",
"/usr/local/share/color/icc/colord/sRGB.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_xyz(icc)
.expect("handle always returned or explicit None");
assert!(
p.is_valid(),
"the cie_xyz_d65_interchange role must build the XYZ→ICC chain from {icc}"
);
// Numeric sanity: the XYZ chain fed with `output_spec_to_xyz_d65` of
// an sRGB-encoded mid-grey must match the classic chain applied to
// the same encoded values — legs 1+2 (XYZ→lin709→XYZ) are the inverse
// round trip of the classic chain's lin709→XYZ leg, so both must land
// on the same device values.
let spec = oak_common::colormath::OutputColorSpec::default();
let encoded = [0.5f32, 0.5, 0.5, 1.0];
let mut xyz_in = encoded;
oak_common::colormath::output_spec_to_xyz_d65(&mut xyz_in, spec);
let mut via_xyz = xyz_in;
let _ = p.convert_f32_rgba(&mut via_xyz, 1);
let srgb = ColorProcessor::create_display_icc("sRGB Encoded Rec.709 (sRGB)", icc)
.expect("handle always returned");
let mut via_srgb = encoded;
let _ = srgb.convert_f32_rgba(&mut via_srgb, 1);
for c in 0..3 {
assert!(
(via_xyz[c] - via_srgb[c]).abs() < 0.02,
"channel {c}: XYZ chain {} vs sRGB chain {} (round trip must be identity)",
via_xyz[c],
via_srgb[c]
);
}
// Grey stays grey; alpha preserved.
assert!(
(via_xyz[0] - via_xyz[1]).abs() < 1e-3 && (via_xyz[1] - via_xyz[2]).abs() < 1e-3,
"grey stays grey: {via_xyz:?}"
);
assert!((via_xyz[3] - 1.0).abs() < 1e-5, "alpha preserved");
}
/// The exact chain the viewers use (BGRA8, display-class ICC from
/// `OAK_DISPLAY_ICC`): a mid-grey frame must NOT collapse to black —
/// the viewer-black-screen regression guard. Skipped without the env
+70 -4
View File
@@ -910,9 +910,57 @@ pub fn render_footage_frame(
dh,
);
}
// Input node: source colorspace → the pipeline working space (ACEScg
// by default; the legacy sRGB working space keeps the pass-through).
convert_decoded_to_working(&mut dst, &decoded);
Ok(Texture::wrap_frame(dst))
}
/// Convert a decoded footage frame (display-referred RGB in the source's
/// own colorspace) into the pipeline working space, driven by the frame's
/// colorimetry metadata (carried on the codec frame's params). A no-op in
/// the legacy sRGB working space or when the frame has no pixel data.
fn convert_decoded_to_working(dst: &mut Frame, decoded: &oak_codec::frame::Frame) {
use oak_common::colormath::{
WorkingColorSpace, source_primaries_from_av, source_transfer_from_av,
};
if crate::color::pipeline_working_space() == WorkingColorSpace::SrgbLegacy {
return;
}
// Frames without colorimetry metadata get the generic fallback (sRGB
// primaries, sRGB transfer) instead of passing through unconverted;
// the missing tag is warned once per process.
let (primaries, transfer) = match decoded.params() {
Some(params) => (
source_primaries_from_av(params.color_primaries()),
source_transfer_from_av(params.color_transfer()),
),
None => {
warn_missing_colorimetry_once();
(source_primaries_from_av(2), source_transfer_from_av(2))
}
};
let w = dst.width.max(0) as usize;
let h = dst.height.max(0) as usize;
if w == 0 || h == 0 {
return;
}
let row_bytes = w * 16; // F32 RGBA
let linesize = dst.linesize_bytes();
for y in 0..h {
let start = y * linesize;
if start + row_bytes > dst.data.len() {
break;
}
oak_common::colormath::decode_to_acescg_bytes(
&mut dst.data[start..start + row_bytes],
w,
primaries,
transfer,
);
}
}
// ---------------------------------------------------------------------------
// Graph-driven sequence rendering
// ---------------------------------------------------------------------------
@@ -933,7 +981,9 @@ fn main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
let s = textureLoad(src_tex, coord, 0);
let d = textureLoad(dst_tex, coord, 0);
let a = clamp(s.a, 0.0, 1.0);
return clamp(vec4<f32>(s.rgb * a + d.rgb * (1.0 - a), a + d.a * (1.0 - a)), vec4<f32>(0.0), vec4<f32>(1.0));
// RGB keeps the working-space values unclamped (HDR/WCG can exceed
// 1.0); only the alpha of the result is clamped to the valid range.
return vec4<f32>(s.rgb * a + d.rgb * (1.0 - a), clamp(a + d.a * (1.0 - a), 0.0, 1.0));
}
"#;
@@ -1290,9 +1340,13 @@ fn scale_rgba_f32(
let sx = sx.max(0.0);
let px = sample(sx, sy);
let off = (y as usize) * (dst_stride as usize) + (x as usize) * 16;
// RGB is not clamped: bilinear lerp is a convex combination,
// so values cannot overshoot the source range, and HDR/WCG
// working-space pixels may legitimately exceed 1.0. Only the
// alpha channel is clamped to its valid range.
for i in 0..4 {
dst[off + i * 4..off + i * 4 + 4]
.copy_from_slice(&px[i].clamp(0.0, 1.0).to_le_bytes());
let v = if i == 3 { px[i].clamp(0.0, 1.0) } else { px[i] };
dst[off + i * 4..off + i * 4 + 4].copy_from_slice(&v.to_le_bytes());
}
}
}
@@ -1393,8 +1447,11 @@ pub fn composite_over(
a + d[3] * (1.0 - a),
];
let off = (y as usize) * (dst_stride as usize) + (x as usize) * 16;
// RGB keeps the working-space values unclamped (HDR/WCG can
// exceed 1.0); only alpha is clamped to its valid range.
for i in 0..4 {
dst[off + i * 4..off + i * 4 + 4].copy_from_slice(&out[i].clamp(0.0, 1.0).to_le_bytes());
let v = if i == 3 { out[i].clamp(0.0, 1.0) } else { out[i] };
dst[off + i * 4..off + i * 4 + 4].copy_from_slice(&v.to_le_bytes());
}
}
}
@@ -1429,6 +1486,15 @@ fn warn_unsupported_once(type_id: &str, reason: &str) {
}
}
/// Warn once (per process) when a decoded frame carries no colorimetry
/// metadata and falls back to the generic sRGB assumptions.
fn warn_missing_colorimetry_once() {
static WARNED: std::sync::Once = std::sync::Once::new();
WARNED.call_once(|| {
eprintln!("decoded frame has no colorimetry metadata; assuming sRGB");
});
}
/// Run a clip's effect stack over its decoded frame (source-first order;
/// disabled effects are bypassed — the C++ traverser's bypass pushes the
/// effect input through unchanged). Effects the montage path cannot
+80 -3
View File
@@ -1235,13 +1235,16 @@ pub struct SharedMemoryRegion {
/// binaries finish via `std::process::exit` (libtest), which skips Rust
/// static destructors — the process-wide render-manager singleton never
/// runs `Drop`, its `shm_unlink` never fires, and every test run leaks one
/// ~66 MiB segment per worker until `/dev/shm` fills up (the next create
/// then `memset`s a mapping backed by a full tmpfs and faults with SIGBUS).
/// ~66 MiB segment per worker until `/dev/shm` fills up.
/// `libc::atexit` handlers DO run under `process::exit`, so each `Create`
/// registers its key here and [`SharedMemoryRegion::atexit_cleanup_owned_shm`]
/// unlinks them all at exit. Unlinking while a peer still maps the segment
/// is safe — POSIX only removes the name; the mapping lives until the last
/// `munmap` (the workers attach without owning, so they never register).
/// A SIGKILL'd process skips even atexit; those orphans are swept by
/// [`SharedMemoryRegion::cleanup_stale_owned_segments`] at the next create,
/// and the eager `posix_fallocate` reservation turns quota exhaustion into
/// a graceful open failure instead of a SIGBUS at first touch.
#[cfg(unix)]
static OWNED_SHM_KEYS: std::sync::Mutex<Option<Vec<String>>> = std::sync::Mutex::new(None);
#[cfg(unix)]
@@ -1292,6 +1295,48 @@ impl SharedMemoryRegion {
}
}
/// Sweep owned segments (`olive-rw-<pid>-…`) whose owner pid no longer
/// exists and unlink them. A SIGKILL'd / aborted owner never runs its
/// atexit unlink, and because the key embeds the dead pid nobody else
/// ever reuses the name — on a quota'd `/dev/shm` the accumulation
/// eventually turns the next create into ENOSPC/EDQUOT. Runs once per
/// process, before the first create. Unlinking only removes the name:
/// a peer still mapping the segment keeps its memory until munmap.
#[cfg(target_os = "linux")]
pub fn cleanup_stale_owned_segments() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let Ok(entries) = std::fs::read_dir("/dev/shm") else {
return;
};
for entry in entries.flatten() {
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
continue;
};
let Some(rest) = name.strip_prefix("olive-rw-") else {
continue;
};
let pid_digits: String =
rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if pid_digits.is_empty() {
continue;
}
// A live owner (or a pid reuse) means the segment is owned;
// only dead owners are swept.
if std::path::Path::new(&format!("/proc/{pid_digits}")).exists() {
continue;
}
Self::unlink_key(&name);
}
});
}
/// No-op outside Linux: POSIX shm segments are not visible as files on
/// every unix (macOS keeps them in a kernel namespace), so there is no
/// directory to sweep.
#[cfg(all(unix, not(target_os = "linux")))]
pub fn cleanup_stale_owned_segments() {}
/// Remember `key` so it is unlinked at process exit (see
/// [`OWNED_SHM_KEYS`]). Safe to call from any thread; duplicate keys
/// are harmless (the unlink is idempotent).
@@ -1335,6 +1380,14 @@ impl SharedMemoryRegion {
self.size = size;
self.mode = mode;
if mode == ShmMode::Create {
// A crashed owner leaks its segments (the name embeds the dead
// pid, so nobody ever reuses or unlinks them); on a quota'd
// tmpfs the accumulation eventually kills the next create.
// Sweep the orphans of dead owners before creating anything.
Self::cleanup_stale_owned_segments();
}
// POSIX shared-memory names must start with a single slash and
// contain no others.
let shm_name = format!("/{}", key.replace('/', "_"));
@@ -1412,7 +1465,31 @@ impl SharedMemoryRegion {
if mode == ShmMode::Create {
Self::track_owned_key(&self.key);
unsafe { ptr::write_bytes(self.data, 0, size) };
// Reserve (and zero) the whole segment up front. `ftruncate`
// alone does not reserve on tmpfs: the pages fault in on first
// touch, and when the tmpfs is full / the user quota is
// exhausted that touch is a SIGBUS that kills the process.
// `posix_fallocate` performs the reservation eagerly and reports
// ENOSPC/EDQUOT as a return value, so quota exhaustion degrades
// to a render-manager fallback instead of a crash. The fresh
// segment is already zero-filled (O_EXCL + stale unlink above),
// so no separate memset pass is needed on success.
let rc = unsafe { libc::posix_fallocate(fd, 0, size as libc::off_t) };
if rc != 0 && rc != libc::EOPNOTSUPP && rc != libc::ENOSYS {
self.error = format!(
"reserving {} bytes of shared memory failed: {}",
size,
std::io::Error::from_raw_os_error(rc)
);
self.close();
return false;
}
if rc != 0 {
// Filesystem without fallocate support: fall back to
// touching every page now (still better than faulting
// lazily mid-render).
unsafe { ptr::write_bytes(self.data, 0, size) };
}
}
true
}