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
+66 -1
View File
@@ -164,6 +164,15 @@ impl ExportTask {
params.subtitles_enabled = self.encoding_params.subtitles_enabled as i32;
params.export_length_num = self.encoding_params.export_length_num;
params.export_length_den = self.encoding_params.export_length_den;
// Delivery colorimetry: tag the output container with the project's
// output colorspace (H.273 code points → mov `colr` atom / VUI).
// Limited range is the video-delivery convention; the encoder's
// RGB→YCbCr runs limited.
let (_working, spec) = self.delivery_color();
params.color_primaries = spec.gamut.av_color_primaries();
params.color_trc = spec.transfer.av_color_trc();
params.color_space = spec.gamut.av_color_space();
params.color_range = 1; // AVCOL_RANGE_MPEG (limited)
params
}
@@ -194,6 +203,57 @@ impl ExportTask {
TimeRange::new(Rational::new(0, 1), length)
}
/// The project's pipeline color settings (working colorspace + the
/// delivery output spec) read off the exported node's project — the
/// export renders to the project's delivery target, not to the display.
fn delivery_color(&self) -> (
oak_common::colormath::WorkingColorSpace,
oak_common::colormath::OutputColorSpec,
) {
let guard = self
.viewer_node
.0
.lock()
.unwrap_or_else(|e| e.into_inner());
(guard.working_color_space(), guard.output_color_spec())
}
/// Convert an F32 codec frame from the pipeline working space to the
/// project's delivery colorspace (row-wise — the codec frame rows are
/// 32-byte aligned, so each row is handled through the byte-based
/// transform). A no-op for non-F32 frames and in the legacy sRGB
/// working space.
fn apply_output_node(&self, frame: &mut oak_codec::frame::Frame) {
if frame.format() != oak_core::PixelFormat::F32 {
return;
}
let (working, spec) = self.delivery_color();
if working == oak_common::colormath::WorkingColorSpace::SrgbLegacy {
return;
}
let w = frame.width().max(0) as usize;
let h = frame.height().max(0) as usize;
if w == 0 || h == 0 {
return;
}
let row_bytes = w * 16; // F32 RGBA
let linesize = frame.linesize_bytes() as usize;
let Some(data) = frame.data_mut() else {
return;
};
for y in 0..h {
let start = y * linesize;
if start + row_bytes > data.len() {
break;
}
oak_common::colormath::acescg_to_output_bytes(
&mut data[start..start + row_bytes],
w,
spec,
);
}
}
/// Copy a rendered `oakrender` CPU texture into an `oakcodec` frame
/// with the matching video params (row-wise copy — line sizes may
/// differ between the render and codec frame layouts).
@@ -317,7 +377,12 @@ impl RenderTaskBehavior for ExportTask {
let Some(encoder) = &self.encoder else {
return Ok(());
};
let codec_frame = Self::to_codec_frame(frame)?;
let mut codec_frame = Self::to_codec_frame(frame)?;
// Output node: the rendered frame is in the pipeline working space
// (ACEScg linear by default); the export converts it to the
// project's delivery colorspace before encoding. (No-op in the
// legacy sRGB working space.)
self.apply_output_node(&mut codec_frame);
if let Err(_) = encoder.write_video(&codec_frame) {
let err = encoder.get_error();
task.set_error(&err);