feat(app): OpenFX UI wiring - effect library, inspector params, startup glue

- src/oakui/ofx.rs: startup sequence (host scan, register_plugin_nodes,
  progress reporter factory -> app progress dialog channel, active
  viewer time provider, project extent sync); all failures degrade to
  logs. oak-worker runtime also scans and registers plugins.
- Effect library groups OpenFX entries by sub-category (Filter/
  Generator/Transition/General); effect insertion goes through
  Factory::create_any so dynamic plugin nodes resolve.
- Inspector renders OFX parameters from node inputs (sliders, combo
  boxes from repeated combo_option/combo_value properties, vec/color
  spinboxes, text with explicit commit, push buttons), edits are
  undoable; persistent plugin messages surface as a card badge.
- oakplugin: push_button_clicked and per-instance persistent message
  counting (thin public layers).
This commit is contained in:
2026-08-18 22:16:08 +08:00
parent d61acb9e0a
commit ec7b7e6d13
17 changed files with 1733 additions and 55 deletions
+97
View File
@@ -105,6 +105,34 @@ pub fn registered_instance_count() -> usize {
instances().lock().unwrap_or_else(|e| e.into_inner()).len()
}
/// Triggers a push-button parameter (the Rust counterpart of the C++
/// `oakengine_plugin_node_push_button_clicked`; called by the
/// inspector's button widget).
///
/// OFX push buttons carry no value: the host's press signal is a single
/// set on the parameter (the plugin reacts in its own instanceChanged
/// action). Locates the instance and parameter, type-checks, then
/// `set_ofx`. Returns false when the instance/parameter is unknown or
/// the parameter is not a push button.
///
/// TODO(instanceChanged): per the OFX contract the press should be
/// routed to the plugin as kOfxActionInstanceChanged (UserEdited);
/// oakplugin has no dispatcher for that action yet — for now the value
/// is only marked, and the plugin-side reaction is future work.
pub fn push_button_clicked(instance: u64, param_name: &str) -> bool {
let Some(inst) = instance_from_id(instance) else {
return false;
};
let Some(p) = inst.value.params.find(param_name) else {
return false;
};
if p.def.ofx_type != ofx::TYPE_PUSHBUTTON {
return false;
}
p.set_ofx(ParamValue::PushButton);
true
}
// ---------------------------------------------------------------------------
// 项目幅面(normalised 坐标默认值 → canonical 的换算基准)
// ---------------------------------------------------------------------------
@@ -961,4 +989,73 @@ mod tests {
assert!(instance_from_id(u64::MAX).is_none());
unregister_instance(u64::MAX);
}
/// 构造一个只含 push-button 参数的最小实例(直接登记进注册表)。
fn instance_with_push_button() -> u64 {
use std::ffi::{c_char, c_void};
use std::sync::atomic::AtomicU32;
use crate::descriptor::EffectDescriptor;
use crate::handle::RefBox;
use crate::host::Plugin;
use crate::param::{ParamDef, ParamInstance, ParamSetInstance};
unsafe extern "C" fn dummy_entry(
_: *const c_char,
_: *const c_void,
_: *mut c_void,
_: *mut c_void,
) -> i32 {
0
}
let plugin = Arc::new(Plugin {
identifier: "test.plugin".into(),
version: (1, 0),
bundle_path: std::path::PathBuf::new(),
contexts: vec![],
descriptor: EffectDescriptor::new(),
lib: std::ptr::null_mut(),
entry: dummy_entry,
ofx_plugin: std::ptr::null_mut(),
});
let mut params = ParamSetInstance { params: Vec::new() };
params.params.push(Box::new(ParamInstance::from_def(ParamDef::new(
"button",
ofx::TYPE_PUSHBUTTON,
))));
params.params.push(Box::new(ParamInstance::from_def(ParamDef::new(
"gain",
ofx::TYPE_DOUBLE,
))));
let inst = crate::instance::Instance {
props: crate::property::PropertySet::new(),
plugin,
context: "OfxImageEffectContextFilter".into(),
params,
clips: Vec::new(),
node_identity: std::sync::atomic::AtomicUsize::new(0),
destroyed: std::sync::atomic::AtomicBool::new(false),
sequence_range: std::sync::Mutex::new(None),
progress_cb: std::sync::Mutex::new(None),
cancel: std::sync::atomic::AtomicBool::new(false),
edit: std::sync::Mutex::new(crate::instance::EditTransaction::new()),
render_lock: std::sync::Mutex::new(()),
};
register_instance(Arc::new(RefBox {
refs: AtomicU32::new(1),
value: inst,
}))
}
/// push_button_clicked:实例/参数查无与类型不匹配 → false;命中 →
/// true 并触发一次 set。
#[test]
fn push_button_clicked_requires_a_push_button_param() {
let id = instance_with_push_button();
assert!(push_button_clicked(id, "button"));
// 类型不匹配 / 查无参数 / 查无实例。
assert!(!push_button_clicked(id, "gain"));
assert!(!push_button_clicked(id, "nope"));
assert!(!push_button_clicked(u64::MAX, "button"));
unregister_instance(id);
}
}
+75
View File
@@ -26,6 +26,7 @@
//! include/plugin/host.h)。本模块按头文件契约建模。
//! (原 C ABI 出口层已随单库化删除;注册点由 facade 直接调用。)
use std::collections::HashMap;
use std::ffi::{c_char, c_int, c_void, CStr};
use crate::suites::status;
@@ -40,6 +41,40 @@ pub(crate) type MessageHandler =
static HANDLER: std::sync::Mutex<(Option<MessageHandler>, usize)> =
std::sync::Mutex::new((None, 0));
// ---------------------------------------------------------------------------
// 持久消息计数(检查器效果卡的徽标数据源,阶段 6b)
// ---------------------------------------------------------------------------
//
// C++ 侧 `OlivePluginInstance::persistentErrors_` 按实例累计持久消息,
// 并 emit `node_->message_count_changed()`。这里以实例 handleprops
// 地址,见 [`crate::suites::tag`])为键累计计数;app 检查器经
// [`crate::node_factory::instance_from_id`] 拿实例后按 props 地址查询。
static PERSISTENT: std::sync::OnceLock<std::sync::Mutex<HashMap<usize, usize>>> =
std::sync::OnceLock::new();
fn persistent_slot() -> &'static std::sync::Mutex<HashMap<usize, usize>> {
PERSISTENT.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
}
/// 实例 handle(props 地址)的持久消息数(未登记 → 0)。
pub fn persistent_message_count(handle: usize) -> usize {
persistent_slot()
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&handle)
.copied()
.unwrap_or(0)
}
/// 清空某实例的持久消息(实例释放/宿主关闭路径;未登记的键 no-op)。
pub fn clear_persistent_messages(handle: usize) {
persistent_slot()
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&handle);
}
/// 注册/注销消息出口(facade 调用;公开:测试直接注入捕获器)。
pub fn set_handler(f: Option<MessageHandler>, userdata: *mut c_void) {
let mut h = HANDLER.lock().unwrap_or_else(|e| e.into_inner());
@@ -88,6 +123,14 @@ pub unsafe extern "C" fn oak_ofx_message_impl(
if type_.is_null() || message.is_null() {
return status::FAILED;
}
// 持久消息:按实例 handle(剥标签后的 props 地址)累计计数
// (徽标数据源;C++ persistentErrors_ 的等价物)。
if !_handle.is_null() {
let key = crate::suites::tag::strip(_handle) as usize;
let mut map = persistent_slot().lock().unwrap_or_else(|e| e.into_inner());
*map.entry(key).or_insert(0) += 1;
drop(map);
}
let (handler, userdata) = handler();
if let Some(h) = handler {
// 头文件契约:返回值 0/1 即答复 NO/YES。
@@ -298,4 +341,36 @@ mod tests {
};
assert_eq!(r, status::REPLY_NO);
}
/// 持久消息按实例 handle 累计(徽标数据源);clear 摘除。
#[test]
fn persistent_messages_accumulate_per_handle() {
let _g = TEST_LOCK.lock().unwrap();
set_handler(None, std::ptr::null_mut());
let props = crate::property::PropertySet::new();
let handle = crate::suites::tag::make(&props, crate::suites::tag::INSTANCE);
let key = crate::suites::tag::strip(handle) as usize;
clear_persistent_messages(key);
let type_ = CString::new("OfxMessageError").unwrap();
let id = CString::new("id").unwrap();
let msg = CString::new("boom").unwrap();
unsafe {
oak_ofx_message_impl(handle, type_.as_ptr(), id.as_ptr(), msg.as_ptr());
oak_ofx_message_impl(handle, type_.as_ptr(), id.as_ptr(), msg.as_ptr());
}
assert_eq!(persistent_message_count(key), 2);
clear_persistent_messages(key);
assert_eq!(persistent_message_count(key), 0);
// 空 handle 不计数。
unsafe {
oak_ofx_message_impl(
std::ptr::null_mut(),
type_.as_ptr(),
id.as_ptr(),
msg.as_ptr(),
);
}
assert_eq!(persistent_message_count(key), 0);
}
}