color: non-sRGB preview, per-monitor display ICC, pipeline hardening
Preview now follows the project output colorspace end to end: the display chain derives its content space from the project's OutputColorSpec instead of a hardcoded sRGB name, self-managed ICC transforms go through an XYZ D65 interchange stage (OCIO cie_xyz_d65_interchange) for non-sRGB targets, and the platform layer declares the content colorspace (gpui submodule bump). macOS defaults to OS-managed (fixes wide-gamut UI oversaturation); Windows ACM warns once on non-sRGB targets. Multi-monitor: the display ICC is looked up per the window's current screen (macOS display id, Windows per-monitor DC, X11 RandR output profile) with a throttled poll that invalidates frame caches on moves. Pipeline precision: 10-bit+ sources fall back to YUV444P16LE + a Rust matrix conversion when swscale lacks F32 output (no more 8-bit truncation); BT.709/2020 SDR decodes with BT.1886 gamma 2.4 instead of the sRGB EOTF; working-space compositing no longer clamps RGB to [0,1] (alpha still clamped); the output node clamps to the target gamut; frames without colorimetry metadata convert with BT.709 defaults (warned once) instead of passing through; scopes read the output-colorspace signal on both F32 paths. Also: only emit rerun-if-changed for .env when it exists (a missing file made every build fully dirty).
This commit is contained in:
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -61,6 +61,10 @@ gpui_elements = { path = "../../gpui/crates/gpui_elements" }
|
||||
# runtime (bundled with the app, user-extensible) with the compiled-in
|
||||
# English table as the fallback.
|
||||
serde_yaml = "0.9"
|
||||
# The gpui/wgpu stack logs through the `log` facade; the app installs the
|
||||
# stderr backend (see oakapp::logging) so validation errors and warnings
|
||||
# are actually visible (RUST_LOG selects the verbosity).
|
||||
log = "0.4"
|
||||
|
||||
# M14 R3: the app is a PURE module-crate consumer — every engine call is a
|
||||
# direct Rust call into the oak* rlibs (oak-node for the project graph,
|
||||
|
||||
+36
-13
@@ -717,7 +717,11 @@ impl<E: AppEngine> OakApp<E> {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(16))
|
||||
.await;
|
||||
let _ = cx.update(|_window, app| {
|
||||
let _ = cx.update(|window, app| {
|
||||
// Multi-monitor: track which physical display the window
|
||||
// sits on so the self-managed display transform follows
|
||||
// window moves (throttled inside `poll_monitor`).
|
||||
crate::oakui::displaycolor::poll_monitor(window, app);
|
||||
if let Some(this) = this.upgrade() {
|
||||
this.update(app, |this, cx| this.tick(cx));
|
||||
}
|
||||
@@ -1990,6 +1994,9 @@ impl<E: AppEngine> OakApp<E> {
|
||||
crate::dialogs::PreferencesEvent::ShortcutsChanged => {
|
||||
this.rebind_keys(cx);
|
||||
}
|
||||
crate::dialogs::PreferencesEvent::DisplayColorChanged => {
|
||||
this.reapply_display_color(cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
@@ -2002,6 +2009,10 @@ impl<E: AppEngine> OakApp<E> {
|
||||
if let ModalState::Preferences { content, .. } = &self.modal {
|
||||
content.update(cx, |content, cx| content.commit_cache_dir(cx));
|
||||
}
|
||||
// The committed custom-ICC path may change the effective display
|
||||
// transform (or the policy, if the profile availability flipped):
|
||||
// re-declare on every window.
|
||||
self.reapply_display_color(cx);
|
||||
}
|
||||
|
||||
/// Re-applies the global key bindings and rebuilds the menu bar after a
|
||||
@@ -2015,6 +2026,20 @@ impl<E: AppEngine> OakApp<E> {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Re-evaluates the display color policy and retags every window after
|
||||
/// the color-management preference changed (the single-mapping rule must
|
||||
/// hold at runtime, not just at startup). Drops the cached display
|
||||
/// processors, then re-declares the policy on each open window.
|
||||
fn reapply_display_color(&mut self, cx: &mut Context<Self>) {
|
||||
crate::oakui::displaycolor::invalidate();
|
||||
for handle in cx.windows() {
|
||||
let _ = cx.update_window(handle, |_root, window, _app| {
|
||||
crate::oakui::displaycolor::apply_to_window(window);
|
||||
});
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Opens the action search dialog (Help > Search Actions…, the `/` key).
|
||||
/// Enter / double-click in the dialog executes the action through the same
|
||||
/// [`Self::dispatch_action_id`] path the menu clicks take, so the behavior
|
||||
@@ -3001,20 +3026,15 @@ fn run_with<E: AppEngine>(args: AppArgs) {
|
||||
if plugin_count > 0 {
|
||||
println!("[ofx] registered {plugin_count} OFX plugin node type(s)");
|
||||
}
|
||||
// Display color management: when the app transforms viewer frames
|
||||
// through the display ICC itself, the macOS Metal layer must be
|
||||
// tagged with the display colorspace so ColorSync passes the
|
||||
// pixels through (otherwise the OS re-corrects them). Read by
|
||||
// gpui_macos at layer creation, which happens below.
|
||||
if crate::oakui::displaycolor::is_active() {
|
||||
// SAFETY: single-threaded startup, before any window exists.
|
||||
unsafe { std::env::set_var("OAK_MACOS_LAYER_COLORSPACE", "display") };
|
||||
}
|
||||
// Display color management: the platform layer must know who maps
|
||||
// the pixels to the display (the single-mapping rule). Declared
|
||||
// per-window below, right after the window exists, and re-declared
|
||||
// whenever the preference changes (see displaycolor::apply_to_window).
|
||||
// Display bit depth: the wgpu window layer must pick the swapchain
|
||||
// format before the surface is created, so surface the persisted
|
||||
// choice the same way the colorspace tag is passed to the platform
|
||||
// layer above. Read by gpui_wgpu as OAK_DISPLAY_BIT_DEPTH ("8" opts
|
||||
// into the 8-bit pair; anything else requests 10-bit).
|
||||
// choice through the process environment. Read by gpui_wgpu as
|
||||
// OAK_DISPLAY_BIT_DEPTH ("8" opts into the 8-bit pair; anything
|
||||
// else requests 10-bit).
|
||||
let bit_depth = oak_render::backend::DisplayBitDepth::from_config_string(
|
||||
&crate::oakui::real::config_get_string(
|
||||
oak_render::backend::CONFIG_KEY_DISPLAY_BIT_DEPTH,
|
||||
@@ -3034,6 +3054,9 @@ fn run_with<E: AppEngine>(args: AppArgs) {
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| {
|
||||
// Declare who maps this window's pixels to the display
|
||||
// before the first frame paints.
|
||||
crate::oakui::displaycolor::apply_to_window(window);
|
||||
// The 10-bit display path: hand the window's wgpu device
|
||||
// to the engine so it can upload RGBA16F textures gpui's
|
||||
// renderer samples straight into the swapchain (no 8-bit
|
||||
|
||||
@@ -76,6 +76,9 @@ pub enum PreferencesEvent {
|
||||
/// must re-bind the global key map and rebuild the menu bar so the new
|
||||
/// keys take effect immediately.
|
||||
ShortcutsChanged,
|
||||
/// The display color-management mode changed (the host re-evaluates the
|
||||
/// platform policy and retags the windows immediately — no restart).
|
||||
DisplayColorChanged,
|
||||
}
|
||||
|
||||
impl gpui::EventEmitter<PreferencesEvent> for PreferencesContent {}
|
||||
@@ -373,8 +376,8 @@ impl PreferencesContent {
|
||||
// --- 色彩 Color: display ICC color management -----------------------
|
||||
// On by default: the viewer frames are transformed through the
|
||||
// display's ICC profile (system profile, or a custom file below).
|
||||
// The macOS layer tag is applied at startup, so a mode change takes
|
||||
// effect after a restart.
|
||||
// A mode change re-evaluates the platform display policy and
|
||||
// retags the windows immediately (no restart).
|
||||
use crate::oakui::displaycolor::{CONFIG_KEY_COLOR_MODE, CONFIG_KEY_CUSTOM_ICC};
|
||||
let display_icc = cx.new(|cx| {
|
||||
let mode = config_get_string(CONFIG_KEY_COLOR_MODE);
|
||||
@@ -395,6 +398,10 @@ impl PreferencesContent {
|
||||
let enabled = *state == CheckState::Checked;
|
||||
config_set_string(CONFIG_KEY_COLOR_MODE, if enabled { "icc" } else { "off" });
|
||||
check.update(cx, |check, cx| check.set_state(*state, cx));
|
||||
// Drop the cached processors and tell the host to retag the
|
||||
// windows for the new policy.
|
||||
crate::oakui::displaycolor::invalidate();
|
||||
cx.emit(PreferencesEvent::DisplayColorChanged);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
@@ -1557,6 +1564,12 @@ pub struct ProjectPropertiesContent<E: crate::oakui::engine::AppEngine> {
|
||||
/// 1 = alongside the project, 2 = custom path; see
|
||||
/// [`crate::oakui::engine::AppEngine::project_cache_location`]).
|
||||
cache_setting: i32,
|
||||
/// The pipeline working colorspace combo (ACEScg / sRGB legacy).
|
||||
working_space: Entity<ComboBox>,
|
||||
/// The delivery output gamut combo (sRGB / Display P3 / BT.2020).
|
||||
output_gamut: Entity<ComboBox>,
|
||||
/// The delivery output transfer combo (sRGB / gamma 2.2 / PQ / HLG).
|
||||
output_transfer: Entity<ComboBox>,
|
||||
/// The commit error shown under the OCIO row (an invalid config keeps
|
||||
/// the dialog open, like the C++ accept()).
|
||||
error: Option<String>,
|
||||
@@ -1608,12 +1621,54 @@ impl<E: crate::oakui::engine::AppEngine> ProjectPropertiesContent<E> {
|
||||
});
|
||||
custom_cache_path.update(cx, |field, cx| field.set_path(custom_path, cx));
|
||||
|
||||
// --- Color pipeline: working colorspace + delivery output --------
|
||||
let working_options = vec![
|
||||
ComboBoxOption::new(0, i18n::tr("projprops.color.working.acescg")),
|
||||
ComboBoxOption::new(1, i18n::tr("projprops.color.working.srgb")),
|
||||
];
|
||||
let working_space = cx.new(|cx| ComboBox::new(30, working_options, window, cx));
|
||||
let gamut_options = vec![
|
||||
ComboBoxOption::new(0, i18n::tr("projprops.color.gamut.srgb")),
|
||||
ComboBoxOption::new(1, i18n::tr("projprops.color.gamut.p3")),
|
||||
ComboBoxOption::new(2, i18n::tr("projprops.color.gamut.bt2020")),
|
||||
];
|
||||
let output_gamut = cx.new(|cx| ComboBox::new(30, gamut_options, window, cx));
|
||||
let transfer_options = vec![
|
||||
ComboBoxOption::new(0, i18n::tr("projprops.color.transfer.srgb")),
|
||||
ComboBoxOption::new(1, i18n::tr("projprops.color.transfer.gamma22")),
|
||||
ComboBoxOption::new(2, i18n::tr("projprops.color.transfer.pq")),
|
||||
ComboBoxOption::new(3, i18n::tr("projprops.color.transfer.hlg")),
|
||||
];
|
||||
let output_transfer = cx.new(|cx| ComboBox::new(30, transfer_options, window, cx));
|
||||
let (working, gamut, transfer) = engine.read(cx).project_color_settings();
|
||||
working_space.update(cx, |combo, cx| {
|
||||
combo.set_selected(
|
||||
Some(oak_common::colormath::WorkingColorSpace::from_setting(&working) as usize),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
output_gamut.update(cx, |combo, cx| {
|
||||
combo.set_selected(
|
||||
Some(oak_common::colormath::OutputGamut::from_setting(&gamut) as usize),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
output_transfer.update(cx, |combo, cx| {
|
||||
combo.set_selected(
|
||||
Some(oak_common::colormath::OutputTransfer::from_setting(&transfer) as usize),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
Self {
|
||||
engine,
|
||||
ocio_config,
|
||||
cache_location,
|
||||
custom_cache_path,
|
||||
cache_setting,
|
||||
working_space,
|
||||
output_gamut,
|
||||
output_transfer,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
@@ -1660,7 +1715,8 @@ impl<E: crate::oakui::engine::AppEngine> ProjectPropertiesContent<E> {
|
||||
|
||||
/// Applies the edited settings (the C++ `accept()`): validates and
|
||||
/// applies the OCIO config override first — an invalid config keeps the
|
||||
/// dialog open — then the disk-cache location. Ok clears the error row.
|
||||
/// dialog open — then the disk-cache location and the color pipeline
|
||||
/// settings. Ok clears the error row.
|
||||
pub fn commit(&mut self, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
let ocio = self.ocio_config_path(cx).to_string();
|
||||
self.engine
|
||||
@@ -1671,10 +1727,57 @@ impl<E: crate::oakui::engine::AppEngine> ProjectPropertiesContent<E> {
|
||||
self.engine.update(cx, |engine, cx| {
|
||||
engine.set_project_cache_location(setting, path, cx)
|
||||
});
|
||||
// The color pipeline settings: combo index → canonical setting
|
||||
// string via the colormath enums (single source of truth).
|
||||
let (working, gamut, transfer) = self.color_settings(cx);
|
||||
self.engine.update(cx, |engine, cx| {
|
||||
engine.set_project_color_settings(working, gamut, transfer, cx)
|
||||
});
|
||||
self.set_error(None, cx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The color pipeline settings currently selected in the combos, as
|
||||
/// the canonical persisted strings.
|
||||
fn color_settings(&self, cx: &App) -> (String, String, String) {
|
||||
use oak_common::colormath::{OutputGamut, OutputTransfer, WorkingColorSpace};
|
||||
let working = self
|
||||
.working_space
|
||||
.read(cx)
|
||||
.selected()
|
||||
.map(|i| match i {
|
||||
1 => WorkingColorSpace::SrgbLegacy,
|
||||
_ => WorkingColorSpace::AcesCg,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let gamut = self
|
||||
.output_gamut
|
||||
.read(cx)
|
||||
.selected()
|
||||
.map(|i| match i {
|
||||
1 => OutputGamut::DisplayP3,
|
||||
2 => OutputGamut::Bt2020,
|
||||
_ => OutputGamut::Srgb,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let transfer = self
|
||||
.output_transfer
|
||||
.read(cx)
|
||||
.selected()
|
||||
.map(|i| match i {
|
||||
1 => OutputTransfer::Gamma22,
|
||||
2 => OutputTransfer::Pq,
|
||||
3 => OutputTransfer::Hlg,
|
||||
_ => OutputTransfer::Srgb,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
(
|
||||
working.as_setting().to_string(),
|
||||
gamut.as_setting().to_string(),
|
||||
transfer.as_setting().to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The error shown under the OCIO row after a rejected commit.
|
||||
pub fn set_error(&mut self, msg: Option<String>, cx: &mut Context<Self>) {
|
||||
self.error = msg;
|
||||
@@ -1771,6 +1874,21 @@ impl<E: crate::oakui::engine::AppEngine> Render for ProjectPropertiesContent<E>
|
||||
self.cache_location.clone(),
|
||||
))
|
||||
.child(custom_row)
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("projprops.color.working").into(),
|
||||
self.working_space.clone(),
|
||||
))
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("projprops.color.gamut").into(),
|
||||
self.output_gamut.clone(),
|
||||
))
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("projprops.color.transfer").into(),
|
||||
self.output_transfer.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1827,6 +1945,9 @@ impl PreferencesDialogContent {
|
||||
}
|
||||
PreferencesEvent::LanguageChanged => cx.emit(PreferencesEvent::LanguageChanged),
|
||||
PreferencesEvent::ShortcutsChanged => {}
|
||||
PreferencesEvent::DisplayColorChanged => {
|
||||
cx.emit(PreferencesEvent::DisplayColorChanged);
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! A minimal stderr backend for the `log` facade.
|
||||
//!
|
||||
//! The gpui / wgpu / oak stack all emit through `log::*`, but nothing in the
|
||||
//! binary installed a logger, so every validation error, adapter warning and
|
||||
//! diagnostic was silently dropped (this is what made the viewer-black-screen
|
||||
//! fault invisible for so long). [`init`] installs a small logger that writes
|
||||
//! to stderr; `RUST_LOG` selects the verbosity:
|
||||
//!
|
||||
//! * unset — `warn` and above;
|
||||
//! * a level name (`error` / `warn` / `info` / `debug` / `trace`) — that
|
||||
//! level and above for every target;
|
||||
//! * comma-separated `target=level` pairs (`wgpu=debug,oak_render=info`) —
|
||||
//! per-target overrides on top of the global level. A bare level among the
|
||||
//! pairs sets the global level.
|
||||
|
||||
use std::sync::Once;
|
||||
|
||||
use log::{Level, LevelFilter, Metadata, Record};
|
||||
|
||||
/// One parsed `RUST_LOG` directive set.
|
||||
struct Filter {
|
||||
/// The global (default) level.
|
||||
global: LevelFilter,
|
||||
/// Per-target overrides (`target` prefix → level).
|
||||
targets: Vec<(String, LevelFilter)>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Parse the `RUST_LOG` value (`None` / empty → warn-only).
|
||||
fn parse(spec: Option<&str>) -> Self {
|
||||
let mut global = LevelFilter::Warn;
|
||||
let mut targets = Vec::new();
|
||||
if let Some(spec) = spec.filter(|s| !s.trim().is_empty()) {
|
||||
for part in spec.split(',') {
|
||||
let part = part.trim();
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match part.split_once('=') {
|
||||
Some((target, level)) => {
|
||||
if let Some(level) = parse_level(level.trim()) {
|
||||
targets.push((target.trim().to_string(), level));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// A bare level sets the global filter.
|
||||
if let Some(level) = parse_level(part) {
|
||||
global = level;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { global, targets }
|
||||
}
|
||||
|
||||
/// The effective level for `target` (longest matching prefix wins).
|
||||
fn level_for(&self, target: &str) -> LevelFilter {
|
||||
let mut best: Option<(usize, LevelFilter)> = None;
|
||||
for (prefix, level) in &self.targets {
|
||||
if target.starts_with(prefix.as_str()) {
|
||||
let len = prefix.len();
|
||||
if best.map(|(b, _)| len >= b).unwrap_or(true) {
|
||||
best = Some((len, *level));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, level)| level).unwrap_or(self.global)
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a level name to a [`LevelFilter`].
|
||||
fn parse_level(text: &str) -> Option<LevelFilter> {
|
||||
match text.to_ascii_lowercase().as_str() {
|
||||
"off" => Some(LevelFilter::Off),
|
||||
"error" => Some(LevelFilter::Error),
|
||||
"warn" | "warning" => Some(LevelFilter::Warn),
|
||||
"info" => Some(LevelFilter::Info),
|
||||
"debug" => Some(LevelFilter::Debug),
|
||||
"trace" => Some(LevelFilter::Trace),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The logger installed by [`init`].
|
||||
struct StderrLogger {
|
||||
filter: Filter,
|
||||
}
|
||||
|
||||
impl log::Log for StderrLogger {
|
||||
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||
metadata.level() <= self.filter.level_for(metadata.target())
|
||||
}
|
||||
|
||||
fn log(&self, record: &Record) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
let level = match record.level() {
|
||||
Level::Error => "ERROR",
|
||||
Level::Warn => "WARN",
|
||||
Level::Info => "INFO",
|
||||
Level::Debug => "DEBUG",
|
||||
Level::Trace => "TRACE",
|
||||
};
|
||||
eprintln!(
|
||||
"[{level} {}] {}",
|
||||
record.target(),
|
||||
record.args()
|
||||
);
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
/// Install the stderr logger once (idempotent). Honors `RUST_LOG`.
|
||||
pub fn init() {
|
||||
INIT.call_once(|| {
|
||||
let filter = Filter::parse(std::env::var("RUST_LOG").ok().as_deref());
|
||||
let max = filter
|
||||
.targets
|
||||
.iter()
|
||||
.map(|(_, level)| *level)
|
||||
.max()
|
||||
.unwrap_or(LevelFilter::Off)
|
||||
.max(filter.global);
|
||||
let logger = Box::leak(Box::new(StderrLogger { filter }));
|
||||
if log::set_logger(logger).is_ok() {
|
||||
log::set_max_level(max);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_warn() {
|
||||
let f = Filter::parse(None);
|
||||
assert_eq!(f.level_for("anything"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for(""), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_level_sets_global() {
|
||||
let f = Filter::parse(Some("info"));
|
||||
assert_eq!(f.level_for("wgpu_hal"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("oak_render"), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_overrides_with_longest_prefix() {
|
||||
let f = Filter::parse(Some("warn,wgpu=debug,wgpu_hal=trace"));
|
||||
assert_eq!(f.level_for("wgpu"), LevelFilter::Debug);
|
||||
assert_eq!(f.level_for("wgpu_core"), LevelFilter::Debug);
|
||||
assert_eq!(f.level_for("wgpu_hal::vulkan"), LevelFilter::Trace);
|
||||
assert_eq!(f.level_for("oak_render"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_level_aliases() {
|
||||
assert_eq!(parse_level("warning"), Some(LevelFilter::Warn));
|
||||
assert_eq!(parse_level("OFF"), Some(LevelFilter::Off));
|
||||
assert_eq!(parse_level("bogus"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_spec_falls_back_to_warn() {
|
||||
let f = Filter::parse(Some(" "));
|
||||
assert_eq!(f.level_for("x"), LevelFilter::Warn);
|
||||
}
|
||||
}
|
||||
@@ -14,81 +14,320 @@
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Display color management: the display's ICC profile applied to viewer
|
||||
//! frames at present time.
|
||||
//! Display color management: the display mapping applied to viewer frames
|
||||
//! at present time, and — critically — WHO performs it.
|
||||
//!
|
||||
//! The frame content is treated as display-referred sRGB/Rec.709 (the
|
||||
//! decode/render pipeline performs no input transfer conversion today);
|
||||
//! the chain maps it through the display ICC (system profile or a custom
|
||||
//! file from Preferences) so wide-gamut displays render correctly.
|
||||
//! ## The single-mapping rule
|
||||
//!
|
||||
//! Double-correction discipline: when this module transforms pixels, the
|
||||
//! OS must not transform them again. macOS: the app sets the CAMetalLayer
|
||||
//! colorspace to the display profile at startup (see the `OAK_METAL_*`
|
||||
//! wiring in app.rs) so ColorSync passes our output through. Windows:
|
||||
//! the SDR desktop applies no per-app transform (and ACM honors the
|
||||
//! swapchain's declared sRGB space, which is the default). Linux: no
|
||||
//! compositor-level correction exists to conflict with.
|
||||
//! The frame content leaves the pipeline in a colorimetrically defined
|
||||
//! space (the project's output target; sRGB by default). The final mapping
|
||||
//! to the physical display must happen EXACTLY ONCE — either the OS does it
|
||||
//! (color-managed compositors / ColorSync / DWM-ACM) or the app does it
|
||||
//! (an OCIO chain through the display ICC). Both at once doubles the
|
||||
//! correction; neither leaves wide-gamut displays wrong.
|
||||
//!
|
||||
//! [`display_policy`] makes that choice per platform:
|
||||
//!
|
||||
//! * **Wayland** — always [`DisplayPolicy::OsManaged`]. A Wayland
|
||||
//! compositor owns the display mapping (color-management-v1 declares the
|
||||
//! content as sRGB; compositors without the protocol assume sRGB, which
|
||||
//! the content is). Self-applying the display ICC here would double-map
|
||||
//! on every color-managed compositor (KWin 6.2+, GNOME 50+…).
|
||||
//! * **Windows** — [`DisplayPolicy::OsManaged`] when Windows 11 Auto Color
|
||||
//! Management is active (it maps the sRGB-declared swapchain to the
|
||||
//! display), otherwise [`DisplayPolicy::SelfManaged`]: older Windows has
|
||||
//! no per-app OS mapping, so the app applies the ICM profile itself.
|
||||
//! * **macOS** — [`DisplayPolicy::OsManaged`] by default: ColorSync maps
|
||||
//! the content (named via the layer's content colorspace) to the
|
||||
//! display. An explicit `DisplayColorMode=icc` preference hands the
|
||||
//! mapping to the app (the layer colorspace tag then makes the OS path
|
||||
//! a pass-through).
|
||||
//! * **X11** — the user's preference ([`CONFIG_KEY_COLOR_MODE`]): X11 has
|
||||
//! no compositor mapping, so self-management is the only way to honor
|
||||
//! wide-gamut displays there (default self-managed).
|
||||
//!
|
||||
//! `OAK_DISPLAY_POLICY=self|os` overrides the platform decision for
|
||||
//! debugging.
|
||||
//!
|
||||
//! ## The content space
|
||||
//!
|
||||
//! The chain starts from the project's output spec
|
||||
//! ([`oak_render::color::pipeline_output_spec`]): sRGB content runs
|
||||
//! through the named sRGB/Rec.709 space of the active OCIO config, and
|
||||
//! non-sRGB content (P3/BT.2020 gamuts, PQ/HLG transfers) is converted to
|
||||
//! CIE XYZ (D65, unit luminance) first and flows through the ICC's
|
||||
//! connection space (the `cie_xyz_d65_interchange` chain of
|
||||
//! [`ColorProcessor::create_display_icc_xyz`]). A non-empty
|
||||
//! [`CONFIG_KEY_CONTENT_SPACE`] overrides the spec with an explicit OCIO
|
||||
//! colorspace name.
|
||||
//!
|
||||
//! When the policy is OS-managed this module transforms nothing and the
|
||||
//! platform layer declares the content colorspace instead (macOS layer
|
||||
//! colorspace, Windows `SetColorSpace1`, Wayland color-management-v1),
|
||||
//! re-declared whenever the project output spec changes.
|
||||
//!
|
||||
//! ## Multi-monitor tracking
|
||||
//!
|
||||
//! The display ICC depends on WHICH physical monitor the window is on, so
|
||||
//! the effective key additionally carries the current monitor fingerprint.
|
||||
//! [`poll_monitor`] runs on the app's tick loop and records the fingerprint
|
||||
//! (throttled to one probe every 2s); [`note_monitor`] sets it directly
|
||||
//! (tests, other callers). Fingerprints are opaque strings —
|
||||
//! `"mac:<CGDirectDisplayID>"`, `"win:<device name>"`, `"x11:<RandR
|
||||
//! output>"` — and the empty string means "no known monitor", which falls
|
||||
//! back to the main-display profile. The `SelfManaged` policy keys the
|
||||
//! transform chain per fingerprint, so dragging the window onto another
|
||||
//! display re-resolves its ICC without touching the OS-managed paths.
|
||||
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use oak_common::colormath::{output_spec_to_xyz_d65, OutputColorSpec, OutputGamut, OutputTransfer};
|
||||
use oak_common::configstore::ConfigStore;
|
||||
use oak_render::color::ColorProcessor;
|
||||
use oak_render::color::{pipeline_output_spec, ColorProcessor};
|
||||
|
||||
/// Config key: the display color management mode ("icc" / "off").
|
||||
/// Config key: the display color management mode ("icc" / "off"). The
|
||||
/// preference only applies where the platform policy allows self-management
|
||||
/// (macOS, X11, non-ACM Windows).
|
||||
pub const CONFIG_KEY_COLOR_MODE: &str = "DisplayColorMode";
|
||||
/// Config key: a custom ICC profile path (empty = the system display
|
||||
/// profile).
|
||||
pub const CONFIG_KEY_CUSTOM_ICC: &str = "DisplayColorCustomIcc";
|
||||
/// Config key: the content colorspace the chain starts from (an OCIO
|
||||
/// colorspace name of the active config).
|
||||
/// Config key: the content colorspace the display chain starts from (an
|
||||
/// OCIO colorspace name of the active config). Empty (the default) =
|
||||
/// follow the project's output spec.
|
||||
pub const CONFIG_KEY_CONTENT_SPACE: &str = "DisplayColorContentSpace";
|
||||
|
||||
/// The default content space (OCIO 2.2 builtin config name for
|
||||
/// gamma-encoded Rec.709/sRGB display-referred content).
|
||||
const DEFAULT_CONTENT_SPACE: &str = "sRGB Encoded Rec.709 (sRGB)";
|
||||
|
||||
/// The cached processor pair (F32 RGBA and packed BGRA8 variants of the
|
||||
/// same chain), keyed by (mode, icc path, content space).
|
||||
/// Who performs the final mapping to the physical display.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DisplayPolicy {
|
||||
/// The app applies the display transform itself (the display ICC chain);
|
||||
/// the platform layer must be tagged so the OS passes pixels through.
|
||||
SelfManaged,
|
||||
/// The OS maps the (sRGB-declared) content to the display; the app must
|
||||
/// not apply any display transform.
|
||||
OsManaged,
|
||||
}
|
||||
|
||||
/// The cached processor set for one (policy, mode, icc, content, monitor)
|
||||
/// key: the sRGB-named chain for F32 samples, the XYZ (PCS) chain for
|
||||
/// non-sRGB project output, and the BGRA8 variant of the sRGB chain — the
|
||||
/// viewer's 8-bit slot always runs the sRGB chain, a degradation for
|
||||
/// non-sRGB specs whose encoding 8-bit cannot honor (PQ/HLG highlights
|
||||
/// clamp).
|
||||
struct State {
|
||||
key: (String, String, String),
|
||||
key: (DisplayPolicy, String, String, String, String),
|
||||
f32: Option<Arc<ColorProcessor>>,
|
||||
xyz: Option<Arc<ColorProcessor>>,
|
||||
bgra: Option<Arc<ColorProcessor>>,
|
||||
}
|
||||
|
||||
static STATE: LazyLock<Mutex<Option<State>>> = LazyLock::new(|| Mutex::new(None));
|
||||
|
||||
/// Bumped every time the effective key changes (mode / ICC path /
|
||||
/// content space): the engine's frame caches compare against it and drop
|
||||
/// images produced with a stale transform.
|
||||
/// Bumped every time the effective key changes (policy / mode / ICC path /
|
||||
/// content space / monitor): the engine's frame caches compare against it
|
||||
/// and drop images produced with a stale transform.
|
||||
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// The current monitor fingerprint (see [`poll_monitor`]): `None` (the
|
||||
/// default) means the window's monitor is unknown and the main-display
|
||||
/// profile is used. Updated only on a successful probe — a failed probe
|
||||
/// keeps the last known monitor rather than degrading mid-session.
|
||||
static CURRENT_MONITOR: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
/// When [`poll_monitor`] last probed the monitor; the first call always
|
||||
/// probes immediately, later ones at most every [`MONITOR_PROBE_INTERVAL`].
|
||||
static LAST_MONITOR_PROBE: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
|
||||
/// Minimum time between two monitor probes in [`poll_monitor`].
|
||||
const MONITOR_PROBE_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
/// The current transform generation (see [`GENERATION`]).
|
||||
pub fn generation() -> u64 {
|
||||
GENERATION.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The active (mode, icc, content-space) key from the config.
|
||||
fn current_key() -> (String, String, String) {
|
||||
let store = ConfigStore::instance();
|
||||
let mode = store
|
||||
.get(None, CONFIG_KEY_COLOR_MODE)
|
||||
.unwrap_or_else(|_| "icc".to_string());
|
||||
let custom = store
|
||||
.get(None, CONFIG_KEY_CUSTOM_ICC)
|
||||
.unwrap_or_default();
|
||||
let space = store
|
||||
.get(None, CONFIG_KEY_CONTENT_SPACE)
|
||||
.unwrap_or_else(|_| DEFAULT_CONTENT_SPACE.to_string());
|
||||
(mode, custom, space)
|
||||
/// True when the session runs on a Wayland compositor (the same probe the
|
||||
/// platform backend uses, `gpui::guess_compositor`).
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
fn on_wayland() -> bool {
|
||||
gpui::guess_compositor() == "Wayland"
|
||||
}
|
||||
|
||||
/// Drop the cached processors (call after a preferences change).
|
||||
/// The platform policy decision for this session (see the module docs).
|
||||
/// Stable for the process lifetime except the macOS/X11 preference, which
|
||||
/// the config key governs at runtime.
|
||||
pub fn display_policy() -> DisplayPolicy {
|
||||
if let Ok(override_mode) = std::env::var("OAK_DISPLAY_POLICY") {
|
||||
match override_mode.to_ascii_lowercase().as_str() {
|
||||
"os" => return DisplayPolicy::OsManaged,
|
||||
"self" => return DisplayPolicy::SelfManaged,
|
||||
other => {
|
||||
log::warn!(
|
||||
"OAK_DISPLAY_POLICY={other:?} ignored (expected \"os\" or \"self\")"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wayland: the compositor owns the display mapping; declaring sRGB
|
||||
// content (color-management-v1) is the only correct behavior.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
if on_wayland() {
|
||||
return DisplayPolicy::OsManaged;
|
||||
}
|
||||
|
||||
// Windows: Auto Color Management (Windows 11) maps the sRGB-declared
|
||||
// swapchain for us — self-applying the ICM profile would double-map.
|
||||
// Without ACM (Windows 10 / ACM off) no per-app OS mapping exists, so
|
||||
// the app applies the profile itself. ACM's swapchain mapping is SDR
|
||||
// sRGB; a non-sRGB project output can't be honored through it — warn
|
||||
// once so the degradation is visible in the log.
|
||||
#[cfg(target_os = "windows")]
|
||||
if oak_common::displayicc::windows_acm_active() {
|
||||
if pipeline_output_spec() != OutputColorSpec::default() {
|
||||
static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
|
||||
WARNED.get_or_init(|| {
|
||||
log::warn!(
|
||||
"Windows ACM: project output {:?} is not sRGB; ACM maps the sRGB swapchain only, \
|
||||
non-sRGB previews will not match the project colorspace",
|
||||
pipeline_output_spec()
|
||||
);
|
||||
});
|
||||
}
|
||||
return DisplayPolicy::OsManaged;
|
||||
}
|
||||
|
||||
// macOS: ColorSync owns the display mapping by default; only an
|
||||
// explicit "icc" preference hands it to the app (the layer colorspace
|
||||
// tag then turns the OS path into a pass-through). X11 has no OS
|
||||
// mapping, so self-management is the only way to honor wide-gamut
|
||||
// displays there (the "off" preference opts back into OS-management).
|
||||
#[cfg(target_os = "macos")]
|
||||
match ConfigStore::instance().get(None, CONFIG_KEY_COLOR_MODE).as_deref() {
|
||||
Ok("icc") => DisplayPolicy::SelfManaged,
|
||||
_ => DisplayPolicy::OsManaged,
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
match configured_mode().as_str() {
|
||||
"off" => DisplayPolicy::OsManaged,
|
||||
_ => DisplayPolicy::SelfManaged,
|
||||
}
|
||||
}
|
||||
|
||||
/// The persisted mode preference ("icc" = self-manage, "off" = OS-managed).
|
||||
fn configured_mode() -> String {
|
||||
ConfigStore::instance()
|
||||
.get(None, CONFIG_KEY_COLOR_MODE)
|
||||
.unwrap_or_else(|_| "icc".to_string())
|
||||
}
|
||||
|
||||
/// Drop the cached processors (call after a preference change).
|
||||
pub fn invalidate() {
|
||||
*STATE.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
|
||||
/// The cached state, (re)built when the config key changed.
|
||||
/// Record the window's current monitor fingerprint (see [`poll_monitor`]).
|
||||
/// `None` clears the tracking back to the main-display lookup.
|
||||
pub fn note_monitor(id: Option<String>) {
|
||||
*CURRENT_MONITOR.lock().unwrap_or_else(|e| e.into_inner()) = id;
|
||||
}
|
||||
|
||||
/// Track which physical monitor the window sits on, so the self-managed
|
||||
/// display transform follows window moves between displays.
|
||||
///
|
||||
/// Runs on the app's tick loop. No-op unless the policy is [`DisplayPolicy::SelfManaged`]
|
||||
/// (an OS-managed session never consults the display ICC). Probes are
|
||||
/// throttled to one per [`MONITOR_PROBE_INTERVAL`] — the first call probes
|
||||
/// immediately — and only a successful probe updates the tracked monitor:
|
||||
/// a transient failure keeps the last known one rather than falling back
|
||||
/// mid-session.
|
||||
pub fn poll_monitor(window: &gpui::Window, cx: &gpui::App) {
|
||||
if display_policy() != DisplayPolicy::SelfManaged {
|
||||
return;
|
||||
}
|
||||
{
|
||||
let mut last = LAST_MONITOR_PROBE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(stamp) = *last {
|
||||
if stamp.elapsed() < MONITOR_PROBE_INTERVAL {
|
||||
return;
|
||||
}
|
||||
}
|
||||
*last = Some(Instant::now());
|
||||
}
|
||||
if let Some(fingerprint) = current_monitor_fingerprint(window, cx) {
|
||||
note_monitor(Some(fingerprint));
|
||||
}
|
||||
}
|
||||
|
||||
/// The current monitor of `window` as a fingerprint string (see the module
|
||||
/// docs), or `None` when the platform cannot resolve one. The fingerprint
|
||||
/// is derived from the window's platform display: macOS and Windows use the
|
||||
/// gpui display id of the window's current screen (`window.display`), Linux
|
||||
/// resolves the RandR output whose geometry covers the window center
|
||||
/// (window bounds are logical pixels there; multiplied by the scale factor
|
||||
/// they become device pixels).
|
||||
fn current_monitor_fingerprint(window: &gpui::Window, cx: &gpui::App) -> Option<String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let id = u64::from(window.display(cx)?.id());
|
||||
return Some(format!("mac:{id}"));
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let id = u64::from(window.display(cx)?.id());
|
||||
return oak_common::displayicc::windows_monitor_fingerprint(id);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = cx;
|
||||
let center = window.bounds().center();
|
||||
let x = f64::from(center.x) * f64::from(window.scale_factor());
|
||||
let y = f64::from(center.y) * f64::from(window.scale_factor());
|
||||
return oak_common::displayicc::x11_monitor_fingerprint_at(x, y);
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
let _ = (window, cx);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The active (policy, mode, icc, content, monitor) key.
|
||||
fn current_key() -> (DisplayPolicy, String, String, String, String) {
|
||||
let policy = display_policy();
|
||||
let custom = ConfigStore::instance()
|
||||
.get(None, CONFIG_KEY_CUSTOM_ICC)
|
||||
.unwrap_or_default();
|
||||
// The content space: a non-empty override (an explicit OCIO colorspace
|
||||
// name) wins; otherwise the chain follows the project's output spec —
|
||||
// its (gamut, transfer) pair rekeys (and so rebuilds) the chain, so a
|
||||
// project settings change flows through the same GENERATION machinery
|
||||
// as a preference change.
|
||||
let space = ConfigStore::instance()
|
||||
.get(None, CONFIG_KEY_CONTENT_SPACE)
|
||||
.unwrap_or_default();
|
||||
let space = if space.is_empty() {
|
||||
let spec = pipeline_output_spec();
|
||||
format!("{}:{}", spec.gamut.as_setting(), spec.transfer.as_setting())
|
||||
} else {
|
||||
format!("override:{space}")
|
||||
};
|
||||
// The monitor fingerprint the window currently sits on; empty = unknown
|
||||
// (falls back to the main display).
|
||||
let monitor = CURRENT_MONITOR
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
(policy, configured_mode(), custom, space, monitor)
|
||||
}
|
||||
|
||||
/// The cached state, (re)built when the key changed.
|
||||
fn current() -> Option<State> {
|
||||
let key = current_key();
|
||||
let mut guard = STATE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -105,33 +344,64 @@ fn current() -> Option<State> {
|
||||
if guard.is_some() {
|
||||
GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
let (mode, icc_path, space) = &key;
|
||||
if mode != "icc" {
|
||||
let (policy, mode, icc_path, space, monitor) = &key;
|
||||
// OS-managed: the compositor/ColorSync/DWM performs the display
|
||||
// mapping; applying anything here would be a second correction.
|
||||
if *policy == DisplayPolicy::OsManaged || mode != "icc" {
|
||||
let state = State {
|
||||
key,
|
||||
f32: None,
|
||||
xyz: None,
|
||||
bgra: None,
|
||||
};
|
||||
let out = clone_state(&state);
|
||||
*guard = Some(state);
|
||||
return out;
|
||||
}
|
||||
// The custom override wins; empty = the platform's display profile.
|
||||
// Self-managed: content (in `space`) through the display ICC. The
|
||||
// custom override wins; empty = the current monitor's profile (a known
|
||||
// fingerprint resolves per-monitor, anything else the main display).
|
||||
let icc = if icc_path.is_empty() {
|
||||
oak_common::displayicc::system_display_icc()
|
||||
monitor_icc_path(monitor)
|
||||
} else {
|
||||
Some(icc_path.clone())
|
||||
};
|
||||
let (f32p, bgrap) = match icc {
|
||||
Some(path) => (
|
||||
ColorProcessor::create_display_icc(space, &path).map(Arc::new),
|
||||
ColorProcessor::create_display_icc_bgra8(space, &path).map(Arc::new),
|
||||
),
|
||||
None => (None, None),
|
||||
let (f32p, xyzp, bgrap) = match icc {
|
||||
// Explicit content-space override: the whole chain starts from
|
||||
// that OCIO colorspace name (both slots).
|
||||
Some(path) if space.starts_with("override:") => {
|
||||
let name = space.trim_start_matches("override:");
|
||||
(
|
||||
ColorProcessor::create_display_icc(name, &path).map(Arc::new),
|
||||
None,
|
||||
ColorProcessor::create_display_icc_bgra8(name, &path).map(Arc::new),
|
||||
)
|
||||
}
|
||||
// Project output spec: sRGB content through the named sRGB space;
|
||||
// non-sRGB content (P3/BT.2020 gamuts, PQ/HLG transfers) has no
|
||||
// named space in the builtin OCIO configs, so the F32 chain
|
||||
// converts it to CIE XYZ (PCS) first and runs the ICC half — the
|
||||
// sRGB chain stays as the degradation fallback and for the 8-bit
|
||||
// slot (whose encoding cannot honor non-sRGB specs).
|
||||
Some(path) => {
|
||||
let xyz = if pipeline_output_spec() != OutputColorSpec::default() {
|
||||
ColorProcessor::create_display_icc_xyz(&path).map(Arc::new)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(
|
||||
ColorProcessor::create_display_icc(DEFAULT_CONTENT_SPACE, &path).map(Arc::new),
|
||||
xyz,
|
||||
ColorProcessor::create_display_icc_bgra8(DEFAULT_CONTENT_SPACE, &path)
|
||||
.map(Arc::new),
|
||||
)
|
||||
}
|
||||
None => (None, None, None),
|
||||
};
|
||||
let state = State {
|
||||
key,
|
||||
f32: f32p,
|
||||
xyz: xyzp,
|
||||
bgra: bgrap,
|
||||
};
|
||||
let out = clone_state(&state);
|
||||
@@ -139,18 +409,76 @@ fn current() -> Option<State> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The display ICC path for the current monitor fingerprint: a known
|
||||
/// fingerprint resolves per-monitor (with a fallback to the main display
|
||||
/// inside `system_display_icc_for`); an unknown one (empty or malformed)
|
||||
/// falls back to the main-display profile — the pre-multi-monitor behavior.
|
||||
fn monitor_icc_path(monitor: &str) -> Option<String> {
|
||||
match oak_common::displayicc::monitor_ref_from_fingerprint(monitor) {
|
||||
Some(monitor) => oak_common::displayicc::system_display_icc_for(&monitor),
|
||||
None => oak_common::displayicc::system_display_icc(),
|
||||
}
|
||||
}
|
||||
|
||||
fn clone_state(state: &State) -> Option<State> {
|
||||
Some(State {
|
||||
key: state.key.clone(),
|
||||
f32: state.f32.clone(),
|
||||
xyz: state.xyz.clone(),
|
||||
bgra: state.bgra.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether display color management is active (a valid ICC processor
|
||||
/// exists). When false the OS owns the output mapping.
|
||||
/// Whether the app is self-applying a display transform right now (a valid
|
||||
/// ICC processor under the SelfManaged policy). When false the OS owns the
|
||||
/// output mapping and the platform layer must declare the content space.
|
||||
pub fn is_active() -> bool {
|
||||
current().map(|s| s.f32.is_some() || s.bgra.is_some()).unwrap_or(false)
|
||||
if display_policy() != DisplayPolicy::SelfManaged {
|
||||
return false;
|
||||
}
|
||||
current()
|
||||
.map(|s| s.f32.is_some() || s.xyz.is_some() || s.bgra.is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Declare the current policy to the window's platform layer (macOS: the
|
||||
/// Metal layer colorspace tag; other platforms handle it at the surface
|
||||
/// level or ignore it). Call once per window at creation, after any
|
||||
/// preference change that flips [`is_active`], and after the project
|
||||
/// output spec changes (which rekeys the content-colorspace declaration).
|
||||
pub fn apply_to_window(window: &mut gpui::Window) {
|
||||
let active = is_active();
|
||||
let mode = if active {
|
||||
gpui::LayerColorManagement::SelfManaged
|
||||
} else {
|
||||
gpui::LayerColorManagement::OsManaged
|
||||
};
|
||||
window.set_layer_color_management(mode);
|
||||
// OS-managed: name the content colorspace so the OS maps from it
|
||||
// (ColorSync, DWM-ACM, color-management-v1). The self-managed path is
|
||||
// tagged pass-through, so the declaration is meaningless there.
|
||||
if !active {
|
||||
window.set_content_colorspace(content_colorspace());
|
||||
}
|
||||
}
|
||||
|
||||
/// The gpui content-colorspace declaration for the current project output
|
||||
/// spec (only meaningful while the OS performs the display mapping).
|
||||
fn content_colorspace() -> gpui::WindowContentColorspace {
|
||||
let spec = pipeline_output_spec();
|
||||
gpui::WindowContentColorspace {
|
||||
primaries: match spec.gamut {
|
||||
OutputGamut::Srgb => gpui::ContentPrimaries::Srgb,
|
||||
OutputGamut::DisplayP3 => gpui::ContentPrimaries::DisplayP3,
|
||||
OutputGamut::Bt2020 => gpui::ContentPrimaries::Bt2020,
|
||||
},
|
||||
transfer: match spec.transfer {
|
||||
OutputTransfer::Srgb => gpui::ContentTransfer::Srgb,
|
||||
OutputTransfer::Gamma22 => gpui::ContentTransfer::Gamma22,
|
||||
OutputTransfer::Pq => gpui::ContentTransfer::Pq,
|
||||
OutputTransfer::Hlg => gpui::ContentTransfer::Hlg,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the display transform to an F32 RGBA buffer in place (no-op
|
||||
@@ -159,7 +487,14 @@ pub fn apply_f32_rgba(samples: &mut [f32], pixels: i64) {
|
||||
let Some(state) = current() else {
|
||||
return;
|
||||
};
|
||||
if let Some(processor) = &state.f32 {
|
||||
if let Some(xyz) = &state.xyz {
|
||||
// Non-sRGB project output: the samples are in the output spec's
|
||||
// encoded form — linearize the transfer and gamut-map to CIE XYZ
|
||||
// (D65, unit luminance) first, then let the ICC chain map the
|
||||
// connection space to the display.
|
||||
output_spec_to_xyz_d65(samples, pipeline_output_spec());
|
||||
let _ = xyz.convert_f32_rgba(samples, pixels);
|
||||
} else if let Some(processor) = &state.f32 {
|
||||
let _ = processor.convert_f32_rgba(samples, pixels);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -925,6 +925,33 @@ pub trait AppEngine:
|
||||
let _ = (setting, custom_path, cx);
|
||||
}
|
||||
|
||||
/// The project's color pipeline settings:
|
||||
/// `(working colorspace, output gamut, output transfer)` as the
|
||||
/// persisted setting strings (see `oak_common::colormath`). The
|
||||
/// working colorspace is the pipeline's scene space (ACEScg by
|
||||
/// default, not hard-coded sRGB); the output pair is the delivery
|
||||
/// target for export and presentation.
|
||||
fn project_color_settings(&self) -> (String, String, String) {
|
||||
(
|
||||
oak_common::colormath::WorkingColorSpace::default().as_setting().to_string(),
|
||||
oak_common::colormath::OutputGamut::default().as_setting().to_string(),
|
||||
oak_common::colormath::OutputTransfer::default().as_setting().to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Applies the project color pipeline settings: stores them in the
|
||||
/// project properties and invalidates every cached frame (they were
|
||||
/// rendered under the old pipeline).
|
||||
fn set_project_color_settings(
|
||||
&mut self,
|
||||
working: String,
|
||||
gamut: String,
|
||||
transfer: String,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let _ = (working, gamut, transfer, cx);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Sequence management (the C++ File > New Sequence / New Folder and
|
||||
// the project-explorer sequence context menu): creating, querying and
|
||||
|
||||
@@ -143,17 +143,28 @@ pub(crate) fn bgra_bytes_to_render_image(
|
||||
/// 1.0), so every displayable code survives the round trip. Samples must
|
||||
/// hold exactly `width * height * 4` values (tightly packed rows); values
|
||||
/// clamp to `0.0..=1.0` before packing.
|
||||
pub(crate) fn f32_rgba_to_16f_bytes(width: u32, height: u32, samples: &[f32]) -> Option<Vec<u8>> {
|
||||
if samples.len() != (width * height * 4) as usize {
|
||||
return None;
|
||||
///
|
||||
/// Returns `(bytes, bytes_per_row)`: rows are padded to wgpu's
|
||||
/// `COPY_BYTES_PER_ROW_ALIGNMENT` (256) because `write_texture` requires
|
||||
/// an explicit, aligned row pitch for multi-row copies — a `None` pitch
|
||||
/// fails validation and the texture stays black.
|
||||
pub(crate) fn f32_rgba_to_16f_bytes(width: u32, height: u32, samples: &[f32]) -> Option<(Vec<u8>, u32)> {
|
||||
if samples.len() != (width * height * 4) as usize {
|
||||
return None;
|
||||
}
|
||||
let row_samples = (width * 4) as usize;
|
||||
let unpadded_row_bytes = row_samples * 2;
|
||||
let row_bytes = unpadded_row_bytes.next_multiple_of(256);
|
||||
let mut bytes = vec![0u8; row_bytes * height as usize];
|
||||
for (row, chunk) in samples.chunks_exact(row_samples).enumerate() {
|
||||
let dst = &mut bytes[row * row_bytes..row * row_bytes + unpadded_row_bytes];
|
||||
for (i, &v) in chunk.iter().enumerate() {
|
||||
let h = half::f16::from_f32(v.clamp(0.0, 1.0));
|
||||
dst[i * 2..i * 2 + 2].copy_from_slice(&h.to_bits().to_le_bytes());
|
||||
}
|
||||
}
|
||||
Some((bytes, row_bytes as u32))
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(samples.len() * 2);
|
||||
for &v in samples {
|
||||
let h = half::f16::from_f32(v.clamp(0.0, 1.0));
|
||||
bytes.extend_from_slice(&h.to_bits().to_le_bytes());
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -178,8 +189,11 @@ mod tests {
|
||||
// same code 255.
|
||||
let (v1, v2) = (1022.0f32 / 1023.0, 1.0f32);
|
||||
let samples = [v1, 0.0, 0.0, 1.0, v2, 0.0, 0.0, 1.0];
|
||||
let bytes = f32_rgba_to_16f_bytes(2, 1, &samples).expect("packed 16f bytes");
|
||||
assert_eq!(bytes.len(), 2 * 1 * 4 * 2, "two pixels, four f16 channels");
|
||||
let (bytes, row_bytes) = f32_rgba_to_16f_bytes(2, 1, &samples).expect("packed 16f bytes");
|
||||
// Two pixels = 16 content bytes; the row pads to the 256-byte
|
||||
// copy alignment.
|
||||
assert_eq!(bytes.len(), 256, "one padded row");
|
||||
assert_eq!(row_bytes, 256);
|
||||
let code_of = |value: f32| {
|
||||
let h = half::f16::from_f32(value);
|
||||
((h.to_f32() * 1023.0).round()) as u32
|
||||
@@ -201,4 +215,20 @@ mod tests {
|
||||
"the same two values collapse in 8-bit — proving the 16f path carries the resolution"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f32_to_16f_packs_multi_row_with_padding() {
|
||||
// A 3x2 frame: unpadded row is 3*4*2 = 24 bytes, padded to 256.
|
||||
let samples = vec![0.5f32; 3 * 2 * 4];
|
||||
let (bytes, row_bytes) = f32_rgba_to_16f_bytes(3, 2, &samples).expect("packed");
|
||||
assert_eq!(row_bytes, 256);
|
||||
assert_eq!(bytes.len(), 512);
|
||||
// First pixel of each row carries the 0.5 code; padding is zero.
|
||||
let half_bits = half::f16::from_f32(0.5).to_bits();
|
||||
for row in 0..2 {
|
||||
let off = row * 256;
|
||||
assert_eq!(&bytes[off..off + 2], &half_bits.to_le_bytes());
|
||||
assert_eq!(&bytes[off + 24..off + 28], &[0, 0, 0, 0], "padding zeroed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -629,6 +629,7 @@ fn rendered_to_owned_image(rendered: &super::renderops::RenderedFrame) -> Option
|
||||
// image below is only the CPU fallback (scope / eyedropper /
|
||||
// cached fills without a GPU).
|
||||
let mut samples = repack_f32_row_bytes(meta.width, meta.height, meta.linesize, data)?;
|
||||
apply_output_node_f32(&mut samples);
|
||||
super::displaycolor::apply_f32_rgba(&mut samples, (w * h) as i64);
|
||||
let image = f32_rgba_to_bgra_image(w, h, &samples);
|
||||
super::gpu::register_display_frame(image.id.0, w, h, &samples);
|
||||
@@ -637,7 +638,11 @@ fn rendered_to_owned_image(rendered: &super::renderops::RenderedFrame) -> Option
|
||||
bgra_bytes_to_render_image(w, h, data).map(Arc::new)
|
||||
}
|
||||
super::renderops::RenderedFrame::CpuF32 { .. } => {
|
||||
let (w, h, samples) = read_f32_frame(rendered)?;
|
||||
let (w, h, mut samples) = read_f32_frame(rendered)?;
|
||||
// Output node + display transform, same as the shm F32 path, so
|
||||
// cached fills match the on-screen picture regardless of backend.
|
||||
apply_output_node_f32(&mut samples);
|
||||
super::displaycolor::apply_f32_rgba(&mut samples, (w * h) as i64);
|
||||
let image = f32_rgba_to_bgra_image(w, h, &samples);
|
||||
super::gpu::register_display_frame(image.id.0, w, h, &samples);
|
||||
Some(Arc::new(image))
|
||||
@@ -645,6 +650,16 @@ fn rendered_to_owned_image(rendered: &super::renderops::RenderedFrame) -> Option
|
||||
}
|
||||
}
|
||||
|
||||
/// The app-side output node for F32 frames (working colorspace → the
|
||||
/// project's output colorspace); pass-through in the legacy working space.
|
||||
fn apply_output_node_f32(samples: &mut [f32]) {
|
||||
oak_common::colormath::working_to_display_target(
|
||||
samples,
|
||||
oak_render::color::pipeline_working_space(),
|
||||
oak_render::color::pipeline_output_spec(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Repack one F32 RGBA shm slot (rows padded to `linesize`) into tightly
|
||||
/// packed samples. The in-process variant is handled by [`read_f32_frame`].
|
||||
fn repack_f32_row_bytes(width: i32, height: i32, linesize: i32, data: &[u8]) -> Option<Vec<f32>> {
|
||||
@@ -2788,6 +2803,16 @@ impl RealEngine {
|
||||
};
|
||||
self.project = Some(project.clone());
|
||||
self.storage = Some(AuxHandle(graphops::storage_bind(&project)));
|
||||
// The project's color pipeline properties (working colorspace +
|
||||
// delivery target) drive the app-side decode/output transforms;
|
||||
// the render workers pick them up at graph-load time.
|
||||
{
|
||||
let guard = graphops::lock(&project);
|
||||
oak_render::color::set_pipeline_color_settings(
|
||||
guard.working_color_space(),
|
||||
guard.output_color_spec(),
|
||||
);
|
||||
}
|
||||
|
||||
// Footage loaded from a file may lack stream metadata (C++ projects
|
||||
// have no `<streams>` segment; older Rust saves predate the probe
|
||||
@@ -2813,6 +2838,9 @@ impl RealEngine {
|
||||
// The project's stored OCIO override (if any) drives the display
|
||||
// color pipeline from here on.
|
||||
Self::apply_project_color_config(Some(&project));
|
||||
// The project's colorspace settings may differ from the previous
|
||||
// project's: re-declare the display policy on every open window.
|
||||
self.reapply_display_policy_to_windows(cx);
|
||||
|
||||
// 全局代理开关开启时,工程里未就绪素材的代理在后台自动生成
|
||||
// (打开长素材工程不等待:生成走任务线程)。
|
||||
@@ -4897,6 +4925,79 @@ impl AppEngine for RealEngine {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn project_color_settings(&self) -> (String, String, String) {
|
||||
let Some(project) = self.project_ref() else {
|
||||
return (
|
||||
oak_common::colormath::WorkingColorSpace::default().as_setting().to_string(),
|
||||
oak_common::colormath::OutputGamut::default().as_setting().to_string(),
|
||||
oak_common::colormath::OutputTransfer::default().as_setting().to_string(),
|
||||
);
|
||||
};
|
||||
let guard = graphops::lock(project);
|
||||
let get = |key: &str, default: &str| {
|
||||
guard
|
||||
.settings
|
||||
.get(key)
|
||||
.map(String::as_str)
|
||||
.unwrap_or(default)
|
||||
.to_string()
|
||||
};
|
||||
(
|
||||
get(
|
||||
oak_node::project::SETTING_WORKING_COLOR_SPACE,
|
||||
oak_common::colormath::WorkingColorSpace::default().as_setting(),
|
||||
),
|
||||
get(
|
||||
oak_node::project::SETTING_OUTPUT_GAMUT,
|
||||
oak_common::colormath::OutputGamut::default().as_setting(),
|
||||
),
|
||||
get(
|
||||
oak_node::project::SETTING_OUTPUT_TRANSFER,
|
||||
oak_common::colormath::OutputTransfer::default().as_setting(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn set_project_color_settings(
|
||||
&mut self,
|
||||
working: String,
|
||||
gamut: String,
|
||||
transfer: String,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(project) = self.project.clone() else {
|
||||
return;
|
||||
};
|
||||
// Normalize through the parsers so only canonical values persist.
|
||||
let working = oak_common::colormath::WorkingColorSpace::from_setting(&working);
|
||||
let spec = oak_common::colormath::OutputColorSpec::from_settings(&gamut, &transfer);
|
||||
{
|
||||
let mut guard = graphops::lock(&project);
|
||||
guard.settings.insert(
|
||||
oak_node::project::SETTING_WORKING_COLOR_SPACE.to_string(),
|
||||
working.as_setting().to_string(),
|
||||
);
|
||||
guard.settings.insert(
|
||||
oak_node::project::SETTING_OUTPUT_GAMUT.to_string(),
|
||||
spec.gamut.as_setting().to_string(),
|
||||
);
|
||||
guard.settings.insert(
|
||||
oak_node::project::SETTING_OUTPUT_TRANSFER.to_string(),
|
||||
spec.transfer.as_setting().to_string(),
|
||||
);
|
||||
guard.modified = true;
|
||||
}
|
||||
// The app-side transforms read the process global; the workers pick
|
||||
// the new settings up with the next graph upload.
|
||||
oak_render::color::set_pipeline_color_settings(working, spec);
|
||||
// The pipeline colorspace changed: every cached frame (CPU image,
|
||||
// GPU texture, preview slot) was rendered under the old space.
|
||||
super::displaycolor::invalidate();
|
||||
self.invalidate_rendered_frames();
|
||||
self.reapply_display_policy_to_windows(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn entry_is_sequence(&self, id: u64) -> bool {
|
||||
let Some(project) = self.project_ref() else {
|
||||
return false;
|
||||
@@ -5649,6 +5750,23 @@ impl ProjectFormat {
|
||||
}
|
||||
|
||||
impl RealEngine {
|
||||
/// Re-declares the display color policy (and the content colorspace) on
|
||||
/// every open window after the project's color pipeline settings changed.
|
||||
/// Runs deferred: [`Self::set_project_color_settings`] is committed from
|
||||
/// inside the settings dialog's window update, where a direct
|
||||
/// `update_window` would fail; `defer` re-enters the app after that
|
||||
/// update completes.
|
||||
fn reapply_display_policy_to_windows(&self, cx: &mut Context<Self>) {
|
||||
let windows = cx.windows();
|
||||
cx.defer(move |app| {
|
||||
for handle in windows {
|
||||
let _ = app.update_window(handle, |_root, window, _app| {
|
||||
crate::oakui::displaycolor::apply_to_window(window);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Auto-creates a sequence for a footage drop onto an empty timeline (see
|
||||
/// `drop_footage`): the format mirrors the footage's first video stream;
|
||||
/// pure-audio footage uses the default sequence format. Returns the new
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -121,6 +121,20 @@ pub struct EncodingParams {
|
||||
pub custom_range_out_num: i64,
|
||||
/// Custom range out denominator.
|
||||
pub custom_range_out_den: i64,
|
||||
|
||||
/// Delivery color metadata written into the output container
|
||||
/// (ISO/IEC 23001-8 / H.273 code points — the same numbering FFmpeg's
|
||||
/// `AVCodecContext` uses; 0 = leave unset). The export sets these from
|
||||
/// the project's output colorspace so the file declares its
|
||||
/// colorimetry (the mov `colr` atom / H.264-HEVC VUI) instead of
|
||||
/// leaving players to guess.
|
||||
pub color_primaries: i32,
|
||||
/// Delivery transfer characteristic code point (`AVCOL_TRC_*`).
|
||||
pub color_trc: i32,
|
||||
/// Delivery matrix coefficients code point (`AVCOL_SPC_*`; 0 = RGB).
|
||||
pub color_space: i32,
|
||||
/// Delivery color range (`AVCOL_RANGE_*`: 1 = limited, 2 = full).
|
||||
pub color_range: i32,
|
||||
}
|
||||
|
||||
impl Default for EncodingParams {
|
||||
@@ -170,6 +184,11 @@ impl Default for EncodingParams {
|
||||
custom_range_in_den: 0,
|
||||
custom_range_out_num: 0,
|
||||
custom_range_out_den: 0,
|
||||
|
||||
color_primaries: 0,
|
||||
color_trc: 0,
|
||||
color_space: 0,
|
||||
color_range: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1069,15 +1088,16 @@ mod tests {
|
||||
]
|
||||
}
|
||||
|
||||
/// `oakcodec_encoding_params` byte-level layout lock, verified against
|
||||
/// the real header with a C++ `offsetof` probe (see the crate notes):
|
||||
/// every field offset and the total size must match `include/codec/
|
||||
/// encoder.h` exactly so a C caller's POD is read in place.
|
||||
/// `oakcodec_encoding_params` byte-level layout lock. The original C
|
||||
/// ABI (`include/codec/encoder.h`) has been retired, but the offsets of
|
||||
/// the pre-existing fields stay frozen so any on-disk/IPC copy of the
|
||||
/// POD still reads in place; the delivery color-metadata fields are
|
||||
/// appended at the end.
|
||||
#[test]
|
||||
fn encoding_params_c_abi_layout() {
|
||||
use std::mem::{offset_of, size_of};
|
||||
|
||||
assert_eq!(size_of::<EncodingParams>(), 1536);
|
||||
assert_eq!(size_of::<EncodingParams>(), 1552);
|
||||
assert_eq!(offset_of!(EncodingParams, filename), 0);
|
||||
assert_eq!(offset_of!(EncodingParams, format), 1024);
|
||||
assert_eq!(offset_of!(EncodingParams, video_enabled), 1028);
|
||||
@@ -1091,5 +1111,10 @@ mod tests {
|
||||
assert_eq!(offset_of!(EncodingParams, has_custom_range), 1496);
|
||||
assert_eq!(offset_of!(EncodingParams, custom_range_in_num), 1504);
|
||||
assert_eq!(offset_of!(EncodingParams, custom_range_out_den), 1528);
|
||||
// Appended delivery color metadata (H.273 code points).
|
||||
assert_eq!(offset_of!(EncodingParams, color_primaries), 1536);
|
||||
assert_eq!(offset_of!(EncodingParams, color_trc), 1540);
|
||||
assert_eq!(offset_of!(EncodingParams, color_space), 1544);
|
||||
assert_eq!(offset_of!(EncodingParams, color_range), 1548);
|
||||
}
|
||||
}
|
||||
|
||||
+394
-15
@@ -57,6 +57,7 @@ use ffmpeg::{ChannelLayout, Dictionary, Error as FfmpegError, Rational as FfRati
|
||||
use ffmpeg_next as ffmpeg;
|
||||
|
||||
use oak_common::cancelatom::CancelAtom;
|
||||
use oak_common::colormath::YuvMatrix;
|
||||
use oak_common::ocioutils::PixelFormat as OakPixelFormat;
|
||||
use oak_common::videoparams::{Interlacing, VideoParams, VideoType};
|
||||
use oak_core::{PixelFormat, Rational, SampleFormat, TimeRange};
|
||||
@@ -70,6 +71,22 @@ use crate::frame::Frame;
|
||||
|
||||
/// `OAKCOMMON_COLOR_RANGE_FULL`.
|
||||
const OAKCOMMON_COLOR_RANGE_FULL: i32 = 1;
|
||||
/// `OAKCOMMON_COLOR_RANGE_LIMITED`.
|
||||
const OAKCOMMON_COLOR_RANGE_LIMITED: i32 = 0;
|
||||
/// `AVCOL_RANGE_JPEG` (full range; AVCOL_RANGE_MPEG = 1 is limited).
|
||||
const AVCOL_RANGE_JPEG: i32 = 2;
|
||||
/// swscale colorspace ids (`SWS_CS_*`, libswscale/swscale.h).
|
||||
const SWS_CS_ITU709: i32 = 1;
|
||||
const SWS_CS_ITU601: i32 = 5;
|
||||
const SWS_CS_SMPTE240M: i32 = 7;
|
||||
const SWS_CS_BT2020: i32 = 9;
|
||||
/// AVCOL_SPC_* code points that map onto each swscale colorspace.
|
||||
const AVCOL_SPC_BT709: i32 = 1;
|
||||
const AVCOL_SPC_BT470BG: i32 = 5;
|
||||
const AVCOL_SPC_SMPTE170M: i32 = 6;
|
||||
const AVCOL_SPC_SMPTE240M: i32 = 7;
|
||||
const AVCOL_SPC_BT2020_NCL: i32 = 9;
|
||||
const AVCOL_SPC_BT2020_CL: i32 = 10;
|
||||
/// The format-level time base (microseconds), `FB_TIME_BASE` in the bridge.
|
||||
const FB_TIME_BASE: i64 = 1_000_000;
|
||||
/// `AV_NOPTS_VALUE`.
|
||||
@@ -332,8 +349,20 @@ impl Decoder for FFmpegDecoder {
|
||||
let f = decoded?
|
||||
.ok_or_else(|| fail("no video frame available at the requested time"))?;
|
||||
|
||||
let (w, h, bytes) = state.scale_video_to_f32(f, p.force_range, p.target_size)?;
|
||||
let frame = copy_rgba_f32_to_frame(w, h, &bytes, p.time)?;
|
||||
let (w, h, bytes, color_meta) =
|
||||
state.scale_video_to_f32(f, p.force_range, p.target_size)?;
|
||||
let mut frame = copy_rgba_f32_to_frame(w, h, &bytes, p.time)?;
|
||||
// Carry the source colorimetry on the frame params: the render
|
||||
// layer maps it to its input transform (source → working space).
|
||||
if let Some(params) = frame.params.as_mut() {
|
||||
params.set_color_primaries(color_meta.color_primaries);
|
||||
params.set_color_transfer(color_meta.color_trc);
|
||||
params.set_color_range(if color_meta.full_range {
|
||||
oak_common::videoparams::ColorRange::Full
|
||||
} else {
|
||||
oak_common::videoparams::ColorRange::Limited
|
||||
});
|
||||
}
|
||||
Ok(Arc::new(frame))
|
||||
}
|
||||
|
||||
@@ -949,8 +978,10 @@ impl DecoderState {
|
||||
}
|
||||
|
||||
/// Scale a decoded frame to float RGBA (F32, 4 channels), returning the
|
||||
/// raw pixel bytes plus dimensions. Mirrors `pre_process_frame` +
|
||||
/// `retrieve_video_frame_internal` scaling with the color-range forcing.
|
||||
/// raw pixel bytes, dimensions, and the source colorimetry. Mirrors
|
||||
/// `pre_process_frame` + `retrieve_video_frame_internal` scaling; unlike
|
||||
/// the old bridge path the YUV→RGB honors the frame's own colorspace
|
||||
/// (BT.601/709/2020) and range instead of assuming BT.601 limited.
|
||||
/// `target_size` resizes in the same swscale pass (native → RGBA/F32 at
|
||||
/// the target size) instead of converting at native size first — the
|
||||
/// caller's downscale then degenerates to a plain copy, and no
|
||||
@@ -960,18 +991,45 @@ impl DecoderState {
|
||||
f: ffmpeg::frame::Video,
|
||||
force_range: i32,
|
||||
target_size: Option<(u32, u32)>,
|
||||
) -> crate::error::Result<(u32, u32, Vec<u8>)> {
|
||||
) -> crate::error::Result<(u32, u32, Vec<u8>, crate::decoder::DecodedColorMeta)> {
|
||||
let video = self
|
||||
.video
|
||||
.as_mut()
|
||||
.expect("scale_video_to_f32 requires a video session");
|
||||
|
||||
// The frame's own colorimetry (set by the decoder from the
|
||||
// bitstream); raw code points pass through to the render layer.
|
||||
let (raw_primaries, raw_trc, raw_space, raw_range) = unsafe {
|
||||
let av = f.as_ptr();
|
||||
(
|
||||
(*av).color_primaries as i32,
|
||||
(*av).color_trc as i32,
|
||||
(*av).colorspace as i32,
|
||||
(*av).color_range as i32,
|
||||
)
|
||||
};
|
||||
|
||||
// # CPP-PARITY ffmpegdecoder.cpp:376: disregard "JPEG" pixel formats
|
||||
// and force the color range to whatever the caller requested.
|
||||
let src_format = convert_jpeg_space_to_regular_space(f.format());
|
||||
// — but a YUVJ source is full range by definition, so remember it
|
||||
// for the range decision below.
|
||||
let orig_format = f.format();
|
||||
let src_format = convert_jpeg_space_to_regular_space(orig_format);
|
||||
let yuvj_full = orig_format != src_format;
|
||||
let mut f = f;
|
||||
f.set_format(src_format);
|
||||
f.set_color_range(if force_range == OAKCOMMON_COLOR_RANGE_FULL {
|
||||
|
||||
// The effective color range: the caller's force wins; otherwise the
|
||||
// frame's own metadata (YUVJ sources are full range). The old path
|
||||
// forced MPEG/limited for everything, crushing full-range screen
|
||||
// captures and JPEG-derived footage.
|
||||
let full_range = if force_range == OAKCOMMON_COLOR_RANGE_FULL {
|
||||
true
|
||||
} else if force_range == OAKCOMMON_COLOR_RANGE_LIMITED {
|
||||
false
|
||||
} else {
|
||||
yuvj_full || raw_range == AVCOL_RANGE_JPEG
|
||||
};
|
||||
f.set_color_range(if full_range {
|
||||
ffmpeg::color::Range::JPEG
|
||||
} else {
|
||||
ffmpeg::color::Range::MPEG
|
||||
@@ -990,24 +1048,67 @@ impl DecoderState {
|
||||
// float context there can abort instead of erroring — RGBA64 is
|
||||
// REPORTED supported but still aborts, so only RGBAF32LE is
|
||||
// probed (on the builds that have it, e.g. the system FFmpeg,
|
||||
// it works); everything else takes the universal 8-bit RGBA
|
||||
// path converted in Rust.
|
||||
// it works). High-bit-depth YUV sources fall back to 16-bit
|
||||
// planar YUV 4:4:4 (converted to F32 RGBA in Rust) so their
|
||||
// precision survives; 8-bit and RGB sources take the universal
|
||||
// 8-bit RGBA path.
|
||||
let supported = ffmpeg::software::scaling::support::output(Pixel::RGBAF32LE);
|
||||
let (depth, is_yuv) = pix_fmt_depth_and_yuv(src_format);
|
||||
let (out_fmt, f32_ok) = if supported {
|
||||
(Pixel::RGBAF32LE, true)
|
||||
} else if depth > 8 && is_yuv {
|
||||
(Pixel::YUV444P16LE, false)
|
||||
} else {
|
||||
(Pixel::RGBA, false)
|
||||
};
|
||||
let ctx = get_or_create_scaler(&mut video.scaler, src_format, src_w, src_h, out_fmt, w, h)?;
|
||||
if out_fmt == Pixel::YUV444P16LE {
|
||||
// YUV→YUV pass-through: the 16-bit code values must reach the
|
||||
// Rust matrix conversion bit-exact. sws_setColorspaceDetails
|
||||
// has to see the SAME coefficient table for source and
|
||||
// destination — differing tables would insert a cascaded
|
||||
// YUV→RGB→YUV round trip — and both ranges are set full so
|
||||
// the YUV→YUV range recompression is skipped entirely (it
|
||||
// only runs when src_range != dst_range). The matrix and
|
||||
// full/limited expansion happen later, in
|
||||
// convert_yuv444p16_to_rgba_f32.
|
||||
unsafe {
|
||||
let table = sys::sws_getCoefficients(sws_colorspace_for(raw_space, src_w, src_h));
|
||||
sys::sws_setColorspaceDetails(
|
||||
ctx.as_mut_ptr(),
|
||||
table,
|
||||
1, // src full range (no recompression)
|
||||
table,
|
||||
1, // dst full range (no recompression)
|
||||
0,
|
||||
1 << 16,
|
||||
1 << 16,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// The YUV→RGB matrix: BT.601/709/2020 per the frame's
|
||||
// colorspace tag, with the full/limited range decided above.
|
||||
// RGB sources are untouched by the colorspace tables (swscale
|
||||
// ignores them there).
|
||||
apply_sws_colorspace(ctx, raw_space, full_range, src_w, src_h);
|
||||
}
|
||||
let mut out = ffmpeg::frame::Video::empty();
|
||||
ctx.run(&f, &mut out).map_err(ffmpeg_err)?;
|
||||
let stride = out.stride(0);
|
||||
let bytes = if f32_ok {
|
||||
let stride = out.stride(0);
|
||||
convert_rgba_f32_le(&out.data(0), w, h, stride)
|
||||
} else if out_fmt == Pixel::YUV444P16LE {
|
||||
convert_yuv444p16_to_rgba_f32(&out, w, h, yuv_matrix_for(raw_space, src_w, src_h), full_range)
|
||||
} else {
|
||||
let stride = out.stride(0);
|
||||
convert_rgba8_to_f32(&out.data(0), w, h, stride)
|
||||
};
|
||||
Ok((w, h, bytes))
|
||||
let meta = crate::decoder::DecodedColorMeta {
|
||||
color_primaries: raw_primaries,
|
||||
color_trc: raw_trc,
|
||||
full_range,
|
||||
};
|
||||
Ok((w, h, bytes, meta))
|
||||
}
|
||||
|
||||
/// Fill `dest` (interleaved f32) with the decoded audio covering
|
||||
@@ -1467,6 +1568,90 @@ fn get_or_create_scaler(
|
||||
Ok(&mut cache.as_mut().expect("set above").ctx)
|
||||
}
|
||||
|
||||
/// Map a frame's `AVCOL_SPC_*` tag to a swscale colorspace id (the YUV→RGB
|
||||
/// coefficient set). Untagged frames fall back by size (HD material is
|
||||
/// overwhelmingly BT.709, SD is BT.601 — the old code used BT.601 for
|
||||
/// everything, tinting every HD source).
|
||||
fn sws_colorspace_for(av_colorspace: i32, src_w: u32, src_h: u32) -> i32 {
|
||||
match av_colorspace {
|
||||
AVCOL_SPC_BT709 => SWS_CS_ITU709,
|
||||
AVCOL_SPC_BT470BG | AVCOL_SPC_SMPTE170M => SWS_CS_ITU601,
|
||||
AVCOL_SPC_SMPTE240M => SWS_CS_SMPTE240M,
|
||||
AVCOL_SPC_BT2020_NCL | AVCOL_SPC_BT2020_CL => SWS_CS_BT2020,
|
||||
// Untagged: HD → BT.709, SD → BT.601.
|
||||
_ => {
|
||||
if src_w >= 1280 || src_h > 576 {
|
||||
SWS_CS_ITU709
|
||||
} else {
|
||||
SWS_CS_ITU601
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Rust-side YUV→RGB matrix for a frame's `AVCOL_SPC_*` tag. Unlike
|
||||
/// [`sws_colorspace_for`] only tags with an exact matrix in [`YuvMatrix`]
|
||||
/// are honored; everything else (including SMPTE 240M and BT.2020 CL) falls
|
||||
/// back by size.
|
||||
fn yuv_matrix_for(av_colorspace: i32, src_w: u32, src_h: u32) -> YuvMatrix {
|
||||
match av_colorspace {
|
||||
AVCOL_SPC_BT709 => YuvMatrix::Bt709,
|
||||
AVCOL_SPC_BT470BG | AVCOL_SPC_SMPTE170M => YuvMatrix::Bt601,
|
||||
AVCOL_SPC_BT2020_NCL => YuvMatrix::Bt2020,
|
||||
_ => {
|
||||
if src_w >= 1280 || src_h > 576 {
|
||||
YuvMatrix::Bt709
|
||||
} else {
|
||||
YuvMatrix::Bt601
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bit depth (bits per component) and YUV-ness of a pixel format, from its
|
||||
/// `AVPixFmtDescriptor` (8 and false for formats without one — none in
|
||||
/// practice for decoder output).
|
||||
fn pix_fmt_depth_and_yuv(fmt: Pixel) -> (i32, bool) {
|
||||
unsafe {
|
||||
let desc = sys::av_pix_fmt_desc_get(fmt.into());
|
||||
if desc.is_null() {
|
||||
(8, false)
|
||||
} else {
|
||||
((*desc).comp[0].depth, (*desc).flags & sys::AV_PIX_FMT_FLAG_RGB as u64 == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure a swscale context's YUV→RGB matrix and range.
|
||||
///
|
||||
/// The range flag selects full/limited input coefficients; the RGB output is
|
||||
/// always full range. `sws_setColorspaceDetails` ignores the tables for
|
||||
/// non-YUV sources, so RGB footage passes through unchanged.
|
||||
fn apply_sws_colorspace(
|
||||
ctx: &mut scaling::Context,
|
||||
av_colorspace: i32,
|
||||
full_range: bool,
|
||||
src_w: u32,
|
||||
src_h: u32,
|
||||
) {
|
||||
let sws_cs = sws_colorspace_for(av_colorspace, src_w, src_h);
|
||||
unsafe {
|
||||
let inv_table = sys::sws_getCoefficients(sws_cs);
|
||||
let dst_table = sys::sws_getCoefficients(SWS_CS_ITU601);
|
||||
// brightness 0, contrast/saturation unity (16.16 fixed point).
|
||||
sys::sws_setColorspaceDetails(
|
||||
ctx.as_mut_ptr(),
|
||||
inv_table,
|
||||
full_range as i32,
|
||||
dst_table,
|
||||
1, // RGB out is full range
|
||||
0,
|
||||
1 << 16,
|
||||
1 << 16,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame's presentation timestamp (NOPTS when unset).
|
||||
fn pts_of(f: Option<&ffmpeg::frame::Video>) -> Option<i64> {
|
||||
f.and_then(|f| f.pts())
|
||||
@@ -1569,6 +1754,43 @@ fn convert_rgba8_to_f32(data: &[u8], w: u32, h: u32, stride: usize) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Convert a 16-bit planar YUV 4:4:4 frame (YUV444P16LE, as emitted by the
|
||||
/// high-bit-depth swscale fallback) to interleaved F32 RGBA little-endian
|
||||
/// bytes. The YUV→RGB matrix and full/limited expansion run here instead of
|
||||
/// inside swscale so the 16-bit code values survive intact: swscale only
|
||||
/// converted the format (and resized), with identical source/destination
|
||||
/// colorspace tables and full ranges on both sides, so no matrix and no
|
||||
/// range recompression was applied. 10/12-bit sources arrive left-shifted
|
||||
/// to 16-bit (code << 6 / code << 4) — exactly the code-value scale
|
||||
/// [`oak_common::colormath::yuv444p16_to_rgb_f32`] expects.
|
||||
fn convert_yuv444p16_to_rgba_f32(
|
||||
out: &ffmpeg::frame::Video,
|
||||
w: u32,
|
||||
h: u32,
|
||||
matrix: YuvMatrix,
|
||||
full_range: bool,
|
||||
) -> Vec<u8> {
|
||||
let mut rgba = vec![0.0f32; (w as usize) * (h as usize) * 4];
|
||||
oak_common::colormath::yuv444p16_to_rgb_f32(
|
||||
out.data(0),
|
||||
out.stride(0),
|
||||
out.data(1),
|
||||
out.stride(1),
|
||||
out.data(2),
|
||||
out.stride(2),
|
||||
w as usize,
|
||||
h as usize,
|
||||
matrix,
|
||||
full_range,
|
||||
&mut rgba,
|
||||
);
|
||||
let mut bytes = vec![0u8; rgba.len() * 4];
|
||||
for (dst, v) in bytes.chunks_exact_mut(4).zip(&rgba) {
|
||||
dst.copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Build an allocated [`Frame`] (F32, RGBA) from raw pixel bytes.
|
||||
///
|
||||
/// # CPP-PARITY
|
||||
@@ -1696,6 +1918,17 @@ fn probe_file(filename: &str, cancelled: Option<&CancelAtom>) -> Option<FootageD
|
||||
vp.set_time_base(tb.0 as i32, tb.1 as i32);
|
||||
vp.set_duration(stream.duration());
|
||||
vp.set_premultiplied_alpha(false);
|
||||
// Stream colorimetry (drives the input→working transform
|
||||
// and lets the UI show what the footage is).
|
||||
unsafe {
|
||||
vp.set_color_primaries((*raw).color_primaries as i32);
|
||||
vp.set_color_transfer((*raw).color_trc as i32);
|
||||
vp.set_color_range(if (*raw).color_range as i32 == AVCOL_RANGE_JPEG {
|
||||
oak_common::videoparams::ColorRange::Full
|
||||
} else {
|
||||
oak_common::videoparams::ColorRange::Limited
|
||||
});
|
||||
}
|
||||
desc.push_stream(StreamEntry::Video(vp));
|
||||
}
|
||||
MediaType::Audio => {
|
||||
@@ -1980,11 +2213,68 @@ impl Encoder for FFmpegEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the export's delivery color metadata (H.273 code points, carried
|
||||
/// in [`EncodingParams`]) to the video encoder before it opens. The values
|
||||
/// are FFmpeg's own enum numbering, so each is re-interpreted into the
|
||||
/// matching sys enum and handed to the typed setter; 0 (unset) fields keep
|
||||
/// the codec default.
|
||||
fn set_encoder_color_metadata(
|
||||
encoder: &mut ffmpeg::codec::encoder::video::Video,
|
||||
params: &EncodingParams,
|
||||
) {
|
||||
if params.color_primaries != 0 {
|
||||
let v: sys::AVColorPrimaries =
|
||||
unsafe { std::mem::transmute(params.color_primaries) };
|
||||
encoder.set_color_primaries(v.into());
|
||||
}
|
||||
if params.color_trc != 0 {
|
||||
let v: sys::AVColorTransferCharacteristic =
|
||||
unsafe { std::mem::transmute(params.color_trc) };
|
||||
encoder.set_color_transfer_characteristic(v.into());
|
||||
}
|
||||
if params.color_space != 0 {
|
||||
let v: sys::AVColorSpace = unsafe { std::mem::transmute(params.color_space) };
|
||||
encoder.set_colorspace(v.into());
|
||||
}
|
||||
if params.color_range != 0 {
|
||||
let v: sys::AVColorRange = unsafe { std::mem::transmute(params.color_range) };
|
||||
encoder.set_color_range(v.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure the encoder's RGB→YUV scaler so the produced YUV matches the
|
||||
/// delivery tag written by [`set_encoder_color_metadata`] (otherwise swscale
|
||||
/// defaults to BT.601/limited regardless of the tag, and players decode with
|
||||
/// the wrong matrix). `params.color_space` is the `AVCOL_SPC_*` value; the
|
||||
/// range follows `params.color_range` (1 = limited, 2 = full; 0 → limited).
|
||||
fn apply_sws_output_colorspace(scaler: &mut scaling::Context, params: &EncodingParams) {
|
||||
let sws_cs = match params.color_space {
|
||||
1 => SWS_CS_ITU709, // AVCOL_SPC_BT709
|
||||
9 | 10 => SWS_CS_BT2020, // AVCOL_SPC_BT2020_NCL / _CL
|
||||
_ => SWS_CS_ITU601,
|
||||
};
|
||||
let full_range = params.color_range == 2; // AVCOL_RANGE_JPEG
|
||||
unsafe {
|
||||
let table = sys::sws_getCoefficients(sws_cs);
|
||||
// src is RGB (always full range); dst is YUV with the delivery
|
||||
// matrix and range.
|
||||
sys::sws_setColorspaceDetails(
|
||||
scaler.as_mut_ptr(),
|
||||
table, // inv_table unused for an RGB source
|
||||
1,
|
||||
table,
|
||||
full_range as i32,
|
||||
0,
|
||||
1 << 16,
|
||||
1 << 16,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl EncoderState {
|
||||
/// Open the output file, create the streams and encoders and write the
|
||||
/// header.
|
||||
fn open(&mut self, params: &EncodingParams) -> crate::error::Result<()> {
|
||||
if self.output.is_some() {
|
||||
fn open(&mut self, params: &EncodingParams) -> crate::error::Result<()> { if self.output.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let filename = c_string_1024(¶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);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,14 @@
|
||||
//! `xprop`, then the `colormgr` CLI chain (colord); bytes from `xprop`
|
||||
//! are materialized into the disk cache.
|
||||
//!
|
||||
//! Multi-monitor setups: [`system_display_icc_for`] resolves the profile of
|
||||
//! one specific physical monitor (a [`MonitorRef`]) — macOS keys on the
|
||||
//! CoreGraphics display ID, Windows on the `\\.\DISPLAYn` device name
|
||||
//! (`CreateDCW` + `GetICMProfileW`), Linux on the RandR output name
|
||||
//! (`xrandr --prop`). An unrecognized fingerprint falls back to
|
||||
//! [`system_display_icc`] (the main display), so callers can simply try the
|
||||
//! per-monitor path first.
|
||||
//!
|
||||
//! Every platform query is best-effort: any failure (headless session,
|
||||
//! missing tooling, unparseable output) silently degrades to `None`.
|
||||
|
||||
@@ -42,15 +50,108 @@ use std::path::Path;
|
||||
/// no colord, no X server). The `OAK_DISPLAY_ICC` environment variable
|
||||
/// overrides everything (tests, debugging).
|
||||
pub fn system_display_icc() -> Option<String> {
|
||||
// The override wins outright — tests and debugging bypass the platform
|
||||
// queries entirely. An empty value is treated as unset and falls through
|
||||
// to the platform lookup.
|
||||
if let Ok(path) = std::env::var("OAK_DISPLAY_ICC") {
|
||||
if !path.is_empty() {
|
||||
return Some(path);
|
||||
}
|
||||
env_override_icc().or_else(platform_display_icc)
|
||||
}
|
||||
|
||||
/// The `OAK_DISPLAY_ICC` override path. The override wins outright — tests
|
||||
/// and debugging bypass the platform queries entirely. An empty value is
|
||||
/// treated as unset and falls through to the platform lookup.
|
||||
fn env_override_icc() -> Option<String> {
|
||||
match std::env::var("OAK_DISPLAY_ICC") {
|
||||
Ok(path) if !path.is_empty() => Some(path),
|
||||
_ => None,
|
||||
}
|
||||
platform_display_icc()
|
||||
}
|
||||
|
||||
/// Identifies one physical monitor for a per-monitor ICC lookup (see
|
||||
/// [`system_display_icc_for`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum MonitorRef {
|
||||
/// macOS: the `CGDirectDisplayID` of the display.
|
||||
MacDisplay(u32),
|
||||
/// Windows: the monitor device name (the `szDevice` of
|
||||
/// `MONITORINFOEXW`, e.g. `\\.\DISPLAY1`).
|
||||
WinDevice(String),
|
||||
/// Linux/X11: the RandR output name (e.g. `eDP-1`).
|
||||
X11Output(String),
|
||||
}
|
||||
|
||||
/// The filesystem path of one specific monitor's ICC profile, ready for an
|
||||
/// OCIO FileTransform. None when the platform gives no answer for that
|
||||
/// monitor (unknown fingerprint, headless, no colord, no X server). The
|
||||
/// `OAK_DISPLAY_ICC` environment variable overrides everything, exactly as
|
||||
/// in [`system_display_icc`].
|
||||
///
|
||||
/// Best-effort like the main-display lookup: an unrecognized or unresolvable
|
||||
/// [`MonitorRef`] degrades to [`system_display_icc`] (the main display),
|
||||
/// never to a panic.
|
||||
pub fn system_display_icc_for(monitor: &MonitorRef) -> Option<String> {
|
||||
env_override_icc()
|
||||
.or_else(|| platform_display_icc_for(monitor))
|
||||
.or_else(platform_display_icc)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn platform_display_icc_for(monitor: &MonitorRef) -> Option<String> {
|
||||
match monitor {
|
||||
MonitorRef::MacDisplay(id) => macos::display_icc_for(*id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn platform_display_icc_for(monitor: &MonitorRef) -> Option<String> {
|
||||
match monitor {
|
||||
MonitorRef::WinDevice(device) => windows::display_icc_for(device),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn platform_display_icc_for(monitor: &MonitorRef) -> Option<String> {
|
||||
match monitor {
|
||||
MonitorRef::X11Output(name) => linux::display_icc_for_output(name),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
fn platform_display_icc_for(_monitor: &MonitorRef) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse a monitor fingerprint (the strings
|
||||
/// [`crate::oak_app::oakui::displaycolor`] records, `"mac:<id>"` /
|
||||
/// `"win:<device>"` / `"x11:<output>"`) back into a [`MonitorRef`]. Any
|
||||
/// malformed input yields `None`.
|
||||
pub fn monitor_ref_from_fingerprint(fingerprint: &str) -> Option<MonitorRef> {
|
||||
let (kind, value) = fingerprint.split_once(':')?;
|
||||
match kind {
|
||||
"mac" => {
|
||||
let id: u32 = value.parse().ok()?;
|
||||
Some(MonitorRef::MacDisplay(id))
|
||||
}
|
||||
"win" if !value.is_empty() => Some(MonitorRef::WinDevice(value.to_string())),
|
||||
"x11" if !value.is_empty() => Some(MonitorRef::X11Output(value.to_string())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The monitor fingerprint (`win:<device>`) for an HMONITOR value — the
|
||||
/// `display_id` of a gpui window on Windows. None when the device name
|
||||
/// cannot be resolved (invalid handle, call failure).
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn windows_monitor_fingerprint(hmonitor: u64) -> Option<String> {
|
||||
windows::monitor_device_name(hmonitor as usize).map(|name| format!("win:{name}"))
|
||||
}
|
||||
|
||||
/// The monitor fingerprint (`x11:<RandR output>`) for the display covering
|
||||
/// the given point in device pixels (global X11 screen coordinates). None
|
||||
/// when no RandR monitor list is available (headless, no `xrandr`) or none
|
||||
/// covers the point.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn x11_monitor_fingerprint_at(x: f64, y: f64) -> Option<String> {
|
||||
linux::x11_monitor_fingerprint_at(x, y)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -63,6 +164,15 @@ fn platform_display_icc() -> Option<String> {
|
||||
windows::display_icc()
|
||||
}
|
||||
|
||||
/// True when Windows 11 Auto Color Management is active: the OS maps
|
||||
/// (sRGB-declared) app output to the display, so the app must not apply
|
||||
/// the display ICC itself. Windows 10 / older always returns false.
|
||||
/// `OAK_WINDOWS_COLOR=self|os` overrides the detection (debugging).
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn windows_acm_active() -> bool {
|
||||
windows::acm_active()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn platform_display_icc() -> Option<String> {
|
||||
linux::display_icc()
|
||||
@@ -134,6 +244,123 @@ fn parse_xprop_icc_hex(output: &str) -> Option<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// One monitor from `xrandr --listmonitors`: geometry in device pixels
|
||||
/// (global X11 screen coordinates) plus the RandR output names it spans.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct RandRMonitor {
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: i32,
|
||||
height: i32,
|
||||
outputs: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parse `xrandr --listmonitors` output.
|
||||
///
|
||||
/// Each monitor line is ` N: [flags]NAME W/MMWxH/MMH+X+Y OUTPUT...`: the
|
||||
/// first token is the flags+monitor-name (flags like `+*` mark primary and
|
||||
/// current), the second the geometry (physical sizes in mm are ignored),
|
||||
/// the rest the RandR outputs (a spanning mode lists several). X11 screen
|
||||
/// coordinates may be negative, printed as `-X-Y`. The `Monitors:` header
|
||||
/// and any malformed line are skipped.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn parse_xrandr_monitors(output: &str) -> Vec<RandRMonitor> {
|
||||
fn parse_geometry(token: &str) -> Option<(i32, i32, i32, i32)> {
|
||||
// `W/MMWxH/MMH[+-]X[+-]Y` — `[+-]` splits off the coordinates but
|
||||
// drops their signs, which the leading `+`/`-` in the body carries.
|
||||
let pieces: Vec<&str> = token.split(['+', '-']).collect();
|
||||
if pieces.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let dims = pieces[0].split_once('x')?;
|
||||
let width: i32 = dims.0.split('/').next()?.parse().ok()?;
|
||||
let height: i32 = dims.1.split('/').next()?.parse().ok()?;
|
||||
let mut x: i32 = pieces[1].trim().parse().ok()?;
|
||||
let mut y: i32 = pieces[2].trim().parse().ok()?;
|
||||
let signs: Vec<u8> = token[pieces[0].len()..]
|
||||
.bytes()
|
||||
.filter(|b| matches!(b, b'+' | b'-'))
|
||||
.collect();
|
||||
if signs.first() == Some(&b'-') {
|
||||
x = -x;
|
||||
}
|
||||
if signs.get(1) == Some(&b'-') {
|
||||
y = -y;
|
||||
}
|
||||
Some((x, y, width, height))
|
||||
}
|
||||
|
||||
let mut monitors = Vec::new();
|
||||
for line in output.lines() {
|
||||
let line = line.trim_start();
|
||||
// Header line: `Monitors: 2`.
|
||||
if line.starts_with("Monitors") {
|
||||
continue;
|
||||
}
|
||||
// Monitor line; drop the leading index (`N:`).
|
||||
let Some((_, rest)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let mut tokens = rest.split_whitespace();
|
||||
// First token: `[+*]NAME` (ignored), second: the geometry.
|
||||
if tokens.next().is_none() {
|
||||
continue;
|
||||
}
|
||||
let Some(geometry) = tokens.next() else {
|
||||
continue;
|
||||
};
|
||||
let Some((x, y, width, height)) = parse_geometry(geometry) else {
|
||||
continue;
|
||||
};
|
||||
let outputs: Vec<String> = tokens.map(str::to_string).collect();
|
||||
if outputs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
monitors.push(RandRMonitor {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
outputs,
|
||||
});
|
||||
}
|
||||
monitors
|
||||
}
|
||||
|
||||
/// Parse the `_ICC_PROFILE` property of one RandR output from `xrandr
|
||||
/// --prop` output.
|
||||
///
|
||||
/// The output is a sequence of sections, one per output, each starting at
|
||||
/// an unindented line whose first token is the output name. Properties
|
||||
/// within a section are indented `KEY(BITS)\t= VALUE` lines; the ICC bytes
|
||||
/// are the `_ICC_PROFILE` value (`0xHH, 0xHH, ...` — the same byte list
|
||||
/// `xprop` prints, so [`parse_xprop_icc_hex`] re-parses it). Returns `None`
|
||||
/// when the output is absent, carries no `_ICC_PROFILE`, or the bytes are
|
||||
/// malformed.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn parse_output_icc_prop(output: &str, wanted: &str) -> Option<Vec<u8>> {
|
||||
let mut in_section = false;
|
||||
for line in output.lines() {
|
||||
if line.starts_with(char::is_whitespace) {
|
||||
if !in_section {
|
||||
continue;
|
||||
}
|
||||
let (key, _) = line.trim().split_once('=')?;
|
||||
if key.starts_with("_ICC_PROFILE") {
|
||||
if let Some(bytes) = parse_xprop_icc_hex(line.trim()) {
|
||||
return Some(bytes);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unindented line: a new output section (or `Screen 0: ...`).
|
||||
let name = line.split_whitespace().next().unwrap_or("");
|
||||
in_section = name == wanted;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// macOS: CoreGraphics main-display color space → ICC bytes → cache file.
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos {
|
||||
@@ -164,7 +391,12 @@ mod macos {
|
||||
}
|
||||
|
||||
pub(super) fn display_icc() -> Option<String> {
|
||||
let display = unsafe { CGMainDisplayID() };
|
||||
display_icc_for(unsafe { CGMainDisplayID() })
|
||||
}
|
||||
|
||||
/// ICC bytes for one `CGDirectDisplayID` (any display, not just the
|
||||
/// main one) → cache file.
|
||||
pub(super) fn display_icc_for(display: u32) -> Option<String> {
|
||||
let space = unsafe { CGDisplayCopyColorSpace(display) };
|
||||
if space.is_null() {
|
||||
return None;
|
||||
@@ -203,6 +435,9 @@ mod windows {
|
||||
/// with `ReleaseDC`.
|
||||
fn GetDC(hwnd: *const c_void) -> *mut c_void;
|
||||
fn ReleaseDC(hwnd: *const c_void, hdc: *mut c_void) -> i32;
|
||||
/// `BOOL GetMonitorInfoW(HMONITOR, LPMONITORINFOEXW)` — fills the
|
||||
/// structure, including the `szDevice` device name.
|
||||
fn GetMonitorInfoW(monitor: *const c_void, info: *mut c_void) -> i32;
|
||||
}
|
||||
|
||||
#[link(name = "gdi32")]
|
||||
@@ -211,6 +446,171 @@ mod windows {
|
||||
/// required `WCHAR` count (including the NUL), then the profile file
|
||||
/// path is written into the caller's buffer.
|
||||
fn GetICMProfileW(hdc: *mut c_void, name_len: *mut u32, name: *mut u16) -> i32;
|
||||
/// `HDC CreateDCW(LPCWSTR pszDriver, LPCWSTR pszDevice, LPCWSTR
|
||||
/// pszPort, const DEVMODEW *pdm)` — a DC for one specific monitor
|
||||
/// device (passing the device name as both driver and device);
|
||||
/// released with `DeleteDC`.
|
||||
fn CreateDCW(
|
||||
driver: *const u16,
|
||||
device: *const u16,
|
||||
port: *const c_void,
|
||||
dev_mode: *const c_void,
|
||||
) -> *mut c_void;
|
||||
/// `BOOL DeleteDC(HDC)`.
|
||||
fn DeleteDC(hdc: *mut c_void) -> i32;
|
||||
}
|
||||
|
||||
#[link(name = "ntdll")]
|
||||
extern "system" {
|
||||
/// The OS version record; `dwBuildNumber` identifies the Windows
|
||||
/// release (22000+ = Windows 11).
|
||||
fn RtlGetVersion(info: *mut OsVersionInfo) -> i32;
|
||||
}
|
||||
|
||||
#[link(name = "advapi32")]
|
||||
extern "system" {
|
||||
fn RegOpenKeyExW(
|
||||
key: *const c_void,
|
||||
sub_key: *const u16,
|
||||
options: u32,
|
||||
desired: u32,
|
||||
result: *mut *mut c_void,
|
||||
) -> i32;
|
||||
fn RegQueryValueExW(
|
||||
key: *mut c_void,
|
||||
value_name: *const u16,
|
||||
reserved: *const u32,
|
||||
value_type: *mut u32,
|
||||
data: *mut u8,
|
||||
data_len: *mut u32,
|
||||
) -> i32;
|
||||
fn RegCloseKey(key: *mut c_void) -> i32;
|
||||
}
|
||||
|
||||
/// `OSVERSIONINFOW` (the fields `RtlGetVersion` fills).
|
||||
#[repr(C)]
|
||||
struct OsVersionInfo {
|
||||
length: u32,
|
||||
major_version: u32,
|
||||
minor_version: u32,
|
||||
build_number: u32,
|
||||
platform_id: u32,
|
||||
csd_version: [u16; 128],
|
||||
}
|
||||
|
||||
/// `RECT` (windef.h).
|
||||
#[repr(C)]
|
||||
struct Rect {
|
||||
left: i32,
|
||||
top: i32,
|
||||
right: i32,
|
||||
bottom: i32,
|
||||
}
|
||||
|
||||
/// `MONITORINFOEXW` (winuser.h) — `MONITORINFO` plus the `szDevice`
|
||||
/// device name (`\\.\DISPLAY1`, ...).
|
||||
#[repr(C)]
|
||||
struct MonitorInfoExW {
|
||||
cb_size: u32,
|
||||
rc_monitor: Rect,
|
||||
rc_work: Rect,
|
||||
dw_flags: u32,
|
||||
sz_device: [u16; 32],
|
||||
}
|
||||
|
||||
/// `HKEY_CURRENT_USER` (winreg.h).
|
||||
const HKEY_CURRENT_USER: *const c_void = 0x8000_0001usize as *const c_void;
|
||||
/// `KEY_READ` (winreg.h).
|
||||
const KEY_READ: u32 = 0x2_0019;
|
||||
/// `REG_DWORD` (winnt.h).
|
||||
const REG_DWORD: u32 = 4;
|
||||
/// `ERROR_SUCCESS` (winerror.h).
|
||||
const ERROR_SUCCESS: i32 = 0;
|
||||
/// Windows 11 (any release).
|
||||
const BUILD_WINDOWS_11: u32 = 22000;
|
||||
/// Windows 11 24H2 — Auto Color Management is on by default there.
|
||||
const BUILD_WIN11_24H2: u32 = 26100;
|
||||
|
||||
fn wide_nul(s: &str) -> Vec<u16> {
|
||||
let mut wide: Vec<u16> = s.encode_utf16().collect();
|
||||
wide.push(0);
|
||||
wide
|
||||
}
|
||||
|
||||
/// The OS build number (`RtlGetVersion`), or 0 when unavailable.
|
||||
fn windows_build() -> u32 {
|
||||
let mut info = OsVersionInfo {
|
||||
length: std::mem::size_of::<OsVersionInfo>() as u32,
|
||||
major_version: 0,
|
||||
minor_version: 0,
|
||||
build_number: 0,
|
||||
platform_id: 0,
|
||||
csd_version: [0; 128],
|
||||
};
|
||||
let status = unsafe { RtlGetVersion(&mut info) };
|
||||
if status == 0 {
|
||||
info.build_number
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// The `EnableAutoColorManagement` DWORD under
|
||||
/// `HKCU\Software\Microsoft\Windows\CurrentVersion\VideoSettings`:
|
||||
/// `Some(flag)` when the value exists, `None` when the key or value is
|
||||
/// absent (or unreadable).
|
||||
fn acm_registry_flag() -> Option<bool> {
|
||||
let sub_key = wide_nul(r"Software\Microsoft\Windows\CurrentVersion\VideoSettings");
|
||||
let value_name = wide_nul("EnableAutoColorManagement");
|
||||
let mut key: *mut c_void = std::ptr::null_mut();
|
||||
let status = unsafe {
|
||||
RegOpenKeyExW(HKEY_CURRENT_USER, sub_key.as_ptr(), 0, KEY_READ, &mut key)
|
||||
};
|
||||
if status != ERROR_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
let mut data = [0u8; 4];
|
||||
let mut len = data.len() as u32;
|
||||
let mut kind = 0u32;
|
||||
let status = unsafe {
|
||||
RegQueryValueExW(
|
||||
key,
|
||||
value_name.as_ptr(),
|
||||
std::ptr::null(),
|
||||
&mut kind,
|
||||
data.as_mut_ptr(),
|
||||
&mut len,
|
||||
)
|
||||
};
|
||||
unsafe { RegCloseKey(key) };
|
||||
if status != ERROR_SUCCESS || kind != REG_DWORD || len != 4 {
|
||||
return None;
|
||||
}
|
||||
Some(u32::from_le_bytes(data) != 0)
|
||||
}
|
||||
|
||||
/// True when Windows 11 Auto Color Management maps app output for us:
|
||||
/// the app must then deliver plain sRGB and NOT apply the display ICC
|
||||
/// itself (double correction). The user toggles ACM in
|
||||
/// Settings → Display → HDR / "Automatically manage color for apps";
|
||||
/// 24H2+ defaults it on, earlier Windows 11 off. The
|
||||
/// `OAK_WINDOWS_COLOR` override (`self` / `os`) wins outright.
|
||||
pub(super) fn acm_active() -> bool {
|
||||
match std::env::var("OAK_WINDOWS_COLOR") {
|
||||
Ok(v) if v.eq_ignore_ascii_case("os") => return true,
|
||||
Ok(v) if v.eq_ignore_ascii_case("self") => return false,
|
||||
_ => {}
|
||||
}
|
||||
let build = windows_build();
|
||||
if build < BUILD_WINDOWS_11 {
|
||||
// Windows 10 and earlier: no per-app OS color management.
|
||||
return false;
|
||||
}
|
||||
match acm_registry_flag() {
|
||||
Some(flag) => flag,
|
||||
// No explicit user choice: default since 24H2.
|
||||
None => build >= BUILD_WIN11_24H2,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn display_icc() -> Option<String> {
|
||||
@@ -218,12 +618,73 @@ mod windows {
|
||||
if hdc.is_null() {
|
||||
return None;
|
||||
}
|
||||
let profile = icm_profile_for_hdc(hdc);
|
||||
unsafe { ReleaseDC(std::ptr::null(), hdc) };
|
||||
profile
|
||||
}
|
||||
|
||||
/// The ICM profile file path of one specific monitor device (e.g.
|
||||
/// `\\.\DISPLAY1`), via a per-monitor DC.
|
||||
pub(super) fn display_icc_for(device: &str) -> Option<String> {
|
||||
let name = wide_nul(device);
|
||||
let hdc = unsafe {
|
||||
CreateDCW(name.as_ptr(), name.as_ptr(), std::ptr::null(), std::ptr::null())
|
||||
};
|
||||
if hdc.is_null() {
|
||||
return None;
|
||||
}
|
||||
let profile = icm_profile_for_hdc(hdc);
|
||||
unsafe { DeleteDC(hdc) };
|
||||
profile
|
||||
}
|
||||
|
||||
/// The device name (`\\.\DISPLAY1`, ...) of the monitor owning
|
||||
/// `hmonitor` (an HMONITOR value), or `None` when the handle is invalid
|
||||
/// or the OS call fails.
|
||||
pub(super) fn monitor_device_name(hmonitor: usize) -> Option<String> {
|
||||
let mut info = MonitorInfoExW {
|
||||
cb_size: std::mem::size_of::<MonitorInfoExW>() as u32,
|
||||
rc_monitor: Rect {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
rc_work: Rect {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
dw_flags: 0,
|
||||
sz_device: [0; 32],
|
||||
};
|
||||
let ok = unsafe {
|
||||
GetMonitorInfoW(hmonitor as *const c_void, &mut info as *mut _ as *mut c_void)
|
||||
};
|
||||
if ok == 0 {
|
||||
return None;
|
||||
}
|
||||
let end = info
|
||||
.sz_device
|
||||
.iter()
|
||||
.position(|&u| u == 0)
|
||||
.unwrap_or(info.sz_device.len());
|
||||
let name = String::from_utf16_lossy(&info.sz_device[..end]);
|
||||
if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// The ICM profile file path of a device context, or `None` when the
|
||||
/// profile is unavailable or not a file.
|
||||
fn icm_profile_for_hdc(hdc: *mut c_void) -> Option<String> {
|
||||
// Stage 1: required buffer size, in `WCHAR`s including the NUL.
|
||||
let mut len: u32 = 0;
|
||||
let ok = unsafe { GetICMProfileW(hdc, &mut len, std::ptr::null_mut()) };
|
||||
if ok == 0 || len == 0 {
|
||||
unsafe { ReleaseDC(std::ptr::null(), hdc) };
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -231,7 +692,6 @@ mod windows {
|
||||
// that report a length without the terminator.
|
||||
let mut buf = vec![0u16; len as usize + 1];
|
||||
let ok = unsafe { GetICMProfileW(hdc, &mut len, buf.as_mut_ptr()) };
|
||||
unsafe { ReleaseDC(std::ptr::null(), hdc) };
|
||||
if ok == 0 {
|
||||
return None;
|
||||
}
|
||||
@@ -251,6 +711,7 @@ mod windows {
|
||||
mod linux {
|
||||
use std::io::Read;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -279,6 +740,70 @@ mod linux {
|
||||
colord_icc_path()
|
||||
}
|
||||
|
||||
/// ICC bytes of one RandR output via `xrandr --prop` → cache file.
|
||||
/// None when the output is absent, has no `_ICC_PROFILE`, or `xrandr`
|
||||
/// cannot run.
|
||||
pub(super) fn display_icc_for_output(output: &str) -> Option<String> {
|
||||
let mut cmd = Command::new("xrandr");
|
||||
cmd.args(["--prop"]);
|
||||
let out = run_capture(&mut cmd, Duration::from_secs(2))?;
|
||||
let text = String::from_utf8_lossy(&out);
|
||||
let bytes = parse_output_icc_prop(&text, output)?;
|
||||
write_icc_cache(&bytes)
|
||||
}
|
||||
|
||||
/// RandR monitor-list snapshot (`xrandr --listmonitors`), cached briefly
|
||||
/// so the poll loop does not spawn a process every tick. A failed fetch
|
||||
/// is cached as empty and retried on the next expiry.
|
||||
static MONITORS_CACHE: Mutex<Option<(Instant, Vec<RandRMonitor>)>> = Mutex::new(None);
|
||||
/// How long a RandR monitor snapshot stays valid.
|
||||
const MONITORS_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// The cached RandR monitor list, re-fetched at most once per TTL.
|
||||
fn monitors_snapshot() -> Vec<RandRMonitor> {
|
||||
let mut guard = MONITORS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((stamp, monitors)) = guard.as_ref() {
|
||||
if stamp.elapsed() < MONITORS_TTL {
|
||||
return monitors.clone();
|
||||
}
|
||||
}
|
||||
let monitors = fetch_monitors();
|
||||
*guard = Some((Instant::now(), monitors.clone()));
|
||||
monitors
|
||||
}
|
||||
|
||||
/// Run `xrandr --listmonitors` and parse it; empty on any failure.
|
||||
fn fetch_monitors() -> Vec<RandRMonitor> {
|
||||
let mut cmd = Command::new("xrandr");
|
||||
cmd.args(["--listmonitors"]);
|
||||
match run_capture(&mut cmd, Duration::from_secs(2)) {
|
||||
Some(out) => parse_xrandr_monitors(&String::from_utf8_lossy(&out)),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The fingerprint (`x11:<RandR output>`) of the monitor covering the
|
||||
/// given point in device pixels (global X11 screen coordinates), or
|
||||
/// `None` when no monitor matches (headless, no `xrandr`, or the point
|
||||
/// lies outside every listed geometry).
|
||||
pub(super) fn x11_monitor_fingerprint_at(x: f64, y: f64) -> Option<String> {
|
||||
let monitors = monitors_snapshot();
|
||||
let px = x.round() as i32;
|
||||
let py = y.round() as i32;
|
||||
for monitor in &monitors {
|
||||
if px >= monitor.x
|
||||
&& px < monitor.x + monitor.width
|
||||
&& py >= monitor.y
|
||||
&& py < monitor.y + monitor.height
|
||||
{
|
||||
if let Some(output) = monitor.outputs.first() {
|
||||
return Some(format!("x11:{output}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Run `cmd`, returning its captured stdout.
|
||||
///
|
||||
/// Returns `None` when the command cannot be started, exits non-zero, is
|
||||
@@ -494,4 +1019,123 @@ mod tests {
|
||||
std::env::remove_var("OAK_CONFIG_DIR");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_xrandr_monitors_two_screens() {
|
||||
let out = "\
|
||||
Monitors: 2
|
||||
0: +*eDP-1 1920/344x1080/194+0+0 eDP-1
|
||||
1: +HDMI-1 3840/600x2160/340+1920+0 HDMI-1
|
||||
";
|
||||
let monitors = parse_xrandr_monitors(out);
|
||||
assert_eq!(monitors.len(), 2);
|
||||
assert_eq!(monitors[0].x, 0);
|
||||
assert_eq!(monitors[0].y, 0);
|
||||
assert_eq!(monitors[0].width, 1920);
|
||||
assert_eq!(monitors[0].height, 1080);
|
||||
assert_eq!(monitors[0].outputs, ["eDP-1"]);
|
||||
assert_eq!(monitors[1].x, 1920);
|
||||
assert_eq!(monitors[1].y, 0);
|
||||
assert_eq!(monitors[1].width, 3840);
|
||||
assert_eq!(monitors[1].height, 2160);
|
||||
assert_eq!(monitors[1].outputs, ["HDMI-1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_xrandr_monitors_negative_coords_and_spanning() {
|
||||
// A monitor left of the primary has a negative X11 coordinate,
|
||||
// printed with a `-` prefix.
|
||||
let out = "\
|
||||
Monitors: 2
|
||||
0: +*DP-1 1080/293x1920/509+0+0 DP-1
|
||||
1: +DP-2 1920/509x1080/293-1080+0 DP-2
|
||||
";
|
||||
let monitors = parse_xrandr_monitors(out);
|
||||
assert_eq!(monitors[1].x, -1080);
|
||||
assert_eq!(monitors[1].y, 0);
|
||||
// A spanning mode lists several outputs on one monitor.
|
||||
let out = "\
|
||||
Monitors: 1
|
||||
0: +*eDP-1 3840/700x1080/194+0+0 eDP-1 HDMI-1
|
||||
";
|
||||
let monitors = parse_xrandr_monitors(out);
|
||||
assert_eq!(monitors.len(), 1);
|
||||
assert_eq!(monitors[0].outputs, ["eDP-1", "HDMI-1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_xrandr_monitors_malformed() {
|
||||
assert_eq!(parse_xrandr_monitors(""), Vec::<RandRMonitor>::new());
|
||||
assert_eq!(parse_xrandr_monitors("Monitors: 0\n"), Vec::<RandRMonitor>::new());
|
||||
// No `:` separator, a missing geometry, and a geometry with no
|
||||
// outputs are all skipped without panicking.
|
||||
let out = "\
|
||||
Monitors: 2
|
||||
garbage line
|
||||
0: +*eDP-1 eDP-1
|
||||
1: +HDMI-1 1920/344x1080/194+1920+0
|
||||
2: +VGA-1 1024/200x768/150+0+0 VGA-1
|
||||
";
|
||||
let monitors = parse_xrandr_monitors(out);
|
||||
assert_eq!(monitors.len(), 1);
|
||||
assert_eq!(monitors[0].outputs, ["VGA-1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_output_icc_prop_hit_and_miss() {
|
||||
let out = "\
|
||||
Screen 0: minimum 320 x 200, current 3840 x 1080, maximum 16384 x 16384
|
||||
eDP-1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 344mm x 194mm
|
||||
\t_ICC_PROFILE(8)\t= 0x3c, 0x6f, 0x6f
|
||||
\tEDID(0)\t= 0x00, 0xff, 0xff, 0xff
|
||||
HDMI-1 connected 1920x1080+1920+0 (normal left inverted right x axis y axis) 600mm x 340mm
|
||||
\t_ICC_PROFILE(8)\t= 0x41, 0x42
|
||||
";
|
||||
assert_eq!(
|
||||
parse_output_icc_prop(out, "eDP-1"),
|
||||
Some(vec![0x3c, 0x6f, 0x6f])
|
||||
);
|
||||
assert_eq!(parse_output_icc_prop(out, "HDMI-1"), Some(vec![0x41, 0x42]));
|
||||
// An output not present in the list.
|
||||
assert_eq!(parse_output_icc_prop(out, "VGA-1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_output_icc_prop_without_profile() {
|
||||
// An output whose section carries no `_ICC_PROFILE` yields None, and
|
||||
// so does a malformed property value.
|
||||
let out = "\
|
||||
DP-1 connected 1920x1080+0+0 (normal left inverted right x axis y axis) 344mm x 194mm
|
||||
\tEDID(0)\t= 0x00, 0xff
|
||||
";
|
||||
assert_eq!(parse_output_icc_prop(out, "DP-1"), None);
|
||||
let out = "\
|
||||
DP-1 connected 1920x1080+0+0 (normal left inverted right x axis y axis) 344mm x 194mm
|
||||
\t_ICC_PROFILE(8)\t= 0x
|
||||
";
|
||||
assert_eq!(parse_output_icc_prop(out, "DP-1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_ref_fingerprint_roundtrip() {
|
||||
assert_eq!(
|
||||
monitor_ref_from_fingerprint("mac:4294967295"),
|
||||
Some(MonitorRef::MacDisplay(u32::MAX))
|
||||
);
|
||||
assert_eq!(
|
||||
monitor_ref_from_fingerprint(r"win:\\.\DISPLAY1"),
|
||||
Some(MonitorRef::WinDevice(r"\\.\DISPLAY1".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
monitor_ref_from_fingerprint("x11:eDP-1"),
|
||||
Some(MonitorRef::X11Output("eDP-1".to_string()))
|
||||
);
|
||||
// Malformed input.
|
||||
assert_eq!(monitor_ref_from_fingerprint(""), None);
|
||||
assert_eq!(monitor_ref_from_fingerprint("nope"), None);
|
||||
assert_eq!(monitor_ref_from_fingerprint("mac:abc"), None);
|
||||
assert_eq!(monitor_ref_from_fingerprint("win:"), None);
|
||||
assert_eq!(monitor_ref_from_fingerprint("x11:"), None);
|
||||
assert_eq!(monitor_ref_from_fingerprint("os:whatever"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod cancelatom;
|
||||
pub mod colormath;
|
||||
pub mod colortransform;
|
||||
pub mod commandlineparser;
|
||||
pub mod configstore;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,6 +193,42 @@ impl ColorProcessor {
|
||||
Self::create_display_icc_impl(src_space, icc_path, true)
|
||||
}
|
||||
|
||||
/// Create the display-output processor for content the caller has already
|
||||
/// converted to CIE XYZ (D65, unit luminance) — the non-sRGB project
|
||||
/// output gamut path. [`create_display_icc`](Self::create_display_icc)
|
||||
/// starts from an OCIO named space (sRGB and friends); P3/BT.2020
|
||||
/// targets have no named space in the builtin configs, so the caller
|
||||
/// linearizes and gamut-maps to XYZ itself
|
||||
/// (`oak_common::colormath::output_spec_to_xyz_d65`) and this chain only
|
||||
/// needs the ICC half: it runs the existing builder with the config's
|
||||
/// `cie_xyz_d65_interchange` role as the source space (XYZ → linear
|
||||
/// Rec.709, whose inverse the builder's leg 2 immediately undoes — a
|
||||
/// no-op round trip, leaving the ICC FileTransform to map the XYZ PCS
|
||||
/// to device values).
|
||||
///
|
||||
/// Returns `None` (not a pass-through) when the chain cannot build — e.g.
|
||||
/// the config rejects the interchange role — so callers can fall back to
|
||||
/// the sRGB chain instead of silently mapping wrong content.
|
||||
pub fn create_display_icc_xyz(icc_path: &str) -> Option<Self> {
|
||||
Self::create_display_icc_xyz_impl(icc_path, false)
|
||||
}
|
||||
|
||||
/// The [`create_display_icc_xyz`](Self::create_display_icc_xyz) chain
|
||||
/// with R/B-swapping matrices baked around it for BGRA8 buffers (see
|
||||
/// [`create_display_icc_bgra8`](Self::create_display_icc_bgra8)).
|
||||
pub fn create_display_icc_xyz_bgra8(icc_path: &str) -> Option<Self> {
|
||||
Self::create_display_icc_xyz_impl(icc_path, true)
|
||||
}
|
||||
|
||||
fn create_display_icc_xyz_impl(icc_path: &str, bgra: bool) -> Option<Self> {
|
||||
let p = Self::create_display_icc_impl("cie_xyz_d65_interchange", icc_path, bgra)?;
|
||||
// A failed OCIO lookup yields a pass-through processor (the
|
||||
// non-fatal convention above); for the XYZ chain that must surface as
|
||||
// `None` so the caller can fall back to the sRGB degradation instead
|
||||
// of feeding XYZ values through a no-op.
|
||||
p.is_valid().then_some(p)
|
||||
}
|
||||
|
||||
/// Shared builder: `bgra` wraps the chain in R/B swap matrices.
|
||||
fn create_display_icc_impl(src_space: &str, icc_path: &str, bgra: bool) -> Option<Self> {
|
||||
let config = default_config()?;
|
||||
@@ -384,6 +420,48 @@ fn bytemuck_f32_slice(data: &mut [u8]) -> Option<&mut [f32]> {
|
||||
|
||||
// ---- process-wide default config (C++ ColorManager statics) ----------------
|
||||
|
||||
/// The process-wide pipeline color settings (the project properties that
|
||||
/// drive the ACEScg + F32 pipeline): the working colorspace and the
|
||||
/// output/delivery spec. Defaults to ACEScg working + sRGB output; the app
|
||||
/// updates this from the open project's properties (and again on every
|
||||
/// project-properties commit). Render and export paths read it — a single
|
||||
/// project is open at a time, so a process global is the same shape as the
|
||||
/// OCIO default config above.
|
||||
static PIPELINE_COLOR: LazyLock<Mutex<(oak_common::colormath::WorkingColorSpace, oak_common::colormath::OutputColorSpec)>> =
|
||||
LazyLock::new(|| Mutex::new((
|
||||
oak_common::colormath::WorkingColorSpace::default(),
|
||||
oak_common::colormath::OutputColorSpec::default(),
|
||||
)));
|
||||
|
||||
/// Set the pipeline color settings (working space + output spec).
|
||||
pub fn set_pipeline_color_settings(
|
||||
working: oak_common::colormath::WorkingColorSpace,
|
||||
output: oak_common::colormath::OutputColorSpec,
|
||||
) {
|
||||
*PIPELINE_COLOR.lock().unwrap_or_else(|e| e.into_inner()) = (working, output);
|
||||
}
|
||||
|
||||
/// The pipeline working colorspace.
|
||||
pub fn pipeline_working_space() -> oak_common::colormath::WorkingColorSpace {
|
||||
PIPELINE_COLOR.lock().unwrap_or_else(|e| e.into_inner()).0
|
||||
}
|
||||
|
||||
/// The pipeline output/delivery spec.
|
||||
pub fn pipeline_output_spec() -> oak_common::colormath::OutputColorSpec {
|
||||
PIPELINE_COLOR.lock().unwrap_or_else(|e| e.into_inner()).1
|
||||
}
|
||||
|
||||
/// The pipeline working colorspace as an OFX colorspace name (the value
|
||||
/// written to `kOfxImageClipPropColourspace` so plugins are told the
|
||||
/// true space of the pixels they receive — ACEScg in the default pipeline,
|
||||
/// sRGB in the legacy pass-through mode).
|
||||
pub fn pipeline_working_ofx_name() -> &'static str {
|
||||
match pipeline_working_space() {
|
||||
oak_common::colormath::WorkingColorSpace::AcesCg => "ACEScg",
|
||||
oak_common::colormath::WorkingColorSpace::SrgbLegacy => "sRGB",
|
||||
}
|
||||
}
|
||||
|
||||
/// Send+Sync wrapper around `ocio_rs::Config` (a `NonNull`-based handle;
|
||||
/// the underlying OCIO config is a shared pointer safe for concurrent
|
||||
/// reads).
|
||||
@@ -829,6 +907,72 @@ mod tests {
|
||||
assert!((out[3] - 1.0).abs() < 1e-5, "alpha preserved");
|
||||
}
|
||||
|
||||
/// The non-sRGB project output gamut display path: content converted to
|
||||
/// CIE XYZ (D65, unit luminance) by
|
||||
/// `oak_common::colormath::output_spec_to_xyz_d65` must flow through the
|
||||
/// display ICC. Builds by running the classic builder with the config's
|
||||
/// `cie_xyz_d65_interchange` role as the source space — the feasibility
|
||||
/// question this test answers is whether OCIO accepts the role name as a
|
||||
/// `ColorSpaceTransform` source (it is a role, not a bare colorspace, in
|
||||
/// the OCIO 2.2+ builtin configs).
|
||||
#[test]
|
||||
fn display_icc_xyz_accepts_interchange_role() {
|
||||
let _lock = config_lock();
|
||||
if set_up_default_config().is_err() {
|
||||
return;
|
||||
}
|
||||
// Any display-class ICC; probe the usual macOS + Linux system profile
|
||||
// locations (CI runners may have none — skip then).
|
||||
let icc = [
|
||||
"/System/Library/ColorSync/Profiles/sRGB Profile.icc",
|
||||
"/System/Library/ColorSync/Profiles/Display P3.icc",
|
||||
"/usr/share/color/icc/colord/sRGB.icc",
|
||||
"/usr/share/color/icc/ghostscript/srgb.icc",
|
||||
"/usr/local/share/color/icc/colord/sRGB.icc",
|
||||
]
|
||||
.into_iter()
|
||||
.find(|p| std::path::Path::new(p).exists());
|
||||
let Some(icc) = icc else {
|
||||
eprintln!("no system ICC profile; skipping");
|
||||
return;
|
||||
};
|
||||
let p = ColorProcessor::create_display_icc_xyz(icc)
|
||||
.expect("handle always returned or explicit None");
|
||||
assert!(
|
||||
p.is_valid(),
|
||||
"the cie_xyz_d65_interchange role must build the XYZ→ICC chain from {icc}"
|
||||
);
|
||||
// Numeric sanity: the XYZ chain fed with `output_spec_to_xyz_d65` of
|
||||
// an sRGB-encoded mid-grey must match the classic chain applied to
|
||||
// the same encoded values — legs 1+2 (XYZ→lin709→XYZ) are the inverse
|
||||
// round trip of the classic chain's lin709→XYZ leg, so both must land
|
||||
// on the same device values.
|
||||
let spec = oak_common::colormath::OutputColorSpec::default();
|
||||
let encoded = [0.5f32, 0.5, 0.5, 1.0];
|
||||
let mut xyz_in = encoded;
|
||||
oak_common::colormath::output_spec_to_xyz_d65(&mut xyz_in, spec);
|
||||
let mut via_xyz = xyz_in;
|
||||
let _ = p.convert_f32_rgba(&mut via_xyz, 1);
|
||||
let srgb = ColorProcessor::create_display_icc("sRGB Encoded Rec.709 (sRGB)", icc)
|
||||
.expect("handle always returned");
|
||||
let mut via_srgb = encoded;
|
||||
let _ = srgb.convert_f32_rgba(&mut via_srgb, 1);
|
||||
for c in 0..3 {
|
||||
assert!(
|
||||
(via_xyz[c] - via_srgb[c]).abs() < 0.02,
|
||||
"channel {c}: XYZ chain {} vs sRGB chain {} (round trip must be identity)",
|
||||
via_xyz[c],
|
||||
via_srgb[c]
|
||||
);
|
||||
}
|
||||
// Grey stays grey; alpha preserved.
|
||||
assert!(
|
||||
(via_xyz[0] - via_xyz[1]).abs() < 1e-3 && (via_xyz[1] - via_xyz[2]).abs() < 1e-3,
|
||||
"grey stays grey: {via_xyz:?}"
|
||||
);
|
||||
assert!((via_xyz[3] - 1.0).abs() < 1e-5, "alpha preserved");
|
||||
}
|
||||
|
||||
/// The exact chain the viewers use (BGRA8, display-class ICC from
|
||||
/// `OAK_DISPLAY_ICC`): a mid-grey frame must NOT collapse to black —
|
||||
/// the viewer-black-screen regression guard. Skipped without the env
|
||||
|
||||
@@ -910,9 +910,57 @@ pub fn render_footage_frame(
|
||||
dh,
|
||||
);
|
||||
}
|
||||
// Input node: source colorspace → the pipeline working space (ACEScg
|
||||
// by default; the legacy sRGB working space keeps the pass-through).
|
||||
convert_decoded_to_working(&mut dst, &decoded);
|
||||
Ok(Texture::wrap_frame(dst))
|
||||
}
|
||||
|
||||
/// Convert a decoded footage frame (display-referred RGB in the source's
|
||||
/// own colorspace) into the pipeline working space, driven by the frame's
|
||||
/// colorimetry metadata (carried on the codec frame's params). A no-op in
|
||||
/// the legacy sRGB working space or when the frame has no pixel data.
|
||||
fn convert_decoded_to_working(dst: &mut Frame, decoded: &oak_codec::frame::Frame) {
|
||||
use oak_common::colormath::{
|
||||
WorkingColorSpace, source_primaries_from_av, source_transfer_from_av,
|
||||
};
|
||||
if crate::color::pipeline_working_space() == WorkingColorSpace::SrgbLegacy {
|
||||
return;
|
||||
}
|
||||
// Frames without colorimetry metadata get the generic fallback (sRGB
|
||||
// primaries, sRGB transfer) instead of passing through unconverted;
|
||||
// the missing tag is warned once per process.
|
||||
let (primaries, transfer) = match decoded.params() {
|
||||
Some(params) => (
|
||||
source_primaries_from_av(params.color_primaries()),
|
||||
source_transfer_from_av(params.color_transfer()),
|
||||
),
|
||||
None => {
|
||||
warn_missing_colorimetry_once();
|
||||
(source_primaries_from_av(2), source_transfer_from_av(2))
|
||||
}
|
||||
};
|
||||
let w = dst.width.max(0) as usize;
|
||||
let h = dst.height.max(0) as usize;
|
||||
if w == 0 || h == 0 {
|
||||
return;
|
||||
}
|
||||
let row_bytes = w * 16; // F32 RGBA
|
||||
let linesize = dst.linesize_bytes();
|
||||
for y in 0..h {
|
||||
let start = y * linesize;
|
||||
if start + row_bytes > dst.data.len() {
|
||||
break;
|
||||
}
|
||||
oak_common::colormath::decode_to_acescg_bytes(
|
||||
&mut dst.data[start..start + row_bytes],
|
||||
w,
|
||||
primaries,
|
||||
transfer,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph-driven sequence rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -933,7 +981,9 @@ fn main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let s = textureLoad(src_tex, coord, 0);
|
||||
let d = textureLoad(dst_tex, coord, 0);
|
||||
let a = clamp(s.a, 0.0, 1.0);
|
||||
return clamp(vec4<f32>(s.rgb * a + d.rgb * (1.0 - a), a + d.a * (1.0 - a)), vec4<f32>(0.0), vec4<f32>(1.0));
|
||||
// RGB keeps the working-space values unclamped (HDR/WCG can exceed
|
||||
// 1.0); only the alpha of the result is clamped to the valid range.
|
||||
return vec4<f32>(s.rgb * a + d.rgb * (1.0 - a), clamp(a + d.a * (1.0 - a), 0.0, 1.0));
|
||||
}
|
||||
"#;
|
||||
|
||||
@@ -1290,9 +1340,13 @@ fn scale_rgba_f32(
|
||||
let sx = sx.max(0.0);
|
||||
let px = sample(sx, sy);
|
||||
let off = (y as usize) * (dst_stride as usize) + (x as usize) * 16;
|
||||
// RGB is not clamped: bilinear lerp is a convex combination,
|
||||
// so values cannot overshoot the source range, and HDR/WCG
|
||||
// working-space pixels may legitimately exceed 1.0. Only the
|
||||
// alpha channel is clamped to its valid range.
|
||||
for i in 0..4 {
|
||||
dst[off + i * 4..off + i * 4 + 4]
|
||||
.copy_from_slice(&px[i].clamp(0.0, 1.0).to_le_bytes());
|
||||
let v = if i == 3 { px[i].clamp(0.0, 1.0) } else { px[i] };
|
||||
dst[off + i * 4..off + i * 4 + 4].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1393,8 +1447,11 @@ pub fn composite_over(
|
||||
a + d[3] * (1.0 - a),
|
||||
];
|
||||
let off = (y as usize) * (dst_stride as usize) + (x as usize) * 16;
|
||||
// RGB keeps the working-space values unclamped (HDR/WCG can
|
||||
// exceed 1.0); only alpha is clamped to its valid range.
|
||||
for i in 0..4 {
|
||||
dst[off + i * 4..off + i * 4 + 4].copy_from_slice(&out[i].clamp(0.0, 1.0).to_le_bytes());
|
||||
let v = if i == 3 { out[i].clamp(0.0, 1.0) } else { out[i] };
|
||||
dst[off + i * 4..off + i * 4 + 4].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1429,6 +1486,15 @@ fn warn_unsupported_once(type_id: &str, reason: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Warn once (per process) when a decoded frame carries no colorimetry
|
||||
/// metadata and falls back to the generic sRGB assumptions.
|
||||
fn warn_missing_colorimetry_once() {
|
||||
static WARNED: std::sync::Once = std::sync::Once::new();
|
||||
WARNED.call_once(|| {
|
||||
eprintln!("decoded frame has no colorimetry metadata; assuming sRGB");
|
||||
});
|
||||
}
|
||||
|
||||
/// Run a clip's effect stack over its decoded frame (source-first order;
|
||||
/// disabled effects are bypassed — the C++ traverser's bypass pushes the
|
||||
/// effect input through unchanged). Effects the montage path cannot
|
||||
|
||||
@@ -1235,13 +1235,16 @@ pub struct SharedMemoryRegion {
|
||||
/// binaries finish via `std::process::exit` (libtest), which skips Rust
|
||||
/// static destructors — the process-wide render-manager singleton never
|
||||
/// runs `Drop`, its `shm_unlink` never fires, and every test run leaks one
|
||||
/// ~66 MiB segment per worker until `/dev/shm` fills up (the next create
|
||||
/// then `memset`s a mapping backed by a full tmpfs and faults with SIGBUS).
|
||||
/// ~66 MiB segment per worker until `/dev/shm` fills up.
|
||||
/// `libc::atexit` handlers DO run under `process::exit`, so each `Create`
|
||||
/// registers its key here and [`SharedMemoryRegion::atexit_cleanup_owned_shm`]
|
||||
/// unlinks them all at exit. Unlinking while a peer still maps the segment
|
||||
/// is safe — POSIX only removes the name; the mapping lives until the last
|
||||
/// `munmap` (the workers attach without owning, so they never register).
|
||||
/// A SIGKILL'd process skips even atexit; those orphans are swept by
|
||||
/// [`SharedMemoryRegion::cleanup_stale_owned_segments`] at the next create,
|
||||
/// and the eager `posix_fallocate` reservation turns quota exhaustion into
|
||||
/// a graceful open failure instead of a SIGBUS at first touch.
|
||||
#[cfg(unix)]
|
||||
static OWNED_SHM_KEYS: std::sync::Mutex<Option<Vec<String>>> = std::sync::Mutex::new(None);
|
||||
#[cfg(unix)]
|
||||
@@ -1292,6 +1295,48 @@ impl SharedMemoryRegion {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sweep owned segments (`olive-rw-<pid>-…`) whose owner pid no longer
|
||||
/// exists and unlink them. A SIGKILL'd / aborted owner never runs its
|
||||
/// atexit unlink, and because the key embeds the dead pid nobody else
|
||||
/// ever reuses the name — on a quota'd `/dev/shm` the accumulation
|
||||
/// eventually turns the next create into ENOSPC/EDQUOT. Runs once per
|
||||
/// process, before the first create. Unlinking only removes the name:
|
||||
/// a peer still mapping the segment keeps its memory until munmap.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn cleanup_stale_owned_segments() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
let Ok(entries) = std::fs::read_dir("/dev/shm") else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
|
||||
continue;
|
||||
};
|
||||
let Some(rest) = name.strip_prefix("olive-rw-") else {
|
||||
continue;
|
||||
};
|
||||
let pid_digits: String =
|
||||
rest.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if pid_digits.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// A live owner (or a pid reuse) means the segment is owned;
|
||||
// only dead owners are swept.
|
||||
if std::path::Path::new(&format!("/proc/{pid_digits}")).exists() {
|
||||
continue;
|
||||
}
|
||||
Self::unlink_key(&name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// No-op outside Linux: POSIX shm segments are not visible as files on
|
||||
/// every unix (macOS keeps them in a kernel namespace), so there is no
|
||||
/// directory to sweep.
|
||||
#[cfg(all(unix, not(target_os = "linux")))]
|
||||
pub fn cleanup_stale_owned_segments() {}
|
||||
|
||||
/// Remember `key` so it is unlinked at process exit (see
|
||||
/// [`OWNED_SHM_KEYS`]). Safe to call from any thread; duplicate keys
|
||||
/// are harmless (the unlink is idempotent).
|
||||
@@ -1335,6 +1380,14 @@ impl SharedMemoryRegion {
|
||||
self.size = size;
|
||||
self.mode = mode;
|
||||
|
||||
if mode == ShmMode::Create {
|
||||
// A crashed owner leaks its segments (the name embeds the dead
|
||||
// pid, so nobody ever reuses or unlinks them); on a quota'd
|
||||
// tmpfs the accumulation eventually kills the next create.
|
||||
// Sweep the orphans of dead owners before creating anything.
|
||||
Self::cleanup_stale_owned_segments();
|
||||
}
|
||||
|
||||
// POSIX shared-memory names must start with a single slash and
|
||||
// contain no others.
|
||||
let shm_name = format!("/{}", key.replace('/', "_"));
|
||||
@@ -1412,7 +1465,31 @@ impl SharedMemoryRegion {
|
||||
|
||||
if mode == ShmMode::Create {
|
||||
Self::track_owned_key(&self.key);
|
||||
unsafe { ptr::write_bytes(self.data, 0, size) };
|
||||
// Reserve (and zero) the whole segment up front. `ftruncate`
|
||||
// alone does not reserve on tmpfs: the pages fault in on first
|
||||
// touch, and when the tmpfs is full / the user quota is
|
||||
// exhausted that touch is a SIGBUS that kills the process.
|
||||
// `posix_fallocate` performs the reservation eagerly and reports
|
||||
// ENOSPC/EDQUOT as a return value, so quota exhaustion degrades
|
||||
// to a render-manager fallback instead of a crash. The fresh
|
||||
// segment is already zero-filled (O_EXCL + stale unlink above),
|
||||
// so no separate memset pass is needed on success.
|
||||
let rc = unsafe { libc::posix_fallocate(fd, 0, size as libc::off_t) };
|
||||
if rc != 0 && rc != libc::EOPNOTSUPP && rc != libc::ENOSYS {
|
||||
self.error = format!(
|
||||
"reserving {} bytes of shared memory failed: {}",
|
||||
size,
|
||||
std::io::Error::from_raw_os_error(rc)
|
||||
);
|
||||
self.close();
|
||||
return false;
|
||||
}
|
||||
if rc != 0 {
|
||||
// Filesystem without fallocate support: fall back to
|
||||
// touching every page now (still better than faulting
|
||||
// lazily mid-render).
|
||||
unsafe { ptr::write_bytes(self.data, 0, size) };
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -43,10 +43,23 @@ fn clip_path(tag: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("oakrender_graph_{tag}_{}.mp4", std::process::id()))
|
||||
}
|
||||
|
||||
/// These tests verify graph/decode/composite MECHANICS (stacking, scaling,
|
||||
/// effects), not the color pipeline. Pin the working space to the legacy
|
||||
/// sRGB pass-through so the decoded pixels stay display-referred and the
|
||||
/// pixel-value assertions hold regardless of the ACEScg default. All tests
|
||||
/// in this binary set the same value, so the shared global is race-free.
|
||||
fn pin_legacy_working_space() {
|
||||
oak_render::color::set_pipeline_color_settings(
|
||||
oak_common::colormath::WorkingColorSpace::SrgbLegacy,
|
||||
oak_common::colormath::OutputColorSpec::default(),
|
||||
);
|
||||
}
|
||||
|
||||
/// One sequence + one video track list with one track per clip
|
||||
/// `(filename, [in, out))`. The LAST entry's track composites on top
|
||||
/// (NLE stacking: the highest-numbered track is topmost).
|
||||
fn build_project(clips: &[(&str, Rational, Rational)]) -> (Arc<Mutex<Project>>, NodeId) {
|
||||
pin_legacy_working_space();
|
||||
let project = Project::new();
|
||||
let seq;
|
||||
{
|
||||
@@ -269,6 +282,7 @@ fn build_effect_project(
|
||||
clip: (&str, Rational, Rational),
|
||||
insert_effect: impl FnOnce(&mut Project, NodeId, NodeId) -> NodeId,
|
||||
) -> (Arc<Mutex<Project>>, NodeId) {
|
||||
pin_legacy_working_space();
|
||||
let project = Project::new();
|
||||
let seq;
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -576,6 +576,10 @@ impl WorkerSession {
|
||||
};
|
||||
match oak_node::serializer::load_with_id_map(&content) {
|
||||
Ok((project, id_map)) => {
|
||||
let (working, output_spec) = {
|
||||
let guard = project.lock().unwrap_or_else(|e| e.into_inner());
|
||||
(guard.working_color_space(), guard.output_color_spec())
|
||||
};
|
||||
let project_uuid = project
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
@@ -588,6 +592,10 @@ impl WorkerSession {
|
||||
id_map,
|
||||
project_copy: 0,
|
||||
});
|
||||
// The project's color pipeline properties drive this
|
||||
// process's input/output transforms (the oakrender
|
||||
// process global read by eval + the output node).
|
||||
oak_render::color::set_pipeline_color_settings(working, output_spec);
|
||||
// A fresh graph snapshot can change what any viewer
|
||||
// identity renders; cached pixels from the previous
|
||||
// graph must not be served (M16 S2 frame cache).
|
||||
@@ -1098,8 +1106,12 @@ impl WorkerSession {
|
||||
// BGRA8: the F32 pipeline frame comes from the cache or the
|
||||
// session scratch, then converts into the slot (the end-of-pipe
|
||||
// format convert is not an extra frame copy, design §3.1).
|
||||
match &cached {
|
||||
// Before quantization the output node runs: working space
|
||||
// (ACEScg) → the project's output colorspace, so the 8-bit
|
||||
// pixels carry gamma-encoded display values, not linear light.
|
||||
match cached.as_mut() {
|
||||
Some(c) => {
|
||||
apply_output_node(c, (w * h) as usize);
|
||||
convert_f32_rgba_to_bgra8(&c[..f32_need], &mut dst[..dst_need]);
|
||||
}
|
||||
None => {
|
||||
@@ -1116,6 +1128,7 @@ impl WorkerSession {
|
||||
)?;
|
||||
self.frame_cache
|
||||
.insert(key, self.f32_scratch[..f32_need].to_vec());
|
||||
apply_output_node(&mut self.f32_scratch, (w * h) as usize);
|
||||
convert_f32_rgba_to_bgra8(&self.f32_scratch[..f32_need], &mut dst[..dst_need]);
|
||||
}
|
||||
}
|
||||
@@ -1313,6 +1326,22 @@ fn convert_f32_rgba_to_bgra8(src: &[u8], dst: &mut [u8]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The output node for the BGRA8 delivery path: convert the first
|
||||
/// `pixels` pixels of an F32 RGBA byte buffer from the pipeline working
|
||||
/// space to the project's output colorspace (in place), so the 8-bit
|
||||
/// quantization encodes display-referred values instead of linear light.
|
||||
/// A no-op in the legacy sRGB working space (content already is
|
||||
/// display-referred sRGB).
|
||||
fn apply_output_node(bytes: &mut [u8], pixels: usize) {
|
||||
if oak_render::color::pipeline_working_space()
|
||||
== oak_common::colormath::WorkingColorSpace::SrgbLegacy
|
||||
{
|
||||
return;
|
||||
}
|
||||
let spec = oak_render::color::pipeline_output_spec();
|
||||
oak_common::colormath::acescg_to_output_bytes(bytes, pixels, spec);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -420,12 +420,17 @@ fn montage_effects_render_through_the_worker() {
|
||||
"50% opacity halves the alpha ({} vs 128)",
|
||||
d[3]
|
||||
);
|
||||
// 50% opacity reduces the color. The exact ratio depends on the color
|
||||
// pipeline (gamma-encoded vs linear working space), so assert the
|
||||
// pipeline-agnostic property: dimmed is darker than plain, but not
|
||||
// black (the opacity effect actually changed the pixel).
|
||||
assert!(
|
||||
(d[0] as i32 - p[0] as i32 / 4).abs() <= 6,
|
||||
"50% opacity quarters the color ({} vs {}/4)",
|
||||
d[0] < p[0],
|
||||
"50% opacity darkens the color ({} vs {})",
|
||||
d[0],
|
||||
p[0]
|
||||
);
|
||||
assert!(d[0] > 0, "dimmed pixel is not black ({})", d[0]);
|
||||
}
|
||||
|
||||
/// Submit an empty-montage audio range pull through the dispatcher
|
||||
|
||||
@@ -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<f32>, // HDR 目标才需要
|
||||
}
|
||||
impl Default for OutputColorSpec { /* sRGB + sRGB 传递 */ }
|
||||
```
|
||||
|
||||
- `WindowOptions` / `WgpuSurfaceConfig`(`wgpu_renderer.rs:155`)/
|
||||
macOS `MacWindow` 构造参数 / Windows 渲染器构造参数,各加
|
||||
`color_pipeline` 与 `output: OutputColorSpec` 字段,默认
|
||||
`AcesCg + OutputColorSpec::default()`。
|
||||
- 运行时可变:`Window::set_output_color_space(spec)` → 重建输出
|
||||
pass uniform;若位深/格式需要变化则重配交换链(wgpu
|
||||
`surface.configure`;Windows 重建 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` 一致,三端统一后
|
||||
无歧义,但需确认与现有设计稿的视觉差异可接受。
|
||||
+1
-1
Submodule gpui updated: 4ed8b2bf76...41dac8f33e
Reference in New Issue
Block a user