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:
2026-08-20 23:46:40 +08:00
parent 0a7f3766e2
commit 9003b78176
31 changed files with 482 additions and 50 deletions
+35 -14
View File
@@ -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
View File
@@ -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")
}
+17
View File
@@ -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).
+11
View File
@@ -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
+34
View File
@@ -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
View File
@@ -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));
+22
View File
@@ -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.
+10 -3
View File
@@ -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
}