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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user