diff --git a/Cargo.lock b/Cargo.lock index 8ce8f813f..a90fba29b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4748,6 +4748,7 @@ dependencies = [ "oakcommon", "oakcore-rs", "oaknode", + "oakplugin", "oakrender", "oakstorage", "oaktask", diff --git a/Cargo.toml b/Cargo.toml index b89d30196..ee2e52812 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/oak-worker/src/worker.rs b/crates/oak-worker/src/worker.rs index c9aaf75d6..17a502f2c 100644 --- a/crates/oak-worker/src/worker.rs +++ b/crates/oak-worker/src/worker.rs @@ -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", diff --git a/crates/oakplugin/src/node_factory.rs b/crates/oakplugin/src/node_factory.rs index 38ad85924..11ccf4478 100644 --- a/crates/oakplugin/src/node_factory.rs +++ b/crates/oakplugin/src/node_factory.rs @@ -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); + } } diff --git a/crates/oakplugin/src/suites/message.rs b/crates/oakplugin/src/suites/message.rs index 604ff1697..a5de702fe 100644 --- a/crates/oakplugin/src/suites/message.rs +++ b/crates/oakplugin/src/suites/message.rs @@ -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, 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::OnceLock::new(); + +fn persistent_slot() -> &'static std::sync::Mutex> { + 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, 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); + } } diff --git a/src/app.rs b/src/app.rs index 63cabd539..2e93951e5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 { shell_focus: gpui::FocusHandle, /// The running export session, if any. export: Option, + /// 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>, /// The library row pending an export save dialog (manager 导出). pending_export: Option, /// The panel that most recently took focus (the target of @@ -606,6 +619,12 @@ impl OakApp { // 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::set_progress_tx(plugin_progress_tx); let shell = Self { engine, program_clock, @@ -619,6 +638,8 @@ impl OakApp { 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 OakApp { .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) { + 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 OakApp { 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(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(); diff --git a/src/i18n.rs b/src/i18n.rs index 1aa440cc1..ec7bbb892 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -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", "关闭"), diff --git a/src/oakui/effectchain.rs b/src/oakui/effectchain.rs index 8e0649959..4c6e1ef92 100644 --- a/src/oakui/effectchain.rs +++ b/src/oakui/effectchain.rs @@ -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 { + 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 { + let behavior = g.get(node)?.behavior.as_any()?; + let plugin = behavior.downcast_ref::()?; + 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> { + 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::().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 { + 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 { + 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()))); + } } diff --git a/src/oakui/engine.rs b/src/oakui/engine.rs index 170cd8db3..1c914b821 100644 --- a/src/oakui/engine.rs +++ b/src/oakui/engine.rs @@ -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, +} + +/// 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, _cx: &mut Context) {} - /// 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 { 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> { + 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, + ) -> 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, + ) -> 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); diff --git a/src/oakui/mock.rs b/src/oakui/mock.rs index d7ab3ad9a..02006818b 100644 --- a/src/oakui/mock.rs +++ b/src/oakui/mock.rs @@ -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 { + // 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 diff --git a/src/oakui/mod.rs b/src/oakui/mod.rs index 2d59da03d..c524f701c 100644 --- a/src/oakui/mod.rs +++ b/src/oakui/mod.rs @@ -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}; diff --git a/src/oakui/ofx.rs b/src/oakui/ofx.rs new file mode 100644 index 000000000..27e91be8f --- /dev/null +++ b/src/oakui/ofx.rs @@ -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 . + +//! 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>>> = + 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> = OnceLock::new(); + +/// The last known project extent (normalised-coordinate default conversion; +/// the engine refreshes it whenever the sequence changes). +static PROJECT_EXTENT: OnceLock> = OnceLock::new(); + +fn viewer_slot() -> &'static Mutex { + 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) { + *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> { + 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>, + 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)); + } +} diff --git a/src/oakui/real.rs b/src/oakui/real.rs index 752f048c6..82e81d056 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -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, + /// The persistent-message badge count (plugin nodes only; `None` + /// otherwise). + badge: Option, } impl EffectData for RealEffect { @@ -683,6 +688,10 @@ impl EffectData for RealEffect { self.title.clone() } + fn subtitle(&self) -> Option { + 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 { + 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); } 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 { super::effectchain::addable_effects() } @@ -2976,6 +3024,57 @@ impl AppEngine for RealEngine { result } + fn effect_params(&self, effect: EffectId) -> Option> { + 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, + ) -> 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, + ) -> 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) { match event { EffectStackEvent::EnableToggled { effect, enabled } => { diff --git a/src/panels/effect_library.rs b/src/panels/effect_library.rs index 4d3596a8d..175ecfbab 100644 --- a/src/panels/effect_library.rs +++ b/src/panels/effect_library.rs @@ -65,9 +65,27 @@ impl Render for EffectLibraryPanel { .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 = 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 Render for EffectLibraryPanel { } } +/// 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 EventEmitter for EffectLibraryPanel {} impl DockPanel for EffectLibraryPanel { @@ -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 = diff --git a/src/panels/inspector.rs b/src/panels/inspector.rs index a8986866c..752238ea5 100644 --- a/src/panels/inspector.rs +++ b/src/panels/inspector.rs @@ -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 { @@ -53,8 +54,16 @@ impl InspectorPanel { /// Builds the stack over `engine`'s effect model. pub fn new(engine: Entity, window: &mut Window, cx: &mut Context) -> 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 InspectorPanel { .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 DockPanel for InspectorPanel { } } -/// 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) -> 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 diff --git a/src/panels/mod.rs b/src/panels/mod.rs index 66326086a..0346d0995 100644 --- a/src/panels/mod.rs +++ b/src/panels/mod.rs @@ -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; diff --git a/src/panels/ofx_params.rs b/src/panels/ofx_params.rs new file mode 100644 index 000000000..8a8c6ea5a --- /dev/null +++ b/src/panels/ofx_params.rs @@ -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 . + +//! 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), + /// A checkbox (boolean). + CheckBox(Entity), + /// A combo box (combo / string combo). + Combo(Entity), + /// One spinbox per component (vec2 / vec3 / color); the usize is the + /// component index within the value. + Spin(Vec<(Entity, usize)>), + /// A text field (string). + Text(Entity), + /// 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 { + engine: Entity, + effect: EffectId, + controls: Vec, +} + +impl OfxParamsView { + /// Builds the control set from the engine's current parameter snapshot. + pub fn new( + effect: EffectId, + engine: Entity, + window: &mut Window, + cx: &mut Context, + ) -> 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) { + 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 { + 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( + param: &EffectParam, + next_id: &mut usize, + window: &mut Window, + cx: &mut Context>, +) -> 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 = 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(view: &OfxParamsView, cx: &mut Context>) { + 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 Render for OfxParamsView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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 + } +}