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
Generated
+2
View File
@@ -4729,6 +4729,7 @@ dependencies = [
"gpui_widgets",
"half",
"image",
"log",
"oak-audio",
"oak-codec",
"oak-common",
@@ -4910,6 +4911,7 @@ version = "0.5.0"
dependencies = [
"libc",
"oak-codec",
"oak-common",
"oak-core",
"oak-node",
"oak-plugin",
+12
View File
@@ -250,6 +250,18 @@
"projprops.cache.default": "Standardort verwenden"
"projprops.cache.alongside": "Zusammen mit dem Projekt speichern"
"projprops.cache.custom": "Benutzerdefinierten Speicherort verwenden:"
"projprops.color.working": "Arbeitsfarbraum"
"projprops.color.working.acescg": "ACEScg (szenenlinear, empfohlen)"
"projprops.color.working.srgb": "sRGB (Legacy-Durchreichung)"
"projprops.color.gamut": "Ausgabefarbraum"
"projprops.color.gamut.srgb": "sRGB / Rec.709"
"projprops.color.gamut.p3": "Display P3"
"projprops.color.gamut.bt2020": "Rec.2020"
"projprops.color.transfer": "Ausgabe-Übertragungsfunktion"
"projprops.color.transfer.srgb": "sRGB"
"projprops.color.transfer.gamma22": "Gamma 2.2"
"projprops.color.transfer.pq": "PQ (ST 2084)"
"projprops.color.transfer.hlg": "HLG"
"seqprops.new.title": "Neue Sequenz"
"seqprops.title": "Sequenzeigenschaften"
"seqprops.default_name": "Sequenz 1"
+12
View File
@@ -250,6 +250,18 @@
"projprops.cache.default": "Use Default Location"
"projprops.cache.alongside": "Store Alongside Project"
"projprops.cache.custom": "Use Custom Location:"
"projprops.color.working": "Working Color Space"
"projprops.color.working.acescg": "ACEScg (scene-linear, recommended)"
"projprops.color.working.srgb": "sRGB (legacy pass-through)"
"projprops.color.gamut": "Output Gamut"
"projprops.color.gamut.srgb": "sRGB / Rec.709"
"projprops.color.gamut.p3": "Display P3"
"projprops.color.gamut.bt2020": "Rec.2020"
"projprops.color.transfer": "Output Transfer"
"projprops.color.transfer.srgb": "sRGB"
"projprops.color.transfer.gamma22": "Gamma 2.2"
"projprops.color.transfer.pq": "PQ (ST 2084)"
"projprops.color.transfer.hlg": "HLG"
"seqprops.new.title": "New Sequence"
"seqprops.title": "Sequence Properties"
"seqprops.default_name": "Sequence 1"
+12
View File
@@ -250,6 +250,18 @@
"projprops.cache.default": "Usar ubicación predeterminada"
"projprops.cache.alongside": "Almacenar junto al proyecto"
"projprops.cache.custom": "Usar ubicación personalizada:"
"projprops.color.working": "Espacio de color de trabajo"
"projprops.color.working.acescg": "ACEScg (lineal de escena, recomendado)"
"projprops.color.working.srgb": "sRGB (paso directo heredado)"
"projprops.color.gamut": "Gama de salida"
"projprops.color.gamut.srgb": "sRGB / Rec.709"
"projprops.color.gamut.p3": "Display P3"
"projprops.color.gamut.bt2020": "Rec.2020"
"projprops.color.transfer": "Función de transferencia de salida"
"projprops.color.transfer.srgb": "sRGB"
"projprops.color.transfer.gamma22": "Gamma 2.2"
"projprops.color.transfer.pq": "PQ (ST 2084)"
"projprops.color.transfer.hlg": "HLG"
"seqprops.new.title": "Nueva secuencia"
"seqprops.title": "Propiedades de la secuencia"
"seqprops.default_name": "Secuencia 1"
+12
View File
@@ -250,6 +250,18 @@
"projprops.cache.default": "Utiliser l'emplacement par défaut"
"projprops.cache.alongside": "Stocker à côté du projet"
"projprops.cache.custom": "Utiliser un emplacement personnalisé :"
"projprops.color.working": "Espace de couleur de travail"
"projprops.color.working.acescg": "ACEScg (linéaire scène, recommandé)"
"projprops.color.working.srgb": "sRGB (passage direct hérité)"
"projprops.color.gamut": "Gamut de sortie"
"projprops.color.gamut.srgb": "sRGB / Rec.709"
"projprops.color.gamut.p3": "Display P3"
"projprops.color.gamut.bt2020": "Rec.2020"
"projprops.color.transfer": "Fonction de transfert de sortie"
"projprops.color.transfer.srgb": "sRGB"
"projprops.color.transfer.gamma22": "Gamma 2.2"
"projprops.color.transfer.pq": "PQ (ST 2084)"
"projprops.color.transfer.hlg": "HLG"
"seqprops.new.title": "Nouvelle séquence"
"seqprops.title": "Propriétés de la séquence"
"seqprops.default_name": "Séquence 1"
+12
View File
@@ -250,6 +250,18 @@
"projprops.cache.default": "デフォルトの場所を使用"
"projprops.cache.alongside": "プロジェクトと同じ場所に保存"
"projprops.cache.custom": "カスタムの場所を使用:"
"projprops.color.working": "作業色空間"
"projprops.color.working.acescg": "ACEScg(シーンリニア、推奨)"
"projprops.color.working.srgb": "sRGB(レガシーパススルー)"
"projprops.color.gamut": "出力色域"
"projprops.color.gamut.srgb": "sRGB / Rec.709"
"projprops.color.gamut.p3": "Display P3"
"projprops.color.gamut.bt2020": "Rec.2020"
"projprops.color.transfer": "出力伝達関数"
"projprops.color.transfer.srgb": "sRGB"
"projprops.color.transfer.gamma22": "ガンマ 2.2"
"projprops.color.transfer.pq": "PQ (ST 2084)"
"projprops.color.transfer.hlg": "HLG"
"seqprops.new.title": "新規シーケンス"
"seqprops.title": "シーケンスのプロパティ"
"seqprops.default_name": "シーケンス 1"
+12
View File
@@ -250,6 +250,18 @@
"projprops.cache.default": "Usar localização padrão"
"projprops.cache.alongside": "Armazenar junto ao projeto"
"projprops.cache.custom": "Usar localização personalizada:"
"projprops.color.working": "Espaço de cor de trabalho"
"projprops.color.working.acescg": "ACEScg (linear de cena, recomendado)"
"projprops.color.working.srgb": "sRGB (passagem direta legada)"
"projprops.color.gamut": "Gama de saída"
"projprops.color.gamut.srgb": "sRGB / Rec.709"
"projprops.color.gamut.p3": "Display P3"
"projprops.color.gamut.bt2020": "Rec.2020"
"projprops.color.transfer": "Função de transferência de saída"
"projprops.color.transfer.srgb": "sRGB"
"projprops.color.transfer.gamma22": "Gama 2.2"
"projprops.color.transfer.pq": "PQ (ST 2084)"
"projprops.color.transfer.hlg": "HLG"
"seqprops.new.title": "Nova sequência"
"seqprops.title": "Propriedades da sequência"
"seqprops.default_name": "Sequência 1"
+12
View File
@@ -250,6 +250,18 @@
"projprops.cache.default": "Использовать расположение по умолчанию"
"projprops.cache.alongside": "Хранить рядом с проектом"
"projprops.cache.custom": "Использовать пользовательское расположение:"
"projprops.color.working": "Рабочее цветовое пространство"
"projprops.color.working.acescg": "ACEScg (сценно-линейное, рекомендуется)"
"projprops.color.working.srgb": "sRGB (устаревший прямой пропуск)"
"projprops.color.gamut": "Выходная цветовая гамма"
"projprops.color.gamut.srgb": "sRGB / Rec.709"
"projprops.color.gamut.p3": "Display P3"
"projprops.color.gamut.bt2020": "Rec.2020"
"projprops.color.transfer": "Выходная передаточная функция"
"projprops.color.transfer.srgb": "sRGB"
"projprops.color.transfer.gamma22": "Гамма 2.2"
"projprops.color.transfer.pq": "PQ (ST 2084)"
"projprops.color.transfer.hlg": "HLG"
"seqprops.new.title": "Новая последовательность"
"seqprops.title": "Свойства последовательности"
"seqprops.default_name": "Последовательность 1"
+12
View File
@@ -250,6 +250,18 @@
"projprops.cache.default": "使用默认位置"
"projprops.cache.alongside": "随工程文件存储"
"projprops.cache.custom": "使用自定义位置:"
"projprops.color.working": "工作色彩空间"
"projprops.color.working.acescg": "ACEScg(场景线性,推荐)"
"projprops.color.working.srgb": "sRGB(传统直通)"
"projprops.color.gamut": "输出色域"
"projprops.color.gamut.srgb": "sRGB / Rec.709"
"projprops.color.gamut.p3": "Display P3"
"projprops.color.gamut.bt2020": "Rec.2020"
"projprops.color.transfer": "输出传递函数"
"projprops.color.transfer.srgb": "sRGB"
"projprops.color.transfer.gamma22": "Gamma 2.2"
"projprops.color.transfer.pq": "PQ (ST 2084)"
"projprops.color.transfer.hlg": "HLG"
"seqprops.new.title": "新建序列"
"seqprops.title": "序列属性"
"seqprops.default_name": "序列 1"
+4
View File
@@ -61,6 +61,10 @@ gpui_elements = { path = "../../gpui/crates/gpui_elements" }
# runtime (bundled with the app, user-extensible) with the compiled-in
# English table as the fallback.
serde_yaml = "0.9"
# The gpui/wgpu stack logs through the `log` facade; the app installs the
# stderr backend (see oakapp::logging) so validation errors and warnings
# are actually visible (RUST_LOG selects the verbosity).
log = "0.4"
# M14 R3: the app is a PURE module-crate consumer — every engine call is a
# direct Rust call into the oak* rlibs (oak-node for the project graph,
+36 -13
View File
@@ -717,7 +717,11 @@ impl<E: AppEngine> OakApp<E> {
cx.background_executor()
.timer(Duration::from_millis(16))
.await;
let _ = cx.update(|_window, app| {
let _ = cx.update(|window, app| {
// Multi-monitor: track which physical display the window
// sits on so the self-managed display transform follows
// window moves (throttled inside `poll_monitor`).
crate::oakui::displaycolor::poll_monitor(window, app);
if let Some(this) = this.upgrade() {
this.update(app, |this, cx| this.tick(cx));
}
@@ -1990,6 +1994,9 @@ impl<E: AppEngine> OakApp<E> {
crate::dialogs::PreferencesEvent::ShortcutsChanged => {
this.rebind_keys(cx);
}
crate::dialogs::PreferencesEvent::DisplayColorChanged => {
this.reapply_display_color(cx);
}
},
)
.detach();
@@ -2002,6 +2009,10 @@ impl<E: AppEngine> OakApp<E> {
if let ModalState::Preferences { content, .. } = &self.modal {
content.update(cx, |content, cx| content.commit_cache_dir(cx));
}
// The committed custom-ICC path may change the effective display
// transform (or the policy, if the profile availability flipped):
// re-declare on every window.
self.reapply_display_color(cx);
}
/// Re-applies the global key bindings and rebuilds the menu bar after a
@@ -2015,6 +2026,20 @@ impl<E: AppEngine> OakApp<E> {
cx.notify();
}
/// Re-evaluates the display color policy and retags every window after
/// the color-management preference changed (the single-mapping rule must
/// hold at runtime, not just at startup). Drops the cached display
/// processors, then re-declares the policy on each open window.
fn reapply_display_color(&mut self, cx: &mut Context<Self>) {
crate::oakui::displaycolor::invalidate();
for handle in cx.windows() {
let _ = cx.update_window(handle, |_root, window, _app| {
crate::oakui::displaycolor::apply_to_window(window);
});
}
cx.notify();
}
/// Opens the action search dialog (Help > Search Actions…, the `/` key).
/// Enter / double-click in the dialog executes the action through the same
/// [`Self::dispatch_action_id`] path the menu clicks take, so the behavior
@@ -3001,20 +3026,15 @@ fn run_with<E: AppEngine>(args: AppArgs) {
if plugin_count > 0 {
println!("[ofx] registered {plugin_count} OFX plugin node type(s)");
}
// Display color management: when the app transforms viewer frames
// through the display ICC itself, the macOS Metal layer must be
// tagged with the display colorspace so ColorSync passes the
// pixels through (otherwise the OS re-corrects them). Read by
// gpui_macos at layer creation, which happens below.
if crate::oakui::displaycolor::is_active() {
// SAFETY: single-threaded startup, before any window exists.
unsafe { std::env::set_var("OAK_MACOS_LAYER_COLORSPACE", "display") };
}
// Display color management: the platform layer must know who maps
// the pixels to the display (the single-mapping rule). Declared
// per-window below, right after the window exists, and re-declared
// whenever the preference changes (see displaycolor::apply_to_window).
// Display bit depth: the wgpu window layer must pick the swapchain
// format before the surface is created, so surface the persisted
// choice the same way the colorspace tag is passed to the platform
// layer above. Read by gpui_wgpu as OAK_DISPLAY_BIT_DEPTH ("8" opts
// into the 8-bit pair; anything else requests 10-bit).
// choice through the process environment. Read by gpui_wgpu as
// OAK_DISPLAY_BIT_DEPTH ("8" opts into the 8-bit pair; anything
// else requests 10-bit).
let bit_depth = oak_render::backend::DisplayBitDepth::from_config_string(
&crate::oakui::real::config_get_string(
oak_render::backend::CONFIG_KEY_DISPLAY_BIT_DEPTH,
@@ -3034,6 +3054,9 @@ fn run_with<E: AppEngine>(args: AppArgs) {
..Default::default()
},
|window, cx| {
// Declare who maps this window's pixels to the display
// before the first frame paints.
crate::oakui::displaycolor::apply_to_window(window);
// The 10-bit display path: hand the window's wgpu device
// to the engine so it can upload RGBA16F textures gpui's
// renderer samples straight into the swapchain (no 8-bit
+124 -3
View File
@@ -76,6 +76,9 @@ pub enum PreferencesEvent {
/// must re-bind the global key map and rebuild the menu bar so the new
/// keys take effect immediately.
ShortcutsChanged,
/// The display color-management mode changed (the host re-evaluates the
/// platform policy and retags the windows immediately — no restart).
DisplayColorChanged,
}
impl gpui::EventEmitter<PreferencesEvent> for PreferencesContent {}
@@ -373,8 +376,8 @@ impl PreferencesContent {
// --- 色彩 Color: display ICC color management -----------------------
// On by default: the viewer frames are transformed through the
// display's ICC profile (system profile, or a custom file below).
// The macOS layer tag is applied at startup, so a mode change takes
// effect after a restart.
// A mode change re-evaluates the platform display policy and
// retags the windows immediately (no restart).
use crate::oakui::displaycolor::{CONFIG_KEY_COLOR_MODE, CONFIG_KEY_CUSTOM_ICC};
let display_icc = cx.new(|cx| {
let mode = config_get_string(CONFIG_KEY_COLOR_MODE);
@@ -395,6 +398,10 @@ impl PreferencesContent {
let enabled = *state == CheckState::Checked;
config_set_string(CONFIG_KEY_COLOR_MODE, if enabled { "icc" } else { "off" });
check.update(cx, |check, cx| check.set_state(*state, cx));
// Drop the cached processors and tell the host to retag the
// windows for the new policy.
crate::oakui::displaycolor::invalidate();
cx.emit(PreferencesEvent::DisplayColorChanged);
}
})
.detach();
@@ -1557,6 +1564,12 @@ pub struct ProjectPropertiesContent<E: crate::oakui::engine::AppEngine> {
/// 1 = alongside the project, 2 = custom path; see
/// [`crate::oakui::engine::AppEngine::project_cache_location`]).
cache_setting: i32,
/// The pipeline working colorspace combo (ACEScg / sRGB legacy).
working_space: Entity<ComboBox>,
/// The delivery output gamut combo (sRGB / Display P3 / BT.2020).
output_gamut: Entity<ComboBox>,
/// The delivery output transfer combo (sRGB / gamma 2.2 / PQ / HLG).
output_transfer: Entity<ComboBox>,
/// The commit error shown under the OCIO row (an invalid config keeps
/// the dialog open, like the C++ accept()).
error: Option<String>,
@@ -1608,12 +1621,54 @@ impl<E: crate::oakui::engine::AppEngine> ProjectPropertiesContent<E> {
});
custom_cache_path.update(cx, |field, cx| field.set_path(custom_path, cx));
// --- Color pipeline: working colorspace + delivery output --------
let working_options = vec![
ComboBoxOption::new(0, i18n::tr("projprops.color.working.acescg")),
ComboBoxOption::new(1, i18n::tr("projprops.color.working.srgb")),
];
let working_space = cx.new(|cx| ComboBox::new(30, working_options, window, cx));
let gamut_options = vec![
ComboBoxOption::new(0, i18n::tr("projprops.color.gamut.srgb")),
ComboBoxOption::new(1, i18n::tr("projprops.color.gamut.p3")),
ComboBoxOption::new(2, i18n::tr("projprops.color.gamut.bt2020")),
];
let output_gamut = cx.new(|cx| ComboBox::new(30, gamut_options, window, cx));
let transfer_options = vec![
ComboBoxOption::new(0, i18n::tr("projprops.color.transfer.srgb")),
ComboBoxOption::new(1, i18n::tr("projprops.color.transfer.gamma22")),
ComboBoxOption::new(2, i18n::tr("projprops.color.transfer.pq")),
ComboBoxOption::new(3, i18n::tr("projprops.color.transfer.hlg")),
];
let output_transfer = cx.new(|cx| ComboBox::new(30, transfer_options, window, cx));
let (working, gamut, transfer) = engine.read(cx).project_color_settings();
working_space.update(cx, |combo, cx| {
combo.set_selected(
Some(oak_common::colormath::WorkingColorSpace::from_setting(&working) as usize),
cx,
)
});
output_gamut.update(cx, |combo, cx| {
combo.set_selected(
Some(oak_common::colormath::OutputGamut::from_setting(&gamut) as usize),
cx,
)
});
output_transfer.update(cx, |combo, cx| {
combo.set_selected(
Some(oak_common::colormath::OutputTransfer::from_setting(&transfer) as usize),
cx,
)
});
Self {
engine,
ocio_config,
cache_location,
custom_cache_path,
cache_setting,
working_space,
output_gamut,
output_transfer,
error: None,
}
}
@@ -1660,7 +1715,8 @@ impl<E: crate::oakui::engine::AppEngine> ProjectPropertiesContent<E> {
/// Applies the edited settings (the C++ `accept()`): validates and
/// applies the OCIO config override first — an invalid config keeps the
/// dialog open — then the disk-cache location. Ok clears the error row.
/// dialog open — then the disk-cache location and the color pipeline
/// settings. Ok clears the error row.
pub fn commit(&mut self, cx: &mut Context<Self>) -> Result<(), String> {
let ocio = self.ocio_config_path(cx).to_string();
self.engine
@@ -1671,10 +1727,57 @@ impl<E: crate::oakui::engine::AppEngine> ProjectPropertiesContent<E> {
self.engine.update(cx, |engine, cx| {
engine.set_project_cache_location(setting, path, cx)
});
// The color pipeline settings: combo index → canonical setting
// string via the colormath enums (single source of truth).
let (working, gamut, transfer) = self.color_settings(cx);
self.engine.update(cx, |engine, cx| {
engine.set_project_color_settings(working, gamut, transfer, cx)
});
self.set_error(None, cx);
Ok(())
}
/// The color pipeline settings currently selected in the combos, as
/// the canonical persisted strings.
fn color_settings(&self, cx: &App) -> (String, String, String) {
use oak_common::colormath::{OutputGamut, OutputTransfer, WorkingColorSpace};
let working = self
.working_space
.read(cx)
.selected()
.map(|i| match i {
1 => WorkingColorSpace::SrgbLegacy,
_ => WorkingColorSpace::AcesCg,
})
.unwrap_or_default();
let gamut = self
.output_gamut
.read(cx)
.selected()
.map(|i| match i {
1 => OutputGamut::DisplayP3,
2 => OutputGamut::Bt2020,
_ => OutputGamut::Srgb,
})
.unwrap_or_default();
let transfer = self
.output_transfer
.read(cx)
.selected()
.map(|i| match i {
1 => OutputTransfer::Gamma22,
2 => OutputTransfer::Pq,
3 => OutputTransfer::Hlg,
_ => OutputTransfer::Srgb,
})
.unwrap_or_default();
(
working.as_setting().to_string(),
gamut.as_setting().to_string(),
transfer.as_setting().to_string(),
)
}
/// The error shown under the OCIO row after a rejected commit.
pub fn set_error(&mut self, msg: Option<String>, cx: &mut Context<Self>) {
self.error = msg;
@@ -1771,6 +1874,21 @@ impl<E: crate::oakui::engine::AppEngine> Render for ProjectPropertiesContent<E>
self.cache_location.clone(),
))
.child(custom_row)
.child(form_row(
&colors,
i18n::tr("projprops.color.working").into(),
self.working_space.clone(),
))
.child(form_row(
&colors,
i18n::tr("projprops.color.gamut").into(),
self.output_gamut.clone(),
))
.child(form_row(
&colors,
i18n::tr("projprops.color.transfer").into(),
self.output_transfer.clone(),
))
}
}
@@ -1827,6 +1945,9 @@ impl PreferencesDialogContent {
}
PreferencesEvent::LanguageChanged => cx.emit(PreferencesEvent::LanguageChanged),
PreferencesEvent::ShortcutsChanged => {}
PreferencesEvent::DisplayColorChanged => {
cx.emit(PreferencesEvent::DisplayColorChanged);
}
},
)
.detach();
+8
View File
@@ -336,6 +336,14 @@ mod tests {
"1/2",
"1/4",
"1/8",
// Standard colorimetry names — identical across languages.
"sRGB / Rec.709",
"Display P3",
"Rec.2020",
"sRGB",
"Gamma 2.2",
"PQ (ST 2084)",
"HLG",
];
let tables = tables().read().unwrap_or_else(|e| e.into_inner());
let en = tables.get("en-US").unwrap();
+5
View File
@@ -60,11 +60,16 @@ pub mod actions;
pub mod app;
pub mod dialogs;
pub mod i18n;
pub mod logging;
pub mod manager;
pub mod oakui;
pub mod panels;
/// The application entry point (called from `main.rs`).
pub fn run() {
// Install the stderr `log` backend before anything else runs, so wgpu
// validation errors and platform warnings are visible (RUST_LOG sets
// the verbosity; default is warn).
logging::init();
app::run();
}
+191
View File
@@ -0,0 +1,191 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! A minimal stderr backend for the `log` facade.
//!
//! The gpui / wgpu / oak stack all emit through `log::*`, but nothing in the
//! binary installed a logger, so every validation error, adapter warning and
//! diagnostic was silently dropped (this is what made the viewer-black-screen
//! fault invisible for so long). [`init`] installs a small logger that writes
//! to stderr; `RUST_LOG` selects the verbosity:
//!
//! * unset — `warn` and above;
//! * a level name (`error` / `warn` / `info` / `debug` / `trace`) — that
//! level and above for every target;
//! * comma-separated `target=level` pairs (`wgpu=debug,oak_render=info`) —
//! per-target overrides on top of the global level. A bare level among the
//! pairs sets the global level.
use std::sync::Once;
use log::{Level, LevelFilter, Metadata, Record};
/// One parsed `RUST_LOG` directive set.
struct Filter {
/// The global (default) level.
global: LevelFilter,
/// Per-target overrides (`target` prefix → level).
targets: Vec<(String, LevelFilter)>,
}
impl Filter {
/// Parse the `RUST_LOG` value (`None` / empty → warn-only).
fn parse(spec: Option<&str>) -> Self {
let mut global = LevelFilter::Warn;
let mut targets = Vec::new();
if let Some(spec) = spec.filter(|s| !s.trim().is_empty()) {
for part in spec.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
match part.split_once('=') {
Some((target, level)) => {
if let Some(level) = parse_level(level.trim()) {
targets.push((target.trim().to_string(), level));
}
}
None => {
// A bare level sets the global filter.
if let Some(level) = parse_level(part) {
global = level;
}
}
}
}
}
Self { global, targets }
}
/// The effective level for `target` (longest matching prefix wins).
fn level_for(&self, target: &str) -> LevelFilter {
let mut best: Option<(usize, LevelFilter)> = None;
for (prefix, level) in &self.targets {
if target.starts_with(prefix.as_str()) {
let len = prefix.len();
if best.map(|(b, _)| len >= b).unwrap_or(true) {
best = Some((len, *level));
}
}
}
best.map(|(_, level)| level).unwrap_or(self.global)
}
}
/// Map a level name to a [`LevelFilter`].
fn parse_level(text: &str) -> Option<LevelFilter> {
match text.to_ascii_lowercase().as_str() {
"off" => Some(LevelFilter::Off),
"error" => Some(LevelFilter::Error),
"warn" | "warning" => Some(LevelFilter::Warn),
"info" => Some(LevelFilter::Info),
"debug" => Some(LevelFilter::Debug),
"trace" => Some(LevelFilter::Trace),
_ => None,
}
}
/// The logger installed by [`init`].
struct StderrLogger {
filter: Filter,
}
impl log::Log for StderrLogger {
fn enabled(&self, metadata: &Metadata) -> bool {
metadata.level() <= self.filter.level_for(metadata.target())
}
fn log(&self, record: &Record) {
if !self.enabled(record.metadata()) {
return;
}
let level = match record.level() {
Level::Error => "ERROR",
Level::Warn => "WARN",
Level::Info => "INFO",
Level::Debug => "DEBUG",
Level::Trace => "TRACE",
};
eprintln!(
"[{level} {}] {}",
record.target(),
record.args()
);
}
fn flush(&self) {}
}
static INIT: Once = Once::new();
/// Install the stderr logger once (idempotent). Honors `RUST_LOG`.
pub fn init() {
INIT.call_once(|| {
let filter = Filter::parse(std::env::var("RUST_LOG").ok().as_deref());
let max = filter
.targets
.iter()
.map(|(_, level)| *level)
.max()
.unwrap_or(LevelFilter::Off)
.max(filter.global);
let logger = Box::leak(Box::new(StderrLogger { filter }));
if log::set_logger(logger).is_ok() {
log::set_max_level(max);
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_warn() {
let f = Filter::parse(None);
assert_eq!(f.level_for("anything"), LevelFilter::Warn);
assert_eq!(f.level_for(""), LevelFilter::Warn);
}
#[test]
fn bare_level_sets_global() {
let f = Filter::parse(Some("info"));
assert_eq!(f.level_for("wgpu_hal"), LevelFilter::Info);
assert_eq!(f.level_for("oak_render"), LevelFilter::Info);
}
#[test]
fn target_overrides_with_longest_prefix() {
let f = Filter::parse(Some("warn,wgpu=debug,wgpu_hal=trace"));
assert_eq!(f.level_for("wgpu"), LevelFilter::Debug);
assert_eq!(f.level_for("wgpu_core"), LevelFilter::Debug);
assert_eq!(f.level_for("wgpu_hal::vulkan"), LevelFilter::Trace);
assert_eq!(f.level_for("oak_render"), LevelFilter::Warn);
}
#[test]
fn parse_level_aliases() {
assert_eq!(parse_level("warning"), Some(LevelFilter::Warn));
assert_eq!(parse_level("OFF"), Some(LevelFilter::Off));
assert_eq!(parse_level("bogus"), None);
}
#[test]
fn empty_spec_falls_back_to_warn() {
let f = Filter::parse(Some(" "));
assert_eq!(f.level_for("x"), LevelFilter::Warn);
}
}
+387 -52
View File
@@ -14,81 +14,320 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Display color management: the display's ICC profile applied to viewer
//! frames at present time.
//! Display color management: the display mapping applied to viewer frames
//! at present time, and — critically — WHO performs it.
//!
//! The frame content is treated as display-referred sRGB/Rec.709 (the
//! decode/render pipeline performs no input transfer conversion today);
//! the chain maps it through the display ICC (system profile or a custom
//! file from Preferences) so wide-gamut displays render correctly.
//! ## The single-mapping rule
//!
//! Double-correction discipline: when this module transforms pixels, the
//! OS must not transform them again. macOS: the app sets the CAMetalLayer
//! colorspace to the display profile at startup (see the `OAK_METAL_*`
//! wiring in app.rs) so ColorSync passes our output through. Windows:
//! the SDR desktop applies no per-app transform (and ACM honors the
//! swapchain's declared sRGB space, which is the default). Linux: no
//! compositor-level correction exists to conflict with.
//! The frame content leaves the pipeline in a colorimetrically defined
//! space (the project's output target; sRGB by default). The final mapping
//! to the physical display must happen EXACTLY ONCE — either the OS does it
//! (color-managed compositors / ColorSync / DWM-ACM) or the app does it
//! (an OCIO chain through the display ICC). Both at once doubles the
//! correction; neither leaves wide-gamut displays wrong.
//!
//! [`display_policy`] makes that choice per platform:
//!
//! * **Wayland** — always [`DisplayPolicy::OsManaged`]. A Wayland
//! compositor owns the display mapping (color-management-v1 declares the
//! content as sRGB; compositors without the protocol assume sRGB, which
//! the content is). Self-applying the display ICC here would double-map
//! on every color-managed compositor (KWin 6.2+, GNOME 50+…).
//! * **Windows** — [`DisplayPolicy::OsManaged`] when Windows 11 Auto Color
//! Management is active (it maps the sRGB-declared swapchain to the
//! display), otherwise [`DisplayPolicy::SelfManaged`]: older Windows has
//! no per-app OS mapping, so the app applies the ICM profile itself.
//! * **macOS** — [`DisplayPolicy::OsManaged`] by default: ColorSync maps
//! the content (named via the layer's content colorspace) to the
//! display. An explicit `DisplayColorMode=icc` preference hands the
//! mapping to the app (the layer colorspace tag then makes the OS path
//! a pass-through).
//! * **X11** — the user's preference ([`CONFIG_KEY_COLOR_MODE`]): X11 has
//! no compositor mapping, so self-management is the only way to honor
//! wide-gamut displays there (default self-managed).
//!
//! `OAK_DISPLAY_POLICY=self|os` overrides the platform decision for
//! debugging.
//!
//! ## The content space
//!
//! The chain starts from the project's output spec
//! ([`oak_render::color::pipeline_output_spec`]): sRGB content runs
//! through the named sRGB/Rec.709 space of the active OCIO config, and
//! non-sRGB content (P3/BT.2020 gamuts, PQ/HLG transfers) is converted to
//! CIE XYZ (D65, unit luminance) first and flows through the ICC's
//! connection space (the `cie_xyz_d65_interchange` chain of
//! [`ColorProcessor::create_display_icc_xyz`]). A non-empty
//! [`CONFIG_KEY_CONTENT_SPACE`] overrides the spec with an explicit OCIO
//! colorspace name.
//!
//! When the policy is OS-managed this module transforms nothing and the
//! platform layer declares the content colorspace instead (macOS layer
//! colorspace, Windows `SetColorSpace1`, Wayland color-management-v1),
//! re-declared whenever the project output spec changes.
//!
//! ## Multi-monitor tracking
//!
//! The display ICC depends on WHICH physical monitor the window is on, so
//! the effective key additionally carries the current monitor fingerprint.
//! [`poll_monitor`] runs on the app's tick loop and records the fingerprint
//! (throttled to one probe every 2s); [`note_monitor`] sets it directly
//! (tests, other callers). Fingerprints are opaque strings —
//! `"mac:<CGDirectDisplayID>"`, `"win:<device name>"`, `"x11:<RandR
//! output>"` — and the empty string means "no known monitor", which falls
//! back to the main-display profile. The `SelfManaged` policy keys the
//! transform chain per fingerprint, so dragging the window onto another
//! display re-resolves its ICC without touching the OS-managed paths.
use std::sync::{Arc, LazyLock, Mutex};
use std::time::{Duration, Instant};
use oak_common::colormath::{output_spec_to_xyz_d65, OutputColorSpec, OutputGamut, OutputTransfer};
use oak_common::configstore::ConfigStore;
use oak_render::color::ColorProcessor;
use oak_render::color::{pipeline_output_spec, ColorProcessor};
/// Config key: the display color management mode ("icc" / "off").
/// Config key: the display color management mode ("icc" / "off"). The
/// preference only applies where the platform policy allows self-management
/// (macOS, X11, non-ACM Windows).
pub const CONFIG_KEY_COLOR_MODE: &str = "DisplayColorMode";
/// Config key: a custom ICC profile path (empty = the system display
/// profile).
pub const CONFIG_KEY_CUSTOM_ICC: &str = "DisplayColorCustomIcc";
/// Config key: the content colorspace the chain starts from (an OCIO
/// colorspace name of the active config).
/// Config key: the content colorspace the display chain starts from (an
/// OCIO colorspace name of the active config). Empty (the default) =
/// follow the project's output spec.
pub const CONFIG_KEY_CONTENT_SPACE: &str = "DisplayColorContentSpace";
/// The default content space (OCIO 2.2 builtin config name for
/// gamma-encoded Rec.709/sRGB display-referred content).
const DEFAULT_CONTENT_SPACE: &str = "sRGB Encoded Rec.709 (sRGB)";
/// The cached processor pair (F32 RGBA and packed BGRA8 variants of the
/// same chain), keyed by (mode, icc path, content space).
/// Who performs the final mapping to the physical display.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DisplayPolicy {
/// The app applies the display transform itself (the display ICC chain);
/// the platform layer must be tagged so the OS passes pixels through.
SelfManaged,
/// The OS maps the (sRGB-declared) content to the display; the app must
/// not apply any display transform.
OsManaged,
}
/// The cached processor set for one (policy, mode, icc, content, monitor)
/// key: the sRGB-named chain for F32 samples, the XYZ (PCS) chain for
/// non-sRGB project output, and the BGRA8 variant of the sRGB chain — the
/// viewer's 8-bit slot always runs the sRGB chain, a degradation for
/// non-sRGB specs whose encoding 8-bit cannot honor (PQ/HLG highlights
/// clamp).
struct State {
key: (String, String, String),
key: (DisplayPolicy, String, String, String, String),
f32: Option<Arc<ColorProcessor>>,
xyz: Option<Arc<ColorProcessor>>,
bgra: Option<Arc<ColorProcessor>>,
}
static STATE: LazyLock<Mutex<Option<State>>> = LazyLock::new(|| Mutex::new(None));
/// Bumped every time the effective key changes (mode / ICC path /
/// content space): the engine's frame caches compare against it and drop
/// images produced with a stale transform.
/// Bumped every time the effective key changes (policy / mode / ICC path /
/// content space / monitor): the engine's frame caches compare against it
/// and drop images produced with a stale transform.
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// The current monitor fingerprint (see [`poll_monitor`]): `None` (the
/// default) means the window's monitor is unknown and the main-display
/// profile is used. Updated only on a successful probe — a failed probe
/// keeps the last known monitor rather than degrading mid-session.
static CURRENT_MONITOR: Mutex<Option<String>> = Mutex::new(None);
/// When [`poll_monitor`] last probed the monitor; the first call always
/// probes immediately, later ones at most every [`MONITOR_PROBE_INTERVAL`].
static LAST_MONITOR_PROBE: Mutex<Option<Instant>> = Mutex::new(None);
/// Minimum time between two monitor probes in [`poll_monitor`].
const MONITOR_PROBE_INTERVAL: Duration = Duration::from_secs(2);
/// The current transform generation (see [`GENERATION`]).
pub fn generation() -> u64 {
GENERATION.load(std::sync::atomic::Ordering::Relaxed)
}
/// The active (mode, icc, content-space) key from the config.
fn current_key() -> (String, String, String) {
let store = ConfigStore::instance();
let mode = store
.get(None, CONFIG_KEY_COLOR_MODE)
.unwrap_or_else(|_| "icc".to_string());
let custom = store
.get(None, CONFIG_KEY_CUSTOM_ICC)
.unwrap_or_default();
let space = store
.get(None, CONFIG_KEY_CONTENT_SPACE)
.unwrap_or_else(|_| DEFAULT_CONTENT_SPACE.to_string());
(mode, custom, space)
/// True when the session runs on a Wayland compositor (the same probe the
/// platform backend uses, `gpui::guess_compositor`).
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn on_wayland() -> bool {
gpui::guess_compositor() == "Wayland"
}
/// Drop the cached processors (call after a preferences change).
/// The platform policy decision for this session (see the module docs).
/// Stable for the process lifetime except the macOS/X11 preference, which
/// the config key governs at runtime.
pub fn display_policy() -> DisplayPolicy {
if let Ok(override_mode) = std::env::var("OAK_DISPLAY_POLICY") {
match override_mode.to_ascii_lowercase().as_str() {
"os" => return DisplayPolicy::OsManaged,
"self" => return DisplayPolicy::SelfManaged,
other => {
log::warn!(
"OAK_DISPLAY_POLICY={other:?} ignored (expected \"os\" or \"self\")"
);
}
}
}
// Wayland: the compositor owns the display mapping; declaring sRGB
// content (color-management-v1) is the only correct behavior.
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
if on_wayland() {
return DisplayPolicy::OsManaged;
}
// Windows: Auto Color Management (Windows 11) maps the sRGB-declared
// swapchain for us — self-applying the ICM profile would double-map.
// Without ACM (Windows 10 / ACM off) no per-app OS mapping exists, so
// the app applies the profile itself. ACM's swapchain mapping is SDR
// sRGB; a non-sRGB project output can't be honored through it — warn
// once so the degradation is visible in the log.
#[cfg(target_os = "windows")]
if oak_common::displayicc::windows_acm_active() {
if pipeline_output_spec() != OutputColorSpec::default() {
static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
WARNED.get_or_init(|| {
log::warn!(
"Windows ACM: project output {:?} is not sRGB; ACM maps the sRGB swapchain only, \
non-sRGB previews will not match the project colorspace",
pipeline_output_spec()
);
});
}
return DisplayPolicy::OsManaged;
}
// macOS: ColorSync owns the display mapping by default; only an
// explicit "icc" preference hands it to the app (the layer colorspace
// tag then turns the OS path into a pass-through). X11 has no OS
// mapping, so self-management is the only way to honor wide-gamut
// displays there (the "off" preference opts back into OS-management).
#[cfg(target_os = "macos")]
match ConfigStore::instance().get(None, CONFIG_KEY_COLOR_MODE).as_deref() {
Ok("icc") => DisplayPolicy::SelfManaged,
_ => DisplayPolicy::OsManaged,
}
#[cfg(not(target_os = "macos"))]
match configured_mode().as_str() {
"off" => DisplayPolicy::OsManaged,
_ => DisplayPolicy::SelfManaged,
}
}
/// The persisted mode preference ("icc" = self-manage, "off" = OS-managed).
fn configured_mode() -> String {
ConfigStore::instance()
.get(None, CONFIG_KEY_COLOR_MODE)
.unwrap_or_else(|_| "icc".to_string())
}
/// Drop the cached processors (call after a preference change).
pub fn invalidate() {
*STATE.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
/// The cached state, (re)built when the config key changed.
/// Record the window's current monitor fingerprint (see [`poll_monitor`]).
/// `None` clears the tracking back to the main-display lookup.
pub fn note_monitor(id: Option<String>) {
*CURRENT_MONITOR.lock().unwrap_or_else(|e| e.into_inner()) = id;
}
/// Track which physical monitor the window sits on, so the self-managed
/// display transform follows window moves between displays.
///
/// Runs on the app's tick loop. No-op unless the policy is [`DisplayPolicy::SelfManaged`]
/// (an OS-managed session never consults the display ICC). Probes are
/// throttled to one per [`MONITOR_PROBE_INTERVAL`] — the first call probes
/// immediately — and only a successful probe updates the tracked monitor:
/// a transient failure keeps the last known one rather than falling back
/// mid-session.
pub fn poll_monitor(window: &gpui::Window, cx: &gpui::App) {
if display_policy() != DisplayPolicy::SelfManaged {
return;
}
{
let mut last = LAST_MONITOR_PROBE.lock().unwrap_or_else(|e| e.into_inner());
if let Some(stamp) = *last {
if stamp.elapsed() < MONITOR_PROBE_INTERVAL {
return;
}
}
*last = Some(Instant::now());
}
if let Some(fingerprint) = current_monitor_fingerprint(window, cx) {
note_monitor(Some(fingerprint));
}
}
/// The current monitor of `window` as a fingerprint string (see the module
/// docs), or `None` when the platform cannot resolve one. The fingerprint
/// is derived from the window's platform display: macOS and Windows use the
/// gpui display id of the window's current screen (`window.display`), Linux
/// resolves the RandR output whose geometry covers the window center
/// (window bounds are logical pixels there; multiplied by the scale factor
/// they become device pixels).
fn current_monitor_fingerprint(window: &gpui::Window, cx: &gpui::App) -> Option<String> {
#[cfg(target_os = "macos")]
{
let id = u64::from(window.display(cx)?.id());
return Some(format!("mac:{id}"));
}
#[cfg(target_os = "windows")]
{
let id = u64::from(window.display(cx)?.id());
return oak_common::displayicc::windows_monitor_fingerprint(id);
}
#[cfg(target_os = "linux")]
{
let _ = cx;
let center = window.bounds().center();
let x = f64::from(center.x) * f64::from(window.scale_factor());
let y = f64::from(center.y) * f64::from(window.scale_factor());
return oak_common::displayicc::x11_monitor_fingerprint_at(x, y);
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
let _ = (window, cx);
None
}
}
/// The active (policy, mode, icc, content, monitor) key.
fn current_key() -> (DisplayPolicy, String, String, String, String) {
let policy = display_policy();
let custom = ConfigStore::instance()
.get(None, CONFIG_KEY_CUSTOM_ICC)
.unwrap_or_default();
// The content space: a non-empty override (an explicit OCIO colorspace
// name) wins; otherwise the chain follows the project's output spec —
// its (gamut, transfer) pair rekeys (and so rebuilds) the chain, so a
// project settings change flows through the same GENERATION machinery
// as a preference change.
let space = ConfigStore::instance()
.get(None, CONFIG_KEY_CONTENT_SPACE)
.unwrap_or_default();
let space = if space.is_empty() {
let spec = pipeline_output_spec();
format!("{}:{}", spec.gamut.as_setting(), spec.transfer.as_setting())
} else {
format!("override:{space}")
};
// The monitor fingerprint the window currently sits on; empty = unknown
// (falls back to the main display).
let monitor = CURRENT_MONITOR
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
.unwrap_or_default();
(policy, configured_mode(), custom, space, monitor)
}
/// The cached state, (re)built when the key changed.
fn current() -> Option<State> {
let key = current_key();
let mut guard = STATE.lock().unwrap_or_else(|e| e.into_inner());
@@ -105,33 +344,64 @@ fn current() -> Option<State> {
if guard.is_some() {
GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
let (mode, icc_path, space) = &key;
if mode != "icc" {
let (policy, mode, icc_path, space, monitor) = &key;
// OS-managed: the compositor/ColorSync/DWM performs the display
// mapping; applying anything here would be a second correction.
if *policy == DisplayPolicy::OsManaged || mode != "icc" {
let state = State {
key,
f32: None,
xyz: None,
bgra: None,
};
let out = clone_state(&state);
*guard = Some(state);
return out;
}
// The custom override wins; empty = the platform's display profile.
// Self-managed: content (in `space`) through the display ICC. The
// custom override wins; empty = the current monitor's profile (a known
// fingerprint resolves per-monitor, anything else the main display).
let icc = if icc_path.is_empty() {
oak_common::displayicc::system_display_icc()
monitor_icc_path(monitor)
} else {
Some(icc_path.clone())
};
let (f32p, bgrap) = match icc {
Some(path) => (
ColorProcessor::create_display_icc(space, &path).map(Arc::new),
ColorProcessor::create_display_icc_bgra8(space, &path).map(Arc::new),
),
None => (None, None),
let (f32p, xyzp, bgrap) = match icc {
// Explicit content-space override: the whole chain starts from
// that OCIO colorspace name (both slots).
Some(path) if space.starts_with("override:") => {
let name = space.trim_start_matches("override:");
(
ColorProcessor::create_display_icc(name, &path).map(Arc::new),
None,
ColorProcessor::create_display_icc_bgra8(name, &path).map(Arc::new),
)
}
// Project output spec: sRGB content through the named sRGB space;
// non-sRGB content (P3/BT.2020 gamuts, PQ/HLG transfers) has no
// named space in the builtin OCIO configs, so the F32 chain
// converts it to CIE XYZ (PCS) first and runs the ICC half — the
// sRGB chain stays as the degradation fallback and for the 8-bit
// slot (whose encoding cannot honor non-sRGB specs).
Some(path) => {
let xyz = if pipeline_output_spec() != OutputColorSpec::default() {
ColorProcessor::create_display_icc_xyz(&path).map(Arc::new)
} else {
None
};
(
ColorProcessor::create_display_icc(DEFAULT_CONTENT_SPACE, &path).map(Arc::new),
xyz,
ColorProcessor::create_display_icc_bgra8(DEFAULT_CONTENT_SPACE, &path)
.map(Arc::new),
)
}
None => (None, None, None),
};
let state = State {
key,
f32: f32p,
xyz: xyzp,
bgra: bgrap,
};
let out = clone_state(&state);
@@ -139,18 +409,76 @@ fn current() -> Option<State> {
out
}
/// The display ICC path for the current monitor fingerprint: a known
/// fingerprint resolves per-monitor (with a fallback to the main display
/// inside `system_display_icc_for`); an unknown one (empty or malformed)
/// falls back to the main-display profile — the pre-multi-monitor behavior.
fn monitor_icc_path(monitor: &str) -> Option<String> {
match oak_common::displayicc::monitor_ref_from_fingerprint(monitor) {
Some(monitor) => oak_common::displayicc::system_display_icc_for(&monitor),
None => oak_common::displayicc::system_display_icc(),
}
}
fn clone_state(state: &State) -> Option<State> {
Some(State {
key: state.key.clone(),
f32: state.f32.clone(),
xyz: state.xyz.clone(),
bgra: state.bgra.clone(),
})
}
/// Whether display color management is active (a valid ICC processor
/// exists). When false the OS owns the output mapping.
/// Whether the app is self-applying a display transform right now (a valid
/// ICC processor under the SelfManaged policy). When false the OS owns the
/// output mapping and the platform layer must declare the content space.
pub fn is_active() -> bool {
current().map(|s| s.f32.is_some() || s.bgra.is_some()).unwrap_or(false)
if display_policy() != DisplayPolicy::SelfManaged {
return false;
}
current()
.map(|s| s.f32.is_some() || s.xyz.is_some() || s.bgra.is_some())
.unwrap_or(false)
}
/// Declare the current policy to the window's platform layer (macOS: the
/// Metal layer colorspace tag; other platforms handle it at the surface
/// level or ignore it). Call once per window at creation, after any
/// preference change that flips [`is_active`], and after the project
/// output spec changes (which rekeys the content-colorspace declaration).
pub fn apply_to_window(window: &mut gpui::Window) {
let active = is_active();
let mode = if active {
gpui::LayerColorManagement::SelfManaged
} else {
gpui::LayerColorManagement::OsManaged
};
window.set_layer_color_management(mode);
// OS-managed: name the content colorspace so the OS maps from it
// (ColorSync, DWM-ACM, color-management-v1). The self-managed path is
// tagged pass-through, so the declaration is meaningless there.
if !active {
window.set_content_colorspace(content_colorspace());
}
}
/// The gpui content-colorspace declaration for the current project output
/// spec (only meaningful while the OS performs the display mapping).
fn content_colorspace() -> gpui::WindowContentColorspace {
let spec = pipeline_output_spec();
gpui::WindowContentColorspace {
primaries: match spec.gamut {
OutputGamut::Srgb => gpui::ContentPrimaries::Srgb,
OutputGamut::DisplayP3 => gpui::ContentPrimaries::DisplayP3,
OutputGamut::Bt2020 => gpui::ContentPrimaries::Bt2020,
},
transfer: match spec.transfer {
OutputTransfer::Srgb => gpui::ContentTransfer::Srgb,
OutputTransfer::Gamma22 => gpui::ContentTransfer::Gamma22,
OutputTransfer::Pq => gpui::ContentTransfer::Pq,
OutputTransfer::Hlg => gpui::ContentTransfer::Hlg,
},
}
}
/// Apply the display transform to an F32 RGBA buffer in place (no-op
@@ -159,7 +487,14 @@ pub fn apply_f32_rgba(samples: &mut [f32], pixels: i64) {
let Some(state) = current() else {
return;
};
if let Some(processor) = &state.f32 {
if let Some(xyz) = &state.xyz {
// Non-sRGB project output: the samples are in the output spec's
// encoded form — linearize the transfer and gamut-map to CIE XYZ
// (D65, unit luminance) first, then let the ICC chain map the
// connection space to the display.
output_spec_to_xyz_d65(samples, pipeline_output_spec());
let _ = xyz.convert_f32_rgba(samples, pixels);
} else if let Some(processor) = &state.f32 {
let _ = processor.convert_f32_rgba(samples, pixels);
}
}
+27
View File
@@ -925,6 +925,33 @@ pub trait AppEngine:
let _ = (setting, custom_path, cx);
}
/// The project's color pipeline settings:
/// `(working colorspace, output gamut, output transfer)` as the
/// persisted setting strings (see `oak_common::colormath`). The
/// working colorspace is the pipeline's scene space (ACEScg by
/// default, not hard-coded sRGB); the output pair is the delivery
/// target for export and presentation.
fn project_color_settings(&self) -> (String, String, String) {
(
oak_common::colormath::WorkingColorSpace::default().as_setting().to_string(),
oak_common::colormath::OutputGamut::default().as_setting().to_string(),
oak_common::colormath::OutputTransfer::default().as_setting().to_string(),
)
}
/// Applies the project color pipeline settings: stores them in the
/// project properties and invalidates every cached frame (they were
/// rendered under the old pipeline).
fn set_project_color_settings(
&mut self,
working: String,
gamut: String,
transfer: String,
cx: &mut Context<Self>,
) {
let _ = (working, gamut, transfer, cx);
}
// -------------------------------------------------------------------
// Sequence management (the C++ File > New Sequence / New Folder and
// the project-explorer sequence context menu): creating, querying and
+42 -12
View File
@@ -143,17 +143,28 @@ pub(crate) fn bgra_bytes_to_render_image(
/// 1.0), so every displayable code survives the round trip. Samples must
/// hold exactly `width * height * 4` values (tightly packed rows); values
/// clamp to `0.0..=1.0` before packing.
pub(crate) fn f32_rgba_to_16f_bytes(width: u32, height: u32, samples: &[f32]) -> Option<Vec<u8>> {
if samples.len() != (width * height * 4) as usize {
return None;
///
/// Returns `(bytes, bytes_per_row)`: rows are padded to wgpu's
/// `COPY_BYTES_PER_ROW_ALIGNMENT` (256) because `write_texture` requires
/// an explicit, aligned row pitch for multi-row copies — a `None` pitch
/// fails validation and the texture stays black.
pub(crate) fn f32_rgba_to_16f_bytes(width: u32, height: u32, samples: &[f32]) -> Option<(Vec<u8>, u32)> {
if samples.len() != (width * height * 4) as usize {
return None;
}
let row_samples = (width * 4) as usize;
let unpadded_row_bytes = row_samples * 2;
let row_bytes = unpadded_row_bytes.next_multiple_of(256);
let mut bytes = vec![0u8; row_bytes * height as usize];
for (row, chunk) in samples.chunks_exact(row_samples).enumerate() {
let dst = &mut bytes[row * row_bytes..row * row_bytes + unpadded_row_bytes];
for (i, &v) in chunk.iter().enumerate() {
let h = half::f16::from_f32(v.clamp(0.0, 1.0));
dst[i * 2..i * 2 + 2].copy_from_slice(&h.to_bits().to_le_bytes());
}
}
Some((bytes, row_bytes as u32))
}
let mut bytes = Vec::with_capacity(samples.len() * 2);
for &v in samples {
let h = half::f16::from_f32(v.clamp(0.0, 1.0));
bytes.extend_from_slice(&h.to_bits().to_le_bytes());
}
Some(bytes)
}
#[cfg(test)]
mod tests {
@@ -178,8 +189,11 @@ mod tests {
// same code 255.
let (v1, v2) = (1022.0f32 / 1023.0, 1.0f32);
let samples = [v1, 0.0, 0.0, 1.0, v2, 0.0, 0.0, 1.0];
let bytes = f32_rgba_to_16f_bytes(2, 1, &samples).expect("packed 16f bytes");
assert_eq!(bytes.len(), 2 * 1 * 4 * 2, "two pixels, four f16 channels");
let (bytes, row_bytes) = f32_rgba_to_16f_bytes(2, 1, &samples).expect("packed 16f bytes");
// Two pixels = 16 content bytes; the row pads to the 256-byte
// copy alignment.
assert_eq!(bytes.len(), 256, "one padded row");
assert_eq!(row_bytes, 256);
let code_of = |value: f32| {
let h = half::f16::from_f32(value);
((h.to_f32() * 1023.0).round()) as u32
@@ -201,4 +215,20 @@ mod tests {
"the same two values collapse in 8-bit — proving the 16f path carries the resolution"
);
}
#[test]
fn f32_to_16f_packs_multi_row_with_padding() {
// A 3x2 frame: unpadded row is 3*4*2 = 24 bytes, padded to 256.
let samples = vec![0.5f32; 3 * 2 * 4];
let (bytes, row_bytes) = f32_rgba_to_16f_bytes(3, 2, &samples).expect("packed");
assert_eq!(row_bytes, 256);
assert_eq!(bytes.len(), 512);
// First pixel of each row carries the 0.5 code; padding is zero.
let half_bits = half::f16::from_f32(0.5).to_bits();
for row in 0..2 {
let off = row * 256;
assert_eq!(&bytes[off..off + 2], &half_bits.to_le_bytes());
assert_eq!(&bytes[off + 24..off + 28], &[0, 0, 0, 0], "padding zeroed");
}
}
}
+5 -2
View File
@@ -69,7 +69,8 @@ pub fn upload_rgba16f(
let ctx = GPU_CONTEXT.lock().ok()?;
ctx.as_ref().map(|(d, q)| (d.clone(), q.clone()))?
};
let bytes = super::frames::f32_rgba_to_16f_bytes(width, height, samples)?;
let (bytes, bytes_per_row) =
super::frames::f32_rgba_to_16f_bytes(width, height, samples)?;
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("oak_display_rgba16f"),
size: wgpu::Extent3d {
@@ -94,7 +95,9 @@ pub fn upload_rgba16f(
&bytes,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: None,
// Multi-row copies require an explicit, 256-aligned pitch; a
// `None` pitch fails validation and the texture stays black.
bytes_per_row: Some(bytes_per_row),
rows_per_image: None,
},
wgpu::Extent3d {
+119 -1
View File
@@ -629,6 +629,7 @@ fn rendered_to_owned_image(rendered: &super::renderops::RenderedFrame) -> Option
// image below is only the CPU fallback (scope / eyedropper /
// cached fills without a GPU).
let mut samples = repack_f32_row_bytes(meta.width, meta.height, meta.linesize, data)?;
apply_output_node_f32(&mut samples);
super::displaycolor::apply_f32_rgba(&mut samples, (w * h) as i64);
let image = f32_rgba_to_bgra_image(w, h, &samples);
super::gpu::register_display_frame(image.id.0, w, h, &samples);
@@ -637,7 +638,11 @@ fn rendered_to_owned_image(rendered: &super::renderops::RenderedFrame) -> Option
bgra_bytes_to_render_image(w, h, data).map(Arc::new)
}
super::renderops::RenderedFrame::CpuF32 { .. } => {
let (w, h, samples) = read_f32_frame(rendered)?;
let (w, h, mut samples) = read_f32_frame(rendered)?;
// Output node + display transform, same as the shm F32 path, so
// cached fills match the on-screen picture regardless of backend.
apply_output_node_f32(&mut samples);
super::displaycolor::apply_f32_rgba(&mut samples, (w * h) as i64);
let image = f32_rgba_to_bgra_image(w, h, &samples);
super::gpu::register_display_frame(image.id.0, w, h, &samples);
Some(Arc::new(image))
@@ -645,6 +650,16 @@ fn rendered_to_owned_image(rendered: &super::renderops::RenderedFrame) -> Option
}
}
/// The app-side output node for F32 frames (working colorspace → the
/// project's output colorspace); pass-through in the legacy working space.
fn apply_output_node_f32(samples: &mut [f32]) {
oak_common::colormath::working_to_display_target(
samples,
oak_render::color::pipeline_working_space(),
oak_render::color::pipeline_output_spec(),
);
}
/// Repack one F32 RGBA shm slot (rows padded to `linesize`) into tightly
/// packed samples. The in-process variant is handled by [`read_f32_frame`].
fn repack_f32_row_bytes(width: i32, height: i32, linesize: i32, data: &[u8]) -> Option<Vec<f32>> {
@@ -2788,6 +2803,16 @@ impl RealEngine {
};
self.project = Some(project.clone());
self.storage = Some(AuxHandle(graphops::storage_bind(&project)));
// The project's color pipeline properties (working colorspace +
// delivery target) drive the app-side decode/output transforms;
// the render workers pick them up at graph-load time.
{
let guard = graphops::lock(&project);
oak_render::color::set_pipeline_color_settings(
guard.working_color_space(),
guard.output_color_spec(),
);
}
// Footage loaded from a file may lack stream metadata (C++ projects
// have no `<streams>` segment; older Rust saves predate the probe
@@ -2813,6 +2838,9 @@ impl RealEngine {
// The project's stored OCIO override (if any) drives the display
// color pipeline from here on.
Self::apply_project_color_config(Some(&project));
// The project's colorspace settings may differ from the previous
// project's: re-declare the display policy on every open window.
self.reapply_display_policy_to_windows(cx);
// 全局代理开关开启时,工程里未就绪素材的代理在后台自动生成
// (打开长素材工程不等待:生成走任务线程)。
@@ -4897,6 +4925,79 @@ impl AppEngine for RealEngine {
cx.notify();
}
fn project_color_settings(&self) -> (String, String, String) {
let Some(project) = self.project_ref() else {
return (
oak_common::colormath::WorkingColorSpace::default().as_setting().to_string(),
oak_common::colormath::OutputGamut::default().as_setting().to_string(),
oak_common::colormath::OutputTransfer::default().as_setting().to_string(),
);
};
let guard = graphops::lock(project);
let get = |key: &str, default: &str| {
guard
.settings
.get(key)
.map(String::as_str)
.unwrap_or(default)
.to_string()
};
(
get(
oak_node::project::SETTING_WORKING_COLOR_SPACE,
oak_common::colormath::WorkingColorSpace::default().as_setting(),
),
get(
oak_node::project::SETTING_OUTPUT_GAMUT,
oak_common::colormath::OutputGamut::default().as_setting(),
),
get(
oak_node::project::SETTING_OUTPUT_TRANSFER,
oak_common::colormath::OutputTransfer::default().as_setting(),
),
)
}
fn set_project_color_settings(
&mut self,
working: String,
gamut: String,
transfer: String,
cx: &mut Context<Self>,
) {
let Some(project) = self.project.clone() else {
return;
};
// Normalize through the parsers so only canonical values persist.
let working = oak_common::colormath::WorkingColorSpace::from_setting(&working);
let spec = oak_common::colormath::OutputColorSpec::from_settings(&gamut, &transfer);
{
let mut guard = graphops::lock(&project);
guard.settings.insert(
oak_node::project::SETTING_WORKING_COLOR_SPACE.to_string(),
working.as_setting().to_string(),
);
guard.settings.insert(
oak_node::project::SETTING_OUTPUT_GAMUT.to_string(),
spec.gamut.as_setting().to_string(),
);
guard.settings.insert(
oak_node::project::SETTING_OUTPUT_TRANSFER.to_string(),
spec.transfer.as_setting().to_string(),
);
guard.modified = true;
}
// The app-side transforms read the process global; the workers pick
// the new settings up with the next graph upload.
oak_render::color::set_pipeline_color_settings(working, spec);
// The pipeline colorspace changed: every cached frame (CPU image,
// GPU texture, preview slot) was rendered under the old space.
super::displaycolor::invalidate();
self.invalidate_rendered_frames();
self.reapply_display_policy_to_windows(cx);
cx.notify();
}
fn entry_is_sequence(&self, id: u64) -> bool {
let Some(project) = self.project_ref() else {
return false;
@@ -5649,6 +5750,23 @@ impl ProjectFormat {
}
impl RealEngine {
/// Re-declares the display color policy (and the content colorspace) on
/// every open window after the project's color pipeline settings changed.
/// Runs deferred: [`Self::set_project_color_settings`] is committed from
/// inside the settings dialog's window update, where a direct
/// `update_window` would fail; `defer` re-enters the app after that
/// update completes.
fn reapply_display_policy_to_windows(&self, cx: &mut Context<Self>) {
let windows = cx.windows();
cx.defer(move |app| {
for handle in windows {
let _ = app.update_window(handle, |_root, window, _app| {
crate::oakui::displaycolor::apply_to_window(window);
});
}
});
}
/// Auto-creates a sequence for a footage drop onto an empty timeline (see
/// `drop_footage`): the format mirrors the footage's first video stream;
/// pure-audio footage uses the default sequence format. Returns the new
+39 -10
View File
@@ -464,16 +464,23 @@ impl RenderedFrame {
let (w, h) = (meta.width.max(0) as u32, meta.height.max(0) as u32);
let pixels = f.shm.slot_bytes(f.slot);
let data = pixels.get(..meta.data_size.max(0) as usize)?;
if meta.format == PIXEL_FORMAT_F32 {
// M15 S3: the worker rendered F32 (the 10-bit display
// path) — repack the padded rows, transform and hand the
// samples back for the RGBA16F texture.
let mut samples = repack_f32_rows(meta.width, meta.height, meta.linesize, data)?;
let scope = analyze_f32_rgba(w, h, &samples);
super::displaycolor::apply_f32_rgba(&mut samples, (w * h) as i64);
let image = f32_rgba_to_bgra_image(w, h, &samples);
Some((image, scope, Some(samples)))
} else {
if meta.format == PIXEL_FORMAT_F32 {
// M15 S3: the worker rendered F32 (the 10-bit display
// path) — repack the padded rows, transform and hand the
// samples back for the RGBA16F texture.
let mut samples = repack_f32_rows(meta.width, meta.height, meta.linesize, data)?;
// Output node: working space → the project's output
// colorspace. The scopes below read the output-colorspace
// signal (same convention as the BGRA8 slot); the display
// policy then decides whether the display ICC is applied on
// top (self-managed) or the OS maps the declared content
// colorspace (OS-managed).
apply_output_node_f32(&mut samples);
let scope = analyze_f32_rgba(w, h, &samples);
super::displaycolor::apply_f32_rgba(&mut samples, (w * h) as i64);
let image = f32_rgba_to_bgra_image(w, h, &samples);
Some((image, scope, Some(samples)))
} else {
// BGRA8 slot: the worker already downconverted — wrap the
// bytes directly (no 10-bit path available for them).
let scope = analyze_bgra8(w, h, data);
@@ -492,6 +499,9 @@ impl RenderedFrame {
} => {
let (w, h) = ((*width).max(0) as u32, (*height).max(0) as u32);
let mut samples = repack_f32_rows(*width, *height, *linesize, data)?;
// Output node first, as in the shm path: the scopes read the
// output-colorspace signal (BGRA8-slot convention).
apply_output_node_f32(&mut samples);
let scope = analyze_f32_rgba(w, h, &samples);
super::displaycolor::apply_f32_rgba(&mut samples, (w * h) as i64);
Some((
@@ -504,6 +514,17 @@ impl RenderedFrame {
}
}
/// The app-side output node for F32 delivery frames: working colorspace →
/// the project's output colorspace, in place on tightly packed samples.
/// Pass-through in the legacy sRGB working space.
fn apply_output_node_f32(samples: &mut [f32]) {
oak_common::colormath::working_to_display_target(
samples,
oak_render::color::pipeline_working_space(),
oak_render::color::pipeline_output_spec(),
);
}
/// Repack one F32 RGBA rendered frame (rows padded to `linesize`) into
/// tightly packed samples. Returns `(width, height, samples)`-style
/// samples only; geometry is validated by the caller.
@@ -1195,6 +1216,14 @@ mod tests {
#[test]
fn montage_effect_stack_reaches_the_rendered_pixels() {
let _media = media_lock();
// This test verifies the effect stack MECHANICS (opacity changes
// pixels; disabling restores them), not the color pipeline. Pin the
// legacy sRGB pass-through so the pixel-value assertions hold
// regardless of the ACEScg default.
oak_render::color::set_pipeline_color_settings(
oak_common::colormath::WorkingColorSpace::SrgbLegacy,
oak_common::colormath::OutputColorSpec::default(),
);
oak_undo::global::clear().unwrap();
let media =
std::env::temp_dir().join(format!("oakapp_montage_fx_{}.mp4", std::process::id()));
+13 -5
View File
@@ -23,6 +23,12 @@
//! viewer costs nothing and no frame is ever walked twice. The scope widgets
//! own the graphing math (histogram binning, waveform envelopes, vectorscope
//! projection); this module only turns pixels into their input samples.
//!
//! The samples are the pipeline / output-colorspace signal, taken BEFORE the
//! display-ICC transform is applied: a scope reads the content's colorimetry,
//! not the viewing monitor's mapping, so the display transform must never
//! feed the scopes (it would make the readings depend on which monitor the
//! app happens to run on).
use std::sync::Arc;
@@ -79,11 +85,13 @@ pub(crate) fn analyze_f32_rgba(width: u32, height: u32, samples: &[f32]) -> Scop
/// Analyzes one BGRA8 frame (the process backend's slot format, M15 S2)
/// into its [`ScopeData`]. The worker converts its F32 pipeline output to
/// BGRA8 at the end of the render, so the scopes read exactly the
/// displayed values with the viewer's 8-bit quantization — precision loss
/// vs the F32 analysis is bounded by 1/255 per channel (acceptable for
/// the scopes; the F32 path stays for the in-process test backend).
/// `bytes` must hold at least `width * height * 4` values.
/// BGRA8 at the end of the render, so the scopes read the output-colorspace
/// signal with the viewer's 8-bit quantization — precision loss vs the F32
/// analysis is bounded by 1/255 per channel (acceptable for the scopes; the
/// F32 path stays for the in-process test backend). Like the F32 path, this
/// runs before the display-ICC transform: scopes read the content signal,
/// not the monitor mapping. `bytes` must hold at least `width * height * 4`
/// values.
pub(crate) fn analyze_bgra8(width: u32, height: u32, bytes: &[u8]) -> ScopeData {
let pixels = (width * height) as usize;
let mut luma = Vec::with_capacity(pixels);
+15
View File
@@ -145,6 +145,21 @@ pub struct RetrieveVideoParams {
pub target_size: Option<(u32, u32)>,
}
/// The colorimetry of a decoded frame, as carried out of the bitstream
/// (raw ISO/IEC 23001-8 / H.273 code points — the same numbering FFmpeg's
/// `AVCodecParameters` uses). The render layer maps these to its input
/// transform (source colorspace → the pipeline working space).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct DecodedColorMeta {
/// Color primaries code point (`AVCOL_PRI_*`; 0/2 = unknown).
pub color_primaries: i32,
/// Transfer characteristic code point (`AVCOL_TRC_*`; 0/2 = unknown).
pub color_trc: i32,
/// True when the decoded RGB is full range (the YUV→RGB used the
/// full-range coefficients).
pub full_range: bool,
}
/// `Decoder::RetrieveAudioStatus` — outcome of an audio retrieve.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RetrieveAudioStatus {
+30 -5
View File
@@ -121,6 +121,20 @@ pub struct EncodingParams {
pub custom_range_out_num: i64,
/// Custom range out denominator.
pub custom_range_out_den: i64,
/// Delivery color metadata written into the output container
/// (ISO/IEC 23001-8 / H.273 code points — the same numbering FFmpeg's
/// `AVCodecContext` uses; 0 = leave unset). The export sets these from
/// the project's output colorspace so the file declares its
/// colorimetry (the mov `colr` atom / H.264-HEVC VUI) instead of
/// leaving players to guess.
pub color_primaries: i32,
/// Delivery transfer characteristic code point (`AVCOL_TRC_*`).
pub color_trc: i32,
/// Delivery matrix coefficients code point (`AVCOL_SPC_*`; 0 = RGB).
pub color_space: i32,
/// Delivery color range (`AVCOL_RANGE_*`: 1 = limited, 2 = full).
pub color_range: i32,
}
impl Default for EncodingParams {
@@ -170,6 +184,11 @@ impl Default for EncodingParams {
custom_range_in_den: 0,
custom_range_out_num: 0,
custom_range_out_den: 0,
color_primaries: 0,
color_trc: 0,
color_space: 0,
color_range: 0,
}
}
}
@@ -1069,15 +1088,16 @@ mod tests {
]
}
/// `oakcodec_encoding_params` byte-level layout lock, verified against
/// the real header with a C++ `offsetof` probe (see the crate notes):
/// every field offset and the total size must match `include/codec/
/// encoder.h` exactly so a C caller's POD is read in place.
/// `oakcodec_encoding_params` byte-level layout lock. The original C
/// ABI (`include/codec/encoder.h`) has been retired, but the offsets of
/// the pre-existing fields stay frozen so any on-disk/IPC copy of the
/// POD still reads in place; the delivery color-metadata fields are
/// appended at the end.
#[test]
fn encoding_params_c_abi_layout() {
use std::mem::{offset_of, size_of};
assert_eq!(size_of::<EncodingParams>(), 1536);
assert_eq!(size_of::<EncodingParams>(), 1552);
assert_eq!(offset_of!(EncodingParams, filename), 0);
assert_eq!(offset_of!(EncodingParams, format), 1024);
assert_eq!(offset_of!(EncodingParams, video_enabled), 1028);
@@ -1091,5 +1111,10 @@ mod tests {
assert_eq!(offset_of!(EncodingParams, has_custom_range), 1496);
assert_eq!(offset_of!(EncodingParams, custom_range_in_num), 1504);
assert_eq!(offset_of!(EncodingParams, custom_range_out_den), 1528);
// Appended delivery color metadata (H.273 code points).
assert_eq!(offset_of!(EncodingParams, color_primaries), 1536);
assert_eq!(offset_of!(EncodingParams, color_trc), 1540);
assert_eq!(offset_of!(EncodingParams, color_space), 1544);
assert_eq!(offset_of!(EncodingParams, color_range), 1548);
}
}
+394 -15
View File
@@ -57,6 +57,7 @@ use ffmpeg::{ChannelLayout, Dictionary, Error as FfmpegError, Rational as FfRati
use ffmpeg_next as ffmpeg;
use oak_common::cancelatom::CancelAtom;
use oak_common::colormath::YuvMatrix;
use oak_common::ocioutils::PixelFormat as OakPixelFormat;
use oak_common::videoparams::{Interlacing, VideoParams, VideoType};
use oak_core::{PixelFormat, Rational, SampleFormat, TimeRange};
@@ -70,6 +71,22 @@ use crate::frame::Frame;
/// `OAKCOMMON_COLOR_RANGE_FULL`.
const OAKCOMMON_COLOR_RANGE_FULL: i32 = 1;
/// `OAKCOMMON_COLOR_RANGE_LIMITED`.
const OAKCOMMON_COLOR_RANGE_LIMITED: i32 = 0;
/// `AVCOL_RANGE_JPEG` (full range; AVCOL_RANGE_MPEG = 1 is limited).
const AVCOL_RANGE_JPEG: i32 = 2;
/// swscale colorspace ids (`SWS_CS_*`, libswscale/swscale.h).
const SWS_CS_ITU709: i32 = 1;
const SWS_CS_ITU601: i32 = 5;
const SWS_CS_SMPTE240M: i32 = 7;
const SWS_CS_BT2020: i32 = 9;
/// AVCOL_SPC_* code points that map onto each swscale colorspace.
const AVCOL_SPC_BT709: i32 = 1;
const AVCOL_SPC_BT470BG: i32 = 5;
const AVCOL_SPC_SMPTE170M: i32 = 6;
const AVCOL_SPC_SMPTE240M: i32 = 7;
const AVCOL_SPC_BT2020_NCL: i32 = 9;
const AVCOL_SPC_BT2020_CL: i32 = 10;
/// The format-level time base (microseconds), `FB_TIME_BASE` in the bridge.
const FB_TIME_BASE: i64 = 1_000_000;
/// `AV_NOPTS_VALUE`.
@@ -332,8 +349,20 @@ impl Decoder for FFmpegDecoder {
let f = decoded?
.ok_or_else(|| fail("no video frame available at the requested time"))?;
let (w, h, bytes) = state.scale_video_to_f32(f, p.force_range, p.target_size)?;
let frame = copy_rgba_f32_to_frame(w, h, &bytes, p.time)?;
let (w, h, bytes, color_meta) =
state.scale_video_to_f32(f, p.force_range, p.target_size)?;
let mut frame = copy_rgba_f32_to_frame(w, h, &bytes, p.time)?;
// Carry the source colorimetry on the frame params: the render
// layer maps it to its input transform (source → working space).
if let Some(params) = frame.params.as_mut() {
params.set_color_primaries(color_meta.color_primaries);
params.set_color_transfer(color_meta.color_trc);
params.set_color_range(if color_meta.full_range {
oak_common::videoparams::ColorRange::Full
} else {
oak_common::videoparams::ColorRange::Limited
});
}
Ok(Arc::new(frame))
}
@@ -949,8 +978,10 @@ impl DecoderState {
}
/// Scale a decoded frame to float RGBA (F32, 4 channels), returning the
/// raw pixel bytes plus dimensions. Mirrors `pre_process_frame` +
/// `retrieve_video_frame_internal` scaling with the color-range forcing.
/// raw pixel bytes, dimensions, and the source colorimetry. Mirrors
/// `pre_process_frame` + `retrieve_video_frame_internal` scaling; unlike
/// the old bridge path the YUV→RGB honors the frame's own colorspace
/// (BT.601/709/2020) and range instead of assuming BT.601 limited.
/// `target_size` resizes in the same swscale pass (native → RGBA/F32 at
/// the target size) instead of converting at native size first — the
/// caller's downscale then degenerates to a plain copy, and no
@@ -960,18 +991,45 @@ impl DecoderState {
f: ffmpeg::frame::Video,
force_range: i32,
target_size: Option<(u32, u32)>,
) -> crate::error::Result<(u32, u32, Vec<u8>)> {
) -> crate::error::Result<(u32, u32, Vec<u8>, crate::decoder::DecodedColorMeta)> {
let video = self
.video
.as_mut()
.expect("scale_video_to_f32 requires a video session");
// The frame's own colorimetry (set by the decoder from the
// bitstream); raw code points pass through to the render layer.
let (raw_primaries, raw_trc, raw_space, raw_range) = unsafe {
let av = f.as_ptr();
(
(*av).color_primaries as i32,
(*av).color_trc as i32,
(*av).colorspace as i32,
(*av).color_range as i32,
)
};
// # CPP-PARITY ffmpegdecoder.cpp:376: disregard "JPEG" pixel formats
// and force the color range to whatever the caller requested.
let src_format = convert_jpeg_space_to_regular_space(f.format());
// — but a YUVJ source is full range by definition, so remember it
// for the range decision below.
let orig_format = f.format();
let src_format = convert_jpeg_space_to_regular_space(orig_format);
let yuvj_full = orig_format != src_format;
let mut f = f;
f.set_format(src_format);
f.set_color_range(if force_range == OAKCOMMON_COLOR_RANGE_FULL {
// The effective color range: the caller's force wins; otherwise the
// frame's own metadata (YUVJ sources are full range). The old path
// forced MPEG/limited for everything, crushing full-range screen
// captures and JPEG-derived footage.
let full_range = if force_range == OAKCOMMON_COLOR_RANGE_FULL {
true
} else if force_range == OAKCOMMON_COLOR_RANGE_LIMITED {
false
} else {
yuvj_full || raw_range == AVCOL_RANGE_JPEG
};
f.set_color_range(if full_range {
ffmpeg::color::Range::JPEG
} else {
ffmpeg::color::Range::MPEG
@@ -990,24 +1048,67 @@ impl DecoderState {
// float context there can abort instead of erroring — RGBA64 is
// REPORTED supported but still aborts, so only RGBAF32LE is
// probed (on the builds that have it, e.g. the system FFmpeg,
// it works); everything else takes the universal 8-bit RGBA
// path converted in Rust.
// it works). High-bit-depth YUV sources fall back to 16-bit
// planar YUV 4:4:4 (converted to F32 RGBA in Rust) so their
// precision survives; 8-bit and RGB sources take the universal
// 8-bit RGBA path.
let supported = ffmpeg::software::scaling::support::output(Pixel::RGBAF32LE);
let (depth, is_yuv) = pix_fmt_depth_and_yuv(src_format);
let (out_fmt, f32_ok) = if supported {
(Pixel::RGBAF32LE, true)
} else if depth > 8 && is_yuv {
(Pixel::YUV444P16LE, false)
} else {
(Pixel::RGBA, false)
};
let ctx = get_or_create_scaler(&mut video.scaler, src_format, src_w, src_h, out_fmt, w, h)?;
if out_fmt == Pixel::YUV444P16LE {
// YUV→YUV pass-through: the 16-bit code values must reach the
// Rust matrix conversion bit-exact. sws_setColorspaceDetails
// has to see the SAME coefficient table for source and
// destination — differing tables would insert a cascaded
// YUV→RGB→YUV round trip — and both ranges are set full so
// the YUV→YUV range recompression is skipped entirely (it
// only runs when src_range != dst_range). The matrix and
// full/limited expansion happen later, in
// convert_yuv444p16_to_rgba_f32.
unsafe {
let table = sys::sws_getCoefficients(sws_colorspace_for(raw_space, src_w, src_h));
sys::sws_setColorspaceDetails(
ctx.as_mut_ptr(),
table,
1, // src full range (no recompression)
table,
1, // dst full range (no recompression)
0,
1 << 16,
1 << 16,
);
}
} else {
// The YUV→RGB matrix: BT.601/709/2020 per the frame's
// colorspace tag, with the full/limited range decided above.
// RGB sources are untouched by the colorspace tables (swscale
// ignores them there).
apply_sws_colorspace(ctx, raw_space, full_range, src_w, src_h);
}
let mut out = ffmpeg::frame::Video::empty();
ctx.run(&f, &mut out).map_err(ffmpeg_err)?;
let stride = out.stride(0);
let bytes = if f32_ok {
let stride = out.stride(0);
convert_rgba_f32_le(&out.data(0), w, h, stride)
} else if out_fmt == Pixel::YUV444P16LE {
convert_yuv444p16_to_rgba_f32(&out, w, h, yuv_matrix_for(raw_space, src_w, src_h), full_range)
} else {
let stride = out.stride(0);
convert_rgba8_to_f32(&out.data(0), w, h, stride)
};
Ok((w, h, bytes))
let meta = crate::decoder::DecodedColorMeta {
color_primaries: raw_primaries,
color_trc: raw_trc,
full_range,
};
Ok((w, h, bytes, meta))
}
/// Fill `dest` (interleaved f32) with the decoded audio covering
@@ -1467,6 +1568,90 @@ fn get_or_create_scaler(
Ok(&mut cache.as_mut().expect("set above").ctx)
}
/// Map a frame's `AVCOL_SPC_*` tag to a swscale colorspace id (the YUV→RGB
/// coefficient set). Untagged frames fall back by size (HD material is
/// overwhelmingly BT.709, SD is BT.601 — the old code used BT.601 for
/// everything, tinting every HD source).
fn sws_colorspace_for(av_colorspace: i32, src_w: u32, src_h: u32) -> i32 {
match av_colorspace {
AVCOL_SPC_BT709 => SWS_CS_ITU709,
AVCOL_SPC_BT470BG | AVCOL_SPC_SMPTE170M => SWS_CS_ITU601,
AVCOL_SPC_SMPTE240M => SWS_CS_SMPTE240M,
AVCOL_SPC_BT2020_NCL | AVCOL_SPC_BT2020_CL => SWS_CS_BT2020,
// Untagged: HD → BT.709, SD → BT.601.
_ => {
if src_w >= 1280 || src_h > 576 {
SWS_CS_ITU709
} else {
SWS_CS_ITU601
}
}
}
}
/// The Rust-side YUV→RGB matrix for a frame's `AVCOL_SPC_*` tag. Unlike
/// [`sws_colorspace_for`] only tags with an exact matrix in [`YuvMatrix`]
/// are honored; everything else (including SMPTE 240M and BT.2020 CL) falls
/// back by size.
fn yuv_matrix_for(av_colorspace: i32, src_w: u32, src_h: u32) -> YuvMatrix {
match av_colorspace {
AVCOL_SPC_BT709 => YuvMatrix::Bt709,
AVCOL_SPC_BT470BG | AVCOL_SPC_SMPTE170M => YuvMatrix::Bt601,
AVCOL_SPC_BT2020_NCL => YuvMatrix::Bt2020,
_ => {
if src_w >= 1280 || src_h > 576 {
YuvMatrix::Bt709
} else {
YuvMatrix::Bt601
}
}
}
}
/// Bit depth (bits per component) and YUV-ness of a pixel format, from its
/// `AVPixFmtDescriptor` (8 and false for formats without one — none in
/// practice for decoder output).
fn pix_fmt_depth_and_yuv(fmt: Pixel) -> (i32, bool) {
unsafe {
let desc = sys::av_pix_fmt_desc_get(fmt.into());
if desc.is_null() {
(8, false)
} else {
((*desc).comp[0].depth, (*desc).flags & sys::AV_PIX_FMT_FLAG_RGB as u64 == 0)
}
}
}
/// Configure a swscale context's YUV→RGB matrix and range.
///
/// The range flag selects full/limited input coefficients; the RGB output is
/// always full range. `sws_setColorspaceDetails` ignores the tables for
/// non-YUV sources, so RGB footage passes through unchanged.
fn apply_sws_colorspace(
ctx: &mut scaling::Context,
av_colorspace: i32,
full_range: bool,
src_w: u32,
src_h: u32,
) {
let sws_cs = sws_colorspace_for(av_colorspace, src_w, src_h);
unsafe {
let inv_table = sys::sws_getCoefficients(sws_cs);
let dst_table = sys::sws_getCoefficients(SWS_CS_ITU601);
// brightness 0, contrast/saturation unity (16.16 fixed point).
sys::sws_setColorspaceDetails(
ctx.as_mut_ptr(),
inv_table,
full_range as i32,
dst_table,
1, // RGB out is full range
0,
1 << 16,
1 << 16,
);
}
}
/// The frame's presentation timestamp (NOPTS when unset).
fn pts_of(f: Option<&ffmpeg::frame::Video>) -> Option<i64> {
f.and_then(|f| f.pts())
@@ -1569,6 +1754,43 @@ fn convert_rgba8_to_f32(data: &[u8], w: u32, h: u32, stride: usize) -> Vec<u8> {
out
}
/// Convert a 16-bit planar YUV 4:4:4 frame (YUV444P16LE, as emitted by the
/// high-bit-depth swscale fallback) to interleaved F32 RGBA little-endian
/// bytes. The YUV→RGB matrix and full/limited expansion run here instead of
/// inside swscale so the 16-bit code values survive intact: swscale only
/// converted the format (and resized), with identical source/destination
/// colorspace tables and full ranges on both sides, so no matrix and no
/// range recompression was applied. 10/12-bit sources arrive left-shifted
/// to 16-bit (code << 6 / code << 4) — exactly the code-value scale
/// [`oak_common::colormath::yuv444p16_to_rgb_f32`] expects.
fn convert_yuv444p16_to_rgba_f32(
out: &ffmpeg::frame::Video,
w: u32,
h: u32,
matrix: YuvMatrix,
full_range: bool,
) -> Vec<u8> {
let mut rgba = vec![0.0f32; (w as usize) * (h as usize) * 4];
oak_common::colormath::yuv444p16_to_rgb_f32(
out.data(0),
out.stride(0),
out.data(1),
out.stride(1),
out.data(2),
out.stride(2),
w as usize,
h as usize,
matrix,
full_range,
&mut rgba,
);
let mut bytes = vec![0u8; rgba.len() * 4];
for (dst, v) in bytes.chunks_exact_mut(4).zip(&rgba) {
dst.copy_from_slice(&v.to_le_bytes());
}
bytes
}
/// Build an allocated [`Frame`] (F32, RGBA) from raw pixel bytes.
///
/// # CPP-PARITY
@@ -1696,6 +1918,17 @@ fn probe_file(filename: &str, cancelled: Option<&CancelAtom>) -> Option<FootageD
vp.set_time_base(tb.0 as i32, tb.1 as i32);
vp.set_duration(stream.duration());
vp.set_premultiplied_alpha(false);
// Stream colorimetry (drives the input→working transform
// and lets the UI show what the footage is).
unsafe {
vp.set_color_primaries((*raw).color_primaries as i32);
vp.set_color_transfer((*raw).color_trc as i32);
vp.set_color_range(if (*raw).color_range as i32 == AVCOL_RANGE_JPEG {
oak_common::videoparams::ColorRange::Full
} else {
oak_common::videoparams::ColorRange::Limited
});
}
desc.push_stream(StreamEntry::Video(vp));
}
MediaType::Audio => {
@@ -1980,11 +2213,68 @@ impl Encoder for FFmpegEncoder {
}
}
/// Apply the export's delivery color metadata (H.273 code points, carried
/// in [`EncodingParams`]) to the video encoder before it opens. The values
/// are FFmpeg's own enum numbering, so each is re-interpreted into the
/// matching sys enum and handed to the typed setter; 0 (unset) fields keep
/// the codec default.
fn set_encoder_color_metadata(
encoder: &mut ffmpeg::codec::encoder::video::Video,
params: &EncodingParams,
) {
if params.color_primaries != 0 {
let v: sys::AVColorPrimaries =
unsafe { std::mem::transmute(params.color_primaries) };
encoder.set_color_primaries(v.into());
}
if params.color_trc != 0 {
let v: sys::AVColorTransferCharacteristic =
unsafe { std::mem::transmute(params.color_trc) };
encoder.set_color_transfer_characteristic(v.into());
}
if params.color_space != 0 {
let v: sys::AVColorSpace = unsafe { std::mem::transmute(params.color_space) };
encoder.set_colorspace(v.into());
}
if params.color_range != 0 {
let v: sys::AVColorRange = unsafe { std::mem::transmute(params.color_range) };
encoder.set_color_range(v.into());
}
}
/// Configure the encoder's RGB→YUV scaler so the produced YUV matches the
/// delivery tag written by [`set_encoder_color_metadata`] (otherwise swscale
/// defaults to BT.601/limited regardless of the tag, and players decode with
/// the wrong matrix). `params.color_space` is the `AVCOL_SPC_*` value; the
/// range follows `params.color_range` (1 = limited, 2 = full; 0 → limited).
fn apply_sws_output_colorspace(scaler: &mut scaling::Context, params: &EncodingParams) {
let sws_cs = match params.color_space {
1 => SWS_CS_ITU709, // AVCOL_SPC_BT709
9 | 10 => SWS_CS_BT2020, // AVCOL_SPC_BT2020_NCL / _CL
_ => SWS_CS_ITU601,
};
let full_range = params.color_range == 2; // AVCOL_RANGE_JPEG
unsafe {
let table = sys::sws_getCoefficients(sws_cs);
// src is RGB (always full range); dst is YUV with the delivery
// matrix and range.
sys::sws_setColorspaceDetails(
scaler.as_mut_ptr(),
table, // inv_table unused for an RGB source
1,
table,
full_range as i32,
0,
1 << 16,
1 << 16,
);
}
}
impl EncoderState {
/// Open the output file, create the streams and encoders and write the
/// header.
fn open(&mut self, params: &EncodingParams) -> crate::error::Result<()> {
if self.output.is_some() {
fn open(&mut self, params: &EncodingParams) -> crate::error::Result<()> { if self.output.is_some() {
return Ok(());
}
let filename = c_string_1024(&params.filename);
@@ -2055,6 +2345,12 @@ impl EncoderState {
.unwrap_or_else(|| default_pixel_format_for_codec(codec_id));
encoder.set_format(pix_fmt);
// Delivery color metadata (H.273 code points) → the container's
// colr atom / H.264-HEVC VUI, so the exported file declares its
// colorimetry instead of leaving players to guess. Only set when
// the export populated them (0 = leave the codec default).
set_encoder_color_metadata(&mut encoder, params);
let opened = encoder.open().map_err(|e| { eprintln!("DBG-AUD: audio open failed: {e:?}"); ffmpeg_err(e) })?;
stream.set_parameters(&opened);
// The encoder may adjust the time base during `open` (x264
@@ -2077,7 +2373,7 @@ impl EncoderState {
let frame_duration = (time_base.1 as i64 * i64::from(frame_rate.1))
/ (i64::from(time_base.0) * i64::from(frame_rate.0)).max(1);
let scaler = scaling::Context::get(
let mut scaler = scaling::Context::get(
Pixel::RGBA,
width,
height,
@@ -2087,6 +2383,9 @@ impl EncoderState {
scaling::Flags::BILINEAR,
)
.map_err(ffmpeg_err)?;
// Match the RGB→YUV conversion to the delivery color tag so
// players decode with the matrix/range the container declares.
apply_sws_output_colorspace(&mut scaler, params);
video = Some(VideoEncoderState {
encoder: opened,
@@ -2665,4 +2964,84 @@ mod tests {
let e = FFmpegEncoder::with_params(p);
assert!(e.open().is_err());
}
#[test]
fn pix_fmt_depth_and_yuv_detects_depth_and_kind() {
// YUV luma depths (on the YUVJ→regular-normalized format).
assert_eq!(pix_fmt_depth_and_yuv(Pixel::YUV420P), (8, true));
assert_eq!(pix_fmt_depth_and_yuv(Pixel::YUV420P10LE), (10, true));
assert_eq!(pix_fmt_depth_and_yuv(Pixel::YUV444P16LE), (16, true));
// RGB formats never take the high-bit-depth YUV fallback.
assert_eq!(pix_fmt_depth_and_yuv(Pixel::RGBA), (8, false));
assert_eq!(pix_fmt_depth_and_yuv(Pixel::RGB48LE), (16, false));
}
#[test]
fn yuv_matrix_mapping_is_strict() {
use oak_common::colormath::YuvMatrix;
assert_eq!(yuv_matrix_for(AVCOL_SPC_BT709, 1920, 1080), YuvMatrix::Bt709);
assert_eq!(yuv_matrix_for(AVCOL_SPC_BT470BG, 640, 480), YuvMatrix::Bt601);
assert_eq!(yuv_matrix_for(AVCOL_SPC_SMPTE170M, 1920, 1080), YuvMatrix::Bt601);
assert_eq!(yuv_matrix_for(AVCOL_SPC_BT2020_NCL, 1920, 1080), YuvMatrix::Bt2020);
// SMPTE 240M / BT.2020 CL / unknown tags are NOT mapped directly —
// they fall back by size (HD → BT.709, SD → BT.601).
assert_eq!(yuv_matrix_for(AVCOL_SPC_SMPTE240M, 1920, 1080), YuvMatrix::Bt709);
assert_eq!(yuv_matrix_for(AVCOL_SPC_BT2020_CL, 640, 480), YuvMatrix::Bt601);
assert_eq!(yuv_matrix_for(0, 1920, 1080), YuvMatrix::Bt709);
assert_eq!(yuv_matrix_for(0, 640, 480), YuvMatrix::Bt601);
assert_eq!(yuv_matrix_for(0, 1000, 600), YuvMatrix::Bt709); // h > 576
assert_eq!(yuv_matrix_for(0, 720, 576), YuvMatrix::Bt601); // 576 is SD
}
#[test]
fn sws_colorspace_mapping_keeps_legacy_behavior() {
assert_eq!(sws_colorspace_for(AVCOL_SPC_BT709, 0, 0), SWS_CS_ITU709);
assert_eq!(sws_colorspace_for(AVCOL_SPC_BT470BG, 0, 0), SWS_CS_ITU601);
assert_eq!(sws_colorspace_for(AVCOL_SPC_SMPTE170M, 0, 0), SWS_CS_ITU601);
assert_eq!(sws_colorspace_for(AVCOL_SPC_SMPTE240M, 0, 0), SWS_CS_SMPTE240M);
assert_eq!(sws_colorspace_for(AVCOL_SPC_BT2020_NCL, 0, 0), SWS_CS_BT2020);
assert_eq!(sws_colorspace_for(AVCOL_SPC_BT2020_CL, 0, 0), SWS_CS_BT2020);
assert_eq!(sws_colorspace_for(0, 1920, 1080), SWS_CS_ITU709);
assert_eq!(sws_colorspace_for(0, 640, 480), SWS_CS_ITU601);
}
/// A 2×1 YUV444P16LE frame: full-range white (Y=65535, neutral C) left,
/// full-range black (Y=0, neutral C) right. Each sample is a u16.
fn synthetic_yuv444p16_frame() -> ffmpeg::frame::Video {
let mut f = ffmpeg::frame::Video::new(Pixel::YUV444P16LE, 2, 1);
for plane in 0..3 {
let data = f.data_mut(plane);
for (px, v) in data.chunks_exact_mut(2).take(2).enumerate() {
let code = match plane {
0 => [65535u16, 0u16][px], // luma: white, black
_ => 32768u16, // chroma: neutral
};
v[..2].copy_from_slice(&code.to_le_bytes());
}
}
f
}
#[test]
fn yuv444p16_fallback_round_trips_full_range_white_and_black() {
let f = synthetic_yuv444p16_frame();
let bytes = convert_yuv444p16_to_rgba_f32(&f, 2, 1, YuvMatrix::Bt709, true);
let px = |i: usize| -> [f32; 4] {
let b = &bytes[i * 16..i * 16 + 16];
[
f32::from_le_bytes(b[0..4].try_into().unwrap()),
f32::from_le_bytes(b[4..8].try_into().unwrap()),
f32::from_le_bytes(b[8..12].try_into().unwrap()),
f32::from_le_bytes(b[12..16].try_into().unwrap()),
]
};
let white = px(0);
let black = px(1);
for c in 0..3 {
assert!((white[c] - 1.0).abs() < 1e-6, "white[{c}] = {}", white[c]);
assert!(black[c].abs() < 1e-6, "black[{c}] = {}", black[c]);
}
assert_eq!(white[3], 1.0);
assert_eq!(black[3], 1.0);
}
}
File diff suppressed because it is too large Load Diff
+655 -11
View File
@@ -29,6 +29,14 @@
//! `xprop`, then the `colormgr` CLI chain (colord); bytes from `xprop`
//! are materialized into the disk cache.
//!
//! Multi-monitor setups: [`system_display_icc_for`] resolves the profile of
//! one specific physical monitor (a [`MonitorRef`]) — macOS keys on the
//! CoreGraphics display ID, Windows on the `\\.\DISPLAYn` device name
//! (`CreateDCW` + `GetICMProfileW`), Linux on the RandR output name
//! (`xrandr --prop`). An unrecognized fingerprint falls back to
//! [`system_display_icc`] (the main display), so callers can simply try the
//! per-monitor path first.
//!
//! Every platform query is best-effort: any failure (headless session,
//! missing tooling, unparseable output) silently degrades to `None`.
@@ -42,15 +50,108 @@ use std::path::Path;
/// no colord, no X server). The `OAK_DISPLAY_ICC` environment variable
/// overrides everything (tests, debugging).
pub fn system_display_icc() -> Option<String> {
// The override wins outright — tests and debugging bypass the platform
// queries entirely. An empty value is treated as unset and falls through
// to the platform lookup.
if let Ok(path) = std::env::var("OAK_DISPLAY_ICC") {
if !path.is_empty() {
return Some(path);
}
env_override_icc().or_else(platform_display_icc)
}
/// The `OAK_DISPLAY_ICC` override path. The override wins outright — tests
/// and debugging bypass the platform queries entirely. An empty value is
/// treated as unset and falls through to the platform lookup.
fn env_override_icc() -> Option<String> {
match std::env::var("OAK_DISPLAY_ICC") {
Ok(path) if !path.is_empty() => Some(path),
_ => None,
}
platform_display_icc()
}
/// Identifies one physical monitor for a per-monitor ICC lookup (see
/// [`system_display_icc_for`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MonitorRef {
/// macOS: the `CGDirectDisplayID` of the display.
MacDisplay(u32),
/// Windows: the monitor device name (the `szDevice` of
/// `MONITORINFOEXW`, e.g. `\\.\DISPLAY1`).
WinDevice(String),
/// Linux/X11: the RandR output name (e.g. `eDP-1`).
X11Output(String),
}
/// The filesystem path of one specific monitor's ICC profile, ready for an
/// OCIO FileTransform. None when the platform gives no answer for that
/// monitor (unknown fingerprint, headless, no colord, no X server). The
/// `OAK_DISPLAY_ICC` environment variable overrides everything, exactly as
/// in [`system_display_icc`].
///
/// Best-effort like the main-display lookup: an unrecognized or unresolvable
/// [`MonitorRef`] degrades to [`system_display_icc`] (the main display),
/// never to a panic.
pub fn system_display_icc_for(monitor: &MonitorRef) -> Option<String> {
env_override_icc()
.or_else(|| platform_display_icc_for(monitor))
.or_else(platform_display_icc)
}
#[cfg(target_os = "macos")]
fn platform_display_icc_for(monitor: &MonitorRef) -> Option<String> {
match monitor {
MonitorRef::MacDisplay(id) => macos::display_icc_for(*id),
_ => None,
}
}
#[cfg(target_os = "windows")]
fn platform_display_icc_for(monitor: &MonitorRef) -> Option<String> {
match monitor {
MonitorRef::WinDevice(device) => windows::display_icc_for(device),
_ => None,
}
}
#[cfg(target_os = "linux")]
fn platform_display_icc_for(monitor: &MonitorRef) -> Option<String> {
match monitor {
MonitorRef::X11Output(name) => linux::display_icc_for_output(name),
_ => None,
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
fn platform_display_icc_for(_monitor: &MonitorRef) -> Option<String> {
None
}
/// Parse a monitor fingerprint (the strings
/// [`crate::oak_app::oakui::displaycolor`] records, `"mac:<id>"` /
/// `"win:<device>"` / `"x11:<output>"`) back into a [`MonitorRef`]. Any
/// malformed input yields `None`.
pub fn monitor_ref_from_fingerprint(fingerprint: &str) -> Option<MonitorRef> {
let (kind, value) = fingerprint.split_once(':')?;
match kind {
"mac" => {
let id: u32 = value.parse().ok()?;
Some(MonitorRef::MacDisplay(id))
}
"win" if !value.is_empty() => Some(MonitorRef::WinDevice(value.to_string())),
"x11" if !value.is_empty() => Some(MonitorRef::X11Output(value.to_string())),
_ => None,
}
}
/// The monitor fingerprint (`win:<device>`) for an HMONITOR value — the
/// `display_id` of a gpui window on Windows. None when the device name
/// cannot be resolved (invalid handle, call failure).
#[cfg(target_os = "windows")]
pub fn windows_monitor_fingerprint(hmonitor: u64) -> Option<String> {
windows::monitor_device_name(hmonitor as usize).map(|name| format!("win:{name}"))
}
/// The monitor fingerprint (`x11:<RandR output>`) for the display covering
/// the given point in device pixels (global X11 screen coordinates). None
/// when no RandR monitor list is available (headless, no `xrandr`) or none
/// covers the point.
#[cfg(target_os = "linux")]
pub fn x11_monitor_fingerprint_at(x: f64, y: f64) -> Option<String> {
linux::x11_monitor_fingerprint_at(x, y)
}
#[cfg(target_os = "macos")]
@@ -63,6 +164,15 @@ fn platform_display_icc() -> Option<String> {
windows::display_icc()
}
/// True when Windows 11 Auto Color Management is active: the OS maps
/// (sRGB-declared) app output to the display, so the app must not apply
/// the display ICC itself. Windows 10 / older always returns false.
/// `OAK_WINDOWS_COLOR=self|os` overrides the detection (debugging).
#[cfg(target_os = "windows")]
pub fn windows_acm_active() -> bool {
windows::acm_active()
}
#[cfg(target_os = "linux")]
fn platform_display_icc() -> Option<String> {
linux::display_icc()
@@ -134,6 +244,123 @@ fn parse_xprop_icc_hex(output: &str) -> Option<Vec<u8>> {
}
}
/// One monitor from `xrandr --listmonitors`: geometry in device pixels
/// (global X11 screen coordinates) plus the RandR output names it spans.
#[cfg(any(target_os = "linux", test))]
#[derive(Clone, Debug, PartialEq, Eq)]
struct RandRMonitor {
x: i32,
y: i32,
width: i32,
height: i32,
outputs: Vec<String>,
}
/// Parse `xrandr --listmonitors` output.
///
/// Each monitor line is ` N: [flags]NAME W/MMWxH/MMH+X+Y OUTPUT...`: the
/// first token is the flags+monitor-name (flags like `+*` mark primary and
/// current), the second the geometry (physical sizes in mm are ignored),
/// the rest the RandR outputs (a spanning mode lists several). X11 screen
/// coordinates may be negative, printed as `-X-Y`. The `Monitors:` header
/// and any malformed line are skipped.
#[cfg(any(target_os = "linux", test))]
fn parse_xrandr_monitors(output: &str) -> Vec<RandRMonitor> {
fn parse_geometry(token: &str) -> Option<(i32, i32, i32, i32)> {
// `W/MMWxH/MMH[+-]X[+-]Y` — `[+-]` splits off the coordinates but
// drops their signs, which the leading `+`/`-` in the body carries.
let pieces: Vec<&str> = token.split(['+', '-']).collect();
if pieces.len() < 3 {
return None;
}
let dims = pieces[0].split_once('x')?;
let width: i32 = dims.0.split('/').next()?.parse().ok()?;
let height: i32 = dims.1.split('/').next()?.parse().ok()?;
let mut x: i32 = pieces[1].trim().parse().ok()?;
let mut y: i32 = pieces[2].trim().parse().ok()?;
let signs: Vec<u8> = token[pieces[0].len()..]
.bytes()
.filter(|b| matches!(b, b'+' | b'-'))
.collect();
if signs.first() == Some(&b'-') {
x = -x;
}
if signs.get(1) == Some(&b'-') {
y = -y;
}
Some((x, y, width, height))
}
let mut monitors = Vec::new();
for line in output.lines() {
let line = line.trim_start();
// Header line: `Monitors: 2`.
if line.starts_with("Monitors") {
continue;
}
// Monitor line; drop the leading index (`N:`).
let Some((_, rest)) = line.split_once(':') else {
continue;
};
let mut tokens = rest.split_whitespace();
// First token: `[+*]NAME` (ignored), second: the geometry.
if tokens.next().is_none() {
continue;
}
let Some(geometry) = tokens.next() else {
continue;
};
let Some((x, y, width, height)) = parse_geometry(geometry) else {
continue;
};
let outputs: Vec<String> = tokens.map(str::to_string).collect();
if outputs.is_empty() {
continue;
}
monitors.push(RandRMonitor {
x,
y,
width,
height,
outputs,
});
}
monitors
}
/// Parse the `_ICC_PROFILE` property of one RandR output from `xrandr
/// --prop` output.
///
/// The output is a sequence of sections, one per output, each starting at
/// an unindented line whose first token is the output name. Properties
/// within a section are indented `KEY(BITS)\t= VALUE` lines; the ICC bytes
/// are the `_ICC_PROFILE` value (`0xHH, 0xHH, ...` — the same byte list
/// `xprop` prints, so [`parse_xprop_icc_hex`] re-parses it). Returns `None`
/// when the output is absent, carries no `_ICC_PROFILE`, or the bytes are
/// malformed.
#[cfg(any(target_os = "linux", test))]
fn parse_output_icc_prop(output: &str, wanted: &str) -> Option<Vec<u8>> {
let mut in_section = false;
for line in output.lines() {
if line.starts_with(char::is_whitespace) {
if !in_section {
continue;
}
let (key, _) = line.trim().split_once('=')?;
if key.starts_with("_ICC_PROFILE") {
if let Some(bytes) = parse_xprop_icc_hex(line.trim()) {
return Some(bytes);
}
}
} else {
// Unindented line: a new output section (or `Screen 0: ...`).
let name = line.split_whitespace().next().unwrap_or("");
in_section = name == wanted;
}
}
None
}
/// macOS: CoreGraphics main-display color space → ICC bytes → cache file.
#[cfg(target_os = "macos")]
mod macos {
@@ -164,7 +391,12 @@ mod macos {
}
pub(super) fn display_icc() -> Option<String> {
let display = unsafe { CGMainDisplayID() };
display_icc_for(unsafe { CGMainDisplayID() })
}
/// ICC bytes for one `CGDirectDisplayID` (any display, not just the
/// main one) → cache file.
pub(super) fn display_icc_for(display: u32) -> Option<String> {
let space = unsafe { CGDisplayCopyColorSpace(display) };
if space.is_null() {
return None;
@@ -203,6 +435,9 @@ mod windows {
/// with `ReleaseDC`.
fn GetDC(hwnd: *const c_void) -> *mut c_void;
fn ReleaseDC(hwnd: *const c_void, hdc: *mut c_void) -> i32;
/// `BOOL GetMonitorInfoW(HMONITOR, LPMONITORINFOEXW)` — fills the
/// structure, including the `szDevice` device name.
fn GetMonitorInfoW(monitor: *const c_void, info: *mut c_void) -> i32;
}
#[link(name = "gdi32")]
@@ -211,6 +446,171 @@ mod windows {
/// required `WCHAR` count (including the NUL), then the profile file
/// path is written into the caller's buffer.
fn GetICMProfileW(hdc: *mut c_void, name_len: *mut u32, name: *mut u16) -> i32;
/// `HDC CreateDCW(LPCWSTR pszDriver, LPCWSTR pszDevice, LPCWSTR
/// pszPort, const DEVMODEW *pdm)` — a DC for one specific monitor
/// device (passing the device name as both driver and device);
/// released with `DeleteDC`.
fn CreateDCW(
driver: *const u16,
device: *const u16,
port: *const c_void,
dev_mode: *const c_void,
) -> *mut c_void;
/// `BOOL DeleteDC(HDC)`.
fn DeleteDC(hdc: *mut c_void) -> i32;
}
#[link(name = "ntdll")]
extern "system" {
/// The OS version record; `dwBuildNumber` identifies the Windows
/// release (22000+ = Windows 11).
fn RtlGetVersion(info: *mut OsVersionInfo) -> i32;
}
#[link(name = "advapi32")]
extern "system" {
fn RegOpenKeyExW(
key: *const c_void,
sub_key: *const u16,
options: u32,
desired: u32,
result: *mut *mut c_void,
) -> i32;
fn RegQueryValueExW(
key: *mut c_void,
value_name: *const u16,
reserved: *const u32,
value_type: *mut u32,
data: *mut u8,
data_len: *mut u32,
) -> i32;
fn RegCloseKey(key: *mut c_void) -> i32;
}
/// `OSVERSIONINFOW` (the fields `RtlGetVersion` fills).
#[repr(C)]
struct OsVersionInfo {
length: u32,
major_version: u32,
minor_version: u32,
build_number: u32,
platform_id: u32,
csd_version: [u16; 128],
}
/// `RECT` (windef.h).
#[repr(C)]
struct Rect {
left: i32,
top: i32,
right: i32,
bottom: i32,
}
/// `MONITORINFOEXW` (winuser.h) — `MONITORINFO` plus the `szDevice`
/// device name (`\\.\DISPLAY1`, ...).
#[repr(C)]
struct MonitorInfoExW {
cb_size: u32,
rc_monitor: Rect,
rc_work: Rect,
dw_flags: u32,
sz_device: [u16; 32],
}
/// `HKEY_CURRENT_USER` (winreg.h).
const HKEY_CURRENT_USER: *const c_void = 0x8000_0001usize as *const c_void;
/// `KEY_READ` (winreg.h).
const KEY_READ: u32 = 0x2_0019;
/// `REG_DWORD` (winnt.h).
const REG_DWORD: u32 = 4;
/// `ERROR_SUCCESS` (winerror.h).
const ERROR_SUCCESS: i32 = 0;
/// Windows 11 (any release).
const BUILD_WINDOWS_11: u32 = 22000;
/// Windows 11 24H2 — Auto Color Management is on by default there.
const BUILD_WIN11_24H2: u32 = 26100;
fn wide_nul(s: &str) -> Vec<u16> {
let mut wide: Vec<u16> = s.encode_utf16().collect();
wide.push(0);
wide
}
/// The OS build number (`RtlGetVersion`), or 0 when unavailable.
fn windows_build() -> u32 {
let mut info = OsVersionInfo {
length: std::mem::size_of::<OsVersionInfo>() as u32,
major_version: 0,
minor_version: 0,
build_number: 0,
platform_id: 0,
csd_version: [0; 128],
};
let status = unsafe { RtlGetVersion(&mut info) };
if status == 0 {
info.build_number
} else {
0
}
}
/// The `EnableAutoColorManagement` DWORD under
/// `HKCU\Software\Microsoft\Windows\CurrentVersion\VideoSettings`:
/// `Some(flag)` when the value exists, `None` when the key or value is
/// absent (or unreadable).
fn acm_registry_flag() -> Option<bool> {
let sub_key = wide_nul(r"Software\Microsoft\Windows\CurrentVersion\VideoSettings");
let value_name = wide_nul("EnableAutoColorManagement");
let mut key: *mut c_void = std::ptr::null_mut();
let status = unsafe {
RegOpenKeyExW(HKEY_CURRENT_USER, sub_key.as_ptr(), 0, KEY_READ, &mut key)
};
if status != ERROR_SUCCESS {
return None;
}
let mut data = [0u8; 4];
let mut len = data.len() as u32;
let mut kind = 0u32;
let status = unsafe {
RegQueryValueExW(
key,
value_name.as_ptr(),
std::ptr::null(),
&mut kind,
data.as_mut_ptr(),
&mut len,
)
};
unsafe { RegCloseKey(key) };
if status != ERROR_SUCCESS || kind != REG_DWORD || len != 4 {
return None;
}
Some(u32::from_le_bytes(data) != 0)
}
/// True when Windows 11 Auto Color Management maps app output for us:
/// the app must then deliver plain sRGB and NOT apply the display ICC
/// itself (double correction). The user toggles ACM in
/// Settings → Display → HDR / "Automatically manage color for apps";
/// 24H2+ defaults it on, earlier Windows 11 off. The
/// `OAK_WINDOWS_COLOR` override (`self` / `os`) wins outright.
pub(super) fn acm_active() -> bool {
match std::env::var("OAK_WINDOWS_COLOR") {
Ok(v) if v.eq_ignore_ascii_case("os") => return true,
Ok(v) if v.eq_ignore_ascii_case("self") => return false,
_ => {}
}
let build = windows_build();
if build < BUILD_WINDOWS_11 {
// Windows 10 and earlier: no per-app OS color management.
return false;
}
match acm_registry_flag() {
Some(flag) => flag,
// No explicit user choice: default since 24H2.
None => build >= BUILD_WIN11_24H2,
}
}
pub(super) fn display_icc() -> Option<String> {
@@ -218,12 +618,73 @@ mod windows {
if hdc.is_null() {
return None;
}
let profile = icm_profile_for_hdc(hdc);
unsafe { ReleaseDC(std::ptr::null(), hdc) };
profile
}
/// The ICM profile file path of one specific monitor device (e.g.
/// `\\.\DISPLAY1`), via a per-monitor DC.
pub(super) fn display_icc_for(device: &str) -> Option<String> {
let name = wide_nul(device);
let hdc = unsafe {
CreateDCW(name.as_ptr(), name.as_ptr(), std::ptr::null(), std::ptr::null())
};
if hdc.is_null() {
return None;
}
let profile = icm_profile_for_hdc(hdc);
unsafe { DeleteDC(hdc) };
profile
}
/// The device name (`\\.\DISPLAY1`, ...) of the monitor owning
/// `hmonitor` (an HMONITOR value), or `None` when the handle is invalid
/// or the OS call fails.
pub(super) fn monitor_device_name(hmonitor: usize) -> Option<String> {
let mut info = MonitorInfoExW {
cb_size: std::mem::size_of::<MonitorInfoExW>() as u32,
rc_monitor: Rect {
left: 0,
top: 0,
right: 0,
bottom: 0,
},
rc_work: Rect {
left: 0,
top: 0,
right: 0,
bottom: 0,
},
dw_flags: 0,
sz_device: [0; 32],
};
let ok = unsafe {
GetMonitorInfoW(hmonitor as *const c_void, &mut info as *mut _ as *mut c_void)
};
if ok == 0 {
return None;
}
let end = info
.sz_device
.iter()
.position(|&u| u == 0)
.unwrap_or(info.sz_device.len());
let name = String::from_utf16_lossy(&info.sz_device[..end]);
if name.is_empty() {
None
} else {
Some(name)
}
}
/// The ICM profile file path of a device context, or `None` when the
/// profile is unavailable or not a file.
fn icm_profile_for_hdc(hdc: *mut c_void) -> Option<String> {
// Stage 1: required buffer size, in `WCHAR`s including the NUL.
let mut len: u32 = 0;
let ok = unsafe { GetICMProfileW(hdc, &mut len, std::ptr::null_mut()) };
if ok == 0 || len == 0 {
unsafe { ReleaseDC(std::ptr::null(), hdc) };
return None;
}
@@ -231,7 +692,6 @@ mod windows {
// that report a length without the terminator.
let mut buf = vec![0u16; len as usize + 1];
let ok = unsafe { GetICMProfileW(hdc, &mut len, buf.as_mut_ptr()) };
unsafe { ReleaseDC(std::ptr::null(), hdc) };
if ok == 0 {
return None;
}
@@ -251,6 +711,7 @@ mod windows {
mod linux {
use std::io::Read;
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::thread;
use std::time::{Duration, Instant};
@@ -279,6 +740,70 @@ mod linux {
colord_icc_path()
}
/// ICC bytes of one RandR output via `xrandr --prop` → cache file.
/// None when the output is absent, has no `_ICC_PROFILE`, or `xrandr`
/// cannot run.
pub(super) fn display_icc_for_output(output: &str) -> Option<String> {
let mut cmd = Command::new("xrandr");
cmd.args(["--prop"]);
let out = run_capture(&mut cmd, Duration::from_secs(2))?;
let text = String::from_utf8_lossy(&out);
let bytes = parse_output_icc_prop(&text, output)?;
write_icc_cache(&bytes)
}
/// RandR monitor-list snapshot (`xrandr --listmonitors`), cached briefly
/// so the poll loop does not spawn a process every tick. A failed fetch
/// is cached as empty and retried on the next expiry.
static MONITORS_CACHE: Mutex<Option<(Instant, Vec<RandRMonitor>)>> = Mutex::new(None);
/// How long a RandR monitor snapshot stays valid.
const MONITORS_TTL: Duration = Duration::from_secs(30);
/// The cached RandR monitor list, re-fetched at most once per TTL.
fn monitors_snapshot() -> Vec<RandRMonitor> {
let mut guard = MONITORS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some((stamp, monitors)) = guard.as_ref() {
if stamp.elapsed() < MONITORS_TTL {
return monitors.clone();
}
}
let monitors = fetch_monitors();
*guard = Some((Instant::now(), monitors.clone()));
monitors
}
/// Run `xrandr --listmonitors` and parse it; empty on any failure.
fn fetch_monitors() -> Vec<RandRMonitor> {
let mut cmd = Command::new("xrandr");
cmd.args(["--listmonitors"]);
match run_capture(&mut cmd, Duration::from_secs(2)) {
Some(out) => parse_xrandr_monitors(&String::from_utf8_lossy(&out)),
None => Vec::new(),
}
}
/// The fingerprint (`x11:<RandR output>`) of the monitor covering the
/// given point in device pixels (global X11 screen coordinates), or
/// `None` when no monitor matches (headless, no `xrandr`, or the point
/// lies outside every listed geometry).
pub(super) fn x11_monitor_fingerprint_at(x: f64, y: f64) -> Option<String> {
let monitors = monitors_snapshot();
let px = x.round() as i32;
let py = y.round() as i32;
for monitor in &monitors {
if px >= monitor.x
&& px < monitor.x + monitor.width
&& py >= monitor.y
&& py < monitor.y + monitor.height
{
if let Some(output) = monitor.outputs.first() {
return Some(format!("x11:{output}"));
}
}
}
None
}
/// Run `cmd`, returning its captured stdout.
///
/// Returns `None` when the command cannot be started, exits non-zero, is
@@ -494,4 +1019,123 @@ mod tests {
std::env::remove_var("OAK_CONFIG_DIR");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn parse_xrandr_monitors_two_screens() {
let out = "\
Monitors: 2
0: +*eDP-1 1920/344x1080/194+0+0 eDP-1
1: +HDMI-1 3840/600x2160/340+1920+0 HDMI-1
";
let monitors = parse_xrandr_monitors(out);
assert_eq!(monitors.len(), 2);
assert_eq!(monitors[0].x, 0);
assert_eq!(monitors[0].y, 0);
assert_eq!(monitors[0].width, 1920);
assert_eq!(monitors[0].height, 1080);
assert_eq!(monitors[0].outputs, ["eDP-1"]);
assert_eq!(monitors[1].x, 1920);
assert_eq!(monitors[1].y, 0);
assert_eq!(monitors[1].width, 3840);
assert_eq!(monitors[1].height, 2160);
assert_eq!(monitors[1].outputs, ["HDMI-1"]);
}
#[test]
fn parse_xrandr_monitors_negative_coords_and_spanning() {
// A monitor left of the primary has a negative X11 coordinate,
// printed with a `-` prefix.
let out = "\
Monitors: 2
0: +*DP-1 1080/293x1920/509+0+0 DP-1
1: +DP-2 1920/509x1080/293-1080+0 DP-2
";
let monitors = parse_xrandr_monitors(out);
assert_eq!(monitors[1].x, -1080);
assert_eq!(monitors[1].y, 0);
// A spanning mode lists several outputs on one monitor.
let out = "\
Monitors: 1
0: +*eDP-1 3840/700x1080/194+0+0 eDP-1 HDMI-1
";
let monitors = parse_xrandr_monitors(out);
assert_eq!(monitors.len(), 1);
assert_eq!(monitors[0].outputs, ["eDP-1", "HDMI-1"]);
}
#[test]
fn parse_xrandr_monitors_malformed() {
assert_eq!(parse_xrandr_monitors(""), Vec::<RandRMonitor>::new());
assert_eq!(parse_xrandr_monitors("Monitors: 0\n"), Vec::<RandRMonitor>::new());
// No `:` separator, a missing geometry, and a geometry with no
// outputs are all skipped without panicking.
let out = "\
Monitors: 2
garbage line
0: +*eDP-1 eDP-1
1: +HDMI-1 1920/344x1080/194+1920+0
2: +VGA-1 1024/200x768/150+0+0 VGA-1
";
let monitors = parse_xrandr_monitors(out);
assert_eq!(monitors.len(), 1);
assert_eq!(monitors[0].outputs, ["VGA-1"]);
}
#[test]
fn parse_output_icc_prop_hit_and_miss() {
let out = "\
Screen 0: minimum 320 x 200, current 3840 x 1080, maximum 16384 x 16384
eDP-1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 344mm x 194mm
\t_ICC_PROFILE(8)\t= 0x3c, 0x6f, 0x6f
\tEDID(0)\t= 0x00, 0xff, 0xff, 0xff
HDMI-1 connected 1920x1080+1920+0 (normal left inverted right x axis y axis) 600mm x 340mm
\t_ICC_PROFILE(8)\t= 0x41, 0x42
";
assert_eq!(
parse_output_icc_prop(out, "eDP-1"),
Some(vec![0x3c, 0x6f, 0x6f])
);
assert_eq!(parse_output_icc_prop(out, "HDMI-1"), Some(vec![0x41, 0x42]));
// An output not present in the list.
assert_eq!(parse_output_icc_prop(out, "VGA-1"), None);
}
#[test]
fn parse_output_icc_prop_without_profile() {
// An output whose section carries no `_ICC_PROFILE` yields None, and
// so does a malformed property value.
let out = "\
DP-1 connected 1920x1080+0+0 (normal left inverted right x axis y axis) 344mm x 194mm
\tEDID(0)\t= 0x00, 0xff
";
assert_eq!(parse_output_icc_prop(out, "DP-1"), None);
let out = "\
DP-1 connected 1920x1080+0+0 (normal left inverted right x axis y axis) 344mm x 194mm
\t_ICC_PROFILE(8)\t= 0x
";
assert_eq!(parse_output_icc_prop(out, "DP-1"), None);
}
#[test]
fn monitor_ref_fingerprint_roundtrip() {
assert_eq!(
monitor_ref_from_fingerprint("mac:4294967295"),
Some(MonitorRef::MacDisplay(u32::MAX))
);
assert_eq!(
monitor_ref_from_fingerprint(r"win:\\.\DISPLAY1"),
Some(MonitorRef::WinDevice(r"\\.\DISPLAY1".to_string()))
);
assert_eq!(
monitor_ref_from_fingerprint("x11:eDP-1"),
Some(MonitorRef::X11Output("eDP-1".to_string()))
);
// Malformed input.
assert_eq!(monitor_ref_from_fingerprint(""), None);
assert_eq!(monitor_ref_from_fingerprint("nope"), None);
assert_eq!(monitor_ref_from_fingerprint("mac:abc"), None);
assert_eq!(monitor_ref_from_fingerprint("win:"), None);
assert_eq!(monitor_ref_from_fingerprint("x11:"), None);
assert_eq!(monitor_ref_from_fingerprint("os:whatever"), None);
}
}
+1
View File
@@ -22,6 +22,7 @@
#![warn(missing_docs)]
pub mod cancelatom;
pub mod colormath;
pub mod colortransform;
pub mod commandlineparser;
pub mod configstore;
+8 -4
View File
@@ -54,10 +54,14 @@ fn main() {
);
println!("cargo:rerun-if-env-changed=FFMPEG_DIR");
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
println!(
"cargo:rerun-if-changed={}",
manifest.join("../..").join(".env").display()
);
// Emitting rerun-if-changed for a MISSING file makes cargo re-run this
// build script on every single build (the "missing" state never
// stabilizes into a fingerprint), cascading rebuilds through every
// dependent crate. Only track the file once it actually exists.
let dotenv = manifest.join("../..").join(".env");
if dotenv.exists() {
println!("cargo:rerun-if-changed={}", dotenv.display());
}
let pkg_path = env_or_dotenv("PKG_CONFIG_PATH").unwrap_or_default();
let output = Command::new("pkg-config")
+40
View File
@@ -83,6 +83,15 @@ pub const SETTING_ROOT: &str = "root";
pub const SETTING_CACHE_LOCATION: &str = "cachesetting";
/// Setting key for the custom cache path (C++ `k_cache_path_key`).
pub const SETTING_CACHE_PATH: &str = "customcachepath";
/// Setting key: the pipeline working colorspace ("acescg" | "srgb_legacy").
/// Absent = the default (ACEScg).
pub const SETTING_WORKING_COLOR_SPACE: &str = "workingcolorspace";
/// Setting key: the output/delivery gamut ("srgb" | "displayp3" | "bt2020").
/// Absent = sRGB.
pub const SETTING_OUTPUT_GAMUT: &str = "outputgamut";
/// Setting key: the output/delivery transfer ("srgb" | "gamma22" | "pq" |
/// "hlg"). Absent = sRGB.
pub const SETTING_OUTPUT_TRANSFER: &str = "outputtransfer";
impl Project {
/// New empty project (no root folder until [`Project::initialize`]).
@@ -129,6 +138,37 @@ impl Project {
Ok(())
}
/// The pipeline working colorspace (project property; ACEScg when the
/// setting is absent).
pub fn working_color_space(&self) -> oak_common::colormath::WorkingColorSpace {
oak_common::colormath::WorkingColorSpace::from_setting(
self.settings.get(SETTING_WORKING_COLOR_SPACE).map(String::as_str).unwrap_or(""),
)
}
/// The output/delivery colorspace (project property; sRGB when the
/// settings are absent).
pub fn output_color_spec(&self) -> oak_common::colormath::OutputColorSpec {
oak_common::colormath::OutputColorSpec::from_settings(
self.settings.get(SETTING_OUTPUT_GAMUT).map(String::as_str).unwrap_or(""),
self.settings.get(SETTING_OUTPUT_TRANSFER).map(String::as_str).unwrap_or(""),
)
}
/// Set the pipeline working colorspace property.
pub fn set_working_color_space(&mut self, space: oak_common::colormath::WorkingColorSpace) {
self.settings
.insert(SETTING_WORKING_COLOR_SPACE.to_string(), space.as_setting().to_string());
}
/// Set the output/delivery colorspace properties.
pub fn set_output_color_spec(&mut self, spec: oak_common::colormath::OutputColorSpec) {
self.settings
.insert(SETTING_OUTPUT_GAMUT.to_string(), spec.gamut.as_setting().to_string());
self.settings
.insert(SETTING_OUTPUT_TRANSFER.to_string(), spec.transfer.as_setting().to_string());
}
/// Deep-copy the whole project for background render isolation
/// (replaces oakrender's C++ ProjectCopier: the copy happens here,
/// inside the module that owns the data — see M-series note on
+5 -1
View File
@@ -82,10 +82,14 @@ impl ClipInstance {
let props = desc.props.clone();
let name = desc.name.clone();
if name != "Output" {
// The working colorspace follows the pipeline setting (project
// property): ACEScg in the default pipeline, sRGB in the legacy
// pass-through mode — plugins must be told the true space of the
// pixels they receive.
props.set_one(
crate::host::PROP_CLIP_COLOURSPACE,
crate::property::Value::String(
std::ffi::CString::new(crate::host::WORKING_COLOURSPACE).unwrap(),
std::ffi::CString::new(oak_render::color::pipeline_working_ofx_name()).unwrap(),
),
);
}
+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
}
@@ -34,6 +34,15 @@ fn test_clip_path() -> std::path::PathBuf {
#[test]
fn footage_decode_renders_known_content() {
// This test verifies DECODE correctness (known red/blue content), not
// the color pipeline. Pin the working space to the legacy sRGB
// pass-through so the decoded pixels stay display-referred and the
// assertions below hold regardless of the ACEScg default.
oak_render::color::set_pipeline_color_settings(
oak_common::colormath::WorkingColorSpace::SrgbLegacy,
oak_common::colormath::OutputColorSpec::default(),
);
// Program-generated media: 10 frames at 10fps, 64x64, known pattern
// (left half red, right half blue on frame 0).
let path = test_clip_path();
+14
View File
@@ -43,10 +43,23 @@ fn clip_path(tag: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("oakrender_graph_{tag}_{}.mp4", std::process::id()))
}
/// These tests verify graph/decode/composite MECHANICS (stacking, scaling,
/// effects), not the color pipeline. Pin the working space to the legacy
/// sRGB pass-through so the decoded pixels stay display-referred and the
/// pixel-value assertions hold regardless of the ACEScg default. All tests
/// in this binary set the same value, so the shared global is race-free.
fn pin_legacy_working_space() {
oak_render::color::set_pipeline_color_settings(
oak_common::colormath::WorkingColorSpace::SrgbLegacy,
oak_common::colormath::OutputColorSpec::default(),
);
}
/// One sequence + one video track list with one track per clip
/// `(filename, [in, out))`. The LAST entry's track composites on top
/// (NLE stacking: the highest-numbered track is topmost).
fn build_project(clips: &[(&str, Rational, Rational)]) -> (Arc<Mutex<Project>>, NodeId) {
pin_legacy_working_space();
let project = Project::new();
let seq;
{
@@ -269,6 +282,7 @@ fn build_effect_project(
clip: (&str, Rational, Rational),
insert_effect: impl FnOnce(&mut Project, NodeId, NodeId) -> NodeId,
) -> (Arc<Mutex<Project>>, NodeId) {
pin_legacy_working_space();
let project = Project::new();
let seq;
{
+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);
+1
View File
@@ -43,6 +43,7 @@ libc = "0.2"
oak-core = { path = "../oak-core" }
oak-node = { path = "../oak-node" }
oak-plugin = { path = "../oak-plugin" }
oak-common = { path = "../oak-common" }
# M14 R2 / M15 S1: oak-worker is a PURE module-crate consumer — the
# worker runtime (src/worker.rs) lives in this binary and calls the oak*
+30 -1
View File
@@ -576,6 +576,10 @@ impl WorkerSession {
};
match oak_node::serializer::load_with_id_map(&content) {
Ok((project, id_map)) => {
let (working, output_spec) = {
let guard = project.lock().unwrap_or_else(|e| e.into_inner());
(guard.working_color_space(), guard.output_color_spec())
};
let project_uuid = project
.lock()
.unwrap_or_else(|e| e.into_inner())
@@ -588,6 +592,10 @@ impl WorkerSession {
id_map,
project_copy: 0,
});
// The project's color pipeline properties drive this
// process's input/output transforms (the oakrender
// process global read by eval + the output node).
oak_render::color::set_pipeline_color_settings(working, output_spec);
// A fresh graph snapshot can change what any viewer
// identity renders; cached pixels from the previous
// graph must not be served (M16 S2 frame cache).
@@ -1098,8 +1106,12 @@ impl WorkerSession {
// BGRA8: the F32 pipeline frame comes from the cache or the
// session scratch, then converts into the slot (the end-of-pipe
// format convert is not an extra frame copy, design §3.1).
match &cached {
// Before quantization the output node runs: working space
// (ACEScg) → the project's output colorspace, so the 8-bit
// pixels carry gamma-encoded display values, not linear light.
match cached.as_mut() {
Some(c) => {
apply_output_node(c, (w * h) as usize);
convert_f32_rgba_to_bgra8(&c[..f32_need], &mut dst[..dst_need]);
}
None => {
@@ -1116,6 +1128,7 @@ impl WorkerSession {
)?;
self.frame_cache
.insert(key, self.f32_scratch[..f32_need].to_vec());
apply_output_node(&mut self.f32_scratch, (w * h) as usize);
convert_f32_rgba_to_bgra8(&self.f32_scratch[..f32_need], &mut dst[..dst_need]);
}
}
@@ -1313,6 +1326,22 @@ fn convert_f32_rgba_to_bgra8(src: &[u8], dst: &mut [u8]) {
}
}
/// The output node for the BGRA8 delivery path: convert the first
/// `pixels` pixels of an F32 RGBA byte buffer from the pipeline working
/// space to the project's output colorspace (in place), so the 8-bit
/// quantization encodes display-referred values instead of linear light.
/// A no-op in the legacy sRGB working space (content already is
/// display-referred sRGB).
fn apply_output_node(bytes: &mut [u8], pixels: usize) {
if oak_render::color::pipeline_working_space()
== oak_common::colormath::WorkingColorSpace::SrgbLegacy
{
return;
}
let spec = oak_render::color::pipeline_output_spec();
oak_common::colormath::acescg_to_output_bytes(bytes, pixels, spec);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
@@ -420,12 +420,17 @@ fn montage_effects_render_through_the_worker() {
"50% opacity halves the alpha ({} vs 128)",
d[3]
);
// 50% opacity reduces the color. The exact ratio depends on the color
// pipeline (gamma-encoded vs linear working space), so assert the
// pipeline-agnostic property: dimmed is darker than plain, but not
// black (the opacity effect actually changed the pixel).
assert!(
(d[0] as i32 - p[0] as i32 / 4).abs() <= 6,
"50% opacity quarters the color ({} vs {}/4)",
d[0] < p[0],
"50% opacity darkens the color ({} vs {})",
d[0],
p[0]
);
assert!(d[0] > 0, "dimmed pixel is not black ({})", d[0]);
}
/// Submit an empty-montage audio range pull through the dispatcher
+390
View File
@@ -0,0 +1,390 @@
# 全链路 ACEScg + F32 色彩管线改造计划
> **⚠️ 已被实际架构取代(2026-08-28)**:本文档是 gpui 中心视角的旧方案
> (要求三套渲染器各自实现离屏 F32 场景 + 输出节点 pass)。实际实现采用
> **引擎侧色彩架构**:oak 引擎完成全部色彩工作(解码→ACEScg F32 工作空间
> →输出节点转项目输出色域),gpui 渲染器只做直通 blit + 窗口内容色域声明
> Wayland color-management-v1 / macOS layer.colorspace / Windows
> SetColorSpace1),UI 保持 sRGB 不进 ACEScg。实际色彩数学在
> `crates/oak-common/src/colormath.rs`,显示策略在
> `crates/oak-app/src/oakui/displaycolor.rs`,内容色域声明 API 在 gpui 的
> `WindowContentColorspace`/`set_content_colorspace`。本文档仅作历史参考,
> 以代码为准。
> 面向实现者的任务书。配套审计:2026-08-27 色彩管理链路审计(会话记录,
> 结论摘要见本文"现状"一节)。本计划只描述改造方案与工作项,**不包含任何
> 已执行的代码修改**。
>
> 仓库边界说明:本仓库(oak-gpui)包含 gpui 核心、三个平台渲染器
> `gpui_macos` / `gpui_windows` / `gpui_wgpu` / `gpui_linux`)与
> `oak_bridge` 视频桥。媒体解码、节点图求值在 oak 主仓库(引擎),
> 本计划为其定义**色彩契约**,并标注哪些工作项需要主仓库配合。
>
> 原则:
> 1. 输入节点把素材转换为 ACEScg + F32
> 2. 输出节点转换回目标色域,目标色域由项目设置指定,未指定则为 sRGB;
> 3. 中间全过程使用 ACEScg + F32
> 4. 三个平台(macOS / Windows / Linux-Wayland)都正确处理颜色:
> 应用只输出**色度学上明确的颜色空间**(如 sRGB),把"到显示器"的最后
> 一次映射交给操作系统,绝不自行施加显示器 ICC 变换,杜绝二次映射。
---
## 1. 目标与动机
Oak 是视频编辑器。现状管线是"全链路 gamma 编码 sRGB + UNORM 交换链"
这对 UI 够用,但对视频编辑有三个根本缺陷:
1. **精度不足**8-bit gamma 编码值做混合/插值/滤镜会产生色带与
暗部误差;多次处理链(特效叠加)累积量化损失。
2. **色彩空间不可控**:素材可能是 BT.709 / Display P3 / BT.2020 HDR
现状要么被当 sRGB 直通(YUV 路径还硬编码了 BT.601 矩阵),要么由
各平台着色器各算各的,三端结果不一致。
3. **输出目标不可配置**:交付色域(sRGB / P3 / BT.2020)应由项目设置
决定,现状没有这个概念。
目标架构:**场景线性(scene-linear)工作空间 = ACEScgAP1 基色,线性
传递),数据精度 = F32**。ACEScg 是影视工业标准工作空间(AP1 色域覆盖
BT.709/P3/BT.2020 大部分,线性光,负值可表示,矩阵运算友好)。
## 2. 现状摘要(审计结论)
- 颜色以 `Hsla` 存于场景(`crates/gpui/src/color.rs:697`
`ColorSpace` 枚举只影响渐变插值,与显示无关);三套着色器
`shaders.metal` / `shaders.hlsl` / `shaders.wgsl`)各自做
HSL→RGB,输出 gamma 编码 sRGB 值。
- 交换链一律非 SRGB 的 UNORMmacOS `BGRA8Unorm`
`metal_renderer.rs:236`),Windows `DXGI_FORMAT_B8G8R8A8_UNORM`
`directx_renderer.rs:39`),wgpu 偏好 `Rgb10a2Unorm`/`Rgba16Float`
`wgpu_renderer.rs:125`)。OS 把内容当 sRGB,各映射一次——当前没有
二次映射。
- 已知缺陷(本计划顺带修复):
- 三端渐变插值不一致(wgpu 的 `linear_to_srgba`/`srgba_to_linear`
双重编码,源于错误注释"`hsla_to_rgba` 返回 linear sRGB");
- Windows HLSL 的 `linear_to_srgb`/`srgb_to_linear` 定义与名称互换,
Oklab 渐变方向全反;
- macOS YUV 直通路径硬编码 BT.601 full-range 矩阵
`shaders.metal:893`);
- Wayland 未用 `color-management-v1` 声明表面色彩空间(依赖已启用
`staging` feature,见 `gpui_linux/Cargo.toml:104`,协议源码在依赖
树中,未接线);
- `OAK_MACOS_LAYER_COLORSPACE=display` 直通标记只取主显示器
`display_colorspace.rs:32`),窗口跨屏/换 ICC 后过期
`window.rs:2362` 不更新)。
## 3. 目标架构总览
```
素材(视频/图片, 任意源色域) UI 颜色(Hsla) 文本/图标
│ 输入节点 │ │
│ 源色域→ACEScg(F32) │ HSL→sRGB→linear→AP1 │ 覆盖率掩码,
▼ ▼ ▼ 颜色同 UI
┌──────────────────────────────────────────────────────────────────┐
│ 场景合成:ACEScg + F32(混合/渐变/模糊/滤镜) │
│ scene texture(RGBA32F) → blur/group/path intermediates(F32) │
└──────────────────────────────────────────────────────────────────┘
│ 输出节点(每个渲染器一个共享语义的最终 pass)
│ ACEScg → 目标色域(项目设置, 默认 sRGB):
│ AP1→目标基色矩阵 → 色域映射/钳制 → 目标传递函数编码
│ → (可选)抖动
交换链(8/10-bit UNORM, 携带"目标色域"语义) → 平台呈现
macOS: CAMetalLayer colorspace = 目标色域 → ColorSync 映射到显示器
Windows: DXGI 默认 sRGB(显式 SetColorSpace1) → DWM/ACM 映射
Wayland: color-management-v1 声明 image description → 合成器映射
```
关键不变量:**全仓库内不存在"显示器 ICC 变换"**。输出节点的目标永远是
色度学空间(sRGB / Display P3 / BT.2020+PQ…),最后一次到显示器的映射
由操作系统完成。macOS 的 `OAK_MACOS_LAYER_COLORSPACE=display` 直通模式
在本计划中退役(见 P2.4)。
## 4. 契约与配置类型(P0 产出)
新增(建议放 `crates/gpui/src/color.rs` 或新模块 `color_pipeline.rs`):
```rust
/// 渲染管线工作模式。本 fork 默认 AcesCg;保留 SrgbLegacy 供
/// gpui-ce 上游用户与回退调试。
pub enum ColorPipeline { SrgbLegacy, AcesCg }
/// 输出节点的"目标色域",由项目设置指定;未指定 = Srgb。
pub struct OutputColorSpec {
pub gamut: OutputGamut, // Srgb | DisplayP3 | Bt2020
pub transfer: OutputTransfer, // Srgb | Gamma22 | Pq | Hlg
pub peak_nits: Option<f32>, // HDR 目标才需要
}
impl Default for OutputColorSpec { /* sRGB + sRGB 传递 */ }
```
- `WindowOptions` / `WgpuSurfaceConfig``wgpu_renderer.rs:155`/
macOS `MacWindow` 构造参数 / Windows 渲染器构造参数,各加
`color_pipeline``output: OutputColorSpec` 字段,默认
`AcesCg + OutputColorSpec::default()`
- 运行时可变:`Window::set_output_color_space(spec)` → 重建输出
pass uniform;若位深/格式需要变化则重配交换链(wgpu
`surface.configure`Windows 重建 swapchainmacOS 更新
`layer.colorspace` 与像素格式)。Oak 主仓库的项目设置面板调用它。
- Surface 契约(输入节点交付面):`paint_surface` 传入的纹理必须是
**ACEScg 线性、F32`Rgba32Float`**`oak_bridge::SurfaceFormat`
`crates/oak_bridge/src/surface.rs`)相应更新。纹理附带可选的
元数据(源色域标签)仅用于引擎内部,交给 gpui 时一律已转换。
- 色彩数学集中在一个模块(矩阵 + 传递函数 + 单元测试),三套着色器
的常量与之保持一致(矩阵同时写进 WGSL/Metal/HLSL,测试比对数值)。
## 5. 输入节点(素材 → ACEScg F32)
"输入节点"在本仓库内对应四个入口:
1. **视频/引擎帧**`paint_surface``window.rs:4192/4211`
`elements/surface.rs`):
- 转换在 oak 引擎侧完成(源色域从解码器元数据读取:
BT.709/BT.2020/P3、transfer、full/limited range),经
`oak_bridge` 交付 `Rgba32Float` ACEScg 纹理。本仓库只定义契约 +
验证(格式不符时 `log::error` 并拒绝,沿用
`metal_renderer.rs:1857` 的检查模式)。
- **退役 macOS 的 YUV 直通路径**`metal_renderer.rs:1857-1864`
`shaders.metal:893` 的 BT.601 矩阵):解码→转换放引擎侧后,
gpui 不再需要 YUV 采样。过渡期保留但标记 deprecated。
2. **位图/图标**polychrome spritesatlas 上传):
atlas 插入时做一次性转换:sRGB 解码 → AP1 矩阵,存为
F32(或 F16,见风险节)atlas 格式。涉及
`metal_atlas.rs` / `wgpu_atlas.rs` / `directx_atlas.rs`
3. **UI 颜色**`Hsla`):在着色器内转换——`hsla_to_rgba` 改为
`hsla_to_acescg`HSL→gamma-sRGB → sRGB EOTF → 线性 sRGB →
AP1 矩阵(sRGB(D65)→ACEScg(D60 白点,矩阵含色适应)):
```
0.6131324224 0.3395230762 0.0473341514
0.0701922769 0.9163536767 0.0134540464
0.0206157712 0.1095697056 0.8698145232
```
(数值以 ACES 官方规范核准版为准,P0 单测锁定。)
4. **截图/回读**`render_to_image` / `render_scene_to_image`
`metal_renderer.rs:679/777`):默认经过输出节点,得到与显示器
一致的 sRGB 编码图;另留内部接口输出 ACEScg 原值供调试/测试。
## 6. 中间处理链(ACEScg + F32
- 场景渲染目标、模糊乒乓、内容滤镜组纹理、path 中间纹理全部改为
`Rgba32Float`wgpu`wgpu_renderer.rs:1290+`
`ensure_blur_textures``create_path_intermediate`Windows
`create_path_intermediate_texture` 等;macOS:新增离屏场景纹理,
见 P2.1)。
- **混合/插值/模糊全部天然变为线性光**——顺带修复审计发现的三端
渐变不一致与 gamma 混合偏暗问题。`ColorSpace::Srgb` 渐变语义改为
"在线性 sRGB 中插值"(端点先 AP1→linear-sRGB),`Oklab` 改为
"AP1→linear-sRGB→Oklab 插值→返回",三端共用同一套函数,统一行为。
- 覆盖掩码类数据(字形 alpha、path 覆盖率)不属于颜色,保持低精度
(R8 / F16)即可;只有**颜色**走 F32。
- 文本外观:现有 `ZED_FONTS_GAMMA` / enhanced-contrast 参数
`wgpu_renderer.rs:2667``RenderingParameters`,及三套
`shaders_subpixel`/color_text_raster)是为 gamma 空间调的,线性化
后需重新调参(P5.4);覆盖率校正本身保留在覆盖率域。
- 抖动(现 `shaders.metal:1243` 等处的 ±2/255 渐变抖动)移到
**输出节点编码之后**,线性域内抖动无意义。
## 7. 输出节点(ACEScg → 目标色域)
每个渲染器增加一个最终全屏 pass(三份实现、同一语义;建议先在
wgpu 端定型再移植):
1. `AP1 → 目标基色`矩阵(目标为 sRGB 时即上面矩阵的逆)。
2. **色域映射**:P1 用简单钳制(UI 颜色几乎不越界);越界严重的
视频内容后续升级为色度压缩(列入开放问题)。
3. **传递函数编码**`sRGB OETF` / `pow(1/2.2)` / `PQ` / `HLG`
4. **HDR→SDR 目标时**需要色调映射(ACES RRT+ODT 或更简单的 roll-off);
列入 P6,首期只做同动态范围目标。
5. 编码后抖动(见上节)。
6. 输出到交换链(仍为 8/10-bit UNORM`OAK_DISPLAY_BIT_DEPTH` 语义
不变)。
随之而来的结构变化:**三个渲染器都必须"离屏场景 + 最终 blit"**。
Windows 已有离屏场景(`directx_renderer.rs:421-578` 的 blur 路径,
泛化为常开);wgpu 已有 blit 基建(`fs_blur_downsample` 的 1:1 拷贝
分支);**macOS 目前是直绘 drawable,需要新增离屏场景纹理**——这是
macOS 端最大的结构改动(P2.1),注意 `presents_with_transaction`
直显模式(`metal_renderer.rs:641`)与 offscreen 的相互作用。
## 8. 平台呈现(单次映射原则)
- **macOS**layer 像素格式保持 `BGRA8Unorm``layer.colorspace`
设为**输出目标色域**(默认 `CGColorSpaceCreateWithName(kCGColorSpaceSRGB)`
目标为 P3 时设为 P3),ColorSync 完成到显示器的唯一一次映射。
替换 `OAK_MACOS_LAYER_COLORSPACE` 逻辑(`metal_renderer.rs:254-266`
`display_colorspace.rs`):**不再使用显示器色彩空间直通**。
窗口跨屏时(`window_did_change_screen``window.rs:2362`)无需
重打标记——标记的是内容色域而非显示器,ColorSync 自动按当前屏映射
(这正是修复审计缺陷之处)。HDR 输出(P6)再启用
`wantsExtendedDynamicRangeContent` + EDR headroom。
- **Windows**:交换链格式与现状一致(`B8G8R8A8_UNORM`
`directx_renderer.rs:39`)。增加显式声明:cast
`IDXGISwapChain1 → IDXGISwapChain3`,调用 `SetColorSpace1`
(默认 `DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709`P3/BT.2020 目标
在 P6 加对应值)。显式声明消除对"默认即 sRGB"的隐式依赖。
- **Linux/Wayland**:实现 `color-management-v1`wayland-protocols
`staging` feature 已在,`gpui_linux/Cargo.toml:104`):
- `client.rs` 绑定 `wp_color_manager_v1`(对齐方式参照现有
`wp_fractional_scale_manager_v1` 的接法,`client.rs:66`);
- 每个窗口 `wl_surface``wayland/window.rs:539` 创建、rwh 句柄
已在本仓库手里)取 `wp_color_management_surface_v1`
`set_image_description` = sRGBBT.709 基色 + sRGB 传递,用
params creator 构造;目标色域变化时更新);
- 合成器不支持该协议时静默降级(内容本来就是 sRGB,合成器默认假设
也是 sRGB,行为不变);
- **绝不在应用侧做显示器映射**——维持审计结论:Wayland 的颜色管理
不可关闭,程序只声明、不代劳。
- **X11**:无协议可用,维持 sRGB 直通,文档注明广色域屏过饱和属
系统限制。
## 9. 分阶段工作项
### P0. 契约与色彩数学基础(无渲染行为变化)
- [ ] `OutputColorSpec` / `ColorPipeline` 类型与默认值(§4)。
- [ ] 色彩数学模块:sRGB↔ACEScg 矩阵、EOTF/OETF、PQ/HLG 占位;
参考实现 + 单测(含与已知测试向量的比对,如 sRGB 红/绿/蓝
原色在 ACEScg 下的坐标)。
- [ ] 三套着色器共用的矩阵/函数清单(哪些函数要改、改成什么),
写成对照表放进实现 PR。
- [ ] `WindowOptions` / 各渲染器配置字段贯通(此阶段
`SrgbLegacy` 行为与现状完全一致,`AcesCg` 先不启用)。
- 验收:`cargo test` 全绿;`SrgbLegacy` 下截图与改造前逐像素一致
(现有 visual test 基线)。
### P1. wgpu 渲染器(Linux)先行试点
- [ ] `shaders.wgsl``hsla_to_rgba``hsla_to_acescg`;渐变/
Oklab/over/blur 全部改为线性语义;删除双重编码路径
`shaders.wgsl:417-421, 473` 审计缺陷顺带消除)。
- [ ] 场景/模糊/组/路径中间纹理改 `Rgba32Float`
`wgpu_renderer.rs``ensure_blur_textures`
`create_path_intermediate``RenderingParameters`
MSAA 采样数适配——部分后端不支持 32F MSAA,需降级策略)。
- [ ] 新增输出节点 pass(§7),交换链仍用
`preferred_surface_formats()``wgpu_renderer.rs:125`)。
- [ ] atlas 改线性(polychrome);字形覆盖率保持。
- [ ] Surface 元素按契约采样 `Rgba32Float`
`wgpu_renderer.rs:1880``draw_surfaces`);格式校验。
- [ ] Wayland `color-management-v1` 接线(§8)。
- 验收:`examples/legacy/gradient.rs` 三端一致(本阶段与 macOS 对照
用截图比对);KWin/启用色彩管理的合成器下声明生效(协议日志或
合成器调试工具确认),不支持的合成器无回归;模糊/滤镜视觉测试通过。
### P2. macOS Metal 渲染器
- [ ] **结构改造:离屏场景纹理(F32)+ 输出节点 pass → drawable**
`metal_renderer.rs``draw`/`draw_primitives` 重构,
注意 `presents_with_transaction``next_drawable` 超时处理与
`render_to_image`/`render_scene_to_image` 测试路径)。
- [ ] `shaders.metal``shaders.wgsl` 对齐(线性语义、
统一的渐变/Oklab 实现、pow(2.2) 近似换精确 sRGB TF)。
- [ ] atlas 线性化(`metal_atlas.rs`)。
- [ ] `layer.colorspace` = 输出目标色域;移除
`OAK_MACOS_LAYER_COLORSPACE`/`display_colorspace.rs` 直通逻辑
(与 oak 主仓库协调:主仓库停止设置该 env)。
- [ ] 退役 YUV 直通路径(§5.1),`oak_bridge` 交付格式契约更新
`Rgba32Float``Bgra8Unorm` 过渡期保留并打警告)。
- 验收:广色域显示器上 UI 颜色与"系统设置-显示器-P3/sRGB 切换"的
行为一致(ColorSync 单次映射);跨屏移动窗口颜色不变;
visual test 基线更新并通过。
### P3. Windows D3D11 渲染器
- [ ] 离屏场景泛化为常开(现有 `scene_rtv/scene_srv` 机制,
`directx_renderer.rs:421-578`+ F32 中间纹理。
- [ ] `shaders.hlsl` 对齐:修复 `linear_to_srgb`/`srgb_to_linear`
名称/定义互换(审计缺陷),统一线性语义。
- [ ] 输出节点 pass 替换 `dx_blit``directx_renderer.rs:1116`)。
- [ ] `IDXGISwapChain3::SetColorSpace1` 显式声明(§8)。
- [ ] atlas 线性化(`directx_atlas.rs`)。
- 验收:与 Linux/macOS 的截图逐像素近似比对(容差来自抖动/驱动);
Win11 ACM 显示器上行为正确;透明窗口(DComposition
premultiplied)无回归。
### P4. 三端一致性与视频链路收口
- [ ] 三端渐变/混合/文本外观交叉比对(用 `gradient` example +
新增 color-checker example24 色卡 + 灰阶 + 色域边界色)。
- [ ] 引擎侧输入节点联调(oak 主仓库):解码元数据→ACEScg 转换、
`oak_bridge` F32 交付、Windows/Linux 的
`paint_surface(wgpu::Texture)` 直连(oak-app-rewrite.md W3
遗留项一并完成)。
- [ ] 项目设置→`OutputColorSpec` 的运行时切换联调(改项目设置后
不重启窗口即生效)。
- 验收:同一项目在三平台导出的检视器画面一致;切换目标色域
(默认 sRGB ↔ P3)立即可见且与外部参考(如系统色彩管理应用)
观感一致。
### P5. 文本与外观回归
- [ ] 线性空间下的文本参数重调(`ZED_FONTS_GAMMA` 等,
`RenderingParameters`),subpixel 覆盖率校正在覆盖率域重推;
提供 A/B 对比工具。
- [ ] 主题/调色板审视:UI 颜色在 ACEScg 管线下的最终呈现与旧管线
应逐像素等价(sRGB→ACEScg→sRGB 往返),若有偏差定位到具体
着色器路径。
- 验收:现有 visual tests 全绿;文本在明/暗背景下的可读性评审通过。
### P6. HDR 与广色域输出(二期,可与主仓库排期解耦)
- [ ] `OutputGamut::Bt2020` + `PQ/HLG`:输出节点色调映射选型
(候选:ACES RRT+ODT / Khronos PBR Neutral / 简单 roll-off),
先在 wgpu 端原型。
- [ ] macOS`wantsExtendedDynamicRangeContent` + EDR headroom 监听;
`Rgba16Float` 交换链(EAC 模式)。
- [ ] WindowsHDR swapchain`DXGI_FORMAT_R16G16B16A16_FLOAT` +
`SetColorSpace1(DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020)`),
查询 `DXGI_OUTPUT_DESC1` 的 HDR 状态。
- [ ] Waylandimage description 声明 BT.2020+PQ;跟随
`preferred` 反馈。
- [ ] 输入侧:HDR 素材(PQ/HLG 源)在引擎侧转 ACEScg 的场景参考
语义定义(与色调映射策略联动)。
- 验收:HDR 显示器上高光细节保留、SDR 内容不炸白;三端行为对齐。
## 10. 测试策略
- **单测**:色彩数学(矩阵往返误差 < 1e-6、传递函数锚点值、
色域边界钳制行为)。
- **headless 截图**`render_scene_to_image` 走输出节点,锁定
golden image`SrgbLegacy` 模式保留旧基线用于回归。
- **跨端比对**color-checker example 三端截图自动比对
(容差需显式定义,抖动用固定种子)。
- **真实显示器**P2/P3/P6 验收需要广色域/EDR/HDR 显示器 + 目视或
色度计;CI 无 GPU 环境跳过(沿用 `oak_bridge` demo 的做法)。
## 11. 风险与缓解
| 风险 | 影响 | 缓解 |
|---|---|---|
| F32 目标带宽/显存 ~4×(模糊乒乓最明显) | 低端 GPU 掉帧 | 提供 `Rgba16Float` 降级开关(视觉差异对 8-bit 交付可忽略);模糊半分辨率已存在;先测量再优化 |
| 32F MSAA 部分后端不支持 | path 抗锯齿退化 | `RenderingParameters::path_sample_count` 已有降级逻辑,F32 下按需降到 1× 或用 F16 中间层做 MSAA |
| macOS 离屏化破坏直显模式性能 | 帧延迟/掉帧 | 保留 `presents_with_transaction` 语义,输出 pass 与 present 同 command buffer 提交;基准对比改造前后 |
| 文本外观变化(线性混合显细) | 可读性回归 | P5 专项;覆盖率校正留覆盖率域;参数可调 |
| Wayland 协议可用性参差 | 声明不生效 | 降级路径 = 现状(合成器按 sRGB 处理,内容恰为 sRGB,无损) |
| 与上游 zed 分叉进一步扩大 | 合并成本 | 改动集中在渲染器/着色器(上游也在快速变动),核心场景结构不动;`SrgbLegacy` 保持与上游行为一致 |
| 引擎侧输入节点未就绪(主仓库依赖) | P4 联调阻塞 | gpui 侧先用合成测试纹理验证契约;YUV 旧路径过渡期保留 |
## 12. 兼容性说明
- `gpui-ce` 的其他使用者:`ColorPipeline::SrgbLegacy` 与现状
逐像素一致,可作默认逃生口;本 fork(oak)默认 `AcesCg`
- `OAK_DISPLAY_BIT_DEPTH` 语义不变(只影响交换链位深)。
- `OAK_MACOS_LAYER_COLORSPACE` 在 P2 移除,需同步通知主仓库
(该 env 由主仓库设置,见审计)。
- 截图/视觉测试的像素基线在 P1-P3 各平台切换时一次性更新,
更新前后用 `SrgbLegacy` 双跑确认差异全部来自预期语义变化。
## 13. 开放问题(实施前需拍板)
1. 中间链 F32 是否允许按设备能力降级 F16Apple Silicon/现代独显
上两者带宽差异显著)?建议:默认 F32,配置项允许 F16。
2. 色域映射算法:首期钳制是否可接受(视频内容可能越界)?
还是 P1 就上色度压缩?
3. HDR→SDR 色调映射选型(P6)——影响输入侧"场景参考"语义定义,
建议 P6 启动时单独评审。
4. Wayland 下目标色域为非 sRGBP3/BT.2020)时,是否要求合成器
支持对应 image description,还是回退 sRGB 输出(合成器能力查询
`wp_color_manager_v1` 的 render intent/primaries 反馈)?
5. `ColorSpace::Oklab` 渐变在线性管线下的语义:Oklab 本为感知
均匀空间,输入应使用线性 sRGB——与 CSS `oklab` 一致,三端统一后
无歧义,但需确认与现有设计稿的视觉差异可接受。
+1 -1
Submodule gpui updated: 4ed8b2bf76...41dac8f33e