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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] {
|
||||
&[]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}"
|
||||
)));
|
||||
|
||||
@@ -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
@@ -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