feat: built-in effect params, clip click-select, effect drag-and-drop, project load with plugins
- serializer resolves node types through the factory's dynamic (runtime-registered OpenFX) entries, so a project carrying plugin nodes loads again (was: "unknown node type"); covered by a new CI-gated round-trip test driving the real fixture plugin - built-in effect nodes expose their inputs as inspector parameters like the C++ parameter editor: localized input names from the behavior, combo option tables via the new NodeBehavior::input_combo_strings (16 nodes, string-for-string from the C++ set_combo_box_strings), connection/data inputs excluded - effect library: live drag-and-drop — onto the inspector's effect stack (lands at the indicator position) and onto the node editor canvas (creates the node at the drop point); double-click still appends to the selected clip - inspector parameter controls are no longer recreated per render (gpui stack view caches them per effect), so sliders drag and checkboxes click; the view observes the engine and silently re-syncs values (undo/redo land on the widgets) - timeline: left-press selects clips (plain/keep-multi/Ctrl-Cmd toggle); clip moves clamp the shared delta so no clip of a linked group lands before frame 0 instead of failing with "invalid move target" - oakplugin: createInstance-rejected instances skip the destroyInstance notification (the plugin never owned them); vendor-suite fetchSuite misses moved behind OAK_OFX_TRACE; the worker logs the discovered/ registered plugin counts - CI: the OFX probe step also runs the serialization round-trip test - gpui submodule: params view caching, clip click-select, library drag payload, graph_position_at
This commit is contained in:
+35
-14
@@ -163,28 +163,27 @@ pub fn plugin_instance_handle(g: &Graph, node: NodeId) -> Option<u64> {
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// the effect exposes no parameter UI. Any effect node — built-in or OFX
|
||||
/// plugin — exposes its inputs as parameters (C++ parity: the parameter
|
||||
/// editor lists every non-hidden, non-connection input); connection/data
|
||||
/// inputs (texture / samples / matrix) and the structural enabled input
|
||||
/// are excluded.
|
||||
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 {
|
||||
// Clip/texture/sample/matrix inputs are graph connections or
|
||||
// internal data, not params; hidden (secret) inputs never render.
|
||||
if matches!(
|
||||
input.value_type,
|
||||
ValueType::Texture | ValueType::Samples | ValueType::Matrix
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if input.flags & input_flags::HIDDEN != 0 {
|
||||
@@ -194,13 +193,35 @@ pub fn effect_params(
|
||||
if input.id == oaknode::node::ENABLED_INPUT {
|
||||
continue;
|
||||
}
|
||||
// Display name: the behavior's localized input name (C++
|
||||
// `retranslate`) wins; OFX plugin nodes don't override it, so
|
||||
// their translation-pass label (input.display_name) is kept.
|
||||
let behavior_name = entry.behavior.input_name(&input.id);
|
||||
let display_name = if behavior_name != input.id {
|
||||
behavior_name.to_string()
|
||||
} else {
|
||||
input.display_name.clone()
|
||||
};
|
||||
// Combo options: built-in nodes carry them on the behavior (C++
|
||||
// `set_combo_box_strings`); OFX plugin params already carry the
|
||||
// ("combo_option", _) properties from the translation pass.
|
||||
let mut properties = input.properties.clone();
|
||||
if input.value_type == ValueType::Combo && !properties.iter().any(|(k, _)| k == "combo_option")
|
||||
{
|
||||
for option in entry.behavior.input_combo_strings(&input.id) {
|
||||
properties.push((
|
||||
"combo_option".to_string(),
|
||||
oaknode::value::NodeValue::Text(option.to_string()),
|
||||
));
|
||||
}
|
||||
}
|
||||
out.push(super::engine::EffectParam {
|
||||
input_id: input.id.clone(),
|
||||
display_name: input.display_name.clone(),
|
||||
display_name,
|
||||
value_type: input.value_type,
|
||||
value: entry.core.standard_value(&input.id, -1),
|
||||
flags: input.flags,
|
||||
properties: input.properties.clone(),
|
||||
properties,
|
||||
});
|
||||
}
|
||||
Some(out)
|
||||
|
||||
+32
-23
@@ -1542,12 +1542,10 @@ fn move_clip_command(
|
||||
|
||||
/// Move `clip` within its track so its in point becomes `new_in_ts`
|
||||
/// (undoable "Move Clip"; the module's `TrackMoveBlockCommand` — the old
|
||||
/// spot becomes a gap, length and media-in are preserved).
|
||||
/// spot becomes a gap, length and media-in are preserved). A negative
|
||||
/// target clamps to frame 0 (the NLE drop-past-the-start behavior).
|
||||
pub fn move_clip(p: &ProjectRef, clip: NodeId, new_in_ts: i64) -> Result<(), String> {
|
||||
if new_in_ts < 0 {
|
||||
return Err("invalid move target".to_string());
|
||||
}
|
||||
push(move_clip_command(p, clip, new_in_ts)?, "Move Clip")
|
||||
push(move_clip_command(p, clip, new_in_ts.max(0))?, "Move Clip")
|
||||
}
|
||||
|
||||
/// The undoable cross-track move commands for one clip (gap on the source
|
||||
@@ -1638,17 +1636,19 @@ pub fn move_clip_to_track(
|
||||
dest_track: NodeId,
|
||||
new_in_ts: i64,
|
||||
) -> Result<(), String> {
|
||||
if new_in_ts < 0 {
|
||||
return Err("invalid move target".to_string());
|
||||
}
|
||||
push_multi(move_clip_to_track_commands(p, clip, dest_track, new_in_ts)?, "Move Clip to Track")
|
||||
push_multi(
|
||||
move_clip_to_track_commands(p, clip, dest_track, new_in_ts.max(0))?,
|
||||
"Move Clip to Track",
|
||||
)
|
||||
}
|
||||
|
||||
/// Move `clip` to `new_in_ts` (`dest_track` when the gesture crosses
|
||||
/// tracks) while every clip in `linked` follows in lockstep: each linked
|
||||
/// clip keeps its own track and moves by the same frame offset. The whole
|
||||
/// group lands as ONE undoable "Move Clip" entry (C++ `block_links_`
|
||||
/// semantics — grouped edits apply to the whole group).
|
||||
/// semantics — grouped edits apply to the whole group). The shared delta
|
||||
/// is clamped so no clip of the group lands before frame 0 (a drag past
|
||||
/// the timeline start pins the group at 0 instead of failing).
|
||||
pub fn move_clip_with_links(
|
||||
p: &ProjectRef,
|
||||
clip: NodeId,
|
||||
@@ -1656,9 +1656,6 @@ pub fn move_clip_with_links(
|
||||
new_in_ts: i64,
|
||||
linked: &[NodeId],
|
||||
) -> Result<(), String> {
|
||||
if new_in_ts < 0 {
|
||||
return Err("invalid move target".to_string());
|
||||
}
|
||||
// The dragged clip's old in point frames the shared frame delta.
|
||||
let old_in_ts = {
|
||||
let g = lock(p);
|
||||
@@ -1673,19 +1670,13 @@ pub fn move_clip_with_links(
|
||||
.ok_or_else(|| "the node is not a clip".to_string())?;
|
||||
rational_to_ts(in_r, tb)
|
||||
};
|
||||
let delta = new_in_ts - old_in_ts;
|
||||
|
||||
let mut commands = Vec::new();
|
||||
match dest_track {
|
||||
Some(track) => commands.extend(move_clip_to_track_commands(p, clip, track, new_in_ts)?),
|
||||
None => commands.push(move_clip_command(p, clip, new_in_ts)?),
|
||||
}
|
||||
// The linked clips' current in points (each stays on its own track;
|
||||
// only its in point follows the shared delta).
|
||||
let mut linked_ins: Vec<(NodeId, i64)> = Vec::new();
|
||||
for &other in linked {
|
||||
if other == clip {
|
||||
continue;
|
||||
}
|
||||
// Each linked clip stays on its own track; only its in point follows
|
||||
// the shared frame delta.
|
||||
let other_in_ts = {
|
||||
let g = lock(p);
|
||||
let tb = clip_track(&g.graph, other)
|
||||
@@ -1699,7 +1690,25 @@ pub fn move_clip_with_links(
|
||||
.ok_or_else(|| "a linked node is not a clip".to_string())?;
|
||||
rational_to_ts(in_r, tb)
|
||||
};
|
||||
commands.push(move_clip_command(p, other, (other_in_ts + delta).max(0))?);
|
||||
linked_ins.push((other, other_in_ts));
|
||||
}
|
||||
// Group-aware clamp: the shared delta may not push ANY clip of the
|
||||
// group below frame 0 (per-clip clamping would silently de-sync the
|
||||
// group).
|
||||
let min_in = linked_ins
|
||||
.iter()
|
||||
.map(|(_, ts)| *ts)
|
||||
.fold(old_in_ts, i64::min);
|
||||
let delta = (new_in_ts - old_in_ts).max(-min_in);
|
||||
let new_in_ts = old_in_ts + delta;
|
||||
|
||||
let mut commands = Vec::new();
|
||||
match dest_track {
|
||||
Some(track) => commands.extend(move_clip_to_track_commands(p, clip, track, new_in_ts)?),
|
||||
None => commands.push(move_clip_command(p, clip, new_in_ts)?),
|
||||
}
|
||||
for (other, other_in_ts) in linked_ins {
|
||||
commands.push(move_clip_command(p, other, other_in_ts + delta)?);
|
||||
}
|
||||
push_multi(commands, "Move Clip")
|
||||
}
|
||||
|
||||
@@ -874,6 +874,23 @@ impl MockEngine {
|
||||
let index = (*index).min(self.effects.len());
|
||||
self.effects.insert(index, card);
|
||||
}
|
||||
// A drag-and-drop add from the effect library: same insert, with
|
||||
// the dropped type's name on the card.
|
||||
EffectStackEvent::AddTypeRequested { index, type_id } => {
|
||||
let id = EffectId(self.next_effect_id);
|
||||
self.next_effect_id += 1;
|
||||
let card = MockEffect {
|
||||
id,
|
||||
kind: EffectCardKind::Effect,
|
||||
title: type_id.clone(),
|
||||
subtitle: None,
|
||||
enabled: true,
|
||||
expanded: false,
|
||||
badge: None,
|
||||
};
|
||||
let index = (*index).min(self.effects.len());
|
||||
self.effects.insert(index, card);
|
||||
}
|
||||
EffectStackEvent::CardSelected { effect } => {
|
||||
// The inspector card click highlights the matching node in
|
||||
// the node editor (the bidirectional node↔inspector link).
|
||||
|
||||
@@ -3471,6 +3471,17 @@ impl AppEngine for RealEngine {
|
||||
let _ = index;
|
||||
cx.notify();
|
||||
}
|
||||
EffectStackEvent::AddTypeRequested { index, type_id } => {
|
||||
// A drag-and-drop add from the effect library: the type is
|
||||
// already chosen (normally handled by the inspector panel;
|
||||
// applied here too so direct event drives work).
|
||||
let (index, type_id) = (*index, type_id.clone());
|
||||
let result = self.add_effect(index, &type_id, cx);
|
||||
if let Err(err) = result {
|
||||
println!("[real engine] drop-add effect failed: {err}");
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
EffectStackEvent::CardSelected { effect } => {
|
||||
// The inspector card click selects the effect's node in the
|
||||
// node editor (the bidirectional node↔inspector link). The
|
||||
|
||||
@@ -103,6 +103,10 @@ impl<E: AppEngine> Render for EffectLibraryPanel<E> {
|
||||
let row_id = entry.type_id.clone();
|
||||
let name = entry.name.clone();
|
||||
let type_id = entry.type_id.clone();
|
||||
let drag_payload = gpui::effect_stack::LibraryEffectDrag {
|
||||
type_id: SharedString::from(entry.type_id.clone()),
|
||||
name: SharedString::from(entry.name.clone()),
|
||||
};
|
||||
list = list.child(
|
||||
div()
|
||||
.id(SharedString::from(format!("effect-library-{type_id}")))
|
||||
@@ -115,6 +119,14 @@ impl<E: AppEngine> Render for EffectLibraryPanel<E> {
|
||||
.text_color(colors.text)
|
||||
.hover(|style| style.bg(colors.selected))
|
||||
.child(name)
|
||||
// Drag the effect onto the inspector's effect stack (or
|
||||
// the node editor) to add it there; double-click adds it
|
||||
// to the selected clip's chain end.
|
||||
.on_drag(drag_payload, |payload, _origin, _window, cx| {
|
||||
cx.new(|_cx| EffectDragGhost {
|
||||
name: payload.name.clone(),
|
||||
})
|
||||
})
|
||||
.on_click(move |event: &ClickEvent, _window, cx| {
|
||||
// Double-click appends the effect to the selected
|
||||
// clip's chain; the backend clamps the index to the
|
||||
@@ -184,6 +196,28 @@ fn group_header(colors: &gpui::colors::Colors, group: &str) -> impl IntoElement
|
||||
.child(group.to_string())
|
||||
}
|
||||
|
||||
/// The drag ghost shown under the pointer while an effect is dragged out
|
||||
/// of the library (a small floating label with the effect name).
|
||||
struct EffectDragGhost {
|
||||
name: SharedString,
|
||||
}
|
||||
|
||||
impl Render for EffectDragGhost {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
div()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded_sm()
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.container)
|
||||
.text_sm()
|
||||
.text_color(colors.text)
|
||||
.child(self.name.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: AppEngine> EventEmitter<PanelEvent> for EffectLibraryPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> DockPanel for EffectLibraryPanel<E> {
|
||||
|
||||
+16
-2
@@ -71,8 +71,22 @@ impl<E: AppEngine> InspectorPanel<E> {
|
||||
// the user pick one from the small menu below (the actual insert
|
||||
// runs through `AppEngine::add_effect`).
|
||||
cx.subscribe(&stack, |this, _stack, event: &EffectStackEvent, cx| {
|
||||
if let EffectStackEvent::AddRequested { index } = event {
|
||||
this.pending_add = Some(*index);
|
||||
match event {
|
||||
EffectStackEvent::AddRequested { index } => {
|
||||
this.pending_add = Some(*index);
|
||||
}
|
||||
// A drop from the effect library carries the type id: insert
|
||||
// directly at the drop position, no picker menu.
|
||||
EffectStackEvent::AddTypeRequested { index, type_id } => {
|
||||
let (index, type_id) = (*index, type_id.clone());
|
||||
this.engine.update(cx, |engine, cx| {
|
||||
if let Err(err) = engine.add_effect(index, &type_id, cx) {
|
||||
println!("[inspector] add effect failed: {err}");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
this.engine
|
||||
.update(cx, |engine, cx| engine.apply_effect_event(event, cx));
|
||||
|
||||
@@ -326,9 +326,31 @@ impl<E: AppEngine> Render for NodeEditorPanel<E> {
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("node-editor-canvas")
|
||||
.debug_selector(|| "node-editor-canvas".into())
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
// An effect dragged out of the effect library drops onto
|
||||
// the canvas as a new node at the drop position (the
|
||||
// background "add node" menu path).
|
||||
.on_drop::<gpui::effect_stack::LibraryEffectDrag>(cx.listener(
|
||||
|this, payload: &gpui::effect_stack::LibraryEffectDrag, window, cx| {
|
||||
let graph_position = this
|
||||
.graph
|
||||
.read(cx)
|
||||
.graph_position_at(window.mouse_position());
|
||||
let type_id = payload.type_id.to_string();
|
||||
this.engine.update(cx, |engine, cx| {
|
||||
if let Err(err) = engine.add_node_at(&type_id, graph_position, cx)
|
||||
{
|
||||
println!("[node editor] add node failed: {err}");
|
||||
}
|
||||
});
|
||||
},
|
||||
))
|
||||
.can_drop(|payload, _window, _cx| {
|
||||
payload.is::<gpui::effect_stack::LibraryEffectDrag>()
|
||||
})
|
||||
.child(self.graph.clone()),
|
||||
)
|
||||
// The right-click popup renders anchored above the panel.
|
||||
|
||||
@@ -36,9 +36,10 @@
|
||||
//! 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.
|
||||
//! The control set is built once per expanded card — the stack view caches
|
||||
//! the params view per effect (recreating it per render would kill
|
||||
//! in-progress slider drags); the view observes the engine and re-syncs
|
||||
//! the widget values from the engine snapshot on every render.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -124,6 +125,12 @@ impl<E: AppEngine> OfxParamsView<E> {
|
||||
controls,
|
||||
};
|
||||
wire_controls(&this, cx);
|
||||
// The stack view caches one params view per effect, so the view
|
||||
// lives across edits: re-render (and thereby `sync_values`, which
|
||||
// silently reapplies the engine snapshot) whenever the engine
|
||||
// changes — undo/redo, external edits, plugin-side updates.
|
||||
cx.observe(&this.engine, |_this, _engine, cx| cx.notify())
|
||||
.detach();
|
||||
this
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user