diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 113c6484f..f09873053 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/crates/oak-worker/src/worker.rs b/crates/oak-worker/src/worker.rs index d4c29aaeb..e727be7c4 100644 --- a/crates/oak-worker/src/worker.rs +++ b/crates/oak-worker/src/worker.rs @@ -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 diff --git a/crates/oaknode/src/node.rs b/crates/oaknode/src/node.rs index 1d3fda80d..4d031a0ca 100644 --- a/crates/oaknode/src/node.rs +++ b/crates/oaknode/src/node.rs @@ -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] { &[] diff --git a/crates/oaknode/src/nodes/blur.rs b/crates/oaknode/src/nodes/blur.rs index 1ce8f61d7..81fbab92c 100644 --- a/crates/oaknode/src/nodes/blur.rs +++ b/crates/oaknode/src/nodes/blur.rs @@ -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 diff --git a/crates/oaknode/src/nodes/colordifferencekey.rs b/crates/oaknode/src/nodes/colordifferencekey.rs index 186ed1a7f..36d68c75a 100644 --- a/crates/oaknode/src/nodes/colordifferencekey.rs +++ b/crates/oaknode/src/nodes/colordifferencekey.rs @@ -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. diff --git a/crates/oaknode/src/nodes/despill.rs b/crates/oaknode/src/nodes/despill.rs index 80ebfa036..27c675f96 100644 --- a/crates/oaknode/src/nodes/despill.rs +++ b/crates/oaknode/src/nodes/despill.rs @@ -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 diff --git a/crates/oaknode/src/nodes/displaytransform.rs b/crates/oaknode/src/nodes/displaytransform.rs index 40e5fe800..26a8301c4 100644 --- a/crates/oaknode/src/nodes/displaytransform.rs +++ b/crates/oaknode/src/nodes/displaytransform.rs @@ -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. diff --git a/crates/oaknode/src/nodes/math.rs b/crates/oaknode/src/nodes/math.rs index 5b2f4ad22..8e06fee19 100644 --- a/crates/oaknode/src/nodes/math.rs +++ b/crates/oaknode/src/nodes/math.rs @@ -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 diff --git a/crates/oaknode/src/nodes/multicamnode.rs b/crates/oaknode/src/nodes/multicamnode.rs index 56c6c6046..7043eb914 100644 --- a/crates/oaknode/src/nodes/multicamnode.rs +++ b/crates/oaknode/src/nodes/multicamnode.rs @@ -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 + /// (`": "`) 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 }`. diff --git a/crates/oaknode/src/nodes/ociolut.rs b/crates/oaknode/src/nodes/ociolut.rs index 92780df19..2fa801ac7 100644 --- a/crates/oaknode/src/nodes/ociolut.rs +++ b/crates/oaknode/src/nodes/ociolut.rs @@ -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 diff --git a/crates/oaknode/src/nodes/shapenode.rs b/crates/oaknode/src/nodes/shapenode.rs index 611443919..a241603aa 100644 --- a/crates/oaknode/src/nodes/shapenode.rs +++ b/crates/oaknode/src/nodes/shapenode.rs @@ -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 diff --git a/crates/oaknode/src/nodes/textv1.rs b/crates/oaknode/src/nodes/textv1.rs index 5f69e718b..ad71b02c0 100644 --- a/crates/oaknode/src/nodes/textv1.rs +++ b/crates/oaknode/src/nodes/textv1.rs @@ -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. diff --git a/crates/oaknode/src/nodes/textv2.rs b/crates/oaknode/src/nodes/textv2.rs index 17fc49add..91554be23 100644 --- a/crates/oaknode/src/nodes/textv2.rs +++ b/crates/oaknode/src/nodes/textv2.rs @@ -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. diff --git a/crates/oaknode/src/nodes/textv3.rs b/crates/oaknode/src/nodes/textv3.rs index 24b25864c..58f367d30 100644 --- a/crates/oaknode/src/nodes/textv3.rs +++ b/crates/oaknode/src/nodes/textv3.rs @@ -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 diff --git a/crates/oaknode/src/nodes/tiledistortnode.rs b/crates/oaknode/src/nodes/tiledistortnode.rs index 867222e24..1bbcbf6ca 100644 --- a/crates/oaknode/src/nodes/tiledistortnode.rs +++ b/crates/oaknode/src/nodes/tiledistortnode.rs @@ -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 diff --git a/crates/oaknode/src/nodes/transformdistortnode.rs b/crates/oaknode/src/nodes/transformdistortnode.rs index e6fe838d9..695aed308 100644 --- a/crates/oaknode/src/nodes/transformdistortnode.rs +++ b/crates/oaknode/src/nodes/transformdistortnode.rs @@ -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` diff --git a/crates/oaknode/src/nodes/trigonometry.rs b/crates/oaknode/src/nodes/trigonometry.rs index fedbc5250..7683d2f0f 100644 --- a/crates/oaknode/src/nodes/trigonometry.rs +++ b/crates/oaknode/src/nodes/trigonometry.rs @@ -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 diff --git a/crates/oaknode/src/nodes/valuenode.rs b/crates/oaknode/src/nodes/valuenode.rs index 60e1c027d..54171e79f 100644 --- a/crates/oaknode/src/nodes/valuenode.rs +++ b/crates/oaknode/src/nodes/valuenode.rs @@ -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( diff --git a/crates/oaknode/src/nodes/wavedistortnode.rs b/crates/oaknode/src/nodes/wavedistortnode.rs index d0a696642..8e63e745f 100644 --- a/crates/oaknode/src/nodes/wavedistortnode.rs +++ b/crates/oaknode/src/nodes/wavedistortnode.rs @@ -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 diff --git a/crates/oaknode/src/serializer.rs b/crates/oaknode/src/serializer.rs index 5c6f9ad13..51b36f996 100644 --- a/crates/oaknode/src/serializer.rs +++ b/crates/oaknode/src/serializer.rs @@ -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) = 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(); diff --git a/crates/oakplugin/src/host.rs b/crates/oakplugin/src/host.rs index d1f9e90e8..4439779aa 100644 --- a/crates/oakplugin/src/host.rs +++ b/crates/oakplugin/src/host.rs @@ -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}" ))); diff --git a/crates/oakplugin/tests/ofx_roundtrip.rs b/crates/oakplugin/tests/ofx_roundtrip.rs new file mode 100644 index 000000000..ded2c1167 --- /dev/null +++ b/crates/oakplugin/tests/ofx_roundtrip.rs @@ -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 . + +//! 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:?})"); +} diff --git a/gpui b/gpui index 8a0b7569a..3325521a8 160000 --- a/gpui +++ b/gpui @@ -1 +1 @@ -Subproject commit 8a0b7569a3c0b9b6e2a0fe75c9c91ea91fb8a911 +Subproject commit 3325521a85a829fccd5d2da76a00392cad3a57f0 diff --git a/src/oakui/effectchain.rs b/src/oakui/effectchain.rs index 8f82846dc..fa71a4581 100644 --- a/src/oakui/effectchain.rs +++ b/src/oakui/effectchain.rs @@ -163,28 +163,27 @@ pub fn plugin_instance_handle(g: &Graph, node: NodeId) -> Option { } /// 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> { 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::().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) diff --git a/src/oakui/graphops.rs b/src/oakui/graphops.rs index 035b68004..3aba85eb0 100644 --- a/src/oakui/graphops.rs +++ b/src/oakui/graphops.rs @@ -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") } diff --git a/src/oakui/mock.rs b/src/oakui/mock.rs index 7dfb96ce9..c37a84fc1 100644 --- a/src/oakui/mock.rs +++ b/src/oakui/mock.rs @@ -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). diff --git a/src/oakui/real.rs b/src/oakui/real.rs index d60f9dfe1..3ff2ea052 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -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 diff --git a/src/panels/effect_library.rs b/src/panels/effect_library.rs index a6e11e642..6ce9a227e 100644 --- a/src/panels/effect_library.rs +++ b/src/panels/effect_library.rs @@ -103,6 +103,10 @@ impl Render for EffectLibraryPanel { 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 Render for EffectLibraryPanel { .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) -> 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 EventEmitter for EffectLibraryPanel {} impl DockPanel for EffectLibraryPanel { diff --git a/src/panels/inspector.rs b/src/panels/inspector.rs index e4b7c499e..7d86ba7c6 100644 --- a/src/panels/inspector.rs +++ b/src/panels/inspector.rs @@ -71,8 +71,22 @@ impl InspectorPanel { // 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)); diff --git a/src/panels/node_editor.rs b/src/panels/node_editor.rs index 0c84223ed..7f36376d6 100644 --- a/src/panels/node_editor.rs +++ b/src/panels/node_editor.rs @@ -326,9 +326,31 @@ impl Render for NodeEditorPanel { ) .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::(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::() + }) .child(self.graph.clone()), ) // The right-click popup renders anchored above the panel. diff --git a/src/panels/ofx_params.rs b/src/panels/ofx_params.rs index 6ffc1433a..ede66957b 100644 --- a/src/panels/ofx_params.rs +++ b/src/panels/ofx_params.rs @@ -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 OfxParamsView { 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 }