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
+4
View File
@@ -196,3 +196,7 @@ jobs:
OFX_PLUGIN_PATH="$PWD/.cache/ofx-fixture" \
cargo run --locked -p oakplugin --example scan_probe > probe.log 2>&1
grep -q 'type_id=rs.oak.CiTestPlugin' probe.log
# A project carrying a plugin node must survive save/load (the
# serializer resolves plugin types via the dynamic factory).
OAK_OFX_FIXTURE_DIR="$PWD/.cache/ofx-fixture" \
cargo test --locked -p oakplugin --test ofx_roundtrip
+3 -1
View File
@@ -340,9 +340,11 @@ impl WorkerSession {
if let Err(e) = oakplugin::host::Host::global().cache.scan() {
log_error(&format!("runtime: OFX plugin scan failed ({e}); continuing"));
}
let discovered = oakplugin::host::Host::global().cache.count();
let registered = oakplugin::node_factory::register_plugin_nodes();
log_error(&format!(
"runtime: registered {} OFX plugin node type(s)",
"runtime: discovered {} OFX plugin(s), registered {} node type(s)",
discovered,
registered.len()
));
// Worker-side plugin progress forwarding (see the module docs): the
+8
View File
@@ -501,6 +501,14 @@ pub trait NodeBehavior: Send {
}
}
/// The option labels of a combo input (C++ `set_combo_box_strings`,
/// called from each node's `retranslate`). Empty for non-combo inputs;
/// the default is no options.
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
let _ = id;
Vec::new()
}
/// Inputs excluded from rendering (C++ `ignore_inputs_for_rendering()`).
fn ignore_inputs_for_rendering(&self) -> &[String] {
&[]
+10
View File
@@ -305,6 +305,16 @@ impl NodeBehavior for BlurFilterNode {
}
}
/// Combo input option labels (C++ `retranslate()` /
/// `set_combo_box_strings`): `method_in` -> "Box", "Gaussian",
/// "Directional", "Radial".
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
match id {
METHOD_INPUT => vec!["Box", "Gaussian", "Directional", "Radial"],
_ => Vec::new(),
}
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// radius <= 0.0, or box/gaussian with both horiz and vert unchecked
/// -> pass-through push of the input texture; otherwise push a shader
@@ -174,6 +174,15 @@ impl NodeBehavior for ColorDifferenceKeyNode {
}
}
/// Combo input option labels (C++ `retranslate()` /
/// `set_combo_box_strings`): `color_in` -> "Green", "Blue".
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
match id {
COLOR_INPUT => vec!["Green", "Blue"],
_ => Vec::new(),
}
}
/// Evaluate outputs (C++ `value()`): no texture on `tex_in` ->
/// push nothing; texture present -> push a `ShaderJob` with the
/// whole input row inserted.
+12
View File
@@ -158,6 +158,18 @@ impl NodeBehavior for DespillNode {
}
}
/// Combo input option labels (C++ `retranslate()` /
/// `set_combo_box_strings`): `color_in` -> "Green", "Blue";
/// `method_in` -> "Average", "Double Red Average", "Double Average",
/// "Limit".
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
match id {
COLOR_INPUT => vec!["Green", "Blue"],
METHOD_INPUT => vec!["Average", "Double Red Average", "Double Average", "Limit"],
_ => Vec::new(),
}
}
/// Evaluate outputs (C++ `value()`): builds a `ShaderJob` from
/// the whole input row, then inserts a `luma_coeffs` vec3 taken
/// from the project's color manager default luma coefficients
@@ -165,6 +165,18 @@ impl NodeBehavior for DisplayTransformNode {
}
}
/// Combo input option labels (C++ `retranslate()` /
/// `set_combo_box_strings`): `dir_in` -> "Forward", "Inverse". The
/// `display_in`/`view_in` strings come from the attached color
/// manager at runtime (`update_displays`/`update_views`) and cannot
/// be expressed statically.
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
match id {
DIRECTION_INPUT => vec!["Forward", "Inverse"],
_ => Vec::new(),
}
}
/// Input value changed (C++ `InputValueChangedEvent`): for
/// `display_in`, `view_in` or `dir_in` regenerates the processor;
/// a `display_in` change additionally refreshes the view combo.
+10
View File
@@ -100,6 +100,16 @@ impl NodeBehavior for MathNode {
}
}
/// Combo input option labels (C++ `retranslate()` /
/// `set_combo_box_strings`): `method_in` -> the five operation names
/// "Add", "Subtract", "Multiply", "Divide", "Power".
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
match id {
METHOD_INPUT => vec!["Add", "Subtract", "Multiply", "Divide", "Power"],
_ => Vec::new(),
}
}
/// Evaluate outputs (C++ `value()`): pushes both operands into
/// single-value tables, runs the [`super::mathbase::PairingCalculator`]
/// heuristic, and if a pairing was found delegates to
+11
View File
@@ -190,6 +190,17 @@ impl NodeBehavior for MultiCamNode {
}
}
/// Combo input option labels (C++ `retranslate()` /
/// `set_combo_box_strings`): `sequence_type_in` -> "Video", "Audio".
/// `current_in`'s strings are built dynamically per connected source
/// (`"<i + 1>: <name>"`) and cannot be expressed statically.
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
match id {
SEQUENCE_TYPE_INPUT => vec!["Video", "Audio"],
_ => Vec::new(),
}
}
/// Inputs excluded from rendering (C++
/// `ignore_inputs_for_rendering()`): always
/// `{ k_sequence_input }`.
+9
View File
@@ -269,6 +269,15 @@ impl NodeBehavior for OCIOLutNode {
}
}
/// Combo input option labels (C++ `retranslate()` /
/// `set_combo_box_strings`): `lut_dir_in` -> "Forward", "Inverse".
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
match id {
DIRECTION_INPUT => vec!["Forward", "Inverse"],
_ => Vec::new(),
}
}
/// Input value changed (C++ `InputValueChangedEvent`): for
/// `lut_file_in` or `lut_dir_in`, regenerates the processor
/// immediately in the main process; in the render worker (where
+10
View File
@@ -175,6 +175,16 @@ impl NodeBehavior for ShapeNode {
}
}
/// Combo input option labels (C++ `retranslate()` /
/// `set_combo_box_strings`): `type_in` -> "Rectangle", "Ellipse",
/// "Rounded Rectangle".
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
match id {
TYPE_INPUT => vec!["Rectangle", "Ellipse", "Rounded Rectangle"],
_ => Vec::new(),
}
}
/// Evaluate outputs (C++ `value()`): builds a `"shape"` shader job
/// from the input row, inserting `resolution_in` (the base
/// texture's virtual resolution when connected, else the sequence
+10
View File
@@ -202,6 +202,16 @@ impl NodeBehavior for TextGeneratorV1 {
}
}
/// Combo input option labels (C++ `retranslate()` /
/// `set_combo_box_strings`): `valign_in` -> "Top", "Center",
/// "Bottom".
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
match id {
V_ALIGN_INPUT => vec!["Top", "Center", "Bottom"],
_ => Vec::new(),
}
}
/// Evaluate outputs (C++ `value()`): if the text input is
/// non-empty, push a texture generate job at the global video
/// params; otherwise push nothing.
+10
View File
@@ -258,6 +258,16 @@ impl NodeBehavior for TextGeneratorV2 {
}
}
/// Combo input option labels (C++ `retranslate()` /
/// `set_combo_box_strings`): `valign_in` -> "Top", "Center",
/// "Bottom".
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
match id {
V_ALIGN_INPUT => vec!["Top", "Center", "Bottom"],
_ => Vec::new(),
}
}
/// Evaluate outputs (C++ `value()`): if the text input is
/// non-empty, push a texture generate job at the global video
/// params forced to `PixelFormat::f32`; otherwise push nothing.
+10
View File
@@ -269,6 +269,16 @@ impl NodeBehavior for TextGeneratorV3 {
}
}
/// Combo input option labels (C++ `retranslate()` /
/// `set_combo_box_strings`): `valign_in` -> "Top", "Middle",
/// "Bottom".
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
match id {
VERTICAL_ALIGNMENT_INPUT => vec!["Top", "Middle", "Bottom"],
_ => Vec::new(),
}
}
/// Evaluate outputs (C++ `value()`): if `use_args_in` is set and
/// the args array is non-empty, expand `%N` placeholders in the
/// text via [`Self::format_string`]; if the resulting text is
@@ -194,6 +194,27 @@ impl NodeBehavior for TileDistortNode {
}
}
/// Combo input option labels (C++ `retranslate()` /
/// `set_combo_box_strings`): `anchor_in` -> "Top-Left", "Top-Center",
/// "Top-Right", "Middle-Left", "Middle-Center", "Middle-Right",
/// "Bottom-Left", "Bottom-Center", "Bottom-Right".
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
match id {
ANCHOR_INPUT => vec![
"Top-Left",
"Top-Center",
"Top-Right",
"Middle-Left",
"Middle-Center",
"Middle-Right",
"Bottom-Left",
"Bottom-Center",
"Bottom-Right",
],
_ => Vec::new(),
}
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// scale differs from 1.0 (an approximate-equality epsilon test:
/// `abs(scale-1)*1e12 > min(abs(scale), 1)`) -> shader job over the
@@ -307,6 +307,18 @@ impl NodeBehavior for TransformDistortNode {
}
}
/// Combo input option labels (C++ `retranslate()` /
/// `set_combo_box_strings`): `autoscale_in` -> "None", "Fit", "Fill",
/// "Stretch"; `interpolation_in` -> "Nearest Neighbor", "Bilinear",
/// "Mipmapped Bilinear".
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
match id {
AUTOSCALE_INPUT => vec!["None", "Fit", "Fill", "Stretch"],
INTERPOLATION_INPUT => vec!["Nearest Neighbor", "Bilinear", "Mipmapped Bilinear"],
_ => Vec::new(),
}
}
/// Evaluate outputs (C++ `value()`): generates the matrix from the
/// inherited transform inputs (position/rotation/scale/anchor,
/// folded with `parent_in`) and always pushes it as a `k_matrix`
+21
View File
@@ -92,6 +92,27 @@ impl NodeBehavior for TrigonometryNode {
}
}
/// Combo input option labels (C++ `retranslate()` /
/// `set_combo_box_strings`): `method_in` -> "Sine", "Cosine",
/// "Tangent", "Inverse Sine", "Inverse Cosine", "Inverse Tangent",
/// "Hyperbolic Sine", "Hyperbolic Cosine", "Hyperbolic Tangent".
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
match id {
METHOD_INPUT => vec![
"Sine",
"Cosine",
"Tangent",
"Inverse Sine",
"Inverse Cosine",
"Inverse Tangent",
"Hyperbolic Sine",
"Hyperbolic Cosine",
"Hyperbolic Tangent",
],
_ => Vec::new(),
}
}
/// Evaluate outputs (C++ `value()`): reads `x_in` as a double,
/// applies the [`Operation`] selected by `method_in`
/// (sin/cos/tan/asin/acos/atan/sinh/cosh/tanh), and pushes the
+23
View File
@@ -88,6 +88,29 @@ impl NodeBehavior for ValueNode {
}
}
/// Combo input option labels (C++ `retranslate()` /
/// `set_combo_box_strings`): `type_in` -> the pretty data-type names
/// of [`SUPPORTED_TYPES`] in order — "Float", "Integer", "Rational",
/// "Vector 2D", "Vector 3D", "Vector 4D", "Color", "Text", "Boolean"
/// (matching the Rust list, which omits the C++ `k_matrix`/`k_font`
/// entries).
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
match id {
TYPE_INPUT => vec![
"Float",
"Integer",
"Rational",
"Vector 2D",
"Vector 3D",
"Vector 4D",
"Color",
"Text",
"Boolean",
],
_ => Vec::new(),
}
}
/// Evaluate outputs (C++ `value()`): pushes the `value_in` value
/// onto the table unchanged.
fn value(
@@ -122,6 +122,16 @@ impl NodeBehavior for WaveDistortNode {
}
}
/// Combo input option labels (C++ `retranslate()` /
/// `set_combo_box_strings`): `vertical_in` -> "Horizontal",
/// "Vertical".
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
match id {
VERTICAL_INPUT => vec!["Horizontal", "Vertical"],
_ => Vec::new(),
}
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// intensity != 0.0 -> shader job over the whole value row rendered
/// at the texture's own params; intensity == 0.0 -> pass-through
+5 -3
View File
@@ -632,12 +632,14 @@ fn load_node(
// Instantiate the node type; timeline structural types (which are
// not in the factory menu) are reconstructed directly, unknown
// types fall back to an error.
// types fall back to an error. `create_any` also covers the dynamic
// (runtime-registered OpenFX plugin) entries — `find` alone would
// reject every project that carries a plugin node.
let (mut core, behavior): (NodeCore, Box<dyn crate::node::NodeBehavior>) =
match create_timeline_type(&type_id) {
Some(x) => x,
None => match crate::factory::Factory::global().find(&type_id) {
Some(meta) => (meta.create)(),
None => match crate::factory::Factory::global().create_any(&type_id) {
Some(x) => x,
None => {
// Unknown type: skip the element body.
reader.skip_current_element();
+10 -3
View File
@@ -158,9 +158,10 @@ unsafe extern "C" fn host_fetch_suite(
p
}
Ok(None) => {
// 诊断:插件请求的 suite 宿主没有describe 常因此返回
// kOfxStatErrMissingHostFeature)。
if !name.is_null() {
// 诊断:插件请求的 suite 宿主没有。厂商套件(Nuke/Vegas/
// Foundry)的探测是插件的正常行为,不打正式日志——需要
// 排查时用 OAK_OFX_TRACE 打开。
if std::env::var_os("OAK_OFX_TRACE").is_some() && !name.is_null() {
if let Ok(n) = unsafe { CStr::from_ptr(name) }.to_str() {
eprintln!("[ofx] fetchSuite miss: {n} v{version}");
}
@@ -1222,6 +1223,12 @@ impl Host {
.call_action(ACTION_CREATE_INSTANCE, inst_handle, &empty, &empty)
};
if stat != status::OK && stat != status::REPLY_DEFAULT {
// 插件拒绝了 createInstance——它从没认领这个实例;把
// destroyed 门置位,让随后的 drop 跳过 destroyInstance 通知
// (否则插件对一个它没创建的实例回 BadIndex 之类的错误)。
arc.value
.destroyed
.store(true, std::sync::atomic::Ordering::Relaxed);
return Err(crate::error::Error::Failed(format!(
"createInstance 失败:{stat}"
)));
+74
View File
@@ -0,0 +1,74 @@
// 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 project serialization round-trip (CI-gated): a project that
//! carries a plugin node must save to XML and load back with the same
//! plugin type id — the serializer resolves plugin types through the
//! factory's dynamic (runtime-registered) entries.
//!
//! Gated on `OAK_OFX_FIXTURE_DIR` pointing at a directory with a built
//! `OakCiTest.ofx.bundle` (see `tests/fixtures/build_fixture.sh`); the
//! test skips silently when the variable is unset so plain `cargo test`
//! runs stay hermetic. The CI workflow builds the fixture and sets it.
use oakplugin::host::Host;
/// The fixture plugin's type id (tests/fixtures/ci_test_plugin.c).
const FIXTURE_TYPE_ID: &str = "rs.oak.CiTestPlugin";
#[test]
fn plugin_node_survives_save_load_roundtrip() {
let Ok(fixture_dir) = std::env::var("OAK_OFX_FIXTURE_DIR") else {
eprintln!("OAK_OFX_FIXTURE_DIR unset; skipping the OFX round-trip test");
return;
};
let host = Host::global();
host.cache
.scan_path(std::path::Path::new(&fixture_dir))
.expect("fixture dir scans");
let registered = oakplugin::node_factory::register_plugin_nodes();
assert!(
registered.iter().any(|id| id == FIXTURE_TYPE_ID),
"the fixture plugin registered (got {registered:?})"
);
// Build a project carrying one plugin node.
let project = oaknode::project::Project::new();
let node_id = {
let mut p = project.lock().unwrap_or_else(|e| e.into_inner());
let (core, behavior) = oaknode::factory::Factory::global()
.create_any(FIXTURE_TYPE_ID)
.expect("the fixture type resolves through the factory");
p.graph.add_node(core, behavior)
};
// Save, wipe, reload: the type id must resolve again (this is the path
// that used to fail with "unknown node type" for plugin nodes).
let xml = {
let p = project.lock().unwrap_or_else(|e| e.into_inner());
oaknode::serializer::save(&p).expect("project saves")
};
let loaded = oaknode::serializer::load(&xml).expect("project with a plugin node loads");
let p = loaded.lock().unwrap_or_else(|e| e.into_inner());
let mut found = false;
for id in p.graph.node_ids() {
let entry = p.graph.get(id).expect("listed node exists");
if entry.behavior.type_id() == FIXTURE_TYPE_ID {
found = true;
}
}
assert!(found, "the plugin node survived the round trip (node {node_id:?})");
}
+1 -1
Submodule gpui updated: 8a0b7569a3...3325521a85
+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
}