From fdb5caabd57f657a491e974cca4370af8ede0dc2 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sat, 29 Aug 2026 00:24:15 +0800 Subject: [PATCH] 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). --- Cargo.lock | 2 + assets/i18n/de.yaml | 12 + assets/i18n/en-US.yaml | 12 + assets/i18n/es.yaml | 12 + assets/i18n/fr.yaml | 12 + assets/i18n/ja.yaml | 12 + assets/i18n/pt.yaml | 12 + assets/i18n/ru.yaml | 12 + assets/i18n/zh-CN.yaml | 12 + crates/oak-app/Cargo.toml | 4 + crates/oak-app/src/app.rs | 49 +- crates/oak-app/src/dialogs.rs | 127 +- crates/oak-app/src/i18n.rs | 8 + crates/oak-app/src/lib.rs | 5 + crates/oak-app/src/logging.rs | 191 +++ crates/oak-app/src/oakui/displaycolor.rs | 439 +++++- crates/oak-app/src/oakui/engine.rs | 27 + crates/oak-app/src/oakui/frames.rs | 54 +- crates/oak-app/src/oakui/gpu.rs | 7 +- crates/oak-app/src/oakui/real.rs | 120 +- crates/oak-app/src/oakui/renderops.rs | 49 +- crates/oak-app/src/oakui/scopes.rs | 18 +- crates/oak-codec/src/decoder.rs | 15 + crates/oak-codec/src/encodingparams.rs | 35 +- crates/oak-codec/src/ffmpeg.rs | 409 ++++- crates/oak-common/src/colormath.rs | 1365 +++++++++++++++++ crates/oak-common/src/displayicc.rs | 666 +++++++- crates/oak-common/src/lib.rs | 1 + crates/oak-ffmpeg-link/build.rs | 12 +- crates/oak-node/src/project.rs | 40 + crates/oak-plugin/src/clip.rs | 6 +- crates/oak-render/src/color.rs | 144 ++ crates/oak-render/src/eval.rs | 74 +- crates/oak-render/src/ipc.rs | 83 +- .../oak-render/tests/footage_decode_test.rs | 9 + crates/oak-render/tests/graph_render.rs | 14 + crates/oak-task/src/export.rs | 67 +- crates/oak-worker/Cargo.toml | 1 + crates/oak-worker/src/worker.rs | 31 +- .../oak-worker/tests/procpool_integration.rs | 9 +- docs/zh/plans/acescg-f32-pipeline.md | 390 +++++ gpui | 2 +- 42 files changed, 4423 insertions(+), 146 deletions(-) create mode 100644 crates/oak-app/src/logging.rs create mode 100644 crates/oak-common/src/colormath.rs create mode 100644 docs/zh/plans/acescg-f32-pipeline.md diff --git a/Cargo.lock b/Cargo.lock index 2ba380460..5cd30257b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/assets/i18n/de.yaml b/assets/i18n/de.yaml index 446460890..a4a052602 100644 --- a/assets/i18n/de.yaml +++ b/assets/i18n/de.yaml @@ -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" diff --git a/assets/i18n/en-US.yaml b/assets/i18n/en-US.yaml index e7137a96c..c6c94af89 100644 --- a/assets/i18n/en-US.yaml +++ b/assets/i18n/en-US.yaml @@ -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" diff --git a/assets/i18n/es.yaml b/assets/i18n/es.yaml index 61006e5e7..e51030366 100644 --- a/assets/i18n/es.yaml +++ b/assets/i18n/es.yaml @@ -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" diff --git a/assets/i18n/fr.yaml b/assets/i18n/fr.yaml index 62ee3d657..71ced939d 100644 --- a/assets/i18n/fr.yaml +++ b/assets/i18n/fr.yaml @@ -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" diff --git a/assets/i18n/ja.yaml b/assets/i18n/ja.yaml index 26fee3466..c43dc34ba 100644 --- a/assets/i18n/ja.yaml +++ b/assets/i18n/ja.yaml @@ -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" diff --git a/assets/i18n/pt.yaml b/assets/i18n/pt.yaml index 346b9eef4..abcce2a36 100644 --- a/assets/i18n/pt.yaml +++ b/assets/i18n/pt.yaml @@ -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" diff --git a/assets/i18n/ru.yaml b/assets/i18n/ru.yaml index 87c712e23..2738d26f5 100644 --- a/assets/i18n/ru.yaml +++ b/assets/i18n/ru.yaml @@ -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" diff --git a/assets/i18n/zh-CN.yaml b/assets/i18n/zh-CN.yaml index 3eb2daa22..eace5b535 100644 --- a/assets/i18n/zh-CN.yaml +++ b/assets/i18n/zh-CN.yaml @@ -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" diff --git a/crates/oak-app/Cargo.toml b/crates/oak-app/Cargo.toml index 85c8a913b..be4976242 100644 --- a/crates/oak-app/Cargo.toml +++ b/crates/oak-app/Cargo.toml @@ -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, diff --git a/crates/oak-app/src/app.rs b/crates/oak-app/src/app.rs index 82874dd4d..df7e2e055 100644 --- a/crates/oak-app/src/app.rs +++ b/crates/oak-app/src/app.rs @@ -717,7 +717,11 @@ impl OakApp { 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 OakApp { crate::dialogs::PreferencesEvent::ShortcutsChanged => { this.rebind_keys(cx); } + crate::dialogs::PreferencesEvent::DisplayColorChanged => { + this.reapply_display_color(cx); + } }, ) .detach(); @@ -2002,6 +2009,10 @@ impl OakApp { 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 OakApp { 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) { + 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(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(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 diff --git a/crates/oak-app/src/dialogs.rs b/crates/oak-app/src/dialogs.rs index bd9e1d18c..a4a323a29 100644 --- a/crates/oak-app/src/dialogs.rs +++ b/crates/oak-app/src/dialogs.rs @@ -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 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 { /// 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, + /// The delivery output gamut combo (sRGB / Display P3 / BT.2020). + output_gamut: Entity, + /// The delivery output transfer combo (sRGB / gamma 2.2 / PQ / HLG). + output_transfer: Entity, /// The commit error shown under the OCIO row (an invalid config keeps /// the dialog open, like the C++ accept()). error: Option, @@ -1608,12 +1621,54 @@ impl ProjectPropertiesContent { }); 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 ProjectPropertiesContent { /// 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) -> Result<(), String> { let ocio = self.ocio_config_path(cx).to_string(); self.engine @@ -1671,10 +1727,57 @@ impl ProjectPropertiesContent { 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, cx: &mut Context) { self.error = msg; @@ -1771,6 +1874,21 @@ impl Render for ProjectPropertiesContent 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(); diff --git a/crates/oak-app/src/i18n.rs b/crates/oak-app/src/i18n.rs index 0245d6e38..8a80a3d68 100644 --- a/crates/oak-app/src/i18n.rs +++ b/crates/oak-app/src/i18n.rs @@ -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(); diff --git a/crates/oak-app/src/lib.rs b/crates/oak-app/src/lib.rs index 9b3cd806b..f6e1dbc13 100644 --- a/crates/oak-app/src/lib.rs +++ b/crates/oak-app/src/lib.rs @@ -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(); } diff --git a/crates/oak-app/src/logging.rs b/crates/oak-app/src/logging.rs new file mode 100644 index 000000000..4c96aab4b --- /dev/null +++ b/crates/oak-app/src/logging.rs @@ -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 . + +//! 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 { + 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); + } +} diff --git a/crates/oak-app/src/oakui/displaycolor.rs b/crates/oak-app/src/oakui/displaycolor.rs index 46de57556..d2d9dcdad 100644 --- a/crates/oak-app/src/oakui/displaycolor.rs +++ b/crates/oak-app/src/oakui/displaycolor.rs @@ -14,81 +14,320 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -//! 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:"`, `"win:"`, `"x11:"` — 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>, + xyz: Option>, bgra: Option>, } static STATE: LazyLock>> = 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> = 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> = 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) { + *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 { + #[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 { let key = current_key(); let mut guard = STATE.lock().unwrap_or_else(|e| e.into_inner()); @@ -105,33 +344,64 @@ fn current() -> Option { 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 { 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 { + 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 { 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); } } diff --git a/crates/oak-app/src/oakui/engine.rs b/crates/oak-app/src/oakui/engine.rs index c065dd4be..97f1cdd60 100644 --- a/crates/oak-app/src/oakui/engine.rs +++ b/crates/oak-app/src/oakui/engine.rs @@ -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, + ) { + let _ = (working, gamut, transfer, cx); + } + // ------------------------------------------------------------------- // Sequence management (the C++ File > New Sequence / New Folder and // the project-explorer sequence context menu): creating, querying and diff --git a/crates/oak-app/src/oakui/frames.rs b/crates/oak-app/src/oakui/frames.rs index 4dafd6b81..364e82f62 100644 --- a/crates/oak-app/src/oakui/frames.rs +++ b/crates/oak-app/src/oakui/frames.rs @@ -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> { - 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, 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"); + } + } } diff --git a/crates/oak-app/src/oakui/gpu.rs b/crates/oak-app/src/oakui/gpu.rs index 7cebe6dc7..ce16ea8bd 100644 --- a/crates/oak-app/src/oakui/gpu.rs +++ b/crates/oak-app/src/oakui/gpu.rs @@ -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 { diff --git a/crates/oak-app/src/oakui/real.rs b/crates/oak-app/src/oakui/real.rs index 7223cf185..d0abcd1ea 100644 --- a/crates/oak-app/src/oakui/real.rs +++ b/crates/oak-app/src/oakui/real.rs @@ -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> { @@ -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 `` 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, + ) { + 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) { + 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 diff --git a/crates/oak-app/src/oakui/renderops.rs b/crates/oak-app/src/oakui/renderops.rs index 5fe9bd96e..d029ae77a 100644 --- a/crates/oak-app/src/oakui/renderops.rs +++ b/crates/oak-app/src/oakui/renderops.rs @@ -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())); diff --git a/crates/oak-app/src/oakui/scopes.rs b/crates/oak-app/src/oakui/scopes.rs index 3cc7b7472..35d91356a 100644 --- a/crates/oak-app/src/oakui/scopes.rs +++ b/crates/oak-app/src/oakui/scopes.rs @@ -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); diff --git a/crates/oak-codec/src/decoder.rs b/crates/oak-codec/src/decoder.rs index 099f27f00..bda9f6d5d 100644 --- a/crates/oak-codec/src/decoder.rs +++ b/crates/oak-codec/src/decoder.rs @@ -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 { diff --git a/crates/oak-codec/src/encodingparams.rs b/crates/oak-codec/src/encodingparams.rs index f49310656..e8f8b15c5 100644 --- a/crates/oak-codec/src/encodingparams.rs +++ b/crates/oak-codec/src/encodingparams.rs @@ -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::(), 1536); + assert_eq!(size_of::(), 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); } } diff --git a/crates/oak-codec/src/ffmpeg.rs b/crates/oak-codec/src/ffmpeg.rs index 364930444..d043410aa 100644 --- a/crates/oak-codec/src/ffmpeg.rs +++ b/crates/oak-codec/src/ffmpeg.rs @@ -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)> { + ) -> crate::error::Result<(u32, u32, Vec, 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 { f.and_then(|f| f.pts()) @@ -1569,6 +1754,43 @@ fn convert_rgba8_to_f32(data: &[u8], w: u32, h: u32, stride: usize) -> Vec { 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 { + 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 { @@ -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(¶ms.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); + } } diff --git a/crates/oak-common/src/colormath.rs b/crates/oak-common/src/colormath.rs new file mode 100644 index 000000000..b6a6871ec --- /dev/null +++ b/crates/oak-common/src/colormath.rs @@ -0,0 +1,1365 @@ +// 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 . + +//! Colorimetry for the ACEScg + F32 pipeline: the one place the matrices +//! and transfer functions live. +//! +//! Pipeline contract (project-properties driven): +//! +//! * input nodes decode any source colorspace into the **working space** +//! (ACEScg linear, F32) via [`decode_to_working`]; +//! * everything between input and output stays ACEScg + F32; +//! * the output node converts to the project's **output colorspace** +//! ([`acescg_to_output`]) for export, and the display path converts to +//! the display target ([`working_to_display_target`]) for presentation. +//! +//! All matrices are derived from the published primaries/white points at +//! build time (no hand-typed composite matrices to drift), with Bradford +//! chromatic adaptation between differing white points (D65 sources → the +//! ACES D60-ish white of AP1). Unit tests pin the results against the +//! published ACES transform values. + +/// A 3x3 row-major matrix. +pub type Mat3 = [[f32; 3]; 3]; + +/// An xy chromaticity pair. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Xy { + /// The x chromaticity coordinate. + pub x: f32, + /// The y chromaticity coordinate. + pub y: f32, +} + +impl Xy { + /// Build a chromaticity. + pub const fn new(x: f32, y: f32) -> Self { + Self { x, y } + } + + /// The XYZ tristimulus at unit luminance (Y = 1). + fn xyz_unit_luminance(self) -> [f32; 3] { + [self.x / self.y, 1.0, (1.0 - self.x - self.y) / self.y] + } +} + +/// The RGB primaries of a colorspace. +#[derive(Clone, Copy, Debug)] +pub struct Primaries { + /// Red primary. + pub red: Xy, + /// Green primary. + pub green: Xy, + /// Blue primary. + pub blue: Xy, + /// White point. + pub white: Xy, +} + +/// D65 standard illuminant. +pub const WHITE_D65: Xy = Xy::new(0.3127, 0.3290); + +/// The ACES white point (≈D60; the exact ACES specification values). +pub const WHITE_ACES: Xy = Xy::new(0.32168, 0.33767); + +/// sRGB / Rec.709 primaries (D65). +pub const PRIMARIES_SRGB: Primaries = Primaries { + red: Xy::new(0.640, 0.330), + green: Xy::new(0.300, 0.600), + blue: Xy::new(0.150, 0.060), + white: WHITE_D65, +}; + +/// Display P3 primaries (D65). +pub const PRIMARIES_DISPLAY_P3: Primaries = Primaries { + red: Xy::new(0.680, 0.320), + green: Xy::new(0.265, 0.690), + blue: Xy::new(0.150, 0.060), + white: WHITE_D65, +}; + +/// Rec.2020 / BT.2020 primaries (D65). +pub const PRIMARIES_BT2020: Primaries = Primaries { + red: Xy::new(0.708, 0.292), + green: Xy::new(0.170, 0.797), + blue: Xy::new(0.131, 0.046), + white: WHITE_D65, +}; + +/// ACES AP1 primaries (the ACEScg gamut; ACES white). +pub const PRIMARIES_AP1: Primaries = Primaries { + red: Xy::new(0.713, 0.293), + green: Xy::new(0.165, 0.830), + blue: Xy::new(0.128, 0.044), + white: WHITE_ACES, +}; + +// --------------------------------------------------------------------------- +// Matrix plumbing +// --------------------------------------------------------------------------- + +fn mat_mul(a: Mat3, b: Mat3) -> Mat3 { + let mut out = [[0.0f32; 3]; 3]; + for r in 0..3 { + for c in 0..3 { + out[r][c] = a[r][0] * b[0][c] + a[r][1] * b[1][c] + a[r][2] * b[2][c]; + } + } + out +} + +fn mat_vec(m: Mat3, v: [f32; 3]) -> [f32; 3] { + [ + m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2], + m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2], + m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2], + ] +} + +fn mat_inv(m: Mat3) -> Mat3 { + let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) + - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]); + let inv_det = 1.0 / det; + [ + [ + (m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv_det, + (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv_det, + (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv_det, + ], + [ + (m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv_det, + (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv_det, + (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv_det, + ], + [ + (m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv_det, + (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv_det, + (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv_det, + ], + ] +} + +fn mat_diag(d: [f32; 3]) -> Mat3 { + [[d[0], 0.0, 0.0], [0.0, d[1], 0.0], [0.0, 0.0, d[2]]] +} + +/// The Bradford cone-response matrix (the ICC-recommended adaptation used +/// by the ACES transforms as well). +const BRADFORD_CONE: Mat3 = [ + [0.8951, 0.2664, -0.1614], + [-0.7502, 1.7135, 0.0367], + [0.0389, -0.0685, 1.0296], +]; + +/// Bradford chromatic adaptation from one white point to another. +pub fn chromatic_adaptation(src_white: Xy, dst_white: Xy) -> Mat3 { + let cone = BRADFORD_CONE; + let cone_inv = mat_inv(cone); + let src = mat_vec(cone, src_white.xyz_unit_luminance()); + let dst = mat_vec(cone, dst_white.xyz_unit_luminance()); + let scale = [dst[0] / src[0], dst[1] / src[1], dst[2] / src[2]]; + mat_mul(mat_mul(cone_inv, mat_diag(scale)), cone) +} + +/// The RGB→CIE XYZ matrix of a colorspace (columns are the primaries' +/// tristimulus scaled so the white point maps to unit luminance). +pub fn rgb_to_xyz_matrix(primaries: Primaries) -> Mat3 { + let raw = [ + [ + primaries.red.xyz_unit_luminance()[0], + primaries.green.xyz_unit_luminance()[0], + primaries.blue.xyz_unit_luminance()[0], + ], + [ + primaries.red.xyz_unit_luminance()[1], + primaries.green.xyz_unit_luminance()[1], + primaries.blue.xyz_unit_luminance()[1], + ], + [ + primaries.red.xyz_unit_luminance()[2], + primaries.green.xyz_unit_luminance()[2], + primaries.blue.xyz_unit_luminance()[2], + ], + ]; + let white_xyz = primaries.white.xyz_unit_luminance(); + let s = mat_vec(mat_inv(raw), white_xyz); + mat_mul(raw, mat_diag(s)) +} + +/// Composite RGB(src) → RGB(dst) matrix: src → XYZ, chromatic adaptation +/// when the white points differ, XYZ → dst. +pub fn rgb_to_rgb_matrix(src: Primaries, dst: Primaries) -> Mat3 { + let to_xyz = rgb_to_xyz_matrix(src); + let from_xyz = mat_inv(rgb_to_xyz_matrix(dst)); + let middle = if src.white != dst.white { + chromatic_adaptation(src.white, dst.white) + } else { + [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + }; + mat_mul(mat_mul(from_xyz, middle), to_xyz) +} + +/// Apply a 3x3 matrix to the RGB channels of one pixel in place. +#[inline] +fn apply_mat(m: Mat3, px: &mut [f32]) { + let r = px[0]; + let g = px[1]; + let b = px[2]; + px[0] = m[0][0] * r + m[0][1] * g + m[0][2] * b; + px[1] = m[1][0] * r + m[1][1] * g + m[1][2] * b; + px[2] = m[2][0] * r + m[2][1] * g + m[2][2] * b; +} + +// --------------------------------------------------------------------------- +// Working / output spaces (the project-properties vocabulary) +// --------------------------------------------------------------------------- + +/// The pipeline working colorspace (project property). ACEScg is the +/// industrial scene-linear working space; `SrgbLegacy` keeps the old +/// display-referred sRGB passthrough for compatibility / debugging. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum WorkingColorSpace { + /// ACEScg (AP1 primaries, linear transfer, F32) — the default. + #[default] + AcesCg, + /// Legacy: gamma-encoded sRGB values pass through untransformed. + SrgbLegacy, +} + +impl WorkingColorSpace { + /// Parse the persisted project-setting value. + pub fn from_setting(value: &str) -> Self { + match value.to_ascii_lowercase().as_str() { + "srgb" | "srgb_legacy" | "legacy" => WorkingColorSpace::SrgbLegacy, + _ => WorkingColorSpace::AcesCg, + } + } + + /// The value persisted into the project settings. + pub fn as_setting(self) -> &'static str { + match self { + WorkingColorSpace::AcesCg => "acescg", + WorkingColorSpace::SrgbLegacy => "srgb_legacy", + } + } +} + +/// The output gamut (project property / export target). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum OutputGamut { + /// Rec.709 / sRGB primaries (D65). + #[default] + Srgb, + /// Display P3 primaries (D65). + DisplayP3, + /// Rec.2020 / BT.2020 primaries (D65). + Bt2020, +} + +impl OutputGamut { + /// Parse the persisted project-setting value. + pub fn from_setting(value: &str) -> Self { + match value.to_ascii_lowercase().as_str() { + "displayp3" | "p3" => OutputGamut::DisplayP3, + "bt2020" | "rec2020" | "2020" => OutputGamut::Bt2020, + _ => OutputGamut::Srgb, + } + } + + /// The value persisted into the project settings. + pub fn as_setting(self) -> &'static str { + match self { + OutputGamut::Srgb => "srgb", + OutputGamut::DisplayP3 => "displayp3", + OutputGamut::Bt2020 => "bt2020", + } + } + + /// The primaries of this gamut. + pub fn primaries(self) -> Primaries { + match self { + OutputGamut::Srgb => PRIMARIES_SRGB, + OutputGamut::DisplayP3 => PRIMARIES_DISPLAY_P3, + OutputGamut::Bt2020 => PRIMARIES_BT2020, + } + } + + /// The H.273 / `AVCOL_PRI_*` code point for container tagging (the + /// mov `colr` atom / H.264-HEVC VUI). + pub fn av_color_primaries(self) -> i32 { + match self { + OutputGamut::Srgb => 1, // BT.709 + OutputGamut::DisplayP3 => 12, // SMPTE EG 432-1 (Display P3) + OutputGamut::Bt2020 => 9, // BT.2020 + } + } + + /// The H.273 / `AVCOL_SPC_*` matrix-coefficients code point for the + /// encoder's RGB→YCbCr conversion tag. + pub fn av_color_space(self) -> i32 { + match self { + OutputGamut::Srgb | OutputGamut::DisplayP3 => 1, // BT.709 + OutputGamut::Bt2020 => 9, // BT.2020 NCL + } + } +} + +/// The output transfer characteristic (project property / export target). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum OutputTransfer { + /// The sRGB piecewise transfer (IEC 61966-2-1). + #[default] + Srgb, + /// Pure gamma 2.2 power function. + Gamma22, + /// SMPTE ST 2084 (PQ), normalized so 1.0 = 10 000 nits. + Pq, + /// ARIB STD-B67 Hybrid Log-Gamma. + Hlg, +} + +impl OutputTransfer { + /// Parse the persisted project-setting value. + pub fn from_setting(value: &str) -> Self { + match value.to_ascii_lowercase().as_str() { + "gamma22" | "2.2" => OutputTransfer::Gamma22, + "pq" | "st2084" => OutputTransfer::Pq, + "hlg" => OutputTransfer::Hlg, + _ => OutputTransfer::Srgb, + } + } + + /// The value persisted into the project settings. + pub fn as_setting(self) -> &'static str { + match self { + OutputTransfer::Srgb => "srgb", + OutputTransfer::Gamma22 => "gamma22", + OutputTransfer::Pq => "pq", + OutputTransfer::Hlg => "hlg", + } + } + + /// The H.273 / `AVCOL_TRC_*` code point for container tagging. + pub fn av_color_trc(self) -> i32 { + match self { + OutputTransfer::Srgb => 13, // IEC 61966-2-1 (sRGB) + OutputTransfer::Gamma22 => 4, // pure gamma 2.2 + OutputTransfer::Pq => 16, // SMPTE ST 2084 (PQ) + OutputTransfer::Hlg => 18, // ARIB STD-B67 (HLG) + } + } +} + +/// The complete output colorspace specification (the project's delivery +/// target; sRGB by default). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct OutputColorSpec { + /// The output gamut. + pub gamut: OutputGamut, + /// The output transfer characteristic. + pub transfer: OutputTransfer, +} + +impl OutputColorSpec { + /// The spec from persisted project-setting values. + pub fn from_settings(gamut: &str, transfer: &str) -> Self { + Self { + gamut: OutputGamut::from_setting(gamut), + transfer: OutputTransfer::from_setting(transfer), + } + } +} + +// --------------------------------------------------------------------------- +// Transfer functions (component-wise) +// --------------------------------------------------------------------------- + +/// sRGB OETF: linear [0,∞) → gamma-encoded code (the piecewise IEC curve, +/// mirrored for negatives so scene values survive). +pub fn srgb_oetf(v: f32) -> f32 { + let sign = if v < 0.0 { -1.0 } else { 1.0 }; + let v = v.abs(); + sign * if v <= 0.0031308 { + 12.92 * v + } else { + 1.055 * v.powf(1.0 / 2.4) - 0.055 + } +} + +/// sRGB EOTF: gamma-encoded code → linear. +pub fn srgb_eotf(v: f32) -> f32 { + let sign = if v < 0.0 { -1.0 } else { 1.0 }; + let v = v.abs(); + sign * if v <= 0.04045 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } +} + +/// Pure-power gamma OETF (linear → code). +pub fn gamma_oetf(v: f32, gamma: f32) -> f32 { + let sign = if v < 0.0 { -1.0 } else { 1.0 }; + sign * v.abs().powf(1.0 / gamma) +} + +/// Pure-power gamma EOTF (code → linear). +pub fn gamma_eotf(v: f32, gamma: f32) -> f32 { + let sign = if v < 0.0 { -1.0 } else { 1.0 }; + sign * v.abs().powf(gamma) +} + +/// SMPTE ST 2084 (PQ) constants (normalized: code 1.0 = 10 000 nits). +const PQ_M1: f32 = 2610.0 / 16384.0; +const PQ_M2: f32 = (2523.0 / 4096.0) * 128.0; +const PQ_C1: f32 = 3424.0 / 4096.0; +const PQ_C2: f32 = (2413.0 / 4096.0) * 32.0; +const PQ_C3: f32 = (2392.0 / 4096.0) * 32.0; + +/// PQ EOTF: code → linear scene/display value (1.0 = 10 000 nits). +pub fn pq_eotf(v: f32) -> f32 { + let sign = if v < 0.0 { -1.0 } else { 1.0 }; + let v = v.abs().clamp(0.0, 1.0).powf(1.0 / PQ_M2); + let num = (v - PQ_C1).max(0.0); + let den = PQ_C2 - PQ_C3 * v; + sign * (num / den).powf(1.0 / PQ_M1) +} + +/// PQ OETF (inverse of [`pq_eotf`]). +pub fn pq_oetf(v: f32) -> f32 { + let sign = if v < 0.0 { -1.0 } else { 1.0 }; + let y = v.abs().powf(PQ_M1); + let num = PQ_C1 + PQ_C2 * y; + let den = 1.0 + PQ_C3 * y; + sign * (num / den).powf(PQ_M2) +} + +/// HLG constants (ARIB STD-B67). `HLG_C` is the precomputed value of +/// `0.5 - a·ln(4a)` (a non-const expression). +const HLG_A: f32 = 0.17883277; +const HLG_B: f32 = 1.0 - 4.0 * HLG_A; // 0.28466892 +const HLG_C: f32 = 0.55992546; + +/// HLG inverse OETF: code → linear (scene-referred half, display 1.0). +pub fn hlg_eotf(v: f32) -> f32 { + let sign = if v < 0.0 { -1.0 } else { 1.0 }; + let v = v.abs(); + sign * if v <= 0.5 { + v * v / 3.0 + } else { + (((v - HLG_C) / HLG_A).exp() + HLG_B) / 12.0 + } +} + +/// HLG OETF (inverse of [`hlg_eotf`]). +pub fn hlg_oetf(v: f32) -> f32 { + let sign = if v < 0.0 { -1.0 } else { 1.0 }; + let v = v.abs(); + sign * if v <= 1.0 / 12.0 { + (3.0 * v).sqrt() + } else { + HLG_A * (12.0 * v - HLG_B).ln() + HLG_C + } +} + +// --------------------------------------------------------------------------- +// Source decode characterization +// --------------------------------------------------------------------------- + +/// The colorimetry of a decoded source, as read from the container / +/// codec metadata (H.264/HEVC VUI, FFmpeg `AVCodecContext` fields). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SourcePrimaries { + /// BT.709 / sRGB primaries (the SDR HDTV default). + Bt709, + /// BT.2020 primaries (UHD / wide-gamut sources). + Bt2020, + /// Display P3 primaries. + DisplayP3, + /// Unknown primaries — treated as BT.709 (the documented fallback). + Unknown, +} + +/// The transfer characteristic of a decoded source. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SourceTransfer { + /// The sRGB piecewise EOTF (IEC 61966-2-1), applied at decode. + SdrGamma, + /// BT.1886 display-referred EOTF (pure power 2.4) — the SDR display + /// reference for BT.709 / BT.2020 sources. + Gamma24, + /// Pure power 2.2 gamma. + Gamma22, + /// Pure power 2.8 gamma. + Gamma28, + /// SMPTE ST 2084 (PQ) HDR. + Pq, + /// ARIB STD-B67 HLG HDR. + Hlg, + /// Linear-light source (EXR-style). + Linear, + /// Unknown transfer — treated as SDR gamma. + Unknown, +} + +/// Map FFmpeg/libav `color_primaries` values to [`SourcePrimaries`]. +/// (AVCOL_PRI_* numbering, H.273 ISO codes.) +pub fn source_primaries_from_av(color_primaries: i32) -> SourcePrimaries { + match color_primaries { + 1 => SourcePrimaries::Bt709, // BT.709 + 9 => SourcePrimaries::Bt2020, // BT.2020 + 11 => SourcePrimaries::DisplayP3, // SMPTE RP 431-2 (DCI-P3) + 12 => SourcePrimaries::DisplayP3, // SMPTE EG 432-1 (Display P3) + _ => SourcePrimaries::Unknown, + } +} + +/// Map FFmpeg/libav `color_trc` values to [`SourceTransfer`]. +/// (AVCOL_TRC_* numbering, H.273 ISO codes.) +pub fn source_transfer_from_av(color_trc: i32) -> SourceTransfer { + match color_trc { + 1 => SourceTransfer::Gamma24, // BT.709 + 4 => SourceTransfer::Gamma22, // gamma 2.2 + 5 => SourceTransfer::Gamma28, // gamma 2.8 + 6 => SourceTransfer::Gamma24, // SMPTE 170M + 13 => SourceTransfer::SdrGamma, // sRGB (its EOTF is applied at decode) + 14 => SourceTransfer::Gamma24, // BT.2020 10-bit + 15 => SourceTransfer::Gamma24, // BT.2020 12-bit + 16 => SourceTransfer::Pq, // SMPTE ST 2084 + 18 => SourceTransfer::Hlg, // ARIB STD-B67 + 8 => SourceTransfer::Linear, // linear + _ => SourceTransfer::Unknown, + } +} + +impl SourcePrimaries { + /// The primaries struct for this source. + pub fn primaries(self) -> Primaries { + match self { + SourcePrimaries::Bt709 | SourcePrimaries::Unknown => PRIMARIES_SRGB, + SourcePrimaries::Bt2020 => PRIMARIES_BT2020, + SourcePrimaries::DisplayP3 => PRIMARIES_DISPLAY_P3, + } + } +} + +// --------------------------------------------------------------------------- +// The pipeline transforms (F32 RGBA, tightly packed or in LE byte buffers) +// --------------------------------------------------------------------------- + +/// Decode-direction transform: gamma-encoded source RGB (in the source's +/// own primaries) → ACEScg linear. `samples` is an F32 RGBA buffer, +/// transformed in place. +pub fn decode_to_acescg( + samples: &mut [f32], + primaries: SourcePrimaries, + transfer: SourceTransfer, +) { + // Stage 1: linearize in the source's own primaries. + match transfer { + SourceTransfer::SdrGamma | SourceTransfer::Unknown => { + for px in samples.chunks_exact_mut(4) { + for c in 0..3 { + px[c] = srgb_eotf(px[c]); + } + } + } + SourceTransfer::Gamma24 | SourceTransfer::Gamma22 | SourceTransfer::Gamma28 => { + let gamma = match transfer { + SourceTransfer::Gamma24 => 2.4, + SourceTransfer::Gamma22 => 2.2, + _ => 2.8, + }; + for px in samples.chunks_exact_mut(4) { + for c in 0..3 { + px[c] = gamma_eotf(px[c], gamma); + } + } + } + SourceTransfer::Pq => { + for px in samples.chunks_exact_mut(4) { + for c in 0..3 { + px[c] = pq_eotf(px[c]); + } + } + } + SourceTransfer::Hlg => { + for px in samples.chunks_exact_mut(4) { + for c in 0..3 { + px[c] = hlg_eotf(px[c]); + } + } + } + SourceTransfer::Linear => {} + } + + // Stage 2: source primaries → AP1 (with chromatic adaptation). + let matrix = rgb_to_rgb_matrix(primaries.primaries(), PRIMARIES_AP1); + for px in samples.chunks_exact_mut(4) { + apply_mat(matrix, px); + } +} + +/// Decode-direction transform on a little-endian F32 RGBA byte buffer +/// (`pixels * 16` bytes). Avoids alignment requirements of the pipeline's +/// `Vec` frame storage. +pub fn decode_to_acescg_bytes( + bytes: &mut [u8], + pixels: usize, + primaries: SourcePrimaries, + transfer: SourceTransfer, +) { + if bytes.len() < pixels * 16 { + return; + } + let linearize: fn(f32) -> f32 = match transfer { + SourceTransfer::SdrGamma | SourceTransfer::Unknown => srgb_eotf, + SourceTransfer::Gamma24 => |v| gamma_eotf(v, 2.4), + SourceTransfer::Gamma22 => |v| gamma_eotf(v, 2.2), + SourceTransfer::Gamma28 => |v| gamma_eotf(v, 2.8), + SourceTransfer::Pq => pq_eotf, + SourceTransfer::Hlg => hlg_eotf, + SourceTransfer::Linear => |v| v, + }; + let matrix = rgb_to_rgb_matrix(primaries.primaries(), PRIMARIES_AP1); + for i in 0..pixels { + let off = i * 16; + let mut px = [ + f32::from_le_bytes(bytes[off..off + 4].try_into().unwrap()), + f32::from_le_bytes(bytes[off + 4..off + 8].try_into().unwrap()), + f32::from_le_bytes(bytes[off + 8..off + 12].try_into().unwrap()), + f32::from_le_bytes(bytes[off + 12..off + 16].try_into().unwrap()), + ]; + for c in 0..3 { + px[c] = linearize(px[c]); + } + apply_mat(matrix, &mut px); + bytes[off..off + 4].copy_from_slice(&px[0].to_le_bytes()); + bytes[off + 4..off + 8].copy_from_slice(&px[1].to_le_bytes()); + bytes[off + 8..off + 12].copy_from_slice(&px[2].to_le_bytes()); + bytes[off + 12..off + 16].copy_from_slice(&px[3].to_le_bytes()); + } +} + +/// Output-direction transform: ACEScg linear → the output colorspace +/// (gamut matrix + transfer encoding). In place. +/// +/// Gamut mapping is the contract's P1 semantics (simple clip): after the +/// AP1 → target-gamut matrix, the RGB channels are clamped to [0, 1] before +/// the transfer encoding, so out-of-gamut values are clipped rather than +/// wrapped. This applies to HDR targets too (PQ/HLG code 1.0 = 10 000 nits); +/// alpha is untouched. +pub fn acescg_to_output(samples: &mut [f32], spec: OutputColorSpec) { + let matrix = rgb_to_rgb_matrix(PRIMARIES_AP1, spec.gamut.primaries()); + for px in samples.chunks_exact_mut(4) { + apply_mat(matrix, px); + for c in 0..3 { + px[c] = px[c].clamp(0.0, 1.0); + } + } + apply_transfer_oetf(samples, spec.transfer); +} + +/// Output-direction transform on a little-endian F32 RGBA byte buffer +/// (`pixels * 16` bytes) — the worker's end-of-pipe conversion before +/// 8-bit quantization. RGB is clamped to [0, 1] after the gamut matrix and +/// before the transfer encoding, exactly as in [`acescg_to_output`]. +pub fn acescg_to_output_bytes(bytes: &mut [u8], pixels: usize, spec: OutputColorSpec) { + if bytes.len() < pixels * 16 { + return; + } + let matrix = rgb_to_rgb_matrix(PRIMARIES_AP1, spec.gamut.primaries()); + let encode: fn(f32) -> f32 = match spec.transfer { + OutputTransfer::Srgb => srgb_oetf, + OutputTransfer::Gamma22 => |v| gamma_oetf(v, 2.2), + OutputTransfer::Pq => pq_oetf, + OutputTransfer::Hlg => hlg_oetf, + }; + for i in 0..pixels { + let off = i * 16; + let mut px = [ + f32::from_le_bytes(bytes[off..off + 4].try_into().unwrap()), + f32::from_le_bytes(bytes[off + 4..off + 8].try_into().unwrap()), + f32::from_le_bytes(bytes[off + 8..off + 12].try_into().unwrap()), + f32::from_le_bytes(bytes[off + 12..off + 16].try_into().unwrap()), + ]; + apply_mat(matrix, &mut px); + for c in 0..3 { + px[c] = px[c].clamp(0.0, 1.0); + } + for c in 0..3 { + px[c] = encode(px[c]); + } + bytes[off..off + 4].copy_from_slice(&px[0].to_le_bytes()); + bytes[off + 4..off + 8].copy_from_slice(&px[1].to_le_bytes()); + bytes[off + 8..off + 12].copy_from_slice(&px[2].to_le_bytes()); + bytes[off + 12..off + 16].copy_from_slice(&px[3].to_le_bytes()); + } +} + +/// Apply just the transfer encoding of `transfer` (linear → code). In place +/// on the RGB channels. +pub fn apply_transfer_oetf(samples: &mut [f32], transfer: OutputTransfer) { + match transfer { + OutputTransfer::Srgb => { + for px in samples.chunks_exact_mut(4) { + for c in 0..3 { + px[c] = srgb_oetf(px[c]); + } + } + } + OutputTransfer::Gamma22 => { + for px in samples.chunks_exact_mut(4) { + for c in 0..3 { + px[c] = gamma_oetf(px[c], 2.2); + } + } + } + OutputTransfer::Pq => { + for px in samples.chunks_exact_mut(4) { + for c in 0..3 { + px[c] = pq_oetf(px[c]); + } + } + } + OutputTransfer::Hlg => { + for px in samples.chunks_exact_mut(4) { + for c in 0..3 { + px[c] = hlg_oetf(px[c]); + } + } + } + } +} + +/// The presentation transform: working space → the display target colorspace +/// (the project's output spec). With the ACEScg working space this is the +/// same chain as [`acescg_to_output`]; with the legacy sRGB working space it +/// is a pass-through (the content already IS display-referred sRGB). +pub fn working_to_display_target( + samples: &mut [f32], + working: WorkingColorSpace, + spec: OutputColorSpec, +) { + match working { + WorkingColorSpace::AcesCg => acescg_to_output(samples, spec), + WorkingColorSpace::SrgbLegacy => {} + } +} + +// --------------------------------------------------------------------------- +// Content → CIE XYZ (D65) +// --------------------------------------------------------------------------- + +/// Encoded content in `spec` → CIE XYZ (D65, unit luminance 1.0), in place +/// on F32 RGBA. This feeds the self-managed display ICC path with its input +/// without depending on an OCIO named colorspace: linearize per the spec's +/// transfer (Srgb → sRGB EOTF, Gamma22 → pure power 2.2, Pq → ST 2084, +/// Hlg → HLG), then convert RGB → XYZ through the gamut's own matrix. All +/// output gamuts share the D65 white point, so no chromatic adaptation is +/// needed; `rgb_to_xyz_matrix` already normalizes the white point to unit +/// luminance. +pub fn output_spec_to_xyz_d65(samples: &mut [f32], spec: OutputColorSpec) { + let linearize: fn(f32) -> f32 = match spec.transfer { + OutputTransfer::Srgb => srgb_eotf, + OutputTransfer::Gamma22 => |v| gamma_eotf(v, 2.2), + OutputTransfer::Pq => pq_eotf, + OutputTransfer::Hlg => hlg_eotf, + }; + let matrix = rgb_to_xyz_matrix(spec.gamut.primaries()); + for px in samples.chunks_exact_mut(4) { + for c in 0..3 { + px[c] = linearize(px[c]); + } + apply_mat(matrix, px); + } +} + +// --------------------------------------------------------------------------- +// YUV → RGB (F32) decode fallback +// --------------------------------------------------------------------------- + +/// The YUV matrix-coefficient set (H.273 / `AVCOL_SPC_*`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum YuvMatrix { + /// ITU-R BT.601 (SD) coefficients. + Bt601, + /// ITU-R BT.709 (HD) coefficients. + Bt709, + /// ITU-R BT.2020 (UHD) coefficients. + Bt2020, +} + +impl YuvMatrix { + /// The (Kr, Kb) luma-coefficient pair of this matrix. + fn kr_kb(self) -> (f32, f32) { + match self { + YuvMatrix::Bt601 => (0.299, 0.114), + YuvMatrix::Bt709 => (0.2126, 0.0722), + YuvMatrix::Bt2020 => (0.2627, 0.0593), + } + } +} + +/// Convert a 16-bit little-endian planar YUV 4:4:4 frame to interleaved F32 +/// RGBA (alpha 1.0). This is the decode fallback path: swscale cannot emit +/// RGBA64/F32 for some configurations and aborts, so the matrix conversion +/// happens here in Rust. +/// +/// Each plane is a `height × stride` byte region of native-endian `u16` +/// samples (`stride ≥ width * 2`; padding between rows is skipped). Code +/// values are expanded per `full_range` before the matrix conversion: +/// +/// * limited: Y ∈ [4096, 60160] (16-bit 16/235 shifted by 8), C ∈ +/// [4096, 61440] with the neutral point at 32768; +/// * full: Y ∈ [0, 65535], C centered on 32768. +/// +/// R/G/B follow from (Kr, Kb): R = Y' + 2(1−Kr)·Cr', B = Y' + 2(1−Kb)·Cb', +/// G = (Y' − Kr·R − Kb·B)/(1−Kr−Kb). Returns early (no panic, `out` +/// untouched) when any plane or `out` is too short for the frame. +pub fn yuv444p16_to_rgb_f32( + y_plane: &[u8], + y_stride: usize, + u_plane: &[u8], + u_stride: usize, + v_plane: &[u8], + v_stride: usize, + width: usize, + height: usize, + matrix: YuvMatrix, + full_range: bool, + out: &mut [f32], +) { + if out.len() < width.saturating_mul(height).saturating_mul(4) { + return; + } + // Each plane must hold (height - 1) full strides plus the last row's + // `width` samples. + let row_bytes = width.saturating_mul(2); + let rows_after = height.saturating_sub(1); + if y_plane.len() < rows_after.saturating_mul(y_stride).saturating_add(row_bytes) + || u_plane.len() < rows_after.saturating_mul(u_stride).saturating_add(row_bytes) + || v_plane.len() < rows_after.saturating_mul(v_stride).saturating_add(row_bytes) + { + return; + } + let (kr, kb) = matrix.kr_kb(); + let kg = 1.0 - kr - kb; + // Code → normalized: limited luma spans 219<<8 above 16<<8, chroma spans + // 224<<8 above its 32768 center; full-range everything spans 65535. + let (y_offset, c_offset, scale) = if full_range { + (0.0f32, 32768.0f32, 1.0 / 65535.0) + } else { + (4096.0f32, 32768.0f32, 1.0 / 57344.0) + }; + let y_scale = if full_range { 1.0 / 65535.0 } else { 1.0 / 56064.0 }; + for row in 0..height { + let y_row = row * y_stride; + let u_row = row * u_stride; + let v_row = row * v_stride; + for x in 0..width { + let off = x * 2; + let y = u16::from_le_bytes([y_plane[y_row + off], y_plane[y_row + off + 1]]) as f32; + let cb = u16::from_le_bytes([u_plane[u_row + off], u_plane[u_row + off + 1]]) as f32; + let cr = u16::from_le_bytes([v_plane[v_row + off], v_plane[v_row + off + 1]]) as f32; + let yn = (y - y_offset) * y_scale; + let cbn = (cb - c_offset) * scale; + let crn = (cr - c_offset) * scale; + let r = yn + 2.0 * (1.0 - kr) * crn; + let b = yn + 2.0 * (1.0 - kb) * cbn; + let g = (yn - kr * r - kb * b) / kg; + let px = (row * width + x) * 4; + out[px] = r; + out[px + 1] = g; + out[px + 2] = b; + out[px + 3] = 1.0; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn approx(a: f32, b: f32, eps: f32) -> bool { + (a - b).abs() < eps + } + + #[test] + fn rgb_to_xyz_srgb_white_is_unit_luminance() { + let m = rgb_to_xyz_matrix(PRIMARIES_SRGB); + let white = mat_vec(m, [1.0, 1.0, 1.0]); + let expected = WHITE_D65.xyz_unit_luminance(); + for i in 0..3 { + assert!( + approx(white[i], expected[i], 1e-4), + "sRGB white → XYZ mismatch at {i}: {} vs {}", + white[i], + expected[i] + ); + } + } + + #[test] + fn srgb_to_acescg_matches_published_matrix() { + // Published sRGB → ACEScg transform (ACES transform docs): + // 0.6131324224 0.3395230762 0.0473445014 + // 0.0701922769 0.9163536767 0.0134540464 + // 0.0206157712 0.1095697056 0.8698145232 + let m = rgb_to_rgb_matrix(PRIMARIES_SRGB, PRIMARIES_AP1); + let expected: Mat3 = [ + [0.6131324224, 0.3395230762, 0.0473445014], + [0.0701922769, 0.9163536767, 0.0134540464], + [0.0206157712, 0.1095697056, 0.8698145232], + ]; + for r in 0..3 { + for c in 0..3 { + assert!( + approx(m[r][c], expected[r][c], 2e-3), + "matrix[{r}][{c}] = {} vs published {}", + m[r][c], + expected[r][c] + ); + } + } + } + + #[test] + fn srgb_red_maps_to_published_acescg() { + let mut samples = [1.0f32, 0.0, 0.0, 1.0]; + decode_to_acescg(&mut samples, SourcePrimaries::Bt709, SourceTransfer::Linear); + // sRGB red in ACEScg ≈ (0.6131, 0.0702, 0.0206). + assert!(approx(samples[0], 0.6131, 2e-3), "R = {}", samples[0]); + assert!(approx(samples[1], 0.0702, 2e-3), "G = {}", samples[1]); + assert!(approx(samples[2], 0.0206, 2e-3), "B = {}", samples[2]); + } + + #[test] + fn round_trip_srgb_acescg_srgb() { + // Gamma-encoded sRGB codes → (EOTF) → linear → ACEScg → (matrix) + // → sRGB linear → (OETF) → the original codes. + let original = [0.25f32, 0.5, 0.75, 1.0]; + let mut samples = original; + decode_to_acescg(&mut samples, SourcePrimaries::Bt709, SourceTransfer::SdrGamma); + acescg_to_output( + &mut samples, + OutputColorSpec { + gamut: OutputGamut::Srgb, + transfer: OutputTransfer::Srgb, + }, + ); + for i in 0..3 { + assert!( + approx(samples[i], original[i], 1e-4), + "round trip channel {i}: {} vs {}", + samples[i], + original[i] + ); + } + } + + #[test] + fn round_trip_bt2020_acescg_bt2020() { + let original = [0.2f32, 0.6, 0.9, 1.0]; + let mut samples = original; + decode_to_acescg(&mut samples, SourcePrimaries::Bt2020, SourceTransfer::SdrGamma); + acescg_to_output( + &mut samples, + OutputColorSpec { + gamut: OutputGamut::Bt2020, + transfer: OutputTransfer::Srgb, + }, + ); + for i in 0..3 { + assert!( + approx(samples[i], original[i], 1e-4), + "round trip channel {i}: {} vs {}", + samples[i], + original[i] + ); + } + } + + #[test] + fn white_stays_white_through_acescg() { + // Every primaries→AP1 matrix has rows summing to ~1, so the white + // point maps to (1,1,1) in ACEScg. + let mut samples = [1.0f32, 1.0, 1.0, 1.0]; + decode_to_acescg(&mut samples, SourcePrimaries::Bt709, SourceTransfer::Linear); + for i in 0..3 { + assert!(approx(samples[i], 1.0, 1e-3), "white channel {i} = {}", samples[i]); + } + } + + #[test] + fn srgb_transfer_anchors() { + // The piecewise curve's join point and endpoints. + assert!(approx(srgb_oetf(0.0), 0.0, 1e-6), "oetf(0)"); + assert!(approx(srgb_oetf(1.0), 1.0, 1e-6), "oetf(1)"); + assert!(approx(srgb_eotf(0.0), 0.0, 1e-6), "eotf(0)"); + assert!(approx(srgb_eotf(1.0), 1.0, 1e-6), "eotf(1)"); + // Mid-grey: linear 0.18 → ~0.461 sRGB code (the classic check). + assert!(approx(srgb_oetf(0.18), 0.4613, 1e-3), "oetf(0.18) = {}", srgb_oetf(0.18)); + // Round trip. + for v in [0.0f32, 0.01, 0.18, 0.5, 0.99, 1.0] { + assert!(approx(srgb_eotf(srgb_oetf(v)), v, 1e-4), "round trip {v}"); + } + } + + #[test] + fn pq_transfer_anchors() { + // PQ code 1.0 = 10 000 nits = normalized 1.0. + assert!(approx(pq_eotf(1.0), 1.0, 1e-4), "pq eotf(1) = {}", pq_eotf(1.0)); + // PQ code 0.5 ≈ 100 nits (normalized 0.01). + assert!(approx(pq_eotf(0.5), 0.01008, 2e-3), "pq eotf(0.5) = {}", pq_eotf(0.5)); + // Round trip. + for v in [0.0f32, 0.25, 0.5, 0.75, 1.0] { + assert!(approx(pq_eotf(pq_oetf(v)), v, 1e-3), "pq round trip {v}"); + } + } + + #[test] + fn hlg_transfer_anchors() { + assert!(approx(hlg_eotf(0.0), 0.0, 1e-6), "hlg eotf(0)"); + assert!(approx(hlg_eotf(1.0), 1.0, 1e-3), "hlg eotf(1) = {}", hlg_eotf(1.0)); + // The piecewise join: code 0.5 ↔ linear 1/12. + assert!(approx(hlg_eotf(0.5), 1.0 / 12.0, 1e-3), "hlg eotf(0.5) = {}", hlg_eotf(0.5)); + // Round trip. + for v in [0.0f32, 0.1, 0.5, 0.9, 1.0] { + assert!(approx(hlg_eotf(hlg_oetf(v)), v, 1e-3), "hlg round trip {v}"); + } + } + + #[test] + fn av_color_code_mapping() { + assert_eq!(source_primaries_from_av(1), SourcePrimaries::Bt709); + assert_eq!(source_primaries_from_av(9), SourcePrimaries::Bt2020); + assert_eq!(source_primaries_from_av(12), SourcePrimaries::DisplayP3); + assert_eq!(source_primaries_from_av(2), SourcePrimaries::Unknown); + assert_eq!(source_transfer_from_av(1), SourceTransfer::Gamma24); + assert_eq!(source_transfer_from_av(4), SourceTransfer::Gamma22); + assert_eq!(source_transfer_from_av(5), SourceTransfer::Gamma28); + assert_eq!(source_transfer_from_av(6), SourceTransfer::Gamma24); + assert_eq!(source_transfer_from_av(13), SourceTransfer::SdrGamma); + assert_eq!(source_transfer_from_av(14), SourceTransfer::Gamma24); + assert_eq!(source_transfer_from_av(15), SourceTransfer::Gamma24); + assert_eq!(source_transfer_from_av(16), SourceTransfer::Pq); + assert_eq!(source_transfer_from_av(18), SourceTransfer::Hlg); + assert_eq!(source_transfer_from_av(8), SourceTransfer::Linear); + assert_eq!(source_transfer_from_av(3), SourceTransfer::Unknown); + assert_eq!(source_transfer_from_av(7), SourceTransfer::Unknown); + } + + #[test] + fn setting_round_trips() { + assert_eq!(WorkingColorSpace::from_setting("acescg"), WorkingColorSpace::AcesCg); + assert_eq!( + WorkingColorSpace::from_setting("srgb_legacy"), + WorkingColorSpace::SrgbLegacy + ); + assert_eq!(WorkingColorSpace::from_setting("bogus"), WorkingColorSpace::AcesCg); + assert_eq!(OutputGamut::from_setting("displayp3"), OutputGamut::DisplayP3); + assert_eq!(OutputGamut::from_setting("bt2020"), OutputGamut::Bt2020); + assert_eq!(OutputGamut::from_setting(""), OutputGamut::Srgb); + assert_eq!(OutputTransfer::from_setting("pq"), OutputTransfer::Pq); + assert_eq!(OutputTransfer::from_setting("hlg"), OutputTransfer::Hlg); + assert_eq!(OutputTransfer::from_setting("gamma22"), OutputTransfer::Gamma22); + assert_eq!(OutputTransfer::from_setting(""), OutputTransfer::Srgb); + assert_eq!( + OutputColorSpec::from_settings("displayp3", "pq"), + OutputColorSpec { + gamut: OutputGamut::DisplayP3, + transfer: OutputTransfer::Pq + } + ); + } + + #[test] + fn legacy_working_space_is_pass_through() { + let mut samples = [0.25f32, 0.5, 0.75, 1.0]; + let original = samples; + working_to_display_target( + &mut samples, + WorkingColorSpace::SrgbLegacy, + OutputColorSpec::default(), + ); + assert_eq!(samples, original); + } + + #[test] + fn gamma_transfer_anchors() { + // Pure-power EOTFs at code 0.5. The 2.4 case is the BT.1886 + // mid-grey link: 0.5²·⁴ ≈ 0.189 (≈ the classic 18 % grey). + assert!(approx(gamma_eotf(0.5, 2.4), 0.1894646, 1e-5), "eotf(0.5, 2.4) = {}", gamma_eotf(0.5, 2.4)); + assert!(approx(gamma_eotf(0.5, 2.2), 0.2176376, 1e-5), "eotf(0.5, 2.2) = {}", gamma_eotf(0.5, 2.2)); + assert!(approx(gamma_eotf(0.5, 2.8), 0.1435894, 1e-5), "eotf(0.5, 2.8) = {}", gamma_eotf(0.5, 2.8)); + // Endpoints and round trips. + for gamma in [2.2f32, 2.4, 2.8] { + assert!(approx(gamma_eotf(0.0, gamma), 0.0, 1e-6), "eotf(0) g{gamma}"); + assert!(approx(gamma_eotf(1.0, gamma), 1.0, 1e-6), "eotf(1) g{gamma}"); + for v in [0.0f32, 0.18, 0.5, 0.9, 1.0] { + assert!( + approx(gamma_eotf(gamma_oetf(v, gamma), gamma), v, 1e-4), + "gamma round trip {v} @ {gamma}" + ); + } + } + } + + #[test] + fn decode_gamma24_dispatch() { + // BT.1886 decode: code 0.5 linearizes via pure power 2.4 (≈ 0.1895, + // not sRGB's piecewise ≈ 0.2140), then the BT.709 → AP1 matrix. + let mut samples = [0.5f32, 0.5, 0.5, 1.0]; + decode_to_acescg(&mut samples, SourcePrimaries::Bt709, SourceTransfer::Gamma24); + let m = rgb_to_rgb_matrix(PRIMARIES_SRGB, PRIMARIES_AP1); + let expected = mat_vec(m, [gamma_eotf(0.5, 2.4); 3]); + for i in 0..3 { + assert!( + approx(samples[i], expected[i], 1e-6), + "Gamma24 decode channel {i}: {} vs {}", + samples[i], + expected[i] + ); + } + // ... and the bytes variant picks the same curve. + let mut bytes = [0u8; 16]; + bytes[0..4].copy_from_slice(&0.5f32.to_le_bytes()); + bytes[4..8].copy_from_slice(&0.5f32.to_le_bytes()); + bytes[8..12].copy_from_slice(&0.5f32.to_le_bytes()); + decode_to_acescg_bytes(&mut bytes, 1, SourcePrimaries::Bt709, SourceTransfer::Gamma24); + let r = f32::from_le_bytes(bytes[0..4].try_into().unwrap()); + assert!(approx(r, expected[0], 1e-6), "bytes Gamma24 R: {r}"); + } + + #[test] + fn output_clamps_out_of_gamut_rgb() { + // Pure AP1 red is far outside the sRGB gamut: after the AP1 → sRGB + // matrix it has R > 1 and negative G/B. The P1 gamut-mapping contract + // clips those channels to [0, 1] before the transfer encoding. + let mut samples = [1.0f32, 0.0, 0.0, 0.5]; + acescg_to_output( + &mut samples, + OutputColorSpec { + gamut: OutputGamut::Srgb, + transfer: OutputTransfer::Srgb, + }, + ); + let m = rgb_to_rgb_matrix(PRIMARIES_AP1, PRIMARIES_SRGB); + let unclamped = mat_vec(m, [1.0, 0.0, 0.0]); + assert!( + unclamped[0] > 1.0 || unclamped[1] < 0.0 || unclamped[2] < 0.0, + "AP1 red should leave the sRGB gamut, got {unclamped:?}" + ); + let expected = [ + srgb_oetf(unclamped[0].clamp(0.0, 1.0)), + srgb_oetf(unclamped[1].clamp(0.0, 1.0)), + srgb_oetf(unclamped[2].clamp(0.0, 1.0)), + ]; + for c in 0..3 { + assert!( + approx(samples[c], expected[c], 1e-6), + "clamped channel {c}: {} vs {}", + samples[c], + expected[c] + ); + assert!(samples[c] >= 0.0 && samples[c] <= 1.0, "channel {c} escaped [0, 1]: {}", samples[c]); + } + assert_eq!(samples[3], 0.5, "alpha must be untouched"); + } + + #[test] + fn output_bytes_clamps_out_of_gamut_rgb() { + let mut bytes = [0u8; 16]; + bytes[0..4].copy_from_slice(&1.0f32.to_le_bytes()); // R = AP1 red + acescg_to_output_bytes( + &mut bytes, + 1, + OutputColorSpec { + gamut: OutputGamut::Srgb, + transfer: OutputTransfer::Gamma22, + }, + ); + let m = rgb_to_rgb_matrix(PRIMARIES_AP1, PRIMARIES_SRGB); + let unclamped = mat_vec(m, [1.0, 0.0, 0.0]); + let expected = gamma_oetf(unclamped[0].clamp(0.0, 1.0), 2.2); + let r = f32::from_le_bytes(bytes[0..4].try_into().unwrap()); + assert!(approx(r, expected, 1e-6), "bytes R: {r} vs {expected}"); + // Alpha (still 0.0) passes through untouched. + assert_eq!(f32::from_le_bytes(bytes[12..16].try_into().unwrap()), 0.0); + } + + /// Build a multi-row 16-bit plane byte buffer from `u16` row samples with + /// the given row stride (padding is left as zero). + fn yuv_plane16(rows: &[&[u16]], width: usize, stride: usize) -> Vec { + let mut plane = vec![0u8; rows.len().saturating_sub(1) * stride + width * 2]; + for (r, row) in rows.iter().enumerate() { + for (x, s) in row.iter().take(width).enumerate() { + let off = r * stride + x * 2; + plane[off] = s.to_le_bytes()[0]; + plane[off + 1] = s.to_le_bytes()[1]; + } + } + plane + } + + #[test] + fn yuv_bt709_limited_gray_anchors() { + // White (Y = 235<<8) → RGB 1.0, black (Y = 16<<8) → 0.0, neutral + // chroma 128<<8, BT.709 matrix, limited range. Stride is padded + // (4 bytes vs 2 for width 1) to exercise stride handling. + let width = 1; + let height = 2; + let stride = 4; + let y = yuv_plane16(&[&[235 << 8], &[16 << 8]], width, stride); + let u = yuv_plane16(&[&[128 << 8], &[128 << 8]], width, stride); + let v = u.clone(); + let mut out = [0.0f32; 8]; + yuv444p16_to_rgb_f32(&y, stride, &u, stride, &v, stride, width, height, YuvMatrix::Bt709, false, &mut out); + for c in 0..3 { + assert!(approx(out[c], 1.0, 1e-4), "white channel {c}: {}", out[c]); + assert!(approx(out[4 + c], 0.0, 1e-4), "black channel {c}: {}", out[4 + c]); + } + assert_eq!(out[3], 1.0, "alpha"); + assert_eq!(out[7], 1.0, "alpha"); + } + + #[test] + fn yuv_bt601_full_range_green_anchor() { + // Pure green in BT.601 full range (chroma span 65535, center 32768): + // Y = 0.587·65535, Cb = 32768 + (−0.587/1.772)·65535, + // Cr = 32768 + (−0.587/1.402)·65535, rounded to 16-bit codes. + let width = 1; + let height = 1; + let stride = 2; + let y = yuv_plane16(&[&[38469]], width, stride); + let u = yuv_plane16(&[&[11059]], width, stride); + let v = yuv_plane16(&[&[5329]], width, stride); + let mut out = [0.0f32; 4]; + yuv444p16_to_rgb_f32(&y, stride, &u, stride, &v, stride, width, height, YuvMatrix::Bt601, true, &mut out); + assert!(approx(out[0], 0.0, 1e-3), "R = {}", out[0]); + assert!(approx(out[1], 1.0, 1e-3), "G = {}", out[1]); + assert!(approx(out[2], 0.0, 1e-3), "B = {}", out[2]); + } + + #[test] + fn yuv_bt709_limited_green_anchor() { + // Pure green in BT.709 limited range: Y = 16 + 0.7152·219 (×256), + // Cb = 32768 + (−0.7152/1.8556)·57344, + // Cr = 32768 + (−0.7152/1.5748)·57344. + let width = 1; + let height = 1; + let stride = 2; + let y = yuv_plane16(&[&[44193]], width, stride); + let u = yuv_plane16(&[&[10666]], width, stride); + let v = yuv_plane16(&[&[6725]], width, stride); + let mut out = [0.0f32; 4]; + yuv444p16_to_rgb_f32(&y, stride, &u, stride, &v, stride, width, height, YuvMatrix::Bt709, false, &mut out); + assert!(approx(out[0], 0.0, 1e-3), "R = {}", out[0]); + assert!(approx(out[1], 1.0, 1e-3), "G = {}", out[1]); + assert!(approx(out[2], 0.0, 1e-3), "B = {}", out[2]); + } + + #[test] + fn yuv_short_planes_return_without_panic() { + // A 2×2 frame at stride 4 needs (2−1)·4 + 2·2 = 8 bytes per plane; + // 4 bytes is too short → early return, `out` untouched. + let mut out = [7.0f32; 4]; + yuv444p16_to_rgb_f32(&[0u8; 4], 4, &[0u8; 4], 4, &[0u8; 4], 4, 2, 2, YuvMatrix::Bt709, false, &mut out); + assert_eq!(out, [7.0; 4]); + // `out` too short for the frame → early return. + let mut out2 = [7.0f32; 3]; + yuv444p16_to_rgb_f32(&[0u8; 8], 4, &[0u8; 8], 4, &[0u8; 8], 4, 2, 2, YuvMatrix::Bt709, false, &mut out2); + assert_eq!(out2, [7.0; 3]); + } + + #[test] + fn output_srgb_to_xyz_d65_white_and_red() { + let spec = OutputColorSpec { + gamut: OutputGamut::Srgb, + transfer: OutputTransfer::Srgb, + }; + // White (1,1,1) → the D65 white point at unit luminance. + let mut white = [1.0f32, 1.0, 1.0, 1.0]; + output_spec_to_xyz_d65(&mut white, spec); + let expected = WHITE_D65.xyz_unit_luminance(); + for i in 0..3 { + assert!( + approx(white[i], expected[i], 1e-4), + "white → XYZ mismatch at {i}: {} vs {}", + white[i], + expected[i] + ); + } + // Red (1,0,0) → the published IEC 61966-2-1 sRGB→XYZ column. + let mut red = [1.0f32, 0.0, 0.0, 1.0]; + output_spec_to_xyz_d65(&mut red, spec); + let m = rgb_to_xyz_matrix(PRIMARIES_SRGB); + for i in 0..3 { + assert!( + approx(red[i], m[i][0], 1e-6), + "red → XYZ mismatch at {i}: {} vs {}", + red[i], + m[i][0] + ); + } + assert!(approx(red[0], 0.4124, 1e-3), "X = {}", red[0]); + assert!(approx(red[1], 0.2126, 1e-3), "Y = {}", red[1]); + assert!(approx(red[2], 0.0193, 1e-3), "Z = {}", red[2]); + } + + #[test] + fn output_spec_to_xyz_d65_honors_transfer() { + // Gamma-2.2 transfer: code 0.5 linearizes to 0.5^2.2 before the + // gamut matrix. + let spec = OutputColorSpec { + gamut: OutputGamut::Srgb, + transfer: OutputTransfer::Gamma22, + }; + let mut s = [0.5f32, 0.5, 0.5, 1.0]; + output_spec_to_xyz_d65(&mut s, spec); + let m = rgb_to_xyz_matrix(PRIMARIES_SRGB); + let expected = mat_vec(m, [gamma_eotf(0.5, 2.2); 3]); + for i in 0..3 { + assert!( + approx(s[i], expected[i], 1e-6), + "Gamma22 → XYZ channel {i}: {} vs {}", + s[i], + expected[i] + ); + } + // PQ / HLG code 1.0 linearizes to 1.0 → the D65 white point for any + // D65 gamut (also exercises the P3 matrix and the PQ/HLG branches). + for transfer in [OutputTransfer::Pq, OutputTransfer::Hlg] { + let spec = OutputColorSpec { + gamut: OutputGamut::DisplayP3, + transfer, + }; + let mut w = [1.0f32, 1.0, 1.0, 1.0]; + output_spec_to_xyz_d65(&mut w, spec); + let expected = WHITE_D65.xyz_unit_luminance(); + for i in 0..3 { + assert!( + approx(w[i], expected[i], 1e-4), + "{transfer:?} white → XYZ mismatch at {i}: {} vs {}", + w[i], + expected[i] + ); + } + } + } +} diff --git a/crates/oak-common/src/displayicc.rs b/crates/oak-common/src/displayicc.rs index dca66d646..577d95510 100644 --- a/crates/oak-common/src/displayicc.rs +++ b/crates/oak-common/src/displayicc.rs @@ -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 { - // 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 { + 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 { + 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 { + match monitor { + MonitorRef::MacDisplay(id) => macos::display_icc_for(*id), + _ => None, + } +} + +#[cfg(target_os = "windows")] +fn platform_display_icc_for(monitor: &MonitorRef) -> Option { + match monitor { + MonitorRef::WinDevice(device) => windows::display_icc_for(device), + _ => None, + } +} + +#[cfg(target_os = "linux")] +fn platform_display_icc_for(monitor: &MonitorRef) -> Option { + 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 { + None +} + +/// Parse a monitor fingerprint (the strings +/// [`crate::oak_app::oakui::displaycolor`] records, `"mac:"` / +/// `"win:"` / `"x11:"`) back into a [`MonitorRef`]. Any +/// malformed input yields `None`. +pub fn monitor_ref_from_fingerprint(fingerprint: &str) -> Option { + 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:`) 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 { + windows::monitor_device_name(hmonitor as usize).map(|name| format!("win:{name}")) +} + +/// The monitor fingerprint (`x11:`) 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 { + linux::x11_monitor_fingerprint_at(x, y) } #[cfg(target_os = "macos")] @@ -63,6 +164,15 @@ fn platform_display_icc() -> Option { 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 { linux::display_icc() @@ -134,6 +244,123 @@ fn parse_xprop_icc_hex(output: &str) -> Option> { } } +/// 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, +} + +/// 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 { + 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 = 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 = 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> { + 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 { - 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 { 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 { + let mut wide: Vec = 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::() 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 { + 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 { @@ -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 { + 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 { + let mut info = MonitorInfoExW { + cb_size: std::mem::size_of::() 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 { // 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 { + 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)>> = 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 { + 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 { + 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:`) 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 { + 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::::new()); + assert_eq!(parse_xrandr_monitors("Monitors: 0\n"), Vec::::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); + } } diff --git a/crates/oak-common/src/lib.rs b/crates/oak-common/src/lib.rs index 1739a8416..81b3c0bb3 100644 --- a/crates/oak-common/src/lib.rs +++ b/crates/oak-common/src/lib.rs @@ -22,6 +22,7 @@ #![warn(missing_docs)] pub mod cancelatom; +pub mod colormath; pub mod colortransform; pub mod commandlineparser; pub mod configstore; diff --git a/crates/oak-ffmpeg-link/build.rs b/crates/oak-ffmpeg-link/build.rs index 9e734a340..6eb843d8e 100644 --- a/crates/oak-ffmpeg-link/build.rs +++ b/crates/oak-ffmpeg-link/build.rs @@ -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") diff --git a/crates/oak-node/src/project.rs b/crates/oak-node/src/project.rs index 9312d818e..38a0c8eb9 100644 --- a/crates/oak-node/src/project.rs +++ b/crates/oak-node/src/project.rs @@ -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 diff --git a/crates/oak-plugin/src/clip.rs b/crates/oak-plugin/src/clip.rs index e995bbdd5..23d396e1d 100644 --- a/crates/oak-plugin/src/clip.rs +++ b/crates/oak-plugin/src/clip.rs @@ -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(), ), ); } diff --git a/crates/oak-render/src/color.rs b/crates/oak-render/src/color.rs index 27e9c5a23..5d0f7dd93 100644 --- a/crates/oak-render/src/color.rs +++ b/crates/oak-render/src/color.rs @@ -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::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::create_display_icc_xyz_impl(icc_path, true) + } + + fn create_display_icc_xyz_impl(icc_path: &str, bgra: bool) -> Option { + 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 { 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> = + 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 diff --git a/crates/oak-render/src/eval.rs b/crates/oak-render/src/eval.rs index 80928abce..a654213aa 100644 --- a/crates/oak-render/src/eval.rs +++ b/crates/oak-render/src/eval.rs @@ -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) -> @location(0) vec4 { 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(s.rgb * a + d.rgb * (1.0 - a), a + d.a * (1.0 - a)), vec4(0.0), vec4(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(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 diff --git a/crates/oak-render/src/ipc.rs b/crates/oak-render/src/ipc.rs index cee23655a..790940c5b 100644 --- a/crates/oak-render/src/ipc.rs +++ b/crates/oak-render/src/ipc.rs @@ -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>> = std::sync::Mutex::new(None); #[cfg(unix)] @@ -1292,6 +1295,48 @@ impl SharedMemoryRegion { } } + /// Sweep owned segments (`olive-rw--…`) 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 } diff --git a/crates/oak-render/tests/footage_decode_test.rs b/crates/oak-render/tests/footage_decode_test.rs index 385dd6073..27a31dc6d 100644 --- a/crates/oak-render/tests/footage_decode_test.rs +++ b/crates/oak-render/tests/footage_decode_test.rs @@ -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(); diff --git a/crates/oak-render/tests/graph_render.rs b/crates/oak-render/tests/graph_render.rs index 3d152f783..5ee134f6e 100644 --- a/crates/oak-render/tests/graph_render.rs +++ b/crates/oak-render/tests/graph_render.rs @@ -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>, 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>, NodeId) { + pin_legacy_working_space(); let project = Project::new(); let seq; { diff --git a/crates/oak-task/src/export.rs b/crates/oak-task/src/export.rs index ec161d59e..085068e0b 100644 --- a/crates/oak-task/src/export.rs +++ b/crates/oak-task/src/export.rs @@ -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); diff --git a/crates/oak-worker/Cargo.toml b/crates/oak-worker/Cargo.toml index 04a9574e8..1207a5b1c 100644 --- a/crates/oak-worker/Cargo.toml +++ b/crates/oak-worker/Cargo.toml @@ -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* diff --git a/crates/oak-worker/src/worker.rs b/crates/oak-worker/src/worker.rs index 4138a684f..4e884e86b 100644 --- a/crates/oak-worker/src/worker.rs +++ b/crates/oak-worker/src/worker.rs @@ -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 // --------------------------------------------------------------------------- diff --git a/crates/oak-worker/tests/procpool_integration.rs b/crates/oak-worker/tests/procpool_integration.rs index dcf90f34f..747de7acd 100644 --- a/crates/oak-worker/tests/procpool_integration.rs +++ b/crates/oak-worker/tests/procpool_integration.rs @@ -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 diff --git a/docs/zh/plans/acescg-f32-pipeline.md b/docs/zh/plans/acescg-f32-pipeline.md new file mode 100644 index 000000000..ab71dedb2 --- /dev/null +++ b/docs/zh/plans/acescg-f32-pipeline.md @@ -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)工作空间 = ACEScg(AP1 基色,线性 +传递),数据精度 = 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 的 UNORM:macOS `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, // 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 重建 swapchain;macOS 更新 + `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 sprites,atlas 上传): + 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` = sRGB(BT.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 example:24 色卡 + 灰阶 + 色域边界色)。 +- [ ] 引擎侧输入节点联调(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 模式)。 +- [ ] Windows:HDR swapchain(`DXGI_FORMAT_R16G16B16A16_FLOAT` + + `SetColorSpace1(DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020)`), + 查询 `DXGI_OUTPUT_DESC1` 的 HDR 状态。 +- [ ] Wayland:image 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 是否允许按设备能力降级 F16(Apple Silicon/现代独显 + 上两者带宽差异显著)?建议:默认 F32,配置项允许 F16。 +2. 色域映射算法:首期钳制是否可接受(视频内容可能越界)? + 还是 P1 就上色度压缩? +3. HDR→SDR 色调映射选型(P6)——影响输入侧"场景参考"语义定义, + 建议 P6 启动时单独评审。 +4. Wayland 下目标色域为非 sRGB(P3/BT.2020)时,是否要求合成器 + 支持对应 image description,还是回退 sRGB 输出(合成器能力查询 + `wp_color_manager_v1` 的 render intent/primaries 反馈)? +5. `ColorSpace::Oklab` 渐变在线性管线下的语义:Oklab 本为感知 + 均匀空间,输入应使用线性 sRGB——与 CSS `oklab` 一致,三端统一后 + 无歧义,但需确认与现有设计稿的视觉差异可接受。 diff --git a/gpui b/gpui index 4ed8b2bf7..41dac8f33 160000 --- a/gpui +++ b/gpui @@ -1 +1 @@ -Subproject commit 4ed8b2bf76b4bb9903c0e4d0ce1063dd09f38fb8 +Subproject commit 41dac8f33e12d9f657a451223511211241f485b1