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:
+319
-13
@@ -93,30 +93,162 @@ pub fn is_enabled(g: &Graph, node: NodeId) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The effect types the user can add to a clip's chain, as
|
||||
/// (type id, display name) pairs — the factory entries flagged
|
||||
/// `video_effect` and not hidden from the create menu.
|
||||
pub fn addable_effects() -> Vec<(String, String)> {
|
||||
/// The effect types the user can add to a clip's chain — the built-in
|
||||
/// factory entries flagged `video_effect` and not hidden from the create
|
||||
/// menu (no group), plus every runtime-registered OpenFX plugin entry
|
||||
/// (grouped by its sub-category: Filter / Generator / Transition /
|
||||
/// General — the C++ `factorymenu.cpp` OpenFX branch).
|
||||
pub fn addable_effects() -> Vec<super::engine::EffectEntry> {
|
||||
use super::engine::EffectEntry;
|
||||
use oaknode::node::flags as node_flags;
|
||||
let mut out = Vec::new();
|
||||
for meta in oaknode::factory::Factory::global().entries() {
|
||||
// A scratch instance per entry just to read its flags (the factory
|
||||
// metadata carries no flag copy).
|
||||
let (core, _behavior) = (meta.create)();
|
||||
let flags = core.flags;
|
||||
if flags & oaknode::node::flags::VIDEO_EFFECT != 0
|
||||
&& flags & oaknode::node::flags::DONT_SHOW_IN_CREATE_MENU == 0
|
||||
if flags & node_flags::VIDEO_EFFECT != 0
|
||||
&& flags & node_flags::DONT_SHOW_IN_CREATE_MENU == 0
|
||||
{
|
||||
let name = if meta.name.is_empty() {
|
||||
meta.type_id.to_string()
|
||||
} else {
|
||||
meta.name.to_string()
|
||||
};
|
||||
out.push((meta.type_id.to_string(), name));
|
||||
out.push(EffectEntry {
|
||||
type_id: meta.type_id.to_string(),
|
||||
name,
|
||||
group: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
for meta in oaknode::factory::Factory::global().dynamic_entries() {
|
||||
// OpenFX plugin entries: grouped by sub-category. The factory
|
||||
// metadata carries no flags; plugin nodes are always video effects.
|
||||
let name = if meta.name.is_empty() {
|
||||
meta.type_id.clone()
|
||||
} else {
|
||||
meta.name
|
||||
};
|
||||
out.push(EffectEntry {
|
||||
type_id: meta.type_id,
|
||||
name,
|
||||
group: Some(meta.sub_category),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OFX parameter data model (stage 6b)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The OFX plugin instance handle of a plugin node (the oakplugin registry
|
||||
/// key), or `None` for built-in nodes. Used by the inspector to read the
|
||||
/// persistent-message badge and to trigger push buttons.
|
||||
pub fn plugin_instance_handle(g: &Graph, node: NodeId) -> Option<u64> {
|
||||
let behavior = g.get(node)?.behavior.as_any()?;
|
||||
let plugin = behavior.downcast_ref::<oaknode::nodes::plugin::PluginNode>()?;
|
||||
let handle = plugin.instance_handle();
|
||||
(!handle.is_null()).then_some(handle.0)
|
||||
}
|
||||
|
||||
/// The parameter controls of `effect` for the inspector, or `None` when
|
||||
/// the effect exposes no parameter UI (not a plugin node, or no editable
|
||||
/// parameters).
|
||||
pub fn effect_params(
|
||||
g: &Graph,
|
||||
node: NodeId,
|
||||
) -> Option<Vec<super::engine::EffectParam>> {
|
||||
use oaknode::input::flags as input_flags;
|
||||
use oaknode::nodes::plugin::PluginNode;
|
||||
use oaknode::value::ValueType;
|
||||
|
||||
let entry = g.get(node)?;
|
||||
// Only OFX plugin nodes expose the parameter UI (built-in effects keep
|
||||
// the inspector's placeholder).
|
||||
let behavior = entry.behavior.as_any()?;
|
||||
if behavior.downcast_ref::<PluginNode>().is_none() {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for input in &entry.core.inputs {
|
||||
// Clip/texture inputs are graph connections, not params; hidden
|
||||
// (secret) inputs never render.
|
||||
if input.value_type == ValueType::Texture {
|
||||
continue;
|
||||
}
|
||||
if input.flags & input_flags::HIDDEN != 0 {
|
||||
continue;
|
||||
}
|
||||
// The standard enabled input is structural, not a parameter.
|
||||
if input.id == oaknode::node::ENABLED_INPUT {
|
||||
continue;
|
||||
}
|
||||
out.push(super::engine::EffectParam {
|
||||
input_id: input.id.clone(),
|
||||
display_name: input.display_name.clone(),
|
||||
value_type: input.value_type,
|
||||
value: entry.core.standard_value(&input.id, -1),
|
||||
flags: input.flags,
|
||||
properties: input.properties.clone(),
|
||||
});
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The combo option labels of a parameter, collected from the repeated
|
||||
/// `("combo_option", Text)` property keys (the OFX translation pass
|
||||
/// carries the choice options that way; `str_combo` values ride the
|
||||
/// `("combo_value", Text)` keys).
|
||||
pub fn combo_options(p: &super::engine::EffectParam) -> Vec<String> {
|
||||
p.properties
|
||||
.iter()
|
||||
.filter(|(k, _)| k == "combo_option")
|
||||
.filter_map(|(_, v)| match v {
|
||||
oaknode::value::NodeValue::Text(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The string-combo values of a parameter (`("combo_value", Text)` keys).
|
||||
pub fn combo_values(p: &super::engine::EffectParam) -> Vec<String> {
|
||||
p.properties
|
||||
.iter()
|
||||
.filter(|(k, _)| k == "combo_value")
|
||||
.filter_map(|(_, v)| match v {
|
||||
oaknode::value::NodeValue::Text(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The `ui_group` / `ui_page` property of a parameter, if any (the OFX
|
||||
/// group/page headers; the inspector renders them as section titles).
|
||||
pub fn ui_section_of(p: &super::engine::EffectParam) -> Option<(String, String)> {
|
||||
let group = p
|
||||
.properties
|
||||
.iter()
|
||||
.find(|(k, _)| k == "ui_group")
|
||||
.and_then(|(_, v)| match v {
|
||||
oaknode::value::NodeValue::Text(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let page = p
|
||||
.properties
|
||||
.iter()
|
||||
.find(|(k, _)| k == "ui_page")
|
||||
.and_then(|(_, v)| match v {
|
||||
oaknode::value::NodeValue::Text(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
});
|
||||
match (group, page) {
|
||||
(None, None) => None,
|
||||
(group, page) => Some((group.unwrap_or_default(), page.unwrap_or_default())),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command pieces
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -218,6 +350,55 @@ pub fn set_enabled(p: &ProjectRef, effect: NodeId, enabled: bool) -> Result<(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Undoable set of an effect parameter (a node input's standard value) —
|
||||
/// "Set Parameter". `input_id` must exist on the node; the value is
|
||||
/// written as the standard value of element -1 (non-keyframed). Used by
|
||||
/// the inspector's OFX parameter controls.
|
||||
pub fn set_input_value(
|
||||
p: &ProjectRef,
|
||||
effect: NodeId,
|
||||
input_id: &str,
|
||||
value: NodeValue,
|
||||
) -> Result<(), String> {
|
||||
let old = {
|
||||
let g = lock(p);
|
||||
let entry = g
|
||||
.graph
|
||||
.get(effect)
|
||||
.ok_or_else(|| "set parameter: node not found".to_string())?;
|
||||
if entry.core.get_input(input_id).is_none() {
|
||||
return Err(format!("set parameter: unknown input \"{input_id}\""));
|
||||
}
|
||||
entry.core.standard_value(input_id, -1)
|
||||
};
|
||||
// NodeValue is not Copy: each closure owns its own clone (the closures
|
||||
// are FnMut and may run more than once across undo/redo cycles).
|
||||
let (p1, p2) = (p.clone(), p.clone());
|
||||
let (input_id, value) = (input_id.to_string(), value);
|
||||
let (redo_input, undo_input) = (input_id.clone(), input_id);
|
||||
let redo_value = value.clone();
|
||||
let undo_old = old.clone();
|
||||
push(
|
||||
UndoCommand::from_closures(
|
||||
move || {
|
||||
let mut g = lock(&p1);
|
||||
if let Some(e) = g.graph.get_mut(effect) {
|
||||
e.core
|
||||
.set_standard_value(&redo_input, -1, redo_value.clone());
|
||||
}
|
||||
},
|
||||
move || {
|
||||
let mut g = lock(&p2);
|
||||
if let Some(e) = g.graph.get_mut(effect) {
|
||||
e.core
|
||||
.set_standard_value(&undo_input, -1, undo_old.clone());
|
||||
}
|
||||
},
|
||||
),
|
||||
"Set Parameter",
|
||||
)
|
||||
}
|
||||
|
||||
/// The neighbors of chain position `pos` in `chain` (length `len`):
|
||||
/// `(upstream, downstream)` — the chain source is `None`, the host closes
|
||||
/// the chain.
|
||||
@@ -250,10 +431,9 @@ pub fn insert(p: &ProjectRef, host: NodeId, index: usize, type_id: &str) -> Resu
|
||||
if effect_input_of(&g.graph, host).is_none() {
|
||||
return Err("node cannot host effects (no effect input)".to_string());
|
||||
}
|
||||
let Some(meta) = oaknode::factory::Factory::global().find(type_id) else {
|
||||
let Some((core, behavior)) = oaknode::factory::Factory::global().create_any(type_id) else {
|
||||
return Err(format!("unknown node type id \"{type_id}\""));
|
||||
};
|
||||
let (core, behavior) = (meta.create)();
|
||||
if core.effect_input.is_empty() {
|
||||
return Err("node type has no effect input; cannot be chained".to_string());
|
||||
}
|
||||
@@ -482,7 +662,7 @@ mod tests {
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("the factory registers at least one video effect")
|
||||
.0
|
||||
.type_id
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -564,9 +744,135 @@ mod tests {
|
||||
fn addable_effects_are_video_effects() {
|
||||
let entries = addable_effects();
|
||||
assert!(!entries.is_empty());
|
||||
for (type_id, name) in &entries {
|
||||
assert!(!type_id.is_empty());
|
||||
assert!(!name.is_empty());
|
||||
for entry in &entries {
|
||||
assert!(!entry.type_id.is_empty());
|
||||
assert!(!entry.name.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// The effect-library grouping: every addable effect is either an
|
||||
/// ungrouped built-in or an OpenFX entry with one of the four
|
||||
/// sub-categories.
|
||||
#[test]
|
||||
fn addable_effects_group_openfx_by_sub_category() {
|
||||
for entry in addable_effects() {
|
||||
if let Some(group) = entry.group {
|
||||
assert!(
|
||||
["Filter", "Generator", "Transition", "General"].contains(&group.as_str()),
|
||||
"unexpected OpenFX sub-category {group:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `set_input_value` writes the standard value undoably and rejects
|
||||
/// unknown input ids.
|
||||
#[test]
|
||||
fn set_input_value_undoes() {
|
||||
let _g = stack_lock();
|
||||
oakundo::global::clear().unwrap();
|
||||
let (project, host) = project_with_clip();
|
||||
|
||||
// Choose a built-in effect whose scratch core exposes a float input
|
||||
// (the value write/undo round-trip needs one).
|
||||
let ty = addable_effects()
|
||||
.into_iter()
|
||||
.find(|entry| {
|
||||
let (core, _behavior) = oaknode::factory::Factory::global()
|
||||
.create_any(&entry.type_id)
|
||||
.expect("the entry resolves");
|
||||
core.inputs.iter().any(|i| {
|
||||
i.value_type == oaknode::value::ValueType::Float
|
||||
&& i.flags & oaknode::input::flags::HIDDEN == 0
|
||||
})
|
||||
})
|
||||
.expect("at least one built-in effect exposes a float input")
|
||||
.type_id;
|
||||
let eff = insert(&project, host, 0, &ty).unwrap();
|
||||
|
||||
// Unknown input: rejected without touching the graph.
|
||||
assert!(set_input_value(&project, eff, "nope", NodeValue::Float(1.0)).is_err());
|
||||
|
||||
// Pick an editable (non-texture, non-hidden) float input of the
|
||||
// inserted effect to exercise the write/undo round-trip.
|
||||
let input_id = {
|
||||
let g = lock(&project);
|
||||
g.graph
|
||||
.get(eff)
|
||||
.and_then(|e| {
|
||||
e.core.inputs.iter().find(|i| {
|
||||
i.value_type == oaknode::value::ValueType::Float
|
||||
&& i.flags & oaknode::input::flags::HIDDEN == 0
|
||||
})
|
||||
})
|
||||
.map(|i| i.id.clone())
|
||||
.expect("the effect exposes a float input")
|
||||
};
|
||||
let before = lock(&project)
|
||||
.graph
|
||||
.get(eff)
|
||||
.unwrap()
|
||||
.core
|
||||
.standard_value(&input_id, -1);
|
||||
set_input_value(&project, eff, &input_id, NodeValue::Float(42.0)).unwrap();
|
||||
assert_eq!(
|
||||
lock(&project).graph.get(eff).unwrap().core.standard_value(&input_id, -1),
|
||||
NodeValue::Float(42.0)
|
||||
);
|
||||
oakundo::global::undo().unwrap();
|
||||
assert_eq!(
|
||||
lock(&project).graph.get(eff).unwrap().core.standard_value(&input_id, -1),
|
||||
before
|
||||
);
|
||||
oakundo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// The combo-option collector reads the repeated `("combo_option", _)`
|
||||
/// property keys (and the string-combo values from `("combo_value", _)`).
|
||||
#[test]
|
||||
fn combo_option_collectors_read_repeated_properties() {
|
||||
use crate::oakui::engine::EffectParam;
|
||||
use oaknode::value::{NodeValue, ValueType};
|
||||
let param = EffectParam {
|
||||
input_id: "mode".into(),
|
||||
display_name: "Mode".into(),
|
||||
value_type: ValueType::Combo,
|
||||
value: NodeValue::Combo(0),
|
||||
flags: 0,
|
||||
properties: vec![
|
||||
("combo_option".into(), NodeValue::Text("Fast".into())),
|
||||
("combo_option".into(), NodeValue::Text("High".into())),
|
||||
("combo_value".into(), NodeValue::Text("fast".into())),
|
||||
("combo_value".into(), NodeValue::Text("high".into())),
|
||||
],
|
||||
};
|
||||
assert_eq!(combo_options(¶m), vec!["Fast", "High"]);
|
||||
assert_eq!(combo_values(¶m), vec!["fast", "high"]);
|
||||
}
|
||||
|
||||
/// ui_group / ui_page surface as a section header (empty halves are
|
||||
/// fine); params without either have no section.
|
||||
#[test]
|
||||
fn ui_section_collects_group_and_page() {
|
||||
use crate::oakui::engine::EffectParam;
|
||||
use oaknode::value::{NodeValue, ValueType};
|
||||
let plain = EffectParam {
|
||||
input_id: "p".into(),
|
||||
display_name: "P".into(),
|
||||
value_type: ValueType::Int,
|
||||
value: NodeValue::Int(0),
|
||||
flags: 0,
|
||||
properties: vec![],
|
||||
};
|
||||
assert!(ui_section_of(&plain).is_none());
|
||||
|
||||
let grouped = EffectParam {
|
||||
properties: vec![
|
||||
("ui_group".into(), NodeValue::Text("Basic".into())),
|
||||
("ui_page".into(), NodeValue::Text("Main".into())),
|
||||
],
|
||||
..plain
|
||||
};
|
||||
assert_eq!(ui_section_of(&grouped), Some(("Basic".into(), "Main".into())));
|
||||
}
|
||||
}
|
||||
|
||||
+74
-6
@@ -35,7 +35,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::effect_stack::{EffectStackDataSource, EffectStackEvent};
|
||||
use gpui::effect_stack::{EffectId, EffectStackDataSource, EffectStackEvent};
|
||||
use gpui::node_graph::{NodeGraphDataSource, NodeGraphEvent};
|
||||
use gpui::timeline::{
|
||||
ClipId, Frame, FrameRate, TimelineDataSource, TimelineEvent, TrackData, TrackKind,
|
||||
@@ -154,6 +154,41 @@ pub struct NodeLibraryEntry {
|
||||
pub category_key: &'static str,
|
||||
}
|
||||
|
||||
/// One addable effect entry (the effect library list and the inspector's
|
||||
/// add-effect menu).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EffectEntry {
|
||||
/// The factory type id handed to [`AppEngine::add_effect`].
|
||||
pub type_id: String,
|
||||
/// The effect's display name.
|
||||
pub name: String,
|
||||
/// The effect-library group: `Some(sub_category)` for OpenFX plugin
|
||||
/// entries (Filter / Generator / Transition / General — the C++
|
||||
/// `factorymenu` OpenFX branch), `None` for built-in effects (rendered
|
||||
/// without a group header).
|
||||
pub group: Option<String>,
|
||||
}
|
||||
|
||||
/// A snapshot of one effect parameter (a node input) for the inspector's
|
||||
/// parameter view. For OFX plugin nodes each entry maps 1:1 to an OFX
|
||||
/// parameter (input id = param name, display name = param label).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EffectParam {
|
||||
/// The input id (the OFX param name for plugin nodes).
|
||||
pub input_id: String,
|
||||
/// The display name (the OFX param label).
|
||||
pub display_name: String,
|
||||
/// The value type.
|
||||
pub value_type: oaknode::value::ValueType,
|
||||
/// The current value.
|
||||
pub value: oaknode::value::NodeValue,
|
||||
/// The input flag bits (`oaknode::input::flags::*`).
|
||||
pub flags: u32,
|
||||
/// The input properties (`combo_option` / `combo_value` / `ui_group` /
|
||||
/// `ui_page` / `min` / `max` / ...).
|
||||
pub properties: Vec<(String, oaknode::value::NodeValue)>,
|
||||
}
|
||||
|
||||
/// The i18n key of a node category submenu, or `None` for categories that
|
||||
/// never appear in the node editor's Add menu (timeline-structural nodes).
|
||||
pub fn node_category_key(category: oaknode::node::Category) -> Option<&'static str> {
|
||||
@@ -292,11 +327,12 @@ pub trait AppEngine:
|
||||
/// selection-driven stack keep their existing behavior).
|
||||
fn set_selected_clips(&mut self, _clips: Vec<ClipId>, _cx: &mut Context<Self>) {}
|
||||
|
||||
/// The effect types the user can add to the selected clip's chain, as
|
||||
/// (type id, display name) pairs — the facade factory entries flagged
|
||||
/// `video_effect` and not hidden from the create menu. The inspector
|
||||
/// panel lists them in its "add effect" menu. Default: empty.
|
||||
fn addable_effects(&self) -> Vec<(String, String)> {
|
||||
/// The effect types the user can add to the selected clip's chain — the
|
||||
/// factory entries flagged `video_effect` and not hidden from the create
|
||||
/// menu, plus every runtime-registered OpenFX plugin entry (grouped by
|
||||
/// its sub-category). The inspector panel lists them in its "add
|
||||
/// effect" menu. Default: empty.
|
||||
fn addable_effects(&self) -> Vec<EffectEntry> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
@@ -315,6 +351,38 @@ pub trait AppEngine:
|
||||
Err("add effect not supported".into())
|
||||
}
|
||||
|
||||
/// The parameter controls of `effect` for the inspector, or `None`
|
||||
/// when the effect exposes no parameter UI (not a plugin node, or no
|
||||
/// editable parameters). Default: `None` (the inspector renders its
|
||||
/// placeholder).
|
||||
fn effect_params(&self, _effect: EffectId) -> Option<Vec<EffectParam>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Sets an effect parameter (a node input) undoably. Returns a
|
||||
/// user-facing error on failure. Default: unsupported.
|
||||
fn set_effect_param(
|
||||
&mut self,
|
||||
_effect: EffectId,
|
||||
_input_id: &str,
|
||||
_value: oaknode::value::NodeValue,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
Err("effect params not supported".into())
|
||||
}
|
||||
|
||||
/// Triggers a push-button parameter of `effect` (the OFX push-button
|
||||
/// press). Returns a user-facing error on failure. Default:
|
||||
/// unsupported.
|
||||
fn effect_push_button(
|
||||
&mut self,
|
||||
_effect: EffectId,
|
||||
_input_id: &str,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
Err("effect push button not supported".into())
|
||||
}
|
||||
|
||||
/// Applies a node-editor edit request to the engine's model.
|
||||
fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context<Self>);
|
||||
|
||||
|
||||
+8
-5
@@ -1273,9 +1273,10 @@ impl AppEngine for MockEngine {
|
||||
self.apply_effect_event(event, cx);
|
||||
}
|
||||
|
||||
fn addable_effects(&self) -> Vec<(String, String)> {
|
||||
// The demo list is the real factory's video-effect table, so the
|
||||
// effect library shows the same entries the real engine would.
|
||||
fn addable_effects(&self) -> Vec<crate::oakui::engine::EffectEntry> {
|
||||
// The demo list is the real factory's effect table (built-ins plus
|
||||
// any registered OpenFX plugins), so the effect library shows the
|
||||
// same entries the real engine would.
|
||||
crate::oakui::effectchain::addable_effects()
|
||||
}
|
||||
|
||||
@@ -1287,7 +1288,8 @@ impl AppEngine for MockEngine {
|
||||
) -> Result<(), String> {
|
||||
let Some((_, name)) = crate::oakui::effectchain::addable_effects()
|
||||
.into_iter()
|
||||
.find(|(id, _)| id == type_id)
|
||||
.find(|entry| entry.type_id == type_id)
|
||||
.map(|entry| (entry.type_id, entry.name))
|
||||
else {
|
||||
return Err(format!("unknown effect \"{type_id}\""));
|
||||
};
|
||||
@@ -2721,7 +2723,8 @@ mod tests {
|
||||
let engine = demo_engine(app);
|
||||
let effects = engine.read(app).addable_effects();
|
||||
assert!(!effects.is_empty(), "the demo list is the factory table");
|
||||
let (type_id, name) = effects[0].clone();
|
||||
let first = effects[0].clone();
|
||||
let (type_id, name) = (first.type_id, first.name);
|
||||
let before = engine.read(app).effects().len();
|
||||
engine.update(app, |engine, cx| {
|
||||
engine
|
||||
|
||||
+4
-3
@@ -48,6 +48,7 @@ pub mod icons;
|
||||
pub mod mock;
|
||||
pub mod multicam;
|
||||
pub mod nodegraph;
|
||||
pub mod ofx;
|
||||
pub mod projectbrowser;
|
||||
pub mod real;
|
||||
pub mod renderops;
|
||||
@@ -58,9 +59,9 @@ pub mod waveform;
|
||||
pub mod waveformsync;
|
||||
|
||||
pub use engine::{
|
||||
AppEngine, EngineClock, EngineGateway, ExportEvent, ExportSession, HistoryEntry,
|
||||
LibraryProject, Monitor, MulticamState, NodeLibraryEntry, Project, ScopeData, Sequence,
|
||||
VideoFormat,
|
||||
AppEngine, EffectEntry, EffectParam, EngineClock, EngineGateway, ExportEvent, ExportSession,
|
||||
HistoryEntry, LibraryProject, Monitor, MulticamState, NodeLibraryEntry, Project, ScopeData,
|
||||
Sequence, VideoFormat,
|
||||
};
|
||||
pub use mock::{MockClock, MockEngine};
|
||||
pub use real::{RealClock, RealEngine};
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! OFX plugin startup wiring (stage 6b).
|
||||
//!
|
||||
//! The app is the only place that holds both the oakplugin host and the UI
|
||||
//! services the OFX suites consult at runtime, so the wiring lives here:
|
||||
//!
|
||||
//! - [`init`] scans the standard plugin paths ([`oakplugin::host::Host`]
|
||||
//! default path set, `host.rs:440-449`), registers every discovered
|
||||
//! plugin into the node factory (the effect library and the add-effect
|
||||
//! menu consume those entries), installs the render executor and the
|
||||
//! plugin-node duplicator (both idempotent), and registers the
|
||||
//! progress-reporter factory plus the active-viewer provider.
|
||||
//! - [`update_project_extent`] / [`update_viewer_time`] keep the
|
||||
//! oakplugin side's fallback project size and timeline time in sync with
|
||||
//! the current sequence (the engine calls them on open / seek / tick).
|
||||
//! - [`set_progress_tx`] wires a progress-event channel the app drains in
|
||||
//! its tick loop to drive the progress dialog.
|
||||
//!
|
||||
//! Every failure degrades to a log: plugin support is an optional
|
||||
//! capability, never a startup dependency.
|
||||
//!
|
||||
//! ## Rendering topology and progress
|
||||
//!
|
||||
//! Preview/export rendering runs through the process-isolated oak-worker
|
||||
//! pool (M15 S2), so plugin rendering happens in the worker process where
|
||||
//! this main-process reporter factory is not in effect. The wiring still
|
||||
//! serves the in-process render paths (e.g. the test-only inline backend)
|
||||
//! and future work; worker-side progress forwarding over IPC is a TODO.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use oakplugin::progress::{ReporterFactory, UiProgressReporter};
|
||||
use oakplugin::suites::timeline::{ActiveViewerProvider, ViewerTimeInfo};
|
||||
|
||||
/// One progress event a plugin reporter pushed to the app channel (drained
|
||||
/// by the app tick, which drives a progress dialog).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PluginProgressEvent {
|
||||
/// The label the plugin passed to progressStart.
|
||||
pub label: String,
|
||||
/// The message the plugin passed to progressStart.
|
||||
pub message: String,
|
||||
/// Progress fraction in 0.0..=1.0.
|
||||
pub fraction: f64,
|
||||
}
|
||||
|
||||
/// The app's progress-event channel (registered by [`set_progress_tx`]).
|
||||
static PROGRESS_TX: OnceLock<Mutex<Option<std::sync::mpsc::Sender<PluginProgressEvent>>>> =
|
||||
OnceLock::new();
|
||||
|
||||
/// The sticky cancel flag read by every live reporter (`update` returns
|
||||
/// false once set; the progress dialog's cancel button sets it).
|
||||
static CANCEL: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// The last active-viewer time snapshot (the timeline-suite provider reads
|
||||
/// it; the engine refreshes it on seek / tick).
|
||||
static VIEWER_TIME: OnceLock<Mutex<ViewerTimeInfo>> = OnceLock::new();
|
||||
|
||||
/// The last known project extent (normalised-coordinate default conversion;
|
||||
/// the engine refreshes it whenever the sequence changes).
|
||||
static PROJECT_EXTENT: OnceLock<Mutex<(f64, f64)>> = OnceLock::new();
|
||||
|
||||
fn viewer_slot() -> &'static Mutex<ViewerTimeInfo> {
|
||||
VIEWER_TIME.get_or_init(|| {
|
||||
Mutex::new(ViewerTimeInfo {
|
||||
time: 0.0,
|
||||
range_min: 0.0,
|
||||
range_max: 0.0,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn extent_slot() -> &'static Mutex<(f64, f64)> {
|
||||
PROJECT_EXTENT.get_or_init(|| Mutex::new((1920.0, 1080.0)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App-driven state sync
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Wires the app's progress-event channel into the OFX progress suite. The
|
||||
/// app keeps the receiving half and drains it in its tick loop.
|
||||
pub fn set_progress_tx(tx: std::sync::mpsc::Sender<PluginProgressEvent>) {
|
||||
*PROGRESS_TX
|
||||
.get_or_init(|| Mutex::new(None))
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = Some(tx);
|
||||
}
|
||||
|
||||
/// Clone of the registered sender, or `None` before
|
||||
/// [`set_progress_tx`] (a reporter then silently continues).
|
||||
fn progress_tx() -> Option<std::sync::mpsc::Sender<PluginProgressEvent>> {
|
||||
PROGRESS_TX
|
||||
.get_or_init(|| Mutex::new(None))
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Updates the active-viewer time snapshot the timeline suite falls back
|
||||
/// to when no render context is live (engine seek / tick path).
|
||||
pub fn update_viewer_time(time: f64, range_min: f64, range_max: f64) {
|
||||
*viewer_slot().lock().unwrap_or_else(|e| e.into_inner()) = ViewerTimeInfo {
|
||||
time,
|
||||
range_min,
|
||||
range_max,
|
||||
};
|
||||
}
|
||||
|
||||
/// Updates the project extent (width/height) the OFX normalised-coordinate
|
||||
/// default conversion uses, and pushes it into oakplugin.
|
||||
pub fn update_project_extent(width: f64, height: f64) {
|
||||
let (w, h) = (width.max(1.0), height.max(1.0));
|
||||
*extent_slot().lock().unwrap_or_else(|e| e.into_inner()) = (w, h);
|
||||
oakplugin::node_factory::set_project_extent(w, h);
|
||||
}
|
||||
|
||||
/// Requests cancellation of the running plugin render (the progress
|
||||
/// dialog's Cancel button). The next progressStart resets the flag.
|
||||
pub fn cancel_plugin_render() {
|
||||
CANCEL.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reporters / providers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A reporter that forwards (label, message, fraction) to the app channel
|
||||
/// and honours the global cancel flag.
|
||||
struct ChannelProgressReporter {
|
||||
tx: Option<std::sync::mpsc::Sender<PluginProgressEvent>>,
|
||||
label: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl UiProgressReporter for ChannelProgressReporter {
|
||||
fn update(&mut self, progress: f64) -> bool {
|
||||
if let Some(tx) = &self.tx {
|
||||
let _ = tx.send(PluginProgressEvent {
|
||||
label: self.label.clone(),
|
||||
message: self.message.clone(),
|
||||
fraction: progress,
|
||||
});
|
||||
}
|
||||
!CANCEL.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
fn reporter_factory() -> ReporterFactory {
|
||||
Arc::new(|label, message| {
|
||||
// A fresh render begins: reset the sticky cancel flag.
|
||||
CANCEL.store(false, Ordering::Relaxed);
|
||||
Box::new(ChannelProgressReporter {
|
||||
tx: progress_tx(),
|
||||
label: label.to_string(),
|
||||
message: message.to_string(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn viewer_provider() -> ActiveViewerProvider {
|
||||
Arc::new(|| {
|
||||
let info = *viewer_slot().lock().unwrap_or_else(|e| e.into_inner());
|
||||
Some(info)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Idempotent OFX startup wiring. Scans the standard plugin directories,
|
||||
/// registers every discovered plugin into the node factory, installs the
|
||||
/// render executor / duplicator, and registers the progress factory and
|
||||
/// the active-viewer provider. Returns the number of plugin node types
|
||||
/// registered (0 when no plugins were discovered or the scan failed).
|
||||
pub fn init() -> usize {
|
||||
// 1. Scan the standard OFX plugin directories (host.rs:440-449 default
|
||||
// path set: ~/.OFX/Plugins, ~/.local/share/OFX/Plugins, ...
|
||||
// plus the OLIVE_OFX_PLUGIN_PATH / OLIVE_PLUGIN_PATH /
|
||||
// OFX_PLUGIN_PATH environment variables). A scan failure only
|
||||
// logs — plugins are optional.
|
||||
if let Err(e) = oakplugin::host::Host::global().cache.scan() {
|
||||
eprintln!("[ofx] plugin scan failed: {e}");
|
||||
}
|
||||
// 2. Register discovered plugins into the node factory (idempotent;
|
||||
// also installs the render executor and the plugin-node duplicator).
|
||||
let registered = oakplugin::node_factory::register_plugin_nodes();
|
||||
// 3. Progress reporter factory -> the app progress channel.
|
||||
oakplugin::progress::set_reporter_factory(Some(reporter_factory()));
|
||||
// 4. Active-viewer time provider (timeline suite fallback).
|
||||
oakplugin::suites::timeline::set_active_viewer_provider(Some(viewer_provider()));
|
||||
// 5. Project extent (the engine refreshes it whenever the sequence
|
||||
// changes; keep the oakplugin side in sync with the default).
|
||||
let (w, h) = *extent_slot().lock().unwrap_or_else(|e| e.into_inner());
|
||||
oakplugin::node_factory::set_project_extent(w, h);
|
||||
registered.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn viewer_time_roundtrip() {
|
||||
update_viewer_time(42.5, 10.0, 200.0);
|
||||
let provider = viewer_provider();
|
||||
let info = provider().expect("provider always reports a snapshot");
|
||||
assert_eq!(info.time, 42.5);
|
||||
assert_eq!((info.range_min, info.range_max), (10.0, 200.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_is_sticky_until_a_new_reporter() {
|
||||
CANCEL.store(false, Ordering::Relaxed);
|
||||
let factory = reporter_factory();
|
||||
let mut a = factory("a", "b");
|
||||
assert!(a.update(0.1));
|
||||
cancel_plugin_render();
|
||||
assert!(!a.update(0.5), "a cancelled render reports no");
|
||||
// A fresh progressStart resets the flag.
|
||||
let mut b = factory("a", "b");
|
||||
assert!(b.update(0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_extent_is_forwarded() {
|
||||
update_project_extent(1280.0, 720.0);
|
||||
let slot = extent_slot();
|
||||
assert_eq!(*slot.lock().unwrap(), (1280.0, 720.0));
|
||||
}
|
||||
}
|
||||
+102
-3
@@ -668,6 +668,11 @@ struct RealEffect {
|
||||
enabled: bool,
|
||||
/// The app-owned expansion state (not undoable).
|
||||
expanded: bool,
|
||||
/// Optional secondary line (the "OpenFX" tag for plugin nodes).
|
||||
subtitle: Option<SharedString>,
|
||||
/// The persistent-message badge count (plugin nodes only; `None`
|
||||
/// otherwise).
|
||||
badge: Option<usize>,
|
||||
}
|
||||
|
||||
impl EffectData for RealEffect {
|
||||
@@ -683,6 +688,10 @@ impl EffectData for RealEffect {
|
||||
self.title.clone()
|
||||
}
|
||||
|
||||
fn subtitle(&self) -> Option<SharedString> {
|
||||
self.subtitle.clone()
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
@@ -690,6 +699,10 @@ impl EffectData for RealEffect {
|
||||
fn is_expanded(&self) -> bool {
|
||||
self.expanded
|
||||
}
|
||||
|
||||
fn badge_count(&self) -> Option<usize> {
|
||||
self.badge
|
||||
}
|
||||
}
|
||||
|
||||
/// A track on the real timeline (snapshot handed to the timeline widget).
|
||||
@@ -2334,6 +2347,23 @@ impl RealEngine {
|
||||
},
|
||||
length,
|
||||
});
|
||||
// Stage 6b: keep the OFX normalised-coordinate default conversion in
|
||||
// sync with the current sequence's extent.
|
||||
crate::oakui::ofx::update_project_extent(width as f64, height as f64);
|
||||
}
|
||||
|
||||
/// Stage 6b: refreshes the OFX timeline-suite fallback time snapshot
|
||||
/// from the program monitor's playhead (seconds) and the sequence
|
||||
/// length (the range bounds). Called on seek and on every tick.
|
||||
fn update_ofx_viewer_time(&self, cx: &App) {
|
||||
let fps = self.frame_rate().as_f64();
|
||||
if fps <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let frame = self.clock_frame(Monitor::Program, cx).0;
|
||||
let time = frame as f64 / fps;
|
||||
let length = self.sequence_length().0 as f64 / fps;
|
||||
crate::oakui::ofx::update_viewer_time(time, 0.0, length);
|
||||
}
|
||||
|
||||
/// Rebuilds the timeline snapshot from the graph.
|
||||
@@ -2528,16 +2558,32 @@ impl RealEngine {
|
||||
for node in super::effectchain::chain(&guard.graph, host) {
|
||||
let identity = node.identity();
|
||||
let type_id = graphops::node_type_id(&guard.graph, node);
|
||||
// `name_of` covers both static (built-in) and dynamic (OpenFX
|
||||
// plugin) factory entries.
|
||||
let title = oaknode::factory::Factory::global()
|
||||
.find(&type_id)
|
||||
.map(|m| m.name.to_string())
|
||||
.name_of(&type_id)
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or(type_id);
|
||||
let plugin_handle = super::effectchain::plugin_instance_handle(&guard.graph, node);
|
||||
// The OpenFX plugin badge: the persistent-message count (the
|
||||
// simplified 徽标/计数 of stage 6b). Built-in effects show none.
|
||||
let badge = plugin_handle.and_then(|handle| {
|
||||
let count =
|
||||
oakplugin::suites::message::persistent_message_count(handle as usize);
|
||||
(count > 0).then_some(count)
|
||||
});
|
||||
let subtitle = plugin_handle.map(|_| {
|
||||
// A muted secondary line identifying the plugin effect as an
|
||||
// OpenFX entry.
|
||||
crate::i18n::tr("inspector.badge.openfx").to_string()
|
||||
});
|
||||
out.push(Arc::new(RealEffect {
|
||||
id: EffectId(identity),
|
||||
title: title.into(),
|
||||
subtitle: subtitle.map(Into::into),
|
||||
enabled: super::effectchain::is_enabled(&guard.graph, node),
|
||||
expanded: self.expanded_effects.contains(&identity),
|
||||
badge,
|
||||
}) as Arc<dyn EffectData>);
|
||||
}
|
||||
out
|
||||
@@ -2600,6 +2646,7 @@ impl EngineGateway for RealEngine {
|
||||
cx.notify();
|
||||
});
|
||||
self.mirror_program_playhead(cx);
|
||||
self.update_ofx_viewer_time(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -2660,6 +2707,7 @@ impl EngineGateway for RealEngine {
|
||||
});
|
||||
}
|
||||
self.mirror_program_playhead(cx);
|
||||
self.update_ofx_viewer_time(cx);
|
||||
self.meter_phase = self.meter_phase.wrapping_add(1);
|
||||
// M15 S2: pump the process dispatcher — ticket completions (the
|
||||
// pre-render window, full-res fills, synchronous renders) are
|
||||
@@ -2955,7 +3003,7 @@ impl AppEngine for RealEngine {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn addable_effects(&self) -> Vec<(String, String)> {
|
||||
fn addable_effects(&self) -> Vec<crate::oakui::engine::EffectEntry> {
|
||||
super::effectchain::addable_effects()
|
||||
}
|
||||
|
||||
@@ -2976,6 +3024,57 @@ impl AppEngine for RealEngine {
|
||||
result
|
||||
}
|
||||
|
||||
fn effect_params(&self, effect: EffectId) -> Option<Vec<crate::oakui::engine::EffectParam>> {
|
||||
let project = self.project_ref()?;
|
||||
let node = graphops::id_of(effect.0)?;
|
||||
let guard = graphops::lock(project);
|
||||
super::effectchain::effect_params(&guard.graph, node)
|
||||
}
|
||||
|
||||
fn set_effect_param(
|
||||
&mut self,
|
||||
effect: EffectId,
|
||||
input_id: &str,
|
||||
value: oaknode::value::NodeValue,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
let Some(project) = self.project.clone() else {
|
||||
return Err("no project open".into());
|
||||
};
|
||||
let Some(node) = graphops::id_of(effect.0) else {
|
||||
return Err("effect node not found".into());
|
||||
};
|
||||
let result = super::effectchain::set_input_value(&project, node, input_id, value);
|
||||
self.apply_edit(result.clone(), "set parameter", cx);
|
||||
result
|
||||
}
|
||||
|
||||
fn effect_push_button(
|
||||
&mut self,
|
||||
effect: EffectId,
|
||||
input_id: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
let Some(project) = self.project_ref() else {
|
||||
return Err("no project open".into());
|
||||
};
|
||||
let Some(node) = graphops::id_of(effect.0) else {
|
||||
return Err("effect node not found".into());
|
||||
};
|
||||
let guard = graphops::lock(project);
|
||||
let Some(instance) = super::effectchain::plugin_instance_handle(&guard.graph, node) else {
|
||||
return Err("not a plugin effect".into());
|
||||
};
|
||||
drop(guard);
|
||||
if !oakplugin::node_factory::push_button_clicked(instance, input_id) {
|
||||
return Err(format!("push button \"{input_id}\" not found"));
|
||||
}
|
||||
// A button press can change the plugin's other parameters; refresh
|
||||
// the snapshots and repaint.
|
||||
cx.notify();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_effect_event(&mut self, event: &EffectStackEvent, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
EffectStackEvent::EnableToggled { effect, enabled } => {
|
||||
|
||||
Reference in New Issue
Block a user