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
+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