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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user