ci: linux xkbcommon-x11, yaml-cpp cstdint patch, hwaccel test skips on VT-less hosts

- Linux: libxkbcommon-x11-dev for the gpui X11 client link
- Windows: patch <cstdint> into the vendored yaml-cpp (a cached cmake
  configure ignores CXXFLAGS; the patch is idempotent and runs after
  cargo fetch)
- macOS: the hw-decode test skips its VideoToolbox engagement
  assertions on hosts where VT cannot initialize (headless/virtualized
  runners) instead of failing
- display color management: the display ICC (system or custom) is
  applied to viewer frames at present time (F32 in place, or in place
  on the BGRA staging copy with the R/B swizzle baked into the OCIO
  chain); preferences get a Color section (mode + custom ICC file); on
  macOS the Metal layer is tagged with the display colorspace when
  self-managing so ColorSync passes pixels through (no double
  correction); frame caches track the transform generation so a mode
  or profile change drops stale pixels
This commit is contained in:
2026-08-21 02:16:39 +08:00
parent fa7d8153bd
commit eff845a5cd
17 changed files with 1079 additions and 58 deletions
+9
View File
@@ -2677,6 +2677,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") };
}
cx.init_colors();
let bounds = Bounds::centered(None, size(px(1600.0), px(900.0)), cx);
let initial = initial.clone();
+114 -3
View File
@@ -97,6 +97,8 @@ pub struct PreferencesContent {
use_proxy: Entity<CheckBox>,
hw_decode: Entity<CheckBox>,
proxy_divider: Entity<ComboBox>,
display_icc: Entity<CheckBox>,
display_icc_path: Entity<PathField>,
snapshot_interval: Entity<SpinBox>,
transition_length: Entity<SpinBox>,
audio_output: Entity<ComboBox>,
@@ -276,6 +278,43 @@ impl PreferencesContent {
})
.detach();
// --- 色彩 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.
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);
CheckBox::new(
13,
if mode != "off" {
CheckState::Checked
} else {
CheckState::Unchecked
},
window,
cx,
)
.with_label(i18n::tr("preferences.color.enable"))
});
cx.subscribe(&display_icc, |_this, check, event: &CheckBoxEvent, cx| {
if let CheckBoxEvent::Toggled { state, .. } = event {
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));
}
})
.detach();
let display_icc_path = cx.new(|cx| {
let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
PathField { editor }
});
let configured_icc = config_get_string(CONFIG_KEY_CUSTOM_ICC);
display_icc_path.update(cx, |field, cx| field.set_path(configured_icc, cx));
// --- 项目 Project: snapshot interval + default transition ----------
let snapshot_interval = cx.new(|cx| {
let current =
@@ -369,6 +408,8 @@ impl PreferencesContent {
use_proxy,
hw_decode,
proxy_divider,
display_icc,
display_icc_path,
snapshot_interval,
transition_length,
audio_output,
@@ -380,6 +421,41 @@ impl PreferencesContent {
}
}
/// Commits the custom ICC path field to the config (called by the host
/// when the dialog closes, like the cache directory).
pub fn commit_display_icc_path(&self, cx: &App) {
let path = self.display_icc_path.read(cx).path(cx).trim().to_string();
config_set_string(
crate::oakui::displaycolor::CONFIG_KEY_CUSTOM_ICC,
&path,
);
}
/// Opens the platform file picker for a custom ICC profile.
fn browse_display_icc(&mut self, cx: &mut Context<Self>) {
let receiver = cx.prompt_for_paths(gpui::PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: Some(i18n::tr("preferences.color.browse").into()),
});
cx.spawn(async move |this, cx| {
let Ok(Ok(Some(paths))) = receiver.await else {
return;
};
let Some(path) = paths.first() else {
return;
};
this.update(cx, |this, cx| {
this.display_icc_path.update(cx, |field, cx| {
field.set_path(path.to_string_lossy().into_owned(), cx)
});
cx.notify();
});
})
.detach();
}
/// The cache directory currently entered.
pub fn cache_dir(&self, cx: &App) -> SharedString {
self.cache_dir.read(cx).path(cx)
@@ -560,6 +636,39 @@ impl Render for PreferencesContent {
i18n::tr("preferences.proxy.resolution").into(),
self.proxy_divider.clone(),
))
// 色彩 Color
.child(section_header(&colors, i18n::tr("preferences.section.color").into()))
.child(self.display_icc.clone())
.child(form_row(
&colors,
i18n::tr("preferences.color.custom").into(),
div()
.flex()
.gap_2()
.child(div().flex_1().child(self.display_icc_path.clone()))
.child(
div()
.id("preferences-icc-browse")
.px_3()
.py_1()
.rounded_md()
.bg(colors.background)
.border_1()
.border_color(colors.border)
.text_color(colors.text)
.cursor_pointer()
.child(i18n::tr("preferences.color.browse"))
.on_click(cx.listener(|this, _event, _window, cx| {
this.browse_display_icc(cx);
})),
),
))
.child(
div()
.text_color(colors.disabled)
.text_xs()
.child(i18n::tr("preferences.color.restart_hint")),
)
// 项目 Project
.child(section_header(&colors, i18n::tr("preferences.section.project").into()))
.child(form_row(
@@ -1221,10 +1330,12 @@ impl PreferencesDialogContent {
}
}
/// Commits the general tab's free-text fields (the cache directory), for
/// the host when the dialog closes.
/// Commits the general tab's free-text fields (the cache directory, the
/// custom ICC path), for the host when the dialog closes.
pub fn commit_cache_dir(&self, cx: &App) {
self.general.read(cx).commit_cache_dir(cx);
let general = self.general.read(cx);
general.commit_cache_dir(cx);
general.commit_display_icc_path(cx);
}
/// The keyboard tab's action-row count (tests).
+171
View File
@@ -0,0 +1,171 @@
// 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/>.
//! Display color management: the display's ICC profile applied to viewer
//! frames at present time.
//!
//! 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.
//!
//! 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.
use std::sync::{Arc, LazyLock, Mutex};
use oakcommon::configstore::ConfigStore;
use oakrender::color::ColorProcessor;
/// Config key: the display color management mode ("icc" / "off").
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).
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).
struct State {
key: (String, String, String),
f32: 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.
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// 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)
}
/// Drop the cached processors (call after a preferences change).
pub fn invalidate() {
*STATE.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
/// The cached state, (re)built when the config key changed.
fn current() -> Option<State> {
let key = current_key();
let mut guard = STATE.lock().unwrap_or_else(|e| e.into_inner());
if let Some(state) = guard.as_ref() {
if state.key == key {
return clone_state(state);
}
}
// The key changed: everything rendered with the old transform is
// stale — bump the generation so frame caches drop their contents.
GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let (mode, icc_path, space) = &key;
if mode != "icc" {
let state = State {
key,
f32: None,
bgra: None,
};
let out = clone_state(&state);
*guard = Some(state);
return out;
}
// The custom override wins; empty = the platform's display profile.
let icc = if icc_path.is_empty() {
oakcommon::displayicc::system_display_icc()
} 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 state = State {
key,
f32: f32p,
bgra: bgrap,
};
let out = clone_state(&state);
*guard = Some(state);
out
}
fn clone_state(state: &State) -> Option<State> {
Some(State {
key: state.key.clone(),
f32: state.f32.clone(),
bgra: state.bgra.clone(),
})
}
/// Whether display color management is active (a valid ICC processor
/// exists). When false the OS owns the output mapping.
pub fn is_active() -> bool {
current().map(|s| s.f32.is_some() || s.bgra.is_some()).unwrap_or(false)
}
/// Apply the display transform to an F32 RGBA buffer in place (no-op
/// when inactive).
pub fn apply_f32_rgba(samples: &mut [f32], pixels: i64) {
let Some(state) = current() else {
return;
};
if let Some(processor) = &state.f32 {
let _ = processor.convert_f32_rgba(samples, pixels);
}
}
/// Apply the display transform to a packed BGRA8 buffer in place (no-op
/// when inactive).
pub fn apply_bgra8(data: &mut [u8], pixels: i64) {
let Some(state) = current() else {
return;
};
if let Some(processor) = &state.bgra {
let _ = processor.convert_bgra8(data, pixels);
}
}
+1
View File
@@ -40,6 +40,7 @@
//! * [`timecode`] — timecode / duration / fps / resolution formatting (pure,
//! unit tested).
pub mod displaycolor;
pub mod effectchain;
pub mod engine;
pub mod frames;
+13
View File
@@ -986,6 +986,10 @@ pub struct RealEngine {
/// [`RealEngine::render_source_frame`]); the synthetic pattern is only
/// the failure fallback.
cpu_frame_cache: Mutex<HashMap<Monitor, MonitorFrameCache>>,
/// The display-color transform generation the frame cache was built
/// against (a change drops every cached image — they were produced
/// with the stale transform).
display_color_gen: std::cell::Cell<u64>,
/// Bumped whenever the rendered content can change underneath an
/// in-flight background full-res job (an edit, a selection change or a
/// project drop); completions tagged with a stale generation are
@@ -1153,6 +1157,7 @@ impl RealEngine {
program_playing: false,
meter_phase: 0,
cpu_frame_cache: Mutex::new(HashMap::new()),
display_color_gen: std::cell::Cell::new(0),
full_res_generation: 0,
preview_windows: Arc::new(Mutex::new(HashMap::new())),
preview_generation: 0,
@@ -3176,6 +3181,14 @@ impl AppEngine for RealEngine {
fn cpu_frame(&self, monitor: Monitor, cx: &App) -> Arc<RenderImage> {
let frame = self.clock_frame(monitor, cx);
let mut cache = self.cpu_frame_cache.lock().unwrap();
// A display-color transform change (mode / ICC / content space)
// invalidates every cached image: they were produced with the old
// transform.
let gen = super::displaycolor::generation();
if gen != self.display_color_gen.get() {
self.display_color_gen.set(gen);
cache.clear();
}
// The full-resolution fill replaces the proxy when its frame matches
// the playhead; otherwise the proxy frame is displayed (rendered
// synchronously below on a cache miss, filled by the background
+11 -4
View File
@@ -403,7 +403,9 @@ impl RenderedFrame {
/// zero-copy onscreen path). For the shm variant the slot's BGRA8
/// bytes are wrapped into the display buffer — the GPU-upload staging
/// copy, the single permitted main-process copy on the preview path
/// (design §3.5). The caller releases the slot afterwards.
/// (design §3.5). The display color transform (display ICC) is applied
/// in place on that staging copy / on the F32 samples, so it costs no
/// extra copy. The caller releases the slot afterwards.
pub fn to_display(&self) -> Option<(RenderImage, ScopeData)> {
match self {
RenderedFrame::Shm(f) => {
@@ -411,8 +413,11 @@ 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)?;
let image = bgra_bytes_to_render_image(w, h, data)?;
let scope = analyze_bgra8(w, h, data);
// The display transform edits the staging copy in place.
let mut owned = data.to_vec();
super::displaycolor::apply_bgra8(&mut owned, (w * h) as i64);
let image = bgra_bytes_to_render_image(w, h, &owned)?;
Some((image, scope))
}
RenderedFrame::CpuF32 {
@@ -422,10 +427,12 @@ impl RenderedFrame {
data,
} => {
let (w, h) = ((*width).max(0) as u32, (*height).max(0) as u32);
let samples = repack_f32_rows(*width, *height, *linesize, data)?;
let mut samples = repack_f32_rows(*width, *height, *linesize, data)?;
let scope = analyze_f32_rgba(w, h, &samples);
super::displaycolor::apply_f32_rgba(&mut samples, (w * h) as i64);
Some((
f32_rgba_to_bgra_image(w, h, &samples),
analyze_f32_rgba(w, h, &samples),
scope,
))
}
}
+51 -29
View File
@@ -143,7 +143,11 @@ impl<E: AppEngine> InspectorPanel<E> {
/// The "add effect" menu: one clickable row per addable effect of the
/// engine. Selecting a row inserts that effect at the recorded stack
/// index; a dismiss row closes the menu without adding.
/// index; the ✕ in the pinned header closes the menu without adding.
/// The list is height-capped and scrollable (with the OFX plugins
/// registered it runs to 150+ rows — an uncapped list pushed the
/// dismiss affordance far off-screen, making the menu impossible to
/// close).
fn render_add_menu(
&mut self,
index: usize,
@@ -151,22 +155,22 @@ impl<E: AppEngine> InspectorPanel<E> {
cx: &mut Context<Self>,
) -> impl IntoElement {
let effects = self.engine.read(cx).addable_effects();
let mut menu = div()
.id("inspector-add-menu")
.px_2()
.py_1()
.border_t_1()
.border_color(colors.separator)
let mut list = div()
.id("inspector-add-menu-list")
.max_h_64()
.overflow_y_scroll()
.flex()
.flex_col()
.gap_1();
.gap_1()
.px_2()
.py_1();
for entry in &effects {
let engine = self.engine.clone();
let type_id = entry.type_id.clone();
let name = entry.name.clone();
let index = index;
menu = menu.child(
list = list.child(
div()
.id(SharedString::from(format!("add-effect-{type_id}")))
.cursor_pointer()
@@ -191,26 +195,44 @@ impl<E: AppEngine> InspectorPanel<E> {
);
}
// A dismiss row, so a cancelled pick does not linger.
menu = menu.child(
div()
.id("add-effect-dismiss")
.cursor_pointer()
.px_2()
.py_1()
.rounded_sm()
.hover(|style| style.bg(colors.selected))
.text_color(colors.disabled)
.text_sm()
.child("")
.on_click(
cx.listener(move |this, _event: &gpui::ClickEvent, _window, cx| {
this.pending_add = None;
cx.notify();
}),
),
);
menu
div()
.id("inspector-add-menu")
.border_t_1()
.border_color(colors.separator)
.flex()
.flex_col()
.child(
div()
.flex()
.items_center()
.px_2()
.py_1()
.child(
div()
.flex_1()
.text_xs()
.text_color(colors.disabled)
.child(crate::i18n::tr("inspector.add_effect")),
)
.child(
div()
.id("add-effect-dismiss")
.cursor_pointer()
.px_1()
.rounded_sm()
.hover(|style| style.bg(colors.selected))
.text_color(colors.disabled)
.text_sm()
.child("")
.on_click(cx.listener(
move |this, _event: &gpui::ClickEvent, _window, cx| {
this.pending_add = None;
cx.notify();
},
)),
),
)
.child(list)
}
}