diff --git a/Cargo.lock b/Cargo.lock index 5aae8b9592..decc201a97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1964,6 +1964,7 @@ dependencies = [ "dyn-any", "glam", "graph-craft", + "graphene-animation", "graphene-application-io", "graphene-core", "graphene-hash", @@ -2006,6 +2007,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "graphene-animation" +version = "0.1.0" +dependencies = [ + "dyn-any", + "glam", + "graphene-hash", + "kurbo", + "serde", +] + [[package]] name = "graphene-application-io" version = "0.1.0" @@ -2068,6 +2080,7 @@ dependencies = [ "core-types", "dyn-any", "glam", + "graphene-animation", "graphene-hash", "graphic-types", "log", @@ -2879,6 +2892,7 @@ dependencies = [ "futures", "glam", "graph-craft", + "graphene-animation", "graphene-core", "graphene-std", "gungraun", diff --git a/Cargo.toml b/Cargo.toml index d31b04f8d2..1fa45f010a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ preprocessor = { path = "node-graph/preprocessor" } math-parser = { path = "libraries/math-parser" } graphene-application-io = { path = "node-graph/libraries/application-io" } graphene-resource = { path = "node-graph/libraries/resources" } +graphene-animation = { path = "node-graph/libraries/animation" } core-types = { path = "node-graph/libraries/core-types" } no-std-types = { path = "node-graph/libraries/no-std-types" } raster-types = { path = "node-graph/libraries/raster-types" } diff --git a/node-graph/graph-craft/Cargo.toml b/node-graph/graph-craft/Cargo.toml index 688a0ea712..2119194a19 100644 --- a/node-graph/graph-craft/Cargo.toml +++ b/node-graph/graph-craft/Cargo.toml @@ -27,6 +27,7 @@ core-types = { workspace = true, features = ["serde"] } brush-nodes = { workspace = true, features = ["serde"] } graphene-core = { workspace = true, features = ["serde"] } graphene-application-io = { workspace = true, features = ["serde"] } +graphene-animation = { workspace = true, features = ["serde"] } rendering = { workspace = true, features = ["serde"] } raster-nodes = { workspace = true, features = ["serde"] } vector-nodes = { workspace = true, features = ["serde"] } diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 271ff6e328..1f618fb56a 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -11,6 +11,7 @@ use core_types::{CacheHash, Color, ContextFeatures, MemoHash, Node, Type, TypeDe use dyn_any::DynAny; pub use dyn_any::StaticType; pub use glam::{DAffine2, DVec2, IVec2, UVec2}; +use graphene_animation::AnimationCurve; use graphene_application_io::resource::ResourceHash; use graphic_types::raster_types::{CPU, Image, Raster}; use graphic_types::vector_types::vector::style::{Fill, Gradient, GradientStops}; @@ -408,6 +409,7 @@ tagged_value! { VectorModification(Box), ImageData(Image), Resource(graphene_application_io::resource::ResourceId), + AnimationCurve(AnimationCurve), // ========== // ENUM TYPES // ========== diff --git a/node-graph/interpreted-executor/Cargo.toml b/node-graph/interpreted-executor/Cargo.toml index 271408240c..429a4ebfd8 100644 --- a/node-graph/interpreted-executor/Cargo.toml +++ b/node-graph/interpreted-executor/Cargo.toml @@ -15,6 +15,7 @@ wasm = ["graphene-std/wasm"] graphene-std = { workspace = true } graph-craft = { workspace = true } graphene-core = { workspace = true } +graphene-animation = { workspace = true } wgpu-executor = { workspace = true } core-types = { workspace = true } dyn-any = { workspace = true } diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 650155a017..e905c14816 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -4,6 +4,7 @@ use graph_craft::application_io::PlatformEditorApi; use graph_craft::document::DocumentNode; use graph_craft::document::value::RenderOutput; use graph_craft::proto::{NodeConstructor, TypeErasedBox}; +use graphene_animation::AnimationCurve; use graphene_std::any::DynAnyNode; use graphene_std::application_io::ImageTexture; use graphene_std::brush::brush_stroke::BrushStroke; @@ -171,6 +172,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => graphene_std::raster::adjustments::RedGreenBlue]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::raster::adjustments::RedGreenBlueAlpha]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::animation::RealTimeMode]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => AnimationCurve]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::raster::adjustments::NoiseType]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::raster::adjustments::FractalType]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::raster::adjustments::CellularDistanceFunction]), @@ -199,6 +201,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => AttributeDyn, Context => graphene_std::ContextFeatures]), async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => AttributeValueDyn, Context => graphene_std::ContextFeatures]), async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => ListDyn, Context => graphene_std::ContextFeatures]), + async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => AnimationCurve, Context => graphene_std::ContextFeatures]), #[cfg(target_family = "wasm")] async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => CanvasHandle, Context => graphene_std::ContextFeatures]), async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => &PlatformEditorApi, Context => graphene_std::ContextFeatures]), @@ -241,6 +244,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Footprint]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => RenderOutput]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => &PlatformEditorApi]), + async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => AnimationCurve]), #[cfg(feature = "gpu")] async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List>]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Option]), diff --git a/node-graph/libraries/animation/Cargo.toml b/node-graph/libraries/animation/Cargo.toml new file mode 100644 index 0000000000..2bb2756eac --- /dev/null +++ b/node-graph/libraries/animation/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "graphene-animation" +version = "0.1.0" +edition = "2024" +description = "Animation primitives for Graphene node system" +authors = ["Graphite Authors "] +license = "MIT OR Apache-2.0" + +[dependencies] +serde = { workspace = true, optional = true } +kurbo = { workspace = true } +glam = { workspace = true } +dyn-any = { workspace = true } +graphene-hash = { workspace = true, features = ["derive"] } + +[features] +default = ["serde"] +serde = ["dep:serde"] + +[lints] +workspace = true diff --git a/node-graph/libraries/animation/src/lib.rs b/node-graph/libraries/animation/src/lib.rs new file mode 100644 index 0000000000..5acfae123b --- /dev/null +++ b/node-graph/libraries/animation/src/lib.rs @@ -0,0 +1,267 @@ +//! Animation Curves + +use dyn_any::DynAny; + +use glam::DVec2; +use graphene_hash::CacheHash; +use kurbo::{CubicBez, ParamCurve, Point}; +use serde::Deserialize; + +// Every keyframe defines a left handle point for any bezier easings to the left, +// and info defining the behavior to the right hand side of the keyframe +#[derive(Debug, Clone, Copy, PartialEq, CacheHash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Keyframe { + /// If None, defaults to knot in the case of a bezier keyframe to the left. + pub left_handle: Option, + pub knot: DVec2, + pub interp_behavior: InterpolationBehavior, +} +impl Keyframe { + pub fn new_linear(knot: DVec2, left_handle: Option) -> Self { + Self { + left_handle, + knot, + interp_behavior: InterpolationBehavior::Linear, + } + } + pub fn new_constant(knot: DVec2, left_handle: Option) -> Self { + Self { + left_handle, + knot, + interp_behavior: InterpolationBehavior::Constant, + } + } + pub fn new_bezier(knot: DVec2, left_handle: Option, right_handle: DVec2) -> Self { + Self { + left_handle, + knot, + interp_behavior: InterpolationBehavior::Bezier { right_handle }, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, CacheHash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum InterpolationBehavior { + Bezier { right_handle: DVec2 }, + Constant, + Linear, +} + +#[derive(Default, Debug, Clone, PartialEq, DynAny, CacheHash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct AnimationCurve { + #[serde(deserialize_with = "deserialize_keyframes")] + keyframes: Vec, // not public to maintain sorted order +} + +impl AnimationCurve { + pub const fn new() -> Self { + Self { keyframes: Vec::new() } + } + + pub fn evaluate(&self, time: f64) -> f64 { + if self.keyframes.is_empty() || !time.is_finite() { + return 0.0; + } + + // keyframes have finite, real coordinates + let index = self.keyframes.binary_search_by(|kf| kf.knot.x.partial_cmp(&time).unwrap_or(std::cmp::Ordering::Equal)); + + // We are on a keyframe, use its knot + if let Ok(idx) = index { + return self.keyframes[idx].knot.y; + } + + let index = index.unwrap_err(); + + // Clamp to the first and last knot y-values when x is outside of all keyframes + if index == 0 { + return self.keyframes[0].knot.y; + } else if index == self.keyframes.len() { + // unwrap is safe because of the non-empty guard at the top + return self.keyframes.last().unwrap().knot.y; + } + + let segment_start = &self.keyframes[index - 1]; + let segment_end = &self.keyframes[index]; + + match segment_start.interp_behavior { + InterpolationBehavior::Bezier { right_handle } => { + let start = segment_start.knot; + let end = segment_end.knot; + let left_handle = segment_end.left_handle.unwrap_or(end); + + // Clamp the handle x-coordinates of the handles to inside the segment. + // This prevents the curve from folding over itself and having multiple values of t where x(t) == time. + let right_x = right_handle.x.clamp(start.x, end.x); + let left_x = left_handle.x.clamp(right_x, end.x); + + let curve = CubicBez::new( + Point::new(start.x, start.y), + Point::new(right_x, right_handle.y), + Point::new(left_x, left_handle.y), + Point::new(end.x, end.y), + ); + + // Find the value of t where curve.x == time to find the value + let t = kurbo::common::solve_itp(|t| curve.eval(t).x - time, 0.0, 1.0, 1e-7, 1, 0.2, segment_start.knot.x - time, segment_end.knot.x - time); + + curve.eval(t).y + } + InterpolationBehavior::Constant => segment_start.knot.y, + InterpolationBehavior::Linear => { + let start = segment_start.knot.y; + let end = segment_end.knot.y; + let i = (time - segment_start.knot.x) / (segment_end.knot.x - segment_start.knot.x); + + start + (end - start) * i + } + } + } + + pub fn keyframes(&self) -> &[Keyframe] { + &self.keyframes + } + + /// Pushes a new keyframe, overwriting one with the same x-value. + /// Returns the index of the keyframe. + /// + /// # Panics + /// + /// This method panics if a keyframe with a non-finite x-coordinate is given. + pub fn insert_keyframe(&mut self, keyframe: Keyframe) -> usize { + assert!(keyframe.knot.x.is_finite(), "Keyframes must have a finite x-coordinate"); + + match self.keyframes.binary_search_by(|kf| kf.knot.x.partial_cmp(&keyframe.knot.x).unwrap_or(std::cmp::Ordering::Equal)) { + // Overwrite a keyframe with the same x-value + Ok(idx) => { + self.keyframes[idx] = keyframe; + idx + } + Err(idx) => { + self.keyframes.insert(idx, keyframe); + idx + } + } + } + + pub fn remove_keyframe(&mut self, idx: usize) -> Option { + if idx >= self.keyframes.len() { + return None; + } + Some(self.keyframes.remove(idx)) + } +} + +/// Deserialize a list of keyframes, ensuring they meet all evaluate preconditions +fn deserialize_keyframes<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let raw_keyframes = >::deserialize(deserializer)?; + let mut temp_curve = AnimationCurve::new(); + // use the existing logic for pushing keyframes to ensure the curve is valid + for kf in raw_keyframes { + if kf.knot.x.is_finite() { + temp_curve.insert_keyframe(kf); + } + } + Ok(temp_curve.keyframes) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + pub fn out_of_bounds() { + let empty_curve = AnimationCurve::new(); + assert_eq!(empty_curve.evaluate(10.0), 0.0); + + let mut single_kf = AnimationCurve::new(); + single_kf.insert_keyframe(Keyframe { + left_handle: None, + knot: DVec2::new(1.0, 10.0), + interp_behavior: InterpolationBehavior::Constant, + }); + assert_eq!(single_kf.evaluate(0.0), 10.0); + assert_eq!(single_kf.evaluate(2.0), 10.0); + } + + #[test] + pub fn bezier_segment() { + let mut anim_curve = AnimationCurve::new(); + anim_curve.insert_keyframe(Keyframe { + left_handle: None, + knot: DVec2::new(0.0, 0.0), + interp_behavior: InterpolationBehavior::Bezier { right_handle: DVec2::new(0.5, 0.0) }, + }); + anim_curve.insert_keyframe(Keyframe { + left_handle: Some(DVec2::new(0.5, 1.0)), + knot: DVec2::new(1.0, 1.0), + interp_behavior: InterpolationBehavior::Constant, + }); + + assert_eq!(anim_curve.evaluate(0.5), 0.5); + assert!((anim_curve.evaluate(0.25) - 0.104).abs() < 0.01); + assert!((anim_curve.evaluate(0.75) - 0.896).abs() < 0.01); + } + + #[test] + pub fn simple_segments() { + let mut anim_curve = AnimationCurve::new(); + anim_curve.insert_keyframe(Keyframe { + left_handle: None, + knot: DVec2::new(0.0, 0.0), + interp_behavior: InterpolationBehavior::Linear, + }); + anim_curve.insert_keyframe(Keyframe { + left_handle: None, + knot: DVec2::new(1.0, 1.0), + interp_behavior: InterpolationBehavior::Constant, + }); + anim_curve.insert_keyframe(Keyframe { + left_handle: None, + knot: DVec2::new(2.0, 0.0), + interp_behavior: InterpolationBehavior::Constant, + }); + anim_curve.insert_keyframe(Keyframe { + left_handle: None, + knot: DVec2::new(3.0, 1.0), + interp_behavior: InterpolationBehavior::Constant, + }); + + assert_eq!(anim_curve.evaluate(0.5), 0.5); + assert_eq!(anim_curve.evaluate(0.25), 0.25); + assert_eq!(anim_curve.evaluate(0.75), 0.75); + + assert_eq!(anim_curve.evaluate(2.5), 0.0); + } + + #[test] + pub fn constant_segment() { + let mut anim_curve = AnimationCurve::new(); + anim_curve.insert_keyframe(Keyframe { + left_handle: None, + knot: DVec2::new(0.0, 0.0), + interp_behavior: InterpolationBehavior::Constant, + }); + anim_curve.insert_keyframe(Keyframe { + left_handle: None, + knot: DVec2::new(1.0, 5.0), + interp_behavior: InterpolationBehavior::Constant, + }); + anim_curve.insert_keyframe(Keyframe { + left_handle: None, + knot: DVec2::new(2.0, -3.0), + interp_behavior: InterpolationBehavior::Constant, + }); + + assert_eq!(anim_curve.evaluate(-1.0), 0.0); + assert_eq!(anim_curve.evaluate(0.0), 0.0); + assert_eq!(anim_curve.evaluate(0.5), 0.0); + assert_eq!(anim_curve.evaluate(1.0), 5.0); + assert_eq!(anim_curve.evaluate(2.0), -3.0); + } +} diff --git a/node-graph/nodes/gcore/Cargo.toml b/node-graph/nodes/gcore/Cargo.toml index 1e22058ff7..6f5551a089 100644 --- a/node-graph/nodes/gcore/Cargo.toml +++ b/node-graph/nodes/gcore/Cargo.toml @@ -24,6 +24,7 @@ graphene-hash = { workspace = true } raster-types = { workspace = true } graphic-types = { workspace = true } node-macro = { workspace = true } +graphene-animation = { workspace = true } # Workspace dependencies dyn-any = { workspace = true } diff --git a/node-graph/nodes/gcore/src/animation.rs b/node-graph/nodes/gcore/src/animation.rs index 4182847d00..a087640097 100644 --- a/node-graph/nodes/gcore/src/animation.rs +++ b/node-graph/nodes/gcore/src/animation.rs @@ -2,6 +2,7 @@ use core_types::list::List; use core_types::transform::Footprint; use core_types::{CacheHash, CloneVarArgs, Color, Context, Ctx, ExtractAll, ExtractAnimationTime, ExtractPointerPosition, ExtractRealTime, OwnedContextImpl}; use glam::{DAffine2, DVec2}; +use graphene_animation::AnimationCurve; use graphic_types::vector_types::GradientStops; use graphic_types::{Artboard, Graphic, Vector}; use raster_types::{CPU, GPU, Raster}; @@ -27,6 +28,13 @@ pub enum AnimationTimeMode { FrameNumber, } +/// Evaluate the value of an animation curve +#[node_macro::node(category("Animation"))] +fn eval_curve(ctx: impl Ctx + ExtractAnimationTime, _primary: (), curve: AnimationCurve) -> f64 { + let time = ctx.try_animation_time().unwrap_or_default(); + curve.evaluate(time) +} + /// Produces a chosen representation of the current real time and date (in UTC) based on the system clock. #[node_macro::node(category("Animation"))] fn real_time( diff --git a/node-graph/nodes/gcore/src/context_modification.rs b/node-graph/nodes/gcore/src/context_modification.rs index aa8f72f1d4..e62deba1bb 100644 --- a/node-graph/nodes/gcore/src/context_modification.rs +++ b/node-graph/nodes/gcore/src/context_modification.rs @@ -5,6 +5,7 @@ use core_types::transform::Footprint; use core_types::uuid::NodeId; use core_types::{Color, OwnedContextImpl}; use glam::{DAffine2, DVec2}; +use graphene_animation::AnimationCurve; use graphic_types::vector_types::GradientStops; use graphic_types::{Artboard, Graphic, Vector}; use raster_types::{CPU, GPU, Raster}; @@ -40,6 +41,7 @@ async fn context_modification( Context -> AttributeDyn, Context -> AttributeValueDyn, Context -> ListDyn, + Context -> AnimationCurve, )] value: impl Node, Output = T>, /// The parts of the context to keep when evaluating the input value. All other parts are nullified. diff --git a/node-graph/preprocessor/src/lib.rs b/node-graph/preprocessor/src/lib.rs index 4e543a7b7a..9442634ffb 100644 --- a/node-graph/preprocessor/src/lib.rs +++ b/node-graph/preprocessor/src/lib.rs @@ -23,6 +23,7 @@ impl Preprocessor { pub fn preprocess(&self, network: &mut NodeNetwork, resolve_resource: &dyn Fn(ResourceId) -> Option) -> Result<(), PreprocessorError> { self.insert_inject_scopes(network); self.replace_resource_inputs(network, resolve_resource)?; + self.replace_animation_curve_inputs(network); self.expand_network(network); Ok(()) } @@ -41,47 +42,103 @@ impl Preprocessor { } } - /// Replace every `TaggedValue::Resource(hash)` input with a reference to a freshly inserted `resource` proto node. - fn replace_resource_inputs(&self, network: &mut NodeNetwork, resolve_resource: &dyn Fn(ResourceId) -> Option) -> Result<(), PreprocessorError> { - let mut hash_to_node_id: HashMap = HashMap::new(); - let mut new_resource_nodes: Vec<(NodeId, DocumentNode)> = Vec::new(); + /// Visits each node in the network and passes it to the visitor closure + fn visit_nodes(&self, network: &mut NodeNetwork, mut visitor: impl FnMut(&mut DocumentNode, &mut Vec<(NodeId, DocumentNode)>) -> Result<(), PreprocessorError>) -> Result<(), PreprocessorError> { + // Inner function avoids type recursion issues with dyn + fn inner(network: &mut NodeNetwork, visitor: &mut dyn FnMut(&mut DocumentNode, &mut Vec<(NodeId, DocumentNode)>) -> Result<(), PreprocessorError>) -> Result<(), PreprocessorError> { + let mut inserts = Vec::new(); - for node in network.nodes.values_mut() { - if let DocumentNodeImplementation::Network(nested) = &mut node.implementation { - self.replace_resource_inputs(nested, resolve_resource)?; - continue; + for node in network.nodes.values_mut() { + if let DocumentNodeImplementation::Network(nested) = &mut node.implementation { + inner(nested, visitor)?; + continue; + } + + visitor(node, &mut inserts)?; } - if matches!(&node.implementation, DocumentNodeImplementation::ProtoNode(identifier) if *identifier == platform_application_io::resource::IDENTIFIER) { - continue; + for (id, node) in inserts { + network.nodes.insert(id, node); + } + + Ok(()) + } + + inner(network, &mut visitor) + } + + /// Visits each input, excluding nodes with a given identifier + fn visit_inputs_exclude( + &self, + network: &mut NodeNetwork, + exclude: &ProtoNodeIdentifier, + mut visitor: impl FnMut(&mut NodeInput, &mut Vec<(NodeId, DocumentNode)>) -> Result<(), PreprocessorError>, + ) -> Result<(), PreprocessorError> { + self.visit_nodes(network, |node, inserts| { + if matches!(&node.implementation, DocumentNodeImplementation::ProtoNode(identifier) if identifier == exclude) { + return Ok(()); } for input in node.inputs.iter_mut() { - let NodeInput::Value { tagged_value, .. } = input else { continue }; - let TaggedValue::Resource(resource_id) = **tagged_value else { continue }; + visitor(input, inserts)?; + } + + Ok(()) + }) + } - let Some(hash) = resolve_resource(resource_id) else { - return Err(PreprocessorError::ResourceNotFound(resource_id)); + /// Replace every `TaggedValue::AnimationCurve(curve)` input with a reference to a freshly inserted `eval_curve` proto node. + fn replace_animation_curve_inputs(&self, network: &mut NodeNetwork) { + self.visit_inputs_exclude(network, &graphene_core::animation::eval_curve::IDENTIFIER, |input, new_curve_nodes| { + let NodeInput::Value { tagged_value, .. } = input else { return Ok(()) }; + let TaggedValue::AnimationCurve(ref curve) = **tagged_value else { return Ok(()) }; + + let id = NodeId::new(); + let resource_node = DocumentNode { + inputs: vec![NodeInput::value(TaggedValue::None, false), NodeInput::value(TaggedValue::AnimationCurve(curve.clone()), false)], + implementation: DocumentNodeImplementation::ProtoNode(graphene_core::animation::eval_curve::IDENTIFIER), + context_features: ContextDependencies { + extract: ContextFeatures::ANIMATION_TIME, + inject: ContextFeatures::empty(), + }, + ..Default::default() + }; + new_curve_nodes.push((id, resource_node)); + + *input = NodeInput::node(id, 0); + + Ok(()) + }) + .unwrap(); // infallible + } + + /// Replace every `TaggedValue::Resource(hash)` input with a reference to a freshly inserted `resource` proto node. + fn replace_resource_inputs(&self, network: &mut NodeNetwork, resolve_resource: &dyn Fn(ResourceId) -> Option) -> Result<(), PreprocessorError> { + let mut hash_to_node_id: HashMap = HashMap::new(); + + self.visit_inputs_exclude(network, &platform_application_io::resource::IDENTIFIER, |input, new_resource_nodes| { + let NodeInput::Value { tagged_value, .. } = input else { return Ok(()) }; + let TaggedValue::Resource(resource_id) = **tagged_value else { return Ok(()) }; + + let Some(hash) = resolve_resource(resource_id) else { + return Err(PreprocessorError::ResourceNotFound(resource_id)); + }; + + let resource_id = *hash_to_node_id.entry(hash).or_insert_with(|| { + let id = NodeId::new(); + let resource_node = DocumentNode { + inputs: vec![NodeInput::value(TaggedValue::ResourceHash(hash), false), NodeInput::scope("editor-api")], + implementation: DocumentNodeImplementation::ProtoNode(platform_application_io::resource::IDENTIFIER), + ..Default::default() }; + new_resource_nodes.push((id, resource_node)); + id + }); - let resource_id = *hash_to_node_id.entry(hash).or_insert_with(|| { - let id = NodeId::new(); - let resource_node = DocumentNode { - inputs: vec![NodeInput::value(TaggedValue::ResourceHash(hash), false), NodeInput::scope("editor-api")], - implementation: DocumentNodeImplementation::ProtoNode(platform_application_io::resource::IDENTIFIER), - ..Default::default() - }; - new_resource_nodes.push((id, resource_node)); - id - }); - - *input = NodeInput::node(resource_id, 0); - } - } + *input = NodeInput::node(resource_id, 0); - for (id, node) in new_resource_nodes { - network.nodes.insert(id, node); - } + Ok(()) + })?; Ok(()) }