Node graph improvements (#855)

* Selecting multiple nodes

* Improve logs

* Log bad types in dyn any

* Add (broken) node links

* New topological sort

* Fix reorder ids function

* Input and output node

* Add nodes that operate on images

* Fixups

* Show node parameters together with layer properties

* New nodes don't crash editor

* Fix tests

* Node positions backend

* Generate node graph on value change

* Add expose input message

* Fix tests

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
0HyperCube
2022-11-17 23:36:23 +00:00
committed by GitHub
parent 5fe7aba85d
commit 6cd0dbdcd0
23 changed files with 734 additions and 331 deletions

View File

@@ -1,35 +1,35 @@
use crate::messages::prelude::*;
use graph_craft::document::{value::TaggedValue, NodeId};
use graph_craft::proto::NodeIdentifier;
#[remain::sorted]
#[impl_message(Message, DocumentMessage, NodeGraph)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum NodeGraphMessage {
// Messages
AddLink {
from: NodeId,
to: NodeId,
to_index: usize,
},
CloseNodeGraph,
ConnectNodesByLink {
output_node: u64,
input_node: u64,
input_node_connector_index: u32,
input_node_connector_index: usize,
},
CreateNode {
// Having the caller generate the id means that we don't have to return it. This can be a random u64.
node_id: NodeId,
// I don't really know what this is for (perhaps a user identifiable name).
name: String,
// The node identifier must mach that found in `node-graph/graph-craft/src/node_registry.rs` e.g. "graphene_core::raster::GrayscaleNode
identifier: NodeIdentifier,
num_inputs: u32,
node_type: String,
},
DeleteNode {
node_id: NodeId,
},
ExposeInput {
node_id: NodeId,
input_index: usize,
new_exposed: bool,
},
MoveSelectedNodes {
displacement_x: i32,
displacement_y: i32,
},
OpenNodeGraph {
layer_path: Vec<graphene::LayerId>,
},

View File

@@ -1,18 +1,27 @@
use crate::messages::layout::utility_types::layout_widget::{LayoutGroup, Widget, WidgetCallback, WidgetHolder};
use crate::messages::layout::utility_types::widgets::input_widgets::{NumberInput, NumberInputMode};
use crate::messages::layout::utility_types::widgets::label_widgets::{Separator, SeparatorDirection, SeparatorType, TextLabel};
use crate::messages::layout::utility_types::layout_widget::LayoutGroup;
use crate::messages::prelude::*;
use graph_craft::document::value::TaggedValue;
use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeInput, NodeNetwork};
use graph_craft::document::{DocumentNode, DocumentNodeImplementation, DocumentNodeMetadata, NodeInput, NodeNetwork};
use graphene::document::Document;
use graphene::layers::layer_info::LayerDataType;
use graphene::layers::nodegraph_layer::NodeGraphFrameLayer;
mod document_node_types;
mod node_properties;
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum DataType {
Raster,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FrontendNode {
pub id: graph_craft::document::NodeId,
#[serde(rename = "displayName")]
pub display_name: String,
#[serde(rename = "exposedInputs")]
pub exposed_inputs: Vec<DataType>,
pub outputs: Vec<DataType>,
pub position: (i32, i32),
}
// (link_start, link_end, link_end_input_index)
@@ -55,111 +64,11 @@ impl NodeGraphMessageHandler {
let network = &node_graph_frame.network;
let mut section = Vec::new();
for node_id in &self.selected_nodes {
let node = *node_id;
let Some(document_node) = network.nodes.get(node_id) else {
continue;
};
let name = format!("Node {} Properties", document_node.name);
let layout = match &document_node.implementation {
DocumentNodeImplementation::Network(_) => match document_node.name.as_str() {
"Hue Shift Color" => vec![LayoutGroup::Row {
widgets: vec![
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Shift degrees".into(),
..Default::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Unrelated,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::NumberInput(NumberInput {
value: Some({
let NodeInput::Value (TaggedValue::F32(x)) = document_node.inputs[1] else {
panic!("Hue rotate should be f32")
};
x as f64
}),
unit: "°".into(),
mode: NumberInputMode::Range,
range_min: Some(-180.),
range_max: Some(180.),
on_update: WidgetCallback::new(move |number_input: &NumberInput| {
NodeGraphMessage::SetInputValue {
node,
input_index: 1,
value: TaggedValue::F32(number_input.value.unwrap() as f32),
}
.into()
}),
..NumberInput::default()
})),
],
}],
"Brighten Color" => vec![LayoutGroup::Row {
widgets: vec![
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Brighten Amount".into(),
..Default::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Unrelated,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::NumberInput(NumberInput {
value: Some({
let NodeInput::Value (TaggedValue::F32(x)) = document_node.inputs[1] else {
panic!("Brighten amount should be f32")
};
x as f64
}),
mode: NumberInputMode::Range,
range_min: Some(-255.),
range_max: Some(255.),
on_update: WidgetCallback::new(move |number_input: &NumberInput| {
NodeGraphMessage::SetInputValue {
node,
input_index: 1,
value: TaggedValue::F32(number_input.value.unwrap() as f32),
}
.into()
}),
..NumberInput::default()
})),
],
}],
_ => vec![LayoutGroup::Row {
widgets: vec![WidgetHolder::new(Widget::TextLabel(TextLabel {
value: format!("Cannot currently display properties for network {}", document_node.name),
..Default::default()
}))],
}],
},
DocumentNodeImplementation::Unresolved(identifier) => match identifier.name.as_ref() {
"graphene_std::raster::MapImageNode" | "graphene_core::ops::IdNode" => vec![LayoutGroup::Row {
widgets: vec![WidgetHolder::new(Widget::TextLabel(TextLabel {
value: format!("{} requires no properties", document_node.name),
..Default::default()
}))],
}],
unknown => {
vec![
LayoutGroup::Row {
widgets: vec![WidgetHolder::new(Widget::TextLabel(TextLabel {
value: format!("TODO: {} properties", unknown),
..Default::default()
}))],
},
LayoutGroup::Row {
widgets: vec![WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Add in editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs".to_string(),
..Default::default()
}))],
},
]
}
},
};
section.push(LayoutGroup::Section { name, layout });
section.push(node_properties::generate_node_properties(document_node, *node_id));
}
section
@@ -173,7 +82,7 @@ impl NodeGraphMessageHandler {
let links = network
.nodes
.iter()
.flat_map(|(link_end, node)| node.inputs.iter().enumerate().map(move |(index, input)| (input, link_end, index)))
.flat_map(|(link_end, node)| node.inputs.iter().filter(|input| input.is_exposed()).enumerate().map(move |(index, input)| (input, link_end, index)))
.filter_map(|(input, &link_end, link_end_input_index)| {
if let NodeInput::Node(link_start) = *input {
Some(FrontendNodeLink {
@@ -192,6 +101,9 @@ impl NodeGraphMessageHandler {
nodes.push(FrontendNode {
id: *id,
display_name: node.name.clone(),
exposed_inputs: node.inputs.iter().filter(|input| input.is_exposed()).map(|_| DataType::Raster).collect(),
outputs: vec![DataType::Raster],
position: node.metadata.position,
})
}
log::debug!("Nodes:\n{:#?}\n\nFrontend Nodes:\n{:#?}\n\nLinks:\n{:#?}", network.nodes, nodes, links);
@@ -204,19 +116,6 @@ impl MessageHandler<NodeGraphMessage, (&mut Document, &InputPreprocessorMessageH
fn process_message(&mut self, message: NodeGraphMessage, (document, _ipp): (&mut Document, &InputPreprocessorMessageHandler), responses: &mut VecDeque<Message>) {
#[remain::sorted]
match message {
NodeGraphMessage::AddLink { from, to, to_index } => {
log::debug!("Connect primary output from node {from} to input of index {to_index} on node {to}.");
if let Some(network) = self.get_active_network_mut(document) {
if let Some(to) = network.nodes.get_mut(&to) {
// Extend number of inputs if not already large enough
if to_index >= to.inputs.len() {
to.inputs.extend(((to.inputs.len() - 1)..to_index).map(|_| NodeInput::Network));
}
to.inputs[to_index] = NodeInput::Node(from);
}
}
}
NodeGraphMessage::CloseNodeGraph => {
if let Some(_old_layer_path) = self.layer_path.take() {
info!("Closing node graph");
@@ -231,45 +130,103 @@ impl MessageHandler<NodeGraphMessage, (&mut Document, &InputPreprocessorMessageH
input_node_connector_index,
} => {
log::debug!("Connect primary output from node {output_node} to input of index {input_node_connector_index} on node {input_node}.");
let Some(network) = self.get_active_network_mut(document) else {
error!("No network");
return;
};
let Some(input_node) = network.nodes.get_mut(&input_node) else {
error!("No to");
return;
};
let Some((actual_index, _)) = input_node.inputs.iter().enumerate().filter(|input|input.1.is_exposed()).nth(input_node_connector_index) else {
error!("Failed to find actual index of connector indes {input_node_connector_index} on node {input_node:#?}");
return;
};
input_node.inputs[actual_index] = NodeInput::Node(output_node);
info!("Inputs: {:?}", input_node.inputs);
Self::send_graph(network, responses);
responses.push_back(DocumentMessage::NodeGraphFrameGenerate.into());
}
NodeGraphMessage::CreateNode {
node_id,
name,
identifier,
num_inputs,
} => {
if let Some(network) = self.get_active_network_mut(document) {
let inner_network = NodeNetwork {
inputs: (0..num_inputs).map(|_| 0).collect(),
output: 0,
nodes: [(
node_id,
DocumentNode {
name: format!("{}_impl", name),
// TODO: Allow inserting nodes that contain other nodes.
implementation: DocumentNodeImplementation::Unresolved(identifier),
inputs: (0..num_inputs).map(|_| NodeInput::Network).collect(),
},
)]
.into_iter()
.collect(),
};
network.nodes.insert(
node_id,
NodeGraphMessage::CreateNode { node_id, node_type } => {
let Some(network) = self.get_active_network_mut(document) else{
warn!("No network");
return;
};
let Some(document_node_type) = document_node_types::resolve_document_node_type(&node_type) else{
responses.push_back(DialogMessage::DisplayDialogError { title: "Cannot insert node".to_string(), description: format!("The document node '{node_type}' does not exist in the document node list") }.into());
return;
};
let num_inputs = document_node_type.default_inputs.len();
let inner_network = NodeNetwork {
inputs: (0..num_inputs).map(|_| 0).collect(),
output: 0,
nodes: [(
0,
DocumentNode {
name,
inputs: (0..num_inputs).map(|_| NodeInput::Network).collect(),
name: format!("{}_impl", document_node_type.name),
// TODO: Allow inserting nodes that contain other nodes.
implementation: DocumentNodeImplementation::Network(inner_network),
implementation: DocumentNodeImplementation::Unresolved(document_node_type.identifier.clone()),
inputs: (0..num_inputs).map(|_| NodeInput::Network).collect(),
metadata: DocumentNodeMetadata::default(),
},
);
Self::send_graph(network, responses);
}
)]
.into_iter()
.collect(),
};
network.nodes.insert(
node_id,
DocumentNode {
name: node_type.clone(),
inputs: document_node_type.default_inputs.to_vec(),
// TODO: Allow inserting nodes that contain other nodes.
implementation: DocumentNodeImplementation::Network(inner_network),
metadata: graph_craft::document::DocumentNodeMetadata {
// TODO: Better position default
position: (node_id as i32 * 7 - 41, node_id as i32 * 2 - 10),
},
},
);
Self::send_graph(network, responses);
}
NodeGraphMessage::DeleteNode { node_id } => {
if let Some(network) = self.get_active_network_mut(document) {
network.nodes.remove(&node_id);
// TODO: Update UI if it is not already updated.
Self::send_graph(network, responses);
responses.push_back(DocumentMessage::NodeGraphFrameGenerate.into());
}
}
NodeGraphMessage::ExposeInput { node_id, input_index, new_exposed } => {
let Some(network) = self.get_active_network_mut(document) else{
warn!("No network");
return;
};
let Some(node) = network.nodes.get_mut(&node_id) else {
warn!("No node");
return;
};
if let NodeInput::Value { exposed, .. } = &mut node.inputs[input_index] {
*exposed = new_exposed;
}
Self::send_graph(network, responses);
}
NodeGraphMessage::MoveSelectedNodes { displacement_x, displacement_y } => {
let Some(network) = self.get_active_network_mut(document) else{
warn!("No network");
return;
};
for node_id in &self.selected_nodes {
if let Some(node) = network.nodes.get_mut(node_id) {
node.metadata.position.0 += displacement_x;
node.metadata.position.1 += displacement_y;
}
}
}
NodeGraphMessage::OpenNodeGraph { layer_path } => {
@@ -283,20 +240,8 @@ impl MessageHandler<NodeGraphMessage, (&mut Document, &InputPreprocessorMessageH
Self::send_graph(network, responses);
// TODO: Dynamic node library
responses.push_back(
FrontendMessage::UpdateNodeTypes {
node_types: vec![
FrontendNodeType::new("Identity"),
FrontendNodeType::new("Grayscale Color"),
FrontendNodeType::new("Brighten Color"),
FrontendNodeType::new("Hue Shift Color"),
FrontendNodeType::new("Add"),
FrontendNodeType::new("Map Image"),
],
}
.into(),
);
let node_types = document_node_types::collect_node_types();
responses.push_back(FrontendMessage::UpdateNodeTypes { node_types }.into());
}
}
NodeGraphMessage::SelectNodes { nodes } => {
@@ -310,7 +255,8 @@ impl MessageHandler<NodeGraphMessage, (&mut Document, &InputPreprocessorMessageH
if input_index >= node.inputs.len() {
node.inputs.extend(((node.inputs.len() - 1)..input_index).map(|_| NodeInput::Network));
}
node.inputs[input_index] = NodeInput::Value(value);
node.inputs[input_index] = NodeInput::Value { tagged_value: value, exposed: false };
responses.push_back(DocumentMessage::NodeGraphFrameGenerate.into());
}
}
}

View File

@@ -0,0 +1,93 @@
use std::borrow::Cow;
use graph_craft::document::value::TaggedValue;
use graph_craft::document::NodeInput;
use graph_craft::proto::{NodeIdentifier, Type};
use graphene_std::raster::Image;
use super::FrontendNodeType;
pub struct DocumentNodeType {
pub name: &'static str,
pub identifier: NodeIdentifier,
pub default_inputs: &'static [NodeInput],
}
// TODO: Dynamic node library
static DOCUMENT_NODE_TYPES: [DocumentNodeType; 5] = [
DocumentNodeType {
name: "Identity",
identifier: NodeIdentifier::new("graphene_core::ops::IdNode", &[Type::Concrete(Cow::Borrowed("Any<'_>"))]),
default_inputs: &[NodeInput::Node(0)],
},
DocumentNodeType {
name: "Grayscale Image",
identifier: NodeIdentifier::new("graphene_std::raster::GrayscaleImageNode", &[]),
default_inputs: &[NodeInput::Value {
tagged_value: TaggedValue::Image(Image {
width: 0,
height: 0,
data: Vec::new(),
}),
exposed: true,
}],
},
DocumentNodeType {
name: "Brighten Image",
identifier: NodeIdentifier::new("graphene_std::raster::BrightenImageNode", &[Type::Concrete(Cow::Borrowed("&TypeErasedNode"))]),
default_inputs: &[
NodeInput::Value {
tagged_value: TaggedValue::Image(Image {
width: 0,
height: 0,
data: Vec::new(),
}),
exposed: true,
},
NodeInput::Value {
tagged_value: TaggedValue::F32(10.),
exposed: false,
},
],
},
DocumentNodeType {
name: "Hue Shift Image",
identifier: NodeIdentifier::new("graphene_std::raster::HueShiftImage", &[Type::Concrete(Cow::Borrowed("&TypeErasedNode"))]),
default_inputs: &[
NodeInput::Value {
tagged_value: TaggedValue::Image(Image {
width: 0,
height: 0,
data: Vec::new(),
}),
exposed: true,
},
NodeInput::Value {
tagged_value: TaggedValue::F32(50.),
exposed: false,
},
],
},
DocumentNodeType {
name: "Add",
identifier: NodeIdentifier::new("graphene_core::ops::AddNode", &[Type::Concrete(Cow::Borrowed("u32")), Type::Concrete(Cow::Borrowed("u32"))]),
default_inputs: &[
NodeInput::Value {
tagged_value: TaggedValue::U32(0),
exposed: false,
},
NodeInput::Value {
tagged_value: TaggedValue::U32(0),
exposed: false,
},
],
},
];
pub fn resolve_document_node_type(name: &str) -> Option<&DocumentNodeType> {
DOCUMENT_NODE_TYPES.iter().find(|node| node.name == name)
}
pub fn collect_node_types() -> Vec<FrontendNodeType> {
DOCUMENT_NODE_TYPES.iter().map(|node_type| FrontendNodeType { name: node_type.name.to_string() }).collect()
}

View File

@@ -0,0 +1,111 @@
use crate::messages::layout::utility_types::layout_widget::{LayoutGroup, Widget, WidgetCallback, WidgetHolder};
use crate::messages::layout::utility_types::widgets::input_widgets::{NumberInput, NumberInputMode};
use crate::messages::layout::utility_types::widgets::label_widgets::{Separator, SeparatorDirection, SeparatorType, TextLabel};
use crate::messages::prelude::NodeGraphMessage;
use graph_craft::document::value::TaggedValue;
use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput};
pub fn generate_node_properties(document_node: &DocumentNode, node_id: NodeId) -> LayoutGroup {
let name = document_node.name.clone();
let layout = match &document_node.implementation {
DocumentNodeImplementation::Network(_) => match document_node.name.as_str() {
"Hue Shift Image" => vec![LayoutGroup::Row {
widgets: vec![
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Shift Degrees".into(),
..Default::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Unrelated,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::NumberInput(NumberInput {
value: Some({
let NodeInput::Value {tagged_value: TaggedValue::F32(x), ..} = document_node.inputs[1] else {
panic!("Hue rotate should be f32")
};
x as f64
}),
unit: "°".into(),
mode: NumberInputMode::Range,
range_min: Some(-180.),
range_max: Some(180.),
on_update: WidgetCallback::new(move |number_input: &NumberInput| {
NodeGraphMessage::SetInputValue {
node: node_id,
input_index: 1,
value: TaggedValue::F32(number_input.value.unwrap() as f32),
}
.into()
}),
..NumberInput::default()
})),
],
}],
"Brighten Image" => vec![LayoutGroup::Row {
widgets: vec![
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Brighten Amount".into(),
..Default::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Unrelated,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::NumberInput(NumberInput {
value: Some({
let NodeInput::Value {tagged_value: TaggedValue::F32(x), ..} = document_node.inputs[1] else {
panic!("Brighten amount should be f32")
};
x as f64
}),
mode: NumberInputMode::Range,
range_min: Some(-255.),
range_max: Some(255.),
on_update: WidgetCallback::new(move |number_input: &NumberInput| {
NodeGraphMessage::SetInputValue {
node: node_id,
input_index: 1,
value: TaggedValue::F32(number_input.value.unwrap() as f32),
}
.into()
}),
..NumberInput::default()
})),
],
}],
_ => vec![LayoutGroup::Row {
widgets: vec![WidgetHolder::new(Widget::TextLabel(TextLabel {
value: format!("Cannot currently display parameters for network {}", document_node.name),
..Default::default()
}))],
}],
},
DocumentNodeImplementation::Unresolved(identifier) => match identifier.name.as_ref() {
"graphene_std::raster::MapImageNode" | "graphene_core::ops::IdNode" => vec![LayoutGroup::Row {
widgets: vec![WidgetHolder::new(Widget::TextLabel(TextLabel {
value: format!("{} exposes no parameters", document_node.name),
..Default::default()
}))],
}],
unknown => {
vec![
LayoutGroup::Row {
widgets: vec![WidgetHolder::new(Widget::TextLabel(TextLabel {
value: format!("TODO: {} parameters", unknown),
..Default::default()
}))],
},
LayoutGroup::Row {
widgets: vec![WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Add in editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs".to_string(),
..Default::default()
}))],
},
]
}
},
};
LayoutGroup::Section { name, layout }
}

View File

@@ -269,7 +269,10 @@ pub fn register_artwork_layer_properties(
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: LayerDataTypeDiscriminant::from(&layer.data).to_string(),
value: match &layer.data {
LayerDataType::NodeGraphFrame(_) => "Node Graph Frame".into(),
other => LayerDataTypeDiscriminant::from(other).to_string(),
},
..TextLabel::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
@@ -322,14 +325,16 @@ pub fn register_artwork_layer_properties(
LayerDataType::NodeGraphFrame(node_graph_frame) => {
let is_graph_open = node_graph_message_handler.layer_path.as_ref().filter(|node_graph| *node_graph == &layer_path).is_some();
let selected_nodes = &node_graph_message_handler.selected_nodes;
let mut properties_sections = vec![
node_section_transform(layer, persistent_data),
node_section_node_graph_frame(layer_path, node_graph_frame, is_graph_open),
];
if !selected_nodes.is_empty() && is_graph_open {
node_graph_message_handler.collate_properties(&node_graph_frame)
} else {
vec![
node_section_transform(layer, persistent_data),
node_section_node_graph_frame(layer_path, node_graph_frame, is_graph_open),
]
let parameters_sections = node_graph_message_handler.collate_properties(node_graph_frame);
properties_sections.extend(parameters_sections.into_iter());
}
properties_sections
}
LayerDataType::Folder(_) => {
vec![node_section_transform(layer, persistent_data)]

View File

@@ -437,7 +437,7 @@ impl MessageHandler<PortfolioMessage, (&InputPreprocessorMessageHandler, &Prefer
// Execute the node graph
let mut network = node_graph_frame.network.clone();
info!("Network {network:?}");
info!("Executing network {network:#?}");
let stack = borrow_stack::FixedSizeStack::new(256);
for node_id in node_graph_frame.network.nodes.keys() {
@@ -447,14 +447,18 @@ impl MessageHandler<PortfolioMessage, (&InputPreprocessorMessageHandler, &Prefer
let mut proto_network = network.into_proto_network();
proto_network.reorder_ids();
info!("proto_network with reordered ids: {proto_network:#?}");
assert_ne!(proto_network.nodes.len(), 0, "No protonodes exist?");
for (_id, node) in proto_network.nodes {
info!("Node {:?}", node);
info!("Inserting proto node {:?}", node);
graph_craft::node_registry::push_node(node, &stack);
}
use borrow_stack::BorrowStack;
use dyn_any::IntoDynAny;
use graphene_core::Node;
let result = unsafe { stack.get().last().unwrap().eval(image.into_dyn()) };
let result = *dyn_any::downcast::<Image>(result).unwrap();
@@ -491,7 +495,7 @@ impl MessageHandler<PortfolioMessage, (&InputPreprocessorMessageHandler, &Prefer
}
Err(description) => responses.push_back(
DialogMessage::DisplayDialogError {
title: "Failed to update image".to_string(),
title: "Failed to update node graph".to_string(),
description,
}
.into(),