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

- src/oakui/ofx.rs: startup sequence (host scan, register_plugin_nodes,
  progress reporter factory -> app progress dialog channel, active
  viewer time provider, project extent sync); all failures degrade to
  logs. oak-worker runtime also scans and registers plugins.
- Effect library groups OpenFX entries by sub-category (Filter/
  Generator/Transition/General); effect insertion goes through
  Factory::create_any so dynamic plugin nodes resolve.
- Inspector renders OFX parameters from node inputs (sliders, combo
  boxes from repeated combo_option/combo_value properties, vec/color
  spinboxes, text with explicit commit, push buttons), edits are
  undoable; persistent plugin messages surface as a card badge.
- oakplugin: push_button_clicked and per-instance persistent message
  counting (thin public layers).
This commit is contained in:
2026-08-18 22:16:08 +08:00
parent d61acb9e0a
commit ec7b7e6d13
17 changed files with 1733 additions and 55 deletions
+319 -13
View File
@@ -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(&param), vec!["Fast", "High"]);
assert_eq!(combo_values(&param), 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
View File
@@ -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
View File
@@ -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
View File
@@ -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};
+248
View File
@@ -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
View File
@@ -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 } => {