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:
Generated
+1
@@ -4748,6 +4748,7 @@ dependencies = [
|
||||
"oakcommon",
|
||||
"oakcore-rs",
|
||||
"oaknode",
|
||||
"oakplugin",
|
||||
"oakrender",
|
||||
"oakstorage",
|
||||
"oaktask",
|
||||
|
||||
@@ -96,6 +96,7 @@ oakcodec = { path = "crates/oakcodec" }
|
||||
oakcommon = { path = "crates/oakcommon" }
|
||||
oakcore-rs = { path = "crates/oakcore" }
|
||||
oaknode = { path = "crates/oaknode" }
|
||||
oakplugin = { path = "crates/oakplugin" }
|
||||
oakrender = { path = "crates/oakrender" }
|
||||
oakstorage = { path = "crates/oakstorage" }
|
||||
oaktask = { path = "crates/oaktask" }
|
||||
|
||||
@@ -235,6 +235,19 @@ impl WorkerSession {
|
||||
// executor slot.
|
||||
log_error("runtime: installing oakplugin render executor");
|
||||
oakplugin::node_factory::install_render_executor();
|
||||
// M15 S2: graphs carrying OFX plugin nodes deserialize/evaluate in
|
||||
// the worker too, so the per-process node factory must register the
|
||||
// discovered plugins exactly like the main process. A failed scan is
|
||||
// non-fatal: the worker stays up for plugin-free graphs.
|
||||
log_error("runtime: scanning and registering OFX plugins");
|
||||
if let Err(e) = oakplugin::host::Host::global().cache.scan() {
|
||||
log_error(&format!("runtime: OFX plugin scan failed ({e}); continuing"));
|
||||
}
|
||||
let registered = oakplugin::node_factory::register_plugin_nodes();
|
||||
log_error(&format!(
|
||||
"runtime: registered {} OFX plugin node type(s)",
|
||||
registered.len()
|
||||
));
|
||||
log_error(
|
||||
"runtime: config / frame manager / disk manager / project \
|
||||
serializer have no Rust backing in the worker binary; skipped",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()`。这里以实例 handle(props
|
||||
// 地址,见 [`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);
|
||||
}
|
||||
}
|
||||
|
||||
+90
-1
@@ -35,7 +35,8 @@
|
||||
//! ```
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::dock::{
|
||||
@@ -108,6 +109,9 @@ mod modal_ids {
|
||||
pub const MANAGER_RENAME: usize = 7;
|
||||
pub const MANAGER_DELETE: usize = 8;
|
||||
pub const PROXY: usize = 9;
|
||||
/// The OFX plugin progress dialog (driven by the plugin-progress
|
||||
/// channel in the tick loop).
|
||||
pub const PLUGIN_PROGRESS: usize = 10;
|
||||
}
|
||||
|
||||
/// What a picked platform-dialog path should do.
|
||||
@@ -295,6 +299,15 @@ pub struct OakApp<E: AppEngine> {
|
||||
shell_focus: gpui::FocusHandle,
|
||||
/// The running export session, if any.
|
||||
export: Option<ExportRun>,
|
||||
/// Whether the progress modal currently on screen is the OFX plugin
|
||||
/// progress dialog (as opposed to the export progress). Guards
|
||||
/// [`poll_plugin_progress`] from hijacking the export's bar.
|
||||
plugin_progress_open: bool,
|
||||
/// The receiving half of the OFX plugin-progress channel (the sending
|
||||
/// half is registered into the oakplugin progress suite by
|
||||
/// [`crate::oakui::ofx::set_progress_tx`]). Drained in the tick loop to
|
||||
/// drive the progress dialog.
|
||||
plugin_progress_rx: Mutex<mpsc::Receiver<crate::oakui::ofx::PluginProgressEvent>>,
|
||||
/// The library row pending an export save dialog (manager 导出).
|
||||
pending_export: Option<String>,
|
||||
/// The panel that most recently took focus (the target of
|
||||
@@ -606,6 +619,12 @@ impl<E: AppEngine> OakApp<E> {
|
||||
// The shell starts focused so the action dispatch layer works before
|
||||
// any panel grabs focus.
|
||||
window.focus(&shell_focus, cx);
|
||||
// OFX plugin-progress channel: the oakplugin progress suite pushes
|
||||
// (label, message, fraction) events here; the tick loop drives the
|
||||
// progress dialog.
|
||||
let (plugin_progress_tx, plugin_progress_rx) =
|
||||
mpsc::channel::<crate::oakui::ofx::PluginProgressEvent>();
|
||||
crate::oakui::ofx::set_progress_tx(plugin_progress_tx);
|
||||
let shell = Self {
|
||||
engine,
|
||||
program_clock,
|
||||
@@ -619,6 +638,8 @@ impl<E: AppEngine> OakApp<E> {
|
||||
shell_focus,
|
||||
export: None,
|
||||
pending_export: None,
|
||||
plugin_progress_open: false,
|
||||
plugin_progress_rx: Mutex::new(plugin_progress_rx),
|
||||
focused_panel: None,
|
||||
panels,
|
||||
active_tool: Tool::Pointer,
|
||||
@@ -655,9 +676,58 @@ impl<E: AppEngine> OakApp<E> {
|
||||
.update(cx, |timeline, _| timeline.state.work_area = work_area.map(|(s, e)| FrameRange::new(s, e)));
|
||||
self.meter.update(cx, |meter, cx| meter.update(cx));
|
||||
self.poll_export(cx);
|
||||
self.poll_plugin_progress(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Drains the OFX plugin-progress channel: the first event of a render
|
||||
/// opens the progress dialog, subsequent events update the bar, and the
|
||||
/// dialog closes when the fraction reaches 1.0 (the plugin's
|
||||
/// progressEnd is not surfaced by the reporter, so completion is
|
||||
/// inferred from the fraction).
|
||||
fn poll_plugin_progress(&mut self, cx: &mut Context<Self>) {
|
||||
let mut events = Vec::new();
|
||||
while let Ok(event) = self.plugin_progress_rx.lock().unwrap().try_recv() {
|
||||
events.push(event);
|
||||
}
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
let last = events.last().cloned().unwrap_or_default();
|
||||
// Ensure the plugin progress dialog is on screen. If another modal
|
||||
// is already up (e.g. export progress), leave it alone.
|
||||
if !self.plugin_progress_open && matches!(self.modal, ModalState::None) {
|
||||
let title = crate::i18n::tr("ofx.progress.title");
|
||||
let label = last.label.clone();
|
||||
let message = last.message.clone();
|
||||
self.spawn_modal(cx, move |window, app| {
|
||||
let (modal, content) = progress_dialog(
|
||||
modal_ids::PLUGIN_PROGRESS,
|
||||
title,
|
||||
format!("{label}\n{message}"),
|
||||
window,
|
||||
app,
|
||||
);
|
||||
ModalState::Progress { modal, content }
|
||||
});
|
||||
self.plugin_progress_open = true;
|
||||
}
|
||||
if self.plugin_progress_open {
|
||||
if let ModalState::Progress { content, .. } = &self.modal {
|
||||
let fraction = last.fraction as f32;
|
||||
content.update(cx, |content, cx| content.set_progress(fraction, cx));
|
||||
}
|
||||
// The reporter answers false after the user cancels, and the
|
||||
// fraction reaches 1.0 when the render completes; either way the
|
||||
// dialog is dismissed.
|
||||
if last.fraction >= 1.0 {
|
||||
self.modal = ModalState::None;
|
||||
self.plugin_progress_open = false;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes a menu click: resolves the item id to its registry action and
|
||||
/// dispatches it through the same path the keyboard shortcuts use, so a
|
||||
/// menu click and a key press can never diverge.
|
||||
@@ -1855,6 +1925,16 @@ impl<E: AppEngine> OakApp<E> {
|
||||
self.cancel_export(cx);
|
||||
}
|
||||
}
|
||||
modal_ids::PLUGIN_PROGRESS => {
|
||||
if *button == 1 {
|
||||
// Cancel the plugin render; the reporter then answers
|
||||
// false and the render aborts at the next progress
|
||||
// update (or the dialog closes on the 1.0 fraction).
|
||||
crate::oakui::ofx::cancel_plugin_render();
|
||||
self.plugin_progress_open = false;
|
||||
self.close_modal(cx);
|
||||
}
|
||||
}
|
||||
modal_ids::PREFERENCES => {
|
||||
self.commit_preferences(cx);
|
||||
self.close_modal(cx);
|
||||
@@ -2273,6 +2353,15 @@ fn run_with<E: AppEngine>(args: AppArgs) {
|
||||
// Bring up the audio manager and apply the persisted device choices
|
||||
// (without an instance, playback pushes fail silently).
|
||||
crate::oakui::real::audio_init_from_config();
|
||||
// Stage 6b: wire the optional OFX plugin host — scan the standard
|
||||
// plugin paths, register every discovered plugin into the node
|
||||
// factory (effect library / add-effect menu), install the render
|
||||
// executor, and register the progress / viewer-time bridges. All
|
||||
// failures degrade to logs; plugins are optional.
|
||||
let plugin_count = crate::oakui::ofx::init();
|
||||
if plugin_count > 0 {
|
||||
println!("[ofx] registered {plugin_count} OFX plugin node type(s)");
|
||||
}
|
||||
cx.init_colors();
|
||||
let bounds = Bounds::centered(None, size(px(1600.0), px(900.0)), cx);
|
||||
let initial = initial.clone();
|
||||
|
||||
@@ -418,6 +418,9 @@ const EN: &[(&str, &str)] = &[
|
||||
("effect_stack.add", "+ Add Effect"),
|
||||
// --- inspector ---
|
||||
("inspector.params", "Parameters (placeholder)"),
|
||||
("inspector.badge.openfx", "OpenFX"),
|
||||
// --- OpenFX progress ---
|
||||
("ofx.progress.title", "OpenFX Plugin Progress"),
|
||||
// --- dialogs ---
|
||||
("dialog.cancel", "Cancel"),
|
||||
("dialog.close", "Close"),
|
||||
@@ -852,6 +855,9 @@ const ZH: &[(&str, &str)] = &[
|
||||
("effect_stack.add", "+ 添加效果"),
|
||||
// --- inspector ---
|
||||
("inspector.params", "参数(占位)"),
|
||||
("inspector.badge.openfx", "OpenFX"),
|
||||
// --- OpenFX progress ---
|
||||
("ofx.progress.title", "OpenFX 插件进度"),
|
||||
// --- dialogs ---
|
||||
("dialog.cancel", "取消"),
|
||||
("dialog.close", "关闭"),
|
||||
|
||||
+319
-13
@@ -93,30 +93,162 @@ pub fn is_enabled(g: &Graph, node: NodeId) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The effect types the user can add to a clip's chain, as
|
||||
/// (type id, display name) pairs — the factory entries flagged
|
||||
/// `video_effect` and not hidden from the create menu.
|
||||
pub fn addable_effects() -> Vec<(String, String)> {
|
||||
/// The effect types the user can add to a clip's chain — the built-in
|
||||
/// factory entries flagged `video_effect` and not hidden from the create
|
||||
/// menu (no group), plus every runtime-registered OpenFX plugin entry
|
||||
/// (grouped by its sub-category: Filter / Generator / Transition /
|
||||
/// General — the C++ `factorymenu.cpp` OpenFX branch).
|
||||
pub fn addable_effects() -> Vec<super::engine::EffectEntry> {
|
||||
use super::engine::EffectEntry;
|
||||
use oaknode::node::flags as node_flags;
|
||||
let mut out = Vec::new();
|
||||
for meta in oaknode::factory::Factory::global().entries() {
|
||||
// A scratch instance per entry just to read its flags (the factory
|
||||
// metadata carries no flag copy).
|
||||
let (core, _behavior) = (meta.create)();
|
||||
let flags = core.flags;
|
||||
if flags & oaknode::node::flags::VIDEO_EFFECT != 0
|
||||
&& flags & oaknode::node::flags::DONT_SHOW_IN_CREATE_MENU == 0
|
||||
if flags & node_flags::VIDEO_EFFECT != 0
|
||||
&& flags & node_flags::DONT_SHOW_IN_CREATE_MENU == 0
|
||||
{
|
||||
let name = if meta.name.is_empty() {
|
||||
meta.type_id.to_string()
|
||||
} else {
|
||||
meta.name.to_string()
|
||||
};
|
||||
out.push((meta.type_id.to_string(), name));
|
||||
out.push(EffectEntry {
|
||||
type_id: meta.type_id.to_string(),
|
||||
name,
|
||||
group: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
for meta in oaknode::factory::Factory::global().dynamic_entries() {
|
||||
// OpenFX plugin entries: grouped by sub-category. The factory
|
||||
// metadata carries no flags; plugin nodes are always video effects.
|
||||
let name = if meta.name.is_empty() {
|
||||
meta.type_id.clone()
|
||||
} else {
|
||||
meta.name
|
||||
};
|
||||
out.push(EffectEntry {
|
||||
type_id: meta.type_id,
|
||||
name,
|
||||
group: Some(meta.sub_category),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OFX parameter data model (stage 6b)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The OFX plugin instance handle of a plugin node (the oakplugin registry
|
||||
/// key), or `None` for built-in nodes. Used by the inspector to read the
|
||||
/// persistent-message badge and to trigger push buttons.
|
||||
pub fn plugin_instance_handle(g: &Graph, node: NodeId) -> Option<u64> {
|
||||
let behavior = g.get(node)?.behavior.as_any()?;
|
||||
let plugin = behavior.downcast_ref::<oaknode::nodes::plugin::PluginNode>()?;
|
||||
let handle = plugin.instance_handle();
|
||||
(!handle.is_null()).then_some(handle.0)
|
||||
}
|
||||
|
||||
/// The parameter controls of `effect` for the inspector, or `None` when
|
||||
/// the effect exposes no parameter UI (not a plugin node, or no editable
|
||||
/// parameters).
|
||||
pub fn effect_params(
|
||||
g: &Graph,
|
||||
node: NodeId,
|
||||
) -> Option<Vec<super::engine::EffectParam>> {
|
||||
use oaknode::input::flags as input_flags;
|
||||
use oaknode::nodes::plugin::PluginNode;
|
||||
use oaknode::value::ValueType;
|
||||
|
||||
let entry = g.get(node)?;
|
||||
// Only OFX plugin nodes expose the parameter UI (built-in effects keep
|
||||
// the inspector's placeholder).
|
||||
let behavior = entry.behavior.as_any()?;
|
||||
if behavior.downcast_ref::<PluginNode>().is_none() {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for input in &entry.core.inputs {
|
||||
// Clip/texture inputs are graph connections, not params; hidden
|
||||
// (secret) inputs never render.
|
||||
if input.value_type == ValueType::Texture {
|
||||
continue;
|
||||
}
|
||||
if input.flags & input_flags::HIDDEN != 0 {
|
||||
continue;
|
||||
}
|
||||
// The standard enabled input is structural, not a parameter.
|
||||
if input.id == oaknode::node::ENABLED_INPUT {
|
||||
continue;
|
||||
}
|
||||
out.push(super::engine::EffectParam {
|
||||
input_id: input.id.clone(),
|
||||
display_name: input.display_name.clone(),
|
||||
value_type: input.value_type,
|
||||
value: entry.core.standard_value(&input.id, -1),
|
||||
flags: input.flags,
|
||||
properties: input.properties.clone(),
|
||||
});
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The combo option labels of a parameter, collected from the repeated
|
||||
/// `("combo_option", Text)` property keys (the OFX translation pass
|
||||
/// carries the choice options that way; `str_combo` values ride the
|
||||
/// `("combo_value", Text)` keys).
|
||||
pub fn combo_options(p: &super::engine::EffectParam) -> Vec<String> {
|
||||
p.properties
|
||||
.iter()
|
||||
.filter(|(k, _)| k == "combo_option")
|
||||
.filter_map(|(_, v)| match v {
|
||||
oaknode::value::NodeValue::Text(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The string-combo values of a parameter (`("combo_value", Text)` keys).
|
||||
pub fn combo_values(p: &super::engine::EffectParam) -> Vec<String> {
|
||||
p.properties
|
||||
.iter()
|
||||
.filter(|(k, _)| k == "combo_value")
|
||||
.filter_map(|(_, v)| match v {
|
||||
oaknode::value::NodeValue::Text(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The `ui_group` / `ui_page` property of a parameter, if any (the OFX
|
||||
/// group/page headers; the inspector renders them as section titles).
|
||||
pub fn ui_section_of(p: &super::engine::EffectParam) -> Option<(String, String)> {
|
||||
let group = p
|
||||
.properties
|
||||
.iter()
|
||||
.find(|(k, _)| k == "ui_group")
|
||||
.and_then(|(_, v)| match v {
|
||||
oaknode::value::NodeValue::Text(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let page = p
|
||||
.properties
|
||||
.iter()
|
||||
.find(|(k, _)| k == "ui_page")
|
||||
.and_then(|(_, v)| match v {
|
||||
oaknode::value::NodeValue::Text(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
});
|
||||
match (group, page) {
|
||||
(None, None) => None,
|
||||
(group, page) => Some((group.unwrap_or_default(), page.unwrap_or_default())),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command pieces
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -218,6 +350,55 @@ pub fn set_enabled(p: &ProjectRef, effect: NodeId, enabled: bool) -> Result<(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Undoable set of an effect parameter (a node input's standard value) —
|
||||
/// "Set Parameter". `input_id` must exist on the node; the value is
|
||||
/// written as the standard value of element -1 (non-keyframed). Used by
|
||||
/// the inspector's OFX parameter controls.
|
||||
pub fn set_input_value(
|
||||
p: &ProjectRef,
|
||||
effect: NodeId,
|
||||
input_id: &str,
|
||||
value: NodeValue,
|
||||
) -> Result<(), String> {
|
||||
let old = {
|
||||
let g = lock(p);
|
||||
let entry = g
|
||||
.graph
|
||||
.get(effect)
|
||||
.ok_or_else(|| "set parameter: node not found".to_string())?;
|
||||
if entry.core.get_input(input_id).is_none() {
|
||||
return Err(format!("set parameter: unknown input \"{input_id}\""));
|
||||
}
|
||||
entry.core.standard_value(input_id, -1)
|
||||
};
|
||||
// NodeValue is not Copy: each closure owns its own clone (the closures
|
||||
// are FnMut and may run more than once across undo/redo cycles).
|
||||
let (p1, p2) = (p.clone(), p.clone());
|
||||
let (input_id, value) = (input_id.to_string(), value);
|
||||
let (redo_input, undo_input) = (input_id.clone(), input_id);
|
||||
let redo_value = value.clone();
|
||||
let undo_old = old.clone();
|
||||
push(
|
||||
UndoCommand::from_closures(
|
||||
move || {
|
||||
let mut g = lock(&p1);
|
||||
if let Some(e) = g.graph.get_mut(effect) {
|
||||
e.core
|
||||
.set_standard_value(&redo_input, -1, redo_value.clone());
|
||||
}
|
||||
},
|
||||
move || {
|
||||
let mut g = lock(&p2);
|
||||
if let Some(e) = g.graph.get_mut(effect) {
|
||||
e.core
|
||||
.set_standard_value(&undo_input, -1, undo_old.clone());
|
||||
}
|
||||
},
|
||||
),
|
||||
"Set Parameter",
|
||||
)
|
||||
}
|
||||
|
||||
/// The neighbors of chain position `pos` in `chain` (length `len`):
|
||||
/// `(upstream, downstream)` — the chain source is `None`, the host closes
|
||||
/// the chain.
|
||||
@@ -250,10 +431,9 @@ pub fn insert(p: &ProjectRef, host: NodeId, index: usize, type_id: &str) -> Resu
|
||||
if effect_input_of(&g.graph, host).is_none() {
|
||||
return Err("node cannot host effects (no effect input)".to_string());
|
||||
}
|
||||
let Some(meta) = oaknode::factory::Factory::global().find(type_id) else {
|
||||
let Some((core, behavior)) = oaknode::factory::Factory::global().create_any(type_id) else {
|
||||
return Err(format!("unknown node type id \"{type_id}\""));
|
||||
};
|
||||
let (core, behavior) = (meta.create)();
|
||||
if core.effect_input.is_empty() {
|
||||
return Err("node type has no effect input; cannot be chained".to_string());
|
||||
}
|
||||
@@ -482,7 +662,7 @@ mod tests {
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("the factory registers at least one video effect")
|
||||
.0
|
||||
.type_id
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -564,9 +744,135 @@ mod tests {
|
||||
fn addable_effects_are_video_effects() {
|
||||
let entries = addable_effects();
|
||||
assert!(!entries.is_empty());
|
||||
for (type_id, name) in &entries {
|
||||
assert!(!type_id.is_empty());
|
||||
assert!(!name.is_empty());
|
||||
for entry in &entries {
|
||||
assert!(!entry.type_id.is_empty());
|
||||
assert!(!entry.name.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// The effect-library grouping: every addable effect is either an
|
||||
/// ungrouped built-in or an OpenFX entry with one of the four
|
||||
/// sub-categories.
|
||||
#[test]
|
||||
fn addable_effects_group_openfx_by_sub_category() {
|
||||
for entry in addable_effects() {
|
||||
if let Some(group) = entry.group {
|
||||
assert!(
|
||||
["Filter", "Generator", "Transition", "General"].contains(&group.as_str()),
|
||||
"unexpected OpenFX sub-category {group:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `set_input_value` writes the standard value undoably and rejects
|
||||
/// unknown input ids.
|
||||
#[test]
|
||||
fn set_input_value_undoes() {
|
||||
let _g = stack_lock();
|
||||
oakundo::global::clear().unwrap();
|
||||
let (project, host) = project_with_clip();
|
||||
|
||||
// Choose a built-in effect whose scratch core exposes a float input
|
||||
// (the value write/undo round-trip needs one).
|
||||
let ty = addable_effects()
|
||||
.into_iter()
|
||||
.find(|entry| {
|
||||
let (core, _behavior) = oaknode::factory::Factory::global()
|
||||
.create_any(&entry.type_id)
|
||||
.expect("the entry resolves");
|
||||
core.inputs.iter().any(|i| {
|
||||
i.value_type == oaknode::value::ValueType::Float
|
||||
&& i.flags & oaknode::input::flags::HIDDEN == 0
|
||||
})
|
||||
})
|
||||
.expect("at least one built-in effect exposes a float input")
|
||||
.type_id;
|
||||
let eff = insert(&project, host, 0, &ty).unwrap();
|
||||
|
||||
// Unknown input: rejected without touching the graph.
|
||||
assert!(set_input_value(&project, eff, "nope", NodeValue::Float(1.0)).is_err());
|
||||
|
||||
// Pick an editable (non-texture, non-hidden) float input of the
|
||||
// inserted effect to exercise the write/undo round-trip.
|
||||
let input_id = {
|
||||
let g = lock(&project);
|
||||
g.graph
|
||||
.get(eff)
|
||||
.and_then(|e| {
|
||||
e.core.inputs.iter().find(|i| {
|
||||
i.value_type == oaknode::value::ValueType::Float
|
||||
&& i.flags & oaknode::input::flags::HIDDEN == 0
|
||||
})
|
||||
})
|
||||
.map(|i| i.id.clone())
|
||||
.expect("the effect exposes a float input")
|
||||
};
|
||||
let before = lock(&project)
|
||||
.graph
|
||||
.get(eff)
|
||||
.unwrap()
|
||||
.core
|
||||
.standard_value(&input_id, -1);
|
||||
set_input_value(&project, eff, &input_id, NodeValue::Float(42.0)).unwrap();
|
||||
assert_eq!(
|
||||
lock(&project).graph.get(eff).unwrap().core.standard_value(&input_id, -1),
|
||||
NodeValue::Float(42.0)
|
||||
);
|
||||
oakundo::global::undo().unwrap();
|
||||
assert_eq!(
|
||||
lock(&project).graph.get(eff).unwrap().core.standard_value(&input_id, -1),
|
||||
before
|
||||
);
|
||||
oakundo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// The combo-option collector reads the repeated `("combo_option", _)`
|
||||
/// property keys (and the string-combo values from `("combo_value", _)`).
|
||||
#[test]
|
||||
fn combo_option_collectors_read_repeated_properties() {
|
||||
use crate::oakui::engine::EffectParam;
|
||||
use oaknode::value::{NodeValue, ValueType};
|
||||
let param = EffectParam {
|
||||
input_id: "mode".into(),
|
||||
display_name: "Mode".into(),
|
||||
value_type: ValueType::Combo,
|
||||
value: NodeValue::Combo(0),
|
||||
flags: 0,
|
||||
properties: vec![
|
||||
("combo_option".into(), NodeValue::Text("Fast".into())),
|
||||
("combo_option".into(), NodeValue::Text("High".into())),
|
||||
("combo_value".into(), NodeValue::Text("fast".into())),
|
||||
("combo_value".into(), NodeValue::Text("high".into())),
|
||||
],
|
||||
};
|
||||
assert_eq!(combo_options(¶m), vec!["Fast", "High"]);
|
||||
assert_eq!(combo_values(¶m), vec!["fast", "high"]);
|
||||
}
|
||||
|
||||
/// ui_group / ui_page surface as a section header (empty halves are
|
||||
/// fine); params without either have no section.
|
||||
#[test]
|
||||
fn ui_section_collects_group_and_page() {
|
||||
use crate::oakui::engine::EffectParam;
|
||||
use oaknode::value::{NodeValue, ValueType};
|
||||
let plain = EffectParam {
|
||||
input_id: "p".into(),
|
||||
display_name: "P".into(),
|
||||
value_type: ValueType::Int,
|
||||
value: NodeValue::Int(0),
|
||||
flags: 0,
|
||||
properties: vec![],
|
||||
};
|
||||
assert!(ui_section_of(&plain).is_none());
|
||||
|
||||
let grouped = EffectParam {
|
||||
properties: vec![
|
||||
("ui_group".into(), NodeValue::Text("Basic".into())),
|
||||
("ui_page".into(), NodeValue::Text("Main".into())),
|
||||
],
|
||||
..plain
|
||||
};
|
||||
assert_eq!(ui_section_of(&grouped), Some(("Basic".into(), "Main".into())));
|
||||
}
|
||||
}
|
||||
|
||||
+74
-6
@@ -35,7 +35,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::effect_stack::{EffectStackDataSource, EffectStackEvent};
|
||||
use gpui::effect_stack::{EffectId, EffectStackDataSource, EffectStackEvent};
|
||||
use gpui::node_graph::{NodeGraphDataSource, NodeGraphEvent};
|
||||
use gpui::timeline::{
|
||||
ClipId, Frame, FrameRate, TimelineDataSource, TimelineEvent, TrackData, TrackKind,
|
||||
@@ -154,6 +154,41 @@ pub struct NodeLibraryEntry {
|
||||
pub category_key: &'static str,
|
||||
}
|
||||
|
||||
/// One addable effect entry (the effect library list and the inspector's
|
||||
/// add-effect menu).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EffectEntry {
|
||||
/// The factory type id handed to [`AppEngine::add_effect`].
|
||||
pub type_id: String,
|
||||
/// The effect's display name.
|
||||
pub name: String,
|
||||
/// The effect-library group: `Some(sub_category)` for OpenFX plugin
|
||||
/// entries (Filter / Generator / Transition / General — the C++
|
||||
/// `factorymenu` OpenFX branch), `None` for built-in effects (rendered
|
||||
/// without a group header).
|
||||
pub group: Option<String>,
|
||||
}
|
||||
|
||||
/// A snapshot of one effect parameter (a node input) for the inspector's
|
||||
/// parameter view. For OFX plugin nodes each entry maps 1:1 to an OFX
|
||||
/// parameter (input id = param name, display name = param label).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EffectParam {
|
||||
/// The input id (the OFX param name for plugin nodes).
|
||||
pub input_id: String,
|
||||
/// The display name (the OFX param label).
|
||||
pub display_name: String,
|
||||
/// The value type.
|
||||
pub value_type: oaknode::value::ValueType,
|
||||
/// The current value.
|
||||
pub value: oaknode::value::NodeValue,
|
||||
/// The input flag bits (`oaknode::input::flags::*`).
|
||||
pub flags: u32,
|
||||
/// The input properties (`combo_option` / `combo_value` / `ui_group` /
|
||||
/// `ui_page` / `min` / `max` / ...).
|
||||
pub properties: Vec<(String, oaknode::value::NodeValue)>,
|
||||
}
|
||||
|
||||
/// The i18n key of a node category submenu, or `None` for categories that
|
||||
/// never appear in the node editor's Add menu (timeline-structural nodes).
|
||||
pub fn node_category_key(category: oaknode::node::Category) -> Option<&'static str> {
|
||||
@@ -292,11 +327,12 @@ pub trait AppEngine:
|
||||
/// selection-driven stack keep their existing behavior).
|
||||
fn set_selected_clips(&mut self, _clips: Vec<ClipId>, _cx: &mut Context<Self>) {}
|
||||
|
||||
/// The effect types the user can add to the selected clip's chain, as
|
||||
/// (type id, display name) pairs — the facade factory entries flagged
|
||||
/// `video_effect` and not hidden from the create menu. The inspector
|
||||
/// panel lists them in its "add effect" menu. Default: empty.
|
||||
fn addable_effects(&self) -> Vec<(String, String)> {
|
||||
/// The effect types the user can add to the selected clip's chain — the
|
||||
/// factory entries flagged `video_effect` and not hidden from the create
|
||||
/// menu, plus every runtime-registered OpenFX plugin entry (grouped by
|
||||
/// its sub-category). The inspector panel lists them in its "add
|
||||
/// effect" menu. Default: empty.
|
||||
fn addable_effects(&self) -> Vec<EffectEntry> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
@@ -315,6 +351,38 @@ pub trait AppEngine:
|
||||
Err("add effect not supported".into())
|
||||
}
|
||||
|
||||
/// The parameter controls of `effect` for the inspector, or `None`
|
||||
/// when the effect exposes no parameter UI (not a plugin node, or no
|
||||
/// editable parameters). Default: `None` (the inspector renders its
|
||||
/// placeholder).
|
||||
fn effect_params(&self, _effect: EffectId) -> Option<Vec<EffectParam>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Sets an effect parameter (a node input) undoably. Returns a
|
||||
/// user-facing error on failure. Default: unsupported.
|
||||
fn set_effect_param(
|
||||
&mut self,
|
||||
_effect: EffectId,
|
||||
_input_id: &str,
|
||||
_value: oaknode::value::NodeValue,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
Err("effect params not supported".into())
|
||||
}
|
||||
|
||||
/// Triggers a push-button parameter of `effect` (the OFX push-button
|
||||
/// press). Returns a user-facing error on failure. Default:
|
||||
/// unsupported.
|
||||
fn effect_push_button(
|
||||
&mut self,
|
||||
_effect: EffectId,
|
||||
_input_id: &str,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
Err("effect push button not supported".into())
|
||||
}
|
||||
|
||||
/// Applies a node-editor edit request to the engine's model.
|
||||
fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context<Self>);
|
||||
|
||||
|
||||
+8
-5
@@ -1273,9 +1273,10 @@ impl AppEngine for MockEngine {
|
||||
self.apply_effect_event(event, cx);
|
||||
}
|
||||
|
||||
fn addable_effects(&self) -> Vec<(String, String)> {
|
||||
// The demo list is the real factory's video-effect table, so the
|
||||
// effect library shows the same entries the real engine would.
|
||||
fn addable_effects(&self) -> Vec<crate::oakui::engine::EffectEntry> {
|
||||
// The demo list is the real factory's effect table (built-ins plus
|
||||
// any registered OpenFX plugins), so the effect library shows the
|
||||
// same entries the real engine would.
|
||||
crate::oakui::effectchain::addable_effects()
|
||||
}
|
||||
|
||||
@@ -1287,7 +1288,8 @@ impl AppEngine for MockEngine {
|
||||
) -> Result<(), String> {
|
||||
let Some((_, name)) = crate::oakui::effectchain::addable_effects()
|
||||
.into_iter()
|
||||
.find(|(id, _)| id == type_id)
|
||||
.find(|entry| entry.type_id == type_id)
|
||||
.map(|entry| (entry.type_id, entry.name))
|
||||
else {
|
||||
return Err(format!("unknown effect \"{type_id}\""));
|
||||
};
|
||||
@@ -2721,7 +2723,8 @@ mod tests {
|
||||
let engine = demo_engine(app);
|
||||
let effects = engine.read(app).addable_effects();
|
||||
assert!(!effects.is_empty(), "the demo list is the factory table");
|
||||
let (type_id, name) = effects[0].clone();
|
||||
let first = effects[0].clone();
|
||||
let (type_id, name) = (first.type_id, first.name);
|
||||
let before = engine.read(app).effects().len();
|
||||
engine.update(app, |engine, cx| {
|
||||
engine
|
||||
|
||||
+4
-3
@@ -48,6 +48,7 @@ pub mod icons;
|
||||
pub mod mock;
|
||||
pub mod multicam;
|
||||
pub mod nodegraph;
|
||||
pub mod ofx;
|
||||
pub mod projectbrowser;
|
||||
pub mod real;
|
||||
pub mod renderops;
|
||||
@@ -58,9 +59,9 @@ pub mod waveform;
|
||||
pub mod waveformsync;
|
||||
|
||||
pub use engine::{
|
||||
AppEngine, EngineClock, EngineGateway, ExportEvent, ExportSession, HistoryEntry,
|
||||
LibraryProject, Monitor, MulticamState, NodeLibraryEntry, Project, ScopeData, Sequence,
|
||||
VideoFormat,
|
||||
AppEngine, EffectEntry, EffectParam, EngineClock, EngineGateway, ExportEvent, ExportSession,
|
||||
HistoryEntry, LibraryProject, Monitor, MulticamState, NodeLibraryEntry, Project, ScopeData,
|
||||
Sequence, VideoFormat,
|
||||
};
|
||||
pub use mock::{MockClock, MockEngine};
|
||||
pub use real::{RealClock, RealEngine};
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
// 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/>.
|
||||
|
||||
//! OFX plugin startup wiring (stage 6b).
|
||||
//!
|
||||
//! The app is the only place that holds both the oakplugin host and the UI
|
||||
//! services the OFX suites consult at runtime, so the wiring lives here:
|
||||
//!
|
||||
//! - [`init`] scans the standard plugin paths ([`oakplugin::host::Host`]
|
||||
//! default path set, `host.rs:440-449`), registers every discovered
|
||||
//! plugin into the node factory (the effect library and the add-effect
|
||||
//! menu consume those entries), installs the render executor and the
|
||||
//! plugin-node duplicator (both idempotent), and registers the
|
||||
//! progress-reporter factory plus the active-viewer provider.
|
||||
//! - [`update_project_extent`] / [`update_viewer_time`] keep the
|
||||
//! oakplugin side's fallback project size and timeline time in sync with
|
||||
//! the current sequence (the engine calls them on open / seek / tick).
|
||||
//! - [`set_progress_tx`] wires a progress-event channel the app drains in
|
||||
//! its tick loop to drive the progress dialog.
|
||||
//!
|
||||
//! Every failure degrades to a log: plugin support is an optional
|
||||
//! capability, never a startup dependency.
|
||||
//!
|
||||
//! ## Rendering topology and progress
|
||||
//!
|
||||
//! Preview/export rendering runs through the process-isolated oak-worker
|
||||
//! pool (M15 S2), so plugin rendering happens in the worker process where
|
||||
//! this main-process reporter factory is not in effect. The wiring still
|
||||
//! serves the in-process render paths (e.g. the test-only inline backend)
|
||||
//! and future work; worker-side progress forwarding over IPC is a TODO.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use oakplugin::progress::{ReporterFactory, UiProgressReporter};
|
||||
use oakplugin::suites::timeline::{ActiveViewerProvider, ViewerTimeInfo};
|
||||
|
||||
/// One progress event a plugin reporter pushed to the app channel (drained
|
||||
/// by the app tick, which drives a progress dialog).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PluginProgressEvent {
|
||||
/// The label the plugin passed to progressStart.
|
||||
pub label: String,
|
||||
/// The message the plugin passed to progressStart.
|
||||
pub message: String,
|
||||
/// Progress fraction in 0.0..=1.0.
|
||||
pub fraction: f64,
|
||||
}
|
||||
|
||||
/// The app's progress-event channel (registered by [`set_progress_tx`]).
|
||||
static PROGRESS_TX: OnceLock<Mutex<Option<std::sync::mpsc::Sender<PluginProgressEvent>>>> =
|
||||
OnceLock::new();
|
||||
|
||||
/// The sticky cancel flag read by every live reporter (`update` returns
|
||||
/// false once set; the progress dialog's cancel button sets it).
|
||||
static CANCEL: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// The last active-viewer time snapshot (the timeline-suite provider reads
|
||||
/// it; the engine refreshes it on seek / tick).
|
||||
static VIEWER_TIME: OnceLock<Mutex<ViewerTimeInfo>> = OnceLock::new();
|
||||
|
||||
/// The last known project extent (normalised-coordinate default conversion;
|
||||
/// the engine refreshes it whenever the sequence changes).
|
||||
static PROJECT_EXTENT: OnceLock<Mutex<(f64, f64)>> = OnceLock::new();
|
||||
|
||||
fn viewer_slot() -> &'static Mutex<ViewerTimeInfo> {
|
||||
VIEWER_TIME.get_or_init(|| {
|
||||
Mutex::new(ViewerTimeInfo {
|
||||
time: 0.0,
|
||||
range_min: 0.0,
|
||||
range_max: 0.0,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn extent_slot() -> &'static Mutex<(f64, f64)> {
|
||||
PROJECT_EXTENT.get_or_init(|| Mutex::new((1920.0, 1080.0)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App-driven state sync
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Wires the app's progress-event channel into the OFX progress suite. The
|
||||
/// app keeps the receiving half and drains it in its tick loop.
|
||||
pub fn set_progress_tx(tx: std::sync::mpsc::Sender<PluginProgressEvent>) {
|
||||
*PROGRESS_TX
|
||||
.get_or_init(|| Mutex::new(None))
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = Some(tx);
|
||||
}
|
||||
|
||||
/// Clone of the registered sender, or `None` before
|
||||
/// [`set_progress_tx`] (a reporter then silently continues).
|
||||
fn progress_tx() -> Option<std::sync::mpsc::Sender<PluginProgressEvent>> {
|
||||
PROGRESS_TX
|
||||
.get_or_init(|| Mutex::new(None))
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Updates the active-viewer time snapshot the timeline suite falls back
|
||||
/// to when no render context is live (engine seek / tick path).
|
||||
pub fn update_viewer_time(time: f64, range_min: f64, range_max: f64) {
|
||||
*viewer_slot().lock().unwrap_or_else(|e| e.into_inner()) = ViewerTimeInfo {
|
||||
time,
|
||||
range_min,
|
||||
range_max,
|
||||
};
|
||||
}
|
||||
|
||||
/// Updates the project extent (width/height) the OFX normalised-coordinate
|
||||
/// default conversion uses, and pushes it into oakplugin.
|
||||
pub fn update_project_extent(width: f64, height: f64) {
|
||||
let (w, h) = (width.max(1.0), height.max(1.0));
|
||||
*extent_slot().lock().unwrap_or_else(|e| e.into_inner()) = (w, h);
|
||||
oakplugin::node_factory::set_project_extent(w, h);
|
||||
}
|
||||
|
||||
/// Requests cancellation of the running plugin render (the progress
|
||||
/// dialog's Cancel button). The next progressStart resets the flag.
|
||||
pub fn cancel_plugin_render() {
|
||||
CANCEL.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reporters / providers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A reporter that forwards (label, message, fraction) to the app channel
|
||||
/// and honours the global cancel flag.
|
||||
struct ChannelProgressReporter {
|
||||
tx: Option<std::sync::mpsc::Sender<PluginProgressEvent>>,
|
||||
label: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl UiProgressReporter for ChannelProgressReporter {
|
||||
fn update(&mut self, progress: f64) -> bool {
|
||||
if let Some(tx) = &self.tx {
|
||||
let _ = tx.send(PluginProgressEvent {
|
||||
label: self.label.clone(),
|
||||
message: self.message.clone(),
|
||||
fraction: progress,
|
||||
});
|
||||
}
|
||||
!CANCEL.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
fn reporter_factory() -> ReporterFactory {
|
||||
Arc::new(|label, message| {
|
||||
// A fresh render begins: reset the sticky cancel flag.
|
||||
CANCEL.store(false, Ordering::Relaxed);
|
||||
Box::new(ChannelProgressReporter {
|
||||
tx: progress_tx(),
|
||||
label: label.to_string(),
|
||||
message: message.to_string(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn viewer_provider() -> ActiveViewerProvider {
|
||||
Arc::new(|| {
|
||||
let info = *viewer_slot().lock().unwrap_or_else(|e| e.into_inner());
|
||||
Some(info)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Idempotent OFX startup wiring. Scans the standard plugin directories,
|
||||
/// registers every discovered plugin into the node factory, installs the
|
||||
/// render executor / duplicator, and registers the progress factory and
|
||||
/// the active-viewer provider. Returns the number of plugin node types
|
||||
/// registered (0 when no plugins were discovered or the scan failed).
|
||||
pub fn init() -> usize {
|
||||
// 1. Scan the standard OFX plugin directories (host.rs:440-449 default
|
||||
// path set: ~/.OFX/Plugins, ~/.local/share/OFX/Plugins, ...
|
||||
// plus the OLIVE_OFX_PLUGIN_PATH / OLIVE_PLUGIN_PATH /
|
||||
// OFX_PLUGIN_PATH environment variables). A scan failure only
|
||||
// logs — plugins are optional.
|
||||
if let Err(e) = oakplugin::host::Host::global().cache.scan() {
|
||||
eprintln!("[ofx] plugin scan failed: {e}");
|
||||
}
|
||||
// 2. Register discovered plugins into the node factory (idempotent;
|
||||
// also installs the render executor and the plugin-node duplicator).
|
||||
let registered = oakplugin::node_factory::register_plugin_nodes();
|
||||
// 3. Progress reporter factory -> the app progress channel.
|
||||
oakplugin::progress::set_reporter_factory(Some(reporter_factory()));
|
||||
// 4. Active-viewer time provider (timeline suite fallback).
|
||||
oakplugin::suites::timeline::set_active_viewer_provider(Some(viewer_provider()));
|
||||
// 5. Project extent (the engine refreshes it whenever the sequence
|
||||
// changes; keep the oakplugin side in sync with the default).
|
||||
let (w, h) = *extent_slot().lock().unwrap_or_else(|e| e.into_inner());
|
||||
oakplugin::node_factory::set_project_extent(w, h);
|
||||
registered.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn viewer_time_roundtrip() {
|
||||
update_viewer_time(42.5, 10.0, 200.0);
|
||||
let provider = viewer_provider();
|
||||
let info = provider().expect("provider always reports a snapshot");
|
||||
assert_eq!(info.time, 42.5);
|
||||
assert_eq!((info.range_min, info.range_max), (10.0, 200.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_is_sticky_until_a_new_reporter() {
|
||||
CANCEL.store(false, Ordering::Relaxed);
|
||||
let factory = reporter_factory();
|
||||
let mut a = factory("a", "b");
|
||||
assert!(a.update(0.1));
|
||||
cancel_plugin_render();
|
||||
assert!(!a.update(0.5), "a cancelled render reports no");
|
||||
// A fresh progressStart resets the flag.
|
||||
let mut b = factory("a", "b");
|
||||
assert!(b.update(0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_extent_is_forwarded() {
|
||||
update_project_extent(1280.0, 720.0);
|
||||
let slot = extent_slot();
|
||||
assert_eq!(*slot.lock().unwrap(), (1280.0, 720.0));
|
||||
}
|
||||
}
|
||||
+102
-3
@@ -668,6 +668,11 @@ struct RealEffect {
|
||||
enabled: bool,
|
||||
/// The app-owned expansion state (not undoable).
|
||||
expanded: bool,
|
||||
/// Optional secondary line (the "OpenFX" tag for plugin nodes).
|
||||
subtitle: Option<SharedString>,
|
||||
/// The persistent-message badge count (plugin nodes only; `None`
|
||||
/// otherwise).
|
||||
badge: Option<usize>,
|
||||
}
|
||||
|
||||
impl EffectData for RealEffect {
|
||||
@@ -683,6 +688,10 @@ impl EffectData for RealEffect {
|
||||
self.title.clone()
|
||||
}
|
||||
|
||||
fn subtitle(&self) -> Option<SharedString> {
|
||||
self.subtitle.clone()
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
@@ -690,6 +699,10 @@ impl EffectData for RealEffect {
|
||||
fn is_expanded(&self) -> bool {
|
||||
self.expanded
|
||||
}
|
||||
|
||||
fn badge_count(&self) -> Option<usize> {
|
||||
self.badge
|
||||
}
|
||||
}
|
||||
|
||||
/// A track on the real timeline (snapshot handed to the timeline widget).
|
||||
@@ -2334,6 +2347,23 @@ impl RealEngine {
|
||||
},
|
||||
length,
|
||||
});
|
||||
// Stage 6b: keep the OFX normalised-coordinate default conversion in
|
||||
// sync with the current sequence's extent.
|
||||
crate::oakui::ofx::update_project_extent(width as f64, height as f64);
|
||||
}
|
||||
|
||||
/// Stage 6b: refreshes the OFX timeline-suite fallback time snapshot
|
||||
/// from the program monitor's playhead (seconds) and the sequence
|
||||
/// length (the range bounds). Called on seek and on every tick.
|
||||
fn update_ofx_viewer_time(&self, cx: &App) {
|
||||
let fps = self.frame_rate().as_f64();
|
||||
if fps <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let frame = self.clock_frame(Monitor::Program, cx).0;
|
||||
let time = frame as f64 / fps;
|
||||
let length = self.sequence_length().0 as f64 / fps;
|
||||
crate::oakui::ofx::update_viewer_time(time, 0.0, length);
|
||||
}
|
||||
|
||||
/// Rebuilds the timeline snapshot from the graph.
|
||||
@@ -2528,16 +2558,32 @@ impl RealEngine {
|
||||
for node in super::effectchain::chain(&guard.graph, host) {
|
||||
let identity = node.identity();
|
||||
let type_id = graphops::node_type_id(&guard.graph, node);
|
||||
// `name_of` covers both static (built-in) and dynamic (OpenFX
|
||||
// plugin) factory entries.
|
||||
let title = oaknode::factory::Factory::global()
|
||||
.find(&type_id)
|
||||
.map(|m| m.name.to_string())
|
||||
.name_of(&type_id)
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or(type_id);
|
||||
let plugin_handle = super::effectchain::plugin_instance_handle(&guard.graph, node);
|
||||
// The OpenFX plugin badge: the persistent-message count (the
|
||||
// simplified 徽标/计数 of stage 6b). Built-in effects show none.
|
||||
let badge = plugin_handle.and_then(|handle| {
|
||||
let count =
|
||||
oakplugin::suites::message::persistent_message_count(handle as usize);
|
||||
(count > 0).then_some(count)
|
||||
});
|
||||
let subtitle = plugin_handle.map(|_| {
|
||||
// A muted secondary line identifying the plugin effect as an
|
||||
// OpenFX entry.
|
||||
crate::i18n::tr("inspector.badge.openfx").to_string()
|
||||
});
|
||||
out.push(Arc::new(RealEffect {
|
||||
id: EffectId(identity),
|
||||
title: title.into(),
|
||||
subtitle: subtitle.map(Into::into),
|
||||
enabled: super::effectchain::is_enabled(&guard.graph, node),
|
||||
expanded: self.expanded_effects.contains(&identity),
|
||||
badge,
|
||||
}) as Arc<dyn EffectData>);
|
||||
}
|
||||
out
|
||||
@@ -2600,6 +2646,7 @@ impl EngineGateway for RealEngine {
|
||||
cx.notify();
|
||||
});
|
||||
self.mirror_program_playhead(cx);
|
||||
self.update_ofx_viewer_time(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -2660,6 +2707,7 @@ impl EngineGateway for RealEngine {
|
||||
});
|
||||
}
|
||||
self.mirror_program_playhead(cx);
|
||||
self.update_ofx_viewer_time(cx);
|
||||
self.meter_phase = self.meter_phase.wrapping_add(1);
|
||||
// M15 S2: pump the process dispatcher — ticket completions (the
|
||||
// pre-render window, full-res fills, synchronous renders) are
|
||||
@@ -2955,7 +3003,7 @@ impl AppEngine for RealEngine {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn addable_effects(&self) -> Vec<(String, String)> {
|
||||
fn addable_effects(&self) -> Vec<crate::oakui::engine::EffectEntry> {
|
||||
super::effectchain::addable_effects()
|
||||
}
|
||||
|
||||
@@ -2976,6 +3024,57 @@ impl AppEngine for RealEngine {
|
||||
result
|
||||
}
|
||||
|
||||
fn effect_params(&self, effect: EffectId) -> Option<Vec<crate::oakui::engine::EffectParam>> {
|
||||
let project = self.project_ref()?;
|
||||
let node = graphops::id_of(effect.0)?;
|
||||
let guard = graphops::lock(project);
|
||||
super::effectchain::effect_params(&guard.graph, node)
|
||||
}
|
||||
|
||||
fn set_effect_param(
|
||||
&mut self,
|
||||
effect: EffectId,
|
||||
input_id: &str,
|
||||
value: oaknode::value::NodeValue,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
let Some(project) = self.project.clone() else {
|
||||
return Err("no project open".into());
|
||||
};
|
||||
let Some(node) = graphops::id_of(effect.0) else {
|
||||
return Err("effect node not found".into());
|
||||
};
|
||||
let result = super::effectchain::set_input_value(&project, node, input_id, value);
|
||||
self.apply_edit(result.clone(), "set parameter", cx);
|
||||
result
|
||||
}
|
||||
|
||||
fn effect_push_button(
|
||||
&mut self,
|
||||
effect: EffectId,
|
||||
input_id: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
let Some(project) = self.project_ref() else {
|
||||
return Err("no project open".into());
|
||||
};
|
||||
let Some(node) = graphops::id_of(effect.0) else {
|
||||
return Err("effect node not found".into());
|
||||
};
|
||||
let guard = graphops::lock(project);
|
||||
let Some(instance) = super::effectchain::plugin_instance_handle(&guard.graph, node) else {
|
||||
return Err("not a plugin effect".into());
|
||||
};
|
||||
drop(guard);
|
||||
if !oakplugin::node_factory::push_button_clicked(instance, input_id) {
|
||||
return Err(format!("push button \"{input_id}\" not found"));
|
||||
}
|
||||
// A button press can change the plugin's other parameters; refresh
|
||||
// the snapshots and repaint.
|
||||
cx.notify();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_effect_event(&mut self, event: &EffectStackEvent, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
EffectStackEvent::EnableToggled { effect, enabled } => {
|
||||
|
||||
@@ -65,9 +65,27 @@ impl<E: AppEngine> Render for EffectLibraryPanel<E> {
|
||||
.gap_1()
|
||||
.p_2()
|
||||
.overflow_y_scroll();
|
||||
for (type_id, name) in effects {
|
||||
|
||||
// Built-in effects render flat; OpenFX plugin entries are grouped
|
||||
// under their sub-category header (Filter / Generator / Transition /
|
||||
// General — the C++ `factorymenu` OpenFX branch).
|
||||
let mut last_group: Option<String> = None;
|
||||
for entry in &effects {
|
||||
match &entry.group {
|
||||
Some(group) => {
|
||||
if last_group.as_deref() != Some(group.as_str()) {
|
||||
last_group = Some(group.clone());
|
||||
list = list.child(group_header(&colors, group));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
last_group = None;
|
||||
}
|
||||
}
|
||||
let engine = self.engine.clone();
|
||||
let row_id = type_id.clone();
|
||||
let row_id = entry.type_id.clone();
|
||||
let name = entry.name.clone();
|
||||
let type_id = entry.type_id.clone();
|
||||
list = list.child(
|
||||
div()
|
||||
.id(SharedString::from(format!("effect-library-{type_id}")))
|
||||
@@ -123,6 +141,20 @@ impl<E: AppEngine> Render for EffectLibraryPanel<E> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sub-category header row of the OpenFX group (a muted, all-caps
|
||||
/// line above the plugin entries).
|
||||
fn group_header(colors: &gpui::colors::Colors, group: &str) -> impl IntoElement {
|
||||
div()
|
||||
.id(SharedString::from(format!("effect-library-group-{group}")))
|
||||
.pt_2()
|
||||
.pb_1()
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.font_weight(gpui::FontWeight(600.0))
|
||||
.text_color(colors.disabled)
|
||||
.child(group.to_string())
|
||||
}
|
||||
|
||||
impl<E: AppEngine> EventEmitter<PanelEvent> for EffectLibraryPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> DockPanel for EffectLibraryPanel<E> {
|
||||
@@ -161,7 +193,8 @@ mod tests {
|
||||
|
||||
let expected = crate::oakui::effectchain::addable_effects();
|
||||
assert!(!expected.is_empty());
|
||||
for (type_id, _) in &expected {
|
||||
for entry in &expected {
|
||||
let type_id = &entry.type_id;
|
||||
// `debug_bounds` takes a &'static selector; the per-row selector is
|
||||
// dynamic, so the test leaks it (process-lifetime, test-only).
|
||||
let selector: &'static str =
|
||||
|
||||
+14
-21
@@ -32,6 +32,7 @@ use crate::menus::context::{ContextMenuHandle, ContextMenuTriggered};
|
||||
use crate::oakui::AppEngine;
|
||||
use crate::panels::commands::PanelCommandHandler;
|
||||
use crate::panels::ids::INSPECTOR;
|
||||
use crate::panels::ofx_params::OfxParamsView;
|
||||
|
||||
/// The inspector / effect stack panel.
|
||||
pub struct InspectorPanel<E: AppEngine> {
|
||||
@@ -53,8 +54,16 @@ impl<E: AppEngine> InspectorPanel<E> {
|
||||
/// Builds the stack over `engine`'s effect model.
|
||||
pub fn new(engine: Entity<E>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let stack = cx.new(|cx| {
|
||||
EffectStackView::new(engine.clone(), cx)
|
||||
.params_renderer(|_effect, _window, cx| cx.new(|_cx| ParamPlaceholder).into())
|
||||
let engine_for_params = engine.clone();
|
||||
EffectStackView::new(engine.clone(), cx).params_renderer(
|
||||
move |effect, window, cx| {
|
||||
// The OFX parameter view: auto-generated controls for the
|
||||
// effect's inputs (empty state for effects without a
|
||||
// parameter UI).
|
||||
cx.new(|cx| OfxParamsView::new(*effect, engine_for_params.clone(), window, cx))
|
||||
.into()
|
||||
},
|
||||
)
|
||||
});
|
||||
// The "edits are requests" loop: forward each request to the engine,
|
||||
// which applies it to its model and notifies. `AddRequested` carries
|
||||
@@ -133,10 +142,10 @@ impl<E: AppEngine> InspectorPanel<E> {
|
||||
.flex_col()
|
||||
.gap_1();
|
||||
|
||||
for (type_id, name) in &effects {
|
||||
for entry in &effects {
|
||||
let engine = self.engine.clone();
|
||||
let type_id = type_id.clone();
|
||||
let name = name.clone();
|
||||
let type_id = entry.type_id.clone();
|
||||
let name = entry.name.clone();
|
||||
let index = index;
|
||||
menu = menu.child(
|
||||
div()
|
||||
@@ -264,22 +273,6 @@ impl<E: AppEngine> DockPanel for InspectorPanel<E> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Placeholder parameter view rendered inside expanded effect cards.
|
||||
/// A real app builds the effect's controls here and calls
|
||||
/// [`EffectStackView::notify_parameter_changed`] after edits.
|
||||
struct ParamPlaceholder;
|
||||
|
||||
impl Render for ParamPlaceholder {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
div()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.text_color(colors.disabled)
|
||||
.child(crate::i18n::tr("inspector.params"))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context menu — an effect card's right-click menu: enable/disable and
|
||||
// remove drive the same `EffectStackEvent`s the card widgets emit; rename
|
||||
|
||||
@@ -29,6 +29,7 @@ pub mod history;
|
||||
pub mod inspector;
|
||||
pub mod multicam;
|
||||
pub mod node_editor;
|
||||
pub mod ofx_params;
|
||||
pub mod program_viewer;
|
||||
pub mod project_explorer;
|
||||
pub mod source_viewer;
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
// 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/>.
|
||||
|
||||
//! The inspector's OFX parameter view (stage 6b): auto-generated controls
|
||||
//! for the effect node's inputs.
|
||||
//!
|
||||
//! The [`EffectStackView`](gpui::effect_stack::EffectStackView) invokes a
|
||||
//! params renderer inside each expanded effect card. For OFX plugin nodes
|
||||
//! this view reads the effect's parameter snapshot from the engine
|
||||
//! ([`AppEngine::effect_params`]) and renders one control per input:
|
||||
//!
|
||||
//! - int / float → [`Slider`] (double-click for direct entry)
|
||||
//! - boolean → [`CheckBox`]
|
||||
//! - combo → [`ComboBox`] fed from the repeated `("combo_option", _)`
|
||||
//! properties; string-combo values come from `("combo_value", _)`
|
||||
//! - text → [`EditableTextState`]
|
||||
//! - vec2 / vec3 / color → one [`SpinBox`] per component
|
||||
//! - push button → a clickable button (`AppEngine::effect_push_button`)
|
||||
//!
|
||||
//! Secret (HIDDEN) inputs never reach the snapshot, so they render
|
||||
//! nothing; `ui_group` / `ui_page` become section titles. Every edit is
|
||||
//! routed through [`AppEngine::set_effect_param`] (undoable).
|
||||
//!
|
||||
//! The control set is rebuilt when the card re-renders (the params view is
|
||||
//! created fresh per expanded-card render), so it carries no state of its
|
||||
//! own; values are re-synced from the engine each frame.
|
||||
|
||||
use gpui::effect_stack::EffectId;
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::{
|
||||
div, prelude::*, px, ClickEvent, Context, Entity, Render, SharedString, Window,
|
||||
};
|
||||
use gpui_elements::editable_text::{text_input, EditableTextState, StringStorage};
|
||||
use gpui_widgets::checkbox::{CheckBox, CheckBoxEvent, CheckState};
|
||||
use gpui_widgets::combo_box::{ComboBox, ComboBoxEvent, ComboBoxOption};
|
||||
use gpui_widgets::slider::{Slider, SliderModel};
|
||||
use gpui_widgets::spinbox::{SpinBox, SpinBoxEvent};
|
||||
use gpui_widgets::value::{SliderValue, ValueKind};
|
||||
|
||||
use oaknode::value::{NodeValue, ValueType};
|
||||
|
||||
use crate::oakui::{AppEngine, EffectParam};
|
||||
|
||||
/// One editable parameter row of the view.
|
||||
struct ParamControl {
|
||||
/// The input id (the OFX param name).
|
||||
input_id: String,
|
||||
/// The display name (the OFX param label).
|
||||
display_name: String,
|
||||
/// The OFX ui_group / ui_page section header, if any.
|
||||
section: Option<(String, String)>,
|
||||
/// The control(s) for this parameter.
|
||||
kind: ControlKind,
|
||||
}
|
||||
|
||||
/// The concrete control(s) for one parameter.
|
||||
enum ControlKind {
|
||||
/// A slider (int / float).
|
||||
Slider(Entity<Slider>),
|
||||
/// A checkbox (boolean).
|
||||
CheckBox(Entity<CheckBox>),
|
||||
/// A combo box (combo / string combo).
|
||||
Combo(Entity<ComboBox>),
|
||||
/// One spinbox per component (vec2 / vec3 / color); the usize is the
|
||||
/// component index within the value.
|
||||
Spin(Vec<(Entity<SpinBox>, usize)>),
|
||||
/// A text field (string).
|
||||
Text(Entity<EditableTextState>),
|
||||
/// A push button (rendered inline, no entity).
|
||||
PushButton,
|
||||
/// A read-only value line (no editable control; e.g. custom/binary).
|
||||
ReadOnly(SharedString),
|
||||
}
|
||||
|
||||
/// The inspector's parameter view for one expanded effect card.
|
||||
pub struct OfxParamsView<E: AppEngine> {
|
||||
engine: Entity<E>,
|
||||
effect: EffectId,
|
||||
controls: Vec<ParamControl>,
|
||||
}
|
||||
|
||||
impl<E: AppEngine> OfxParamsView<E> {
|
||||
/// Builds the control set from the engine's current parameter snapshot.
|
||||
pub fn new(
|
||||
effect: EffectId,
|
||||
engine: Entity<E>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let params = engine.read(cx).effect_params(effect).unwrap_or_default();
|
||||
let mut control_id = 0usize;
|
||||
let controls = params
|
||||
.iter()
|
||||
.map(|param| build_control(param, &mut control_id, window, cx))
|
||||
.collect();
|
||||
|
||||
let this = Self {
|
||||
engine,
|
||||
effect,
|
||||
controls,
|
||||
};
|
||||
wire_controls(&this, cx);
|
||||
this
|
||||
}
|
||||
|
||||
/// Applies the engine's current values to every control (called each
|
||||
/// render so external edits / undo / redo land on the controls).
|
||||
fn sync_values(&mut self, cx: &mut Context<Self>) {
|
||||
let params = self.engine.read(cx).effect_params(self.effect).unwrap_or_default();
|
||||
for control in &self.controls {
|
||||
let Some(param) = params.iter().find(|p| p.input_id == control.input_id) else {
|
||||
continue;
|
||||
};
|
||||
match &control.kind {
|
||||
ControlKind::Slider(slider) => {
|
||||
let sv = slider_value(param);
|
||||
let slider = slider.clone();
|
||||
slider.update(cx, |slider, _| slider.set_value(sv));
|
||||
}
|
||||
ControlKind::CheckBox(check) => {
|
||||
let state = match param.value {
|
||||
NodeValue::Boolean(true) => CheckState::Checked,
|
||||
_ => CheckState::Unchecked,
|
||||
};
|
||||
let check = check.clone();
|
||||
check.update(cx, |check, cx| check.set_state(state, cx));
|
||||
}
|
||||
ControlKind::Combo(combo) => {
|
||||
let index = combo_index_for(param);
|
||||
let combo = combo.clone();
|
||||
combo.update(cx, |combo, cx| {
|
||||
combo.set_selected(Some(index), cx);
|
||||
});
|
||||
}
|
||||
ControlKind::Spin(spins) => {
|
||||
let components = value_components(¶m.value);
|
||||
for (spin, channel) in spins {
|
||||
if let Some(value) = components.get(*channel) {
|
||||
let spin = spin.clone();
|
||||
let sv = SliderValue::Float(*value);
|
||||
spin.update(cx, |spin, cx| spin.set_value(sv, cx));
|
||||
}
|
||||
}
|
||||
}
|
||||
ControlKind::Text(editor) => {
|
||||
let text = match ¶m.value {
|
||||
NodeValue::Text(s) => s.clone(),
|
||||
NodeValue::StrCombo(s) => s.clone(),
|
||||
_ => continue,
|
||||
};
|
||||
let editor = editor.clone();
|
||||
editor.update(cx, |editor, cx| {
|
||||
if editor.as_str() != text {
|
||||
editor.emplace(&text, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
ControlKind::PushButton | ControlKind::ReadOnly(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The SliderValue for a param's current value (int → Integer, float →
|
||||
/// Float).
|
||||
fn slider_value(param: &EffectParam) -> SliderValue {
|
||||
match param.value {
|
||||
NodeValue::Int(v) => SliderValue::Integer(v),
|
||||
NodeValue::Float(v) => SliderValue::Float(v),
|
||||
_ => SliderValue::Float(param.value.to_double()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected option index of a combo/string-combo parameter. Integer
|
||||
/// combos carry the index directly; string combos are matched against the
|
||||
/// `("combo_value", _)` (or `("combo_option", _)`) list by value.
|
||||
fn combo_index_for(param: &EffectParam) -> usize {
|
||||
if param.value_type == ValueType::StrCombo {
|
||||
let values = crate::oakui::effectchain::combo_values(param);
|
||||
let haystack = if values.is_empty() {
|
||||
crate::oakui::effectchain::combo_options(param)
|
||||
} else {
|
||||
values
|
||||
};
|
||||
match ¶m.value {
|
||||
NodeValue::StrCombo(s) | NodeValue::Text(s) => {
|
||||
haystack.iter().position(|v| v == s).unwrap_or(0)
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
} else {
|
||||
param.value.to_double().max(0.0) as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// The component list of a vec/color value (empty for other types).
|
||||
fn value_components(value: &NodeValue) -> Vec<f64> {
|
||||
match value {
|
||||
NodeValue::Vec2(v) => vec![v[0], v[1]],
|
||||
NodeValue::Vec3(v) => vec![v[0], v[1], v[2]],
|
||||
NodeValue::Vec4(v) => vec![v[0], v[1], v[2], v[3]],
|
||||
NodeValue::Color(v) => vec![v[0], v[1], v[2], v[3]],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The default numeric range when the parameter carries no min/max
|
||||
/// properties (the OFX translation only attaches min/max to colour
|
||||
/// inputs). Wide ranges keep every value reachable; the slider's
|
||||
/// double-click entry allows exact typing.
|
||||
fn default_range(value_type: ValueType) -> (f64, f64) {
|
||||
match value_type {
|
||||
ValueType::Int => (-100000.0, 100000.0),
|
||||
_ => (-10000.0, 10000.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The min/max from the parameter's `("min", Float)` / `("max", Float)`
|
||||
/// properties, falling back to [`default_range`].
|
||||
fn numeric_range(param: &EffectParam) -> (f64, f64) {
|
||||
let prop = |key: &str| {
|
||||
param
|
||||
.properties
|
||||
.iter()
|
||||
.find(|(k, _)| k == key)
|
||||
.and_then(|(_, v)| match v {
|
||||
NodeValue::Float(f) => Some(*f),
|
||||
NodeValue::Int(i) => Some(*i as f64),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let (dmin, dmax) = default_range(param.value_type);
|
||||
(
|
||||
prop("min").unwrap_or(dmin),
|
||||
prop("max").unwrap_or(dmax),
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds one [`ParamControl`] for `param`, creating the control entities
|
||||
/// (each consuming one control id from `next_id`).
|
||||
fn build_control<E: AppEngine>(
|
||||
param: &EffectParam,
|
||||
next_id: &mut usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<OfxParamsView<E>>,
|
||||
) -> ParamControl {
|
||||
let kind = match param.value_type {
|
||||
ValueType::Int | ValueType::Float => {
|
||||
let (min, max) = numeric_range(param);
|
||||
let kind = if param.value_type == ValueType::Int {
|
||||
ValueKind::Integer
|
||||
} else {
|
||||
ValueKind::Float
|
||||
};
|
||||
let step = if param.value_type == ValueType::Int {
|
||||
1.0
|
||||
} else {
|
||||
((max - min) / 200.0).max(0.001)
|
||||
};
|
||||
let default_raw = param.value.to_double().clamp(min, max);
|
||||
let model = SliderModel::new(kind, min, max, step, default_raw);
|
||||
let slider = cx.new(|cx| Slider::new(*next_id, model, window, cx));
|
||||
*next_id += 1;
|
||||
ControlKind::Slider(slider)
|
||||
}
|
||||
ValueType::Boolean => {
|
||||
let state = match param.value {
|
||||
NodeValue::Boolean(true) => CheckState::Checked,
|
||||
_ => CheckState::Unchecked,
|
||||
};
|
||||
let check = cx.new(|cx| CheckBox::new(*next_id, state, window, cx));
|
||||
*next_id += 1;
|
||||
ControlKind::CheckBox(check)
|
||||
}
|
||||
ValueType::Combo | ValueType::StrCombo => {
|
||||
let options: Vec<String> = if param.value_type == ValueType::StrCombo {
|
||||
let values = crate::oakui::effectchain::combo_values(param);
|
||||
if values.is_empty() {
|
||||
crate::oakui::effectchain::combo_options(param)
|
||||
} else {
|
||||
values
|
||||
}
|
||||
} else {
|
||||
crate::oakui::effectchain::combo_options(param)
|
||||
};
|
||||
if options.is_empty() {
|
||||
// No option list: show the raw value read-only.
|
||||
let text = if param.value_type == ValueType::Combo {
|
||||
format!("{}", param.value.to_double() as i64)
|
||||
} else {
|
||||
match ¶m.value {
|
||||
NodeValue::StrCombo(s) | NodeValue::Text(s) => s.clone(),
|
||||
_ => String::new(),
|
||||
}
|
||||
};
|
||||
ControlKind::ReadOnly(text.into())
|
||||
} else {
|
||||
let options = options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, label)| ComboBoxOption::new(i, label.clone()))
|
||||
.collect();
|
||||
let combo = cx.new(|cx| ComboBox::new(*next_id, options, window, cx));
|
||||
let index = combo_index_for(param);
|
||||
combo.update(cx, |combo, cx| combo.set_selected(Some(index), cx));
|
||||
*next_id += 1;
|
||||
ControlKind::Combo(combo)
|
||||
}
|
||||
}
|
||||
ValueType::Text => {
|
||||
let text = match ¶m.value {
|
||||
NodeValue::Text(s) => s.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
|
||||
editor.update(cx, |editor, cx| editor.emplace(&text, cx));
|
||||
*next_id += 1;
|
||||
ControlKind::Text(editor)
|
||||
}
|
||||
ValueType::Vec2 | ValueType::Vec3 | ValueType::Color => {
|
||||
let components = value_components(¶m.value);
|
||||
let count = if param.value_type == ValueType::Vec2 {
|
||||
2
|
||||
} else if param.value_type == ValueType::Vec3 {
|
||||
3
|
||||
} else {
|
||||
4
|
||||
};
|
||||
let (min, max) = if param.value_type == ValueType::Color {
|
||||
// Colour channels live in 0..1 (or the attached min/max).
|
||||
(0.0f64, 1.0f64)
|
||||
} else {
|
||||
default_range(param.value_type)
|
||||
};
|
||||
let mut spins = Vec::new();
|
||||
for channel in 0..count {
|
||||
let value = components.get(channel).copied().unwrap_or(0.0).clamp(min, max);
|
||||
let model = SliderModel::new(ValueKind::Float, min, max, 0.001, value);
|
||||
let spin = cx.new(|cx| SpinBox::new(*next_id, model, window, cx));
|
||||
*next_id += 1;
|
||||
spins.push((spin, channel));
|
||||
}
|
||||
ControlKind::Spin(spins)
|
||||
}
|
||||
ValueType::PushButton => ControlKind::PushButton,
|
||||
// Custom / binary and anything without an editable control: a
|
||||
// read-only line (or nothing).
|
||||
_ => ControlKind::ReadOnly(SharedString::new("")),
|
||||
};
|
||||
|
||||
ParamControl {
|
||||
input_id: param.input_id.clone(),
|
||||
display_name: param.display_name.clone(),
|
||||
section: crate::oakui::effectchain::ui_section_of(param),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wires every control's events to the engine's `set_effect_param` /
|
||||
/// `effect_push_button`.
|
||||
fn wire_controls<E: AppEngine>(view: &OfxParamsView<E>, cx: &mut Context<OfxParamsView<E>>) {
|
||||
for control in &view.controls {
|
||||
let input_id = control.input_id.clone();
|
||||
let effect = view.effect;
|
||||
let engine = view.engine.clone();
|
||||
match &control.kind {
|
||||
ControlKind::Slider(slider) => {
|
||||
let slider = slider.clone();
|
||||
cx.subscribe(&slider, move |_, _, event: &gpui_widgets::slider::SliderEvent, cx| {
|
||||
if let gpui_widgets::slider::SliderEvent::ValueChanged { value, .. } = event {
|
||||
let nv = match value {
|
||||
SliderValue::Integer(v) => NodeValue::Int(*v),
|
||||
_ => NodeValue::Float(value.to_f64()),
|
||||
};
|
||||
engine.update(cx, |engine, cx| {
|
||||
let _ = engine.set_effect_param(effect, &input_id, nv, cx);
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
ControlKind::CheckBox(check) => {
|
||||
let check = check.clone();
|
||||
cx.subscribe(&check, move |_, _, event: &CheckBoxEvent, cx| {
|
||||
let CheckBoxEvent::Toggled { state, .. } = event;
|
||||
let nv = NodeValue::Boolean(*state == CheckState::Checked);
|
||||
engine.update(cx, |engine, cx| {
|
||||
let _ = engine.set_effect_param(effect, &input_id, nv, cx);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
ControlKind::Combo(combo) => {
|
||||
let combo = combo.clone();
|
||||
cx.subscribe(&combo, move |_, _, event: &ComboBoxEvent, cx| {
|
||||
if let ComboBoxEvent::Selected { value, .. } = event {
|
||||
// Integer combos carry the index; string combos map
|
||||
// the picked option back to its string value.
|
||||
let param = engine.update(cx, |engine, cx| {
|
||||
engine
|
||||
.effect_params(effect)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.find(|p| p.input_id == input_id)
|
||||
});
|
||||
let nv = match ¶m {
|
||||
Some(p) if p.value_type == ValueType::StrCombo => {
|
||||
let values = crate::oakui::effectchain::combo_values(p);
|
||||
let haystack = if values.is_empty() {
|
||||
crate::oakui::effectchain::combo_options(p)
|
||||
} else {
|
||||
values
|
||||
};
|
||||
NodeValue::StrCombo(
|
||||
haystack.get(*value).cloned().unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
_ => NodeValue::Combo(*value as i64),
|
||||
};
|
||||
engine.update(cx, |engine, cx| {
|
||||
let _ = engine.set_effect_param(effect, &input_id, nv, cx);
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
ControlKind::Spin(spins) => {
|
||||
let spins = spins.clone();
|
||||
for (spin, channel) in spins {
|
||||
let spin = spin.clone();
|
||||
let channel = channel;
|
||||
// Clone per iteration: each spinbox's closure owns its
|
||||
// own engine / input id.
|
||||
let engine = engine.clone();
|
||||
let input_id = input_id.clone();
|
||||
cx.subscribe(&spin, move |_, _, event: &SpinBoxEvent, cx| {
|
||||
if let SpinBoxEvent::ValueChanged { value, .. } = event {
|
||||
// Re-read the current value, patch the changed
|
||||
// component, and write the whole value back.
|
||||
let patched = engine.update(cx, |engine, cx| {
|
||||
let params = engine.effect_params(effect).unwrap_or_default();
|
||||
let current = params
|
||||
.iter()
|
||||
.find(|p| p.input_id == input_id)
|
||||
.map(|p| p.value.clone())
|
||||
.unwrap_or(NodeValue::None);
|
||||
let patched = patch_component(¤t, channel, value.to_f64());
|
||||
engine.set_effect_param(effect, &input_id, patched, cx).is_ok()
|
||||
});
|
||||
let _ = patched;
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
ControlKind::Text(_editor) => {
|
||||
// The text field commits explicitly (the commit button in the
|
||||
// row). No event subscription here: the params view is rebuilt
|
||||
// on every card render, so committing on TextChanged would
|
||||
// re-enter the engine update on the same frame the value is
|
||||
// re-synced (an endless re-render loop).
|
||||
}
|
||||
ControlKind::PushButton | ControlKind::ReadOnly(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `value` with the component at `channel` replaced by `component`.
|
||||
fn patch_component(value: &NodeValue, channel: usize, component: f64) -> NodeValue {
|
||||
let mut out = value.clone();
|
||||
match &mut out {
|
||||
NodeValue::Vec2(v) if channel < 2 => v[channel] = component,
|
||||
NodeValue::Vec3(v) if channel < 3 => v[channel] = component,
|
||||
NodeValue::Vec4(v) if channel < 4 => v[channel] = component,
|
||||
NodeValue::Color(v) if channel < 4 => v[channel] = component,
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl<E: AppEngine> Render for OfxParamsView<E> {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
self.sync_values(cx);
|
||||
|
||||
let mut body = div().flex().flex_col().gap_1().p_2();
|
||||
let mut last_section: Option<(String, String)> = None;
|
||||
for control in &self.controls {
|
||||
if control.section != last_section {
|
||||
last_section = control.section.clone();
|
||||
if let Some((group, page)) = &last_section {
|
||||
let title = if page.is_empty() {
|
||||
group.clone()
|
||||
} else if group.is_empty() {
|
||||
page.clone()
|
||||
} else {
|
||||
format!("{group} · {page}")
|
||||
};
|
||||
body = body.child(
|
||||
div()
|
||||
.py_1()
|
||||
.text_xs()
|
||||
.font_weight(gpui::FontWeight(600.0))
|
||||
.text_color(colors.selected)
|
||||
.child(title),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A push button renders full-width with its own label (the OFX
|
||||
// param label IS the button text); every other control gets the
|
||||
// label column + control layout.
|
||||
let row_element: gpui::AnyElement = if matches!(control.kind, ControlKind::PushButton) {
|
||||
let engine = self.engine.clone();
|
||||
let effect = self.effect;
|
||||
let input_id = control.input_id.clone();
|
||||
let button_label = control.display_name.clone();
|
||||
div()
|
||||
.id(SharedString::from(format!("ofx-push-{}", control.input_id)))
|
||||
.flex_1()
|
||||
.cursor_pointer()
|
||||
.rounded_sm()
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.selected)
|
||||
.text_sm()
|
||||
.text_color(colors.text)
|
||||
.text_center()
|
||||
.py_1()
|
||||
.child(button_label)
|
||||
.on_click(move |_event: &ClickEvent, _window, cx| {
|
||||
engine.update(cx, |engine, cx| {
|
||||
let _ = engine.effect_push_button(effect, &input_id, cx);
|
||||
});
|
||||
})
|
||||
.into_any_element()
|
||||
} else {
|
||||
let label = div()
|
||||
.flex_shrink_0()
|
||||
.w(px(110.0))
|
||||
.text_sm()
|
||||
.text_color(colors.text)
|
||||
.child(control.display_name.clone());
|
||||
let widget = match &control.kind {
|
||||
ControlKind::Slider(slider) => div().flex_1().child(slider.clone()).into_any_element(),
|
||||
ControlKind::CheckBox(check) => div().flex_1().child(check.clone()).into_any_element(),
|
||||
ControlKind::Combo(combo) => div().flex_1().child(combo.clone()).into_any_element(),
|
||||
ControlKind::Spin(spins) => {
|
||||
let mut row = div().flex_1().flex().gap_1();
|
||||
for (spin, _) in spins {
|
||||
row = row.child(div().flex_1().child(spin.clone()));
|
||||
}
|
||||
row.into_any_element()
|
||||
}
|
||||
ControlKind::Text(editor) => {
|
||||
let weak = editor.downgrade();
|
||||
let engine = self.engine.clone();
|
||||
let effect = self.effect;
|
||||
let input_id = control.input_id.clone();
|
||||
let editor_commit = editor.clone();
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.background)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.child(text_input(format!("ofx-param-{}", control.input_id)).state(weak).accepts_input(true)),
|
||||
)
|
||||
.child(
|
||||
// Explicit commit: reads the field and pushes the
|
||||
// string to the engine (avoids the re-render loop of
|
||||
// committing on every keystroke).
|
||||
div()
|
||||
.id(SharedString::from(format!("ofx-commit-{}", control.input_id)))
|
||||
.cursor_pointer()
|
||||
.rounded_sm()
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.selected)
|
||||
.text_sm()
|
||||
.text_color(colors.text)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.child("✓")
|
||||
.on_click(move |_event: &ClickEvent, _window, cx| {
|
||||
let text = editor_commit.read(cx).as_str().to_string();
|
||||
engine.update(cx, |engine, cx| {
|
||||
let _ = engine
|
||||
.set_effect_param(effect, &input_id, NodeValue::Text(text), cx);
|
||||
});
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
ControlKind::ReadOnly(text) => div()
|
||||
.flex_1()
|
||||
.text_sm()
|
||||
.text_color(colors.disabled)
|
||||
.child(text.clone())
|
||||
.into_any_element(),
|
||||
ControlKind::PushButton => unreachable!("handled above"),
|
||||
};
|
||||
div()
|
||||
.id(SharedString::from(format!("ofx-param-{}", control.input_id)))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(label)
|
||||
.child(widget)
|
||||
.into_any_element()
|
||||
};
|
||||
body = body.child(row_element);
|
||||
}
|
||||
if self.controls.is_empty() {
|
||||
body = body.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(colors.disabled)
|
||||
.child(crate::i18n::tr("inspector.params")),
|
||||
);
|
||||
}
|
||||
body
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user