Merge branch 'main' into feat/mediapipe-vision

This commit is contained in:
RyanOnTheInside
2025-04-23 17:58:24 +02:00
committed by GitHub
7 changed files with 930 additions and 15 deletions
+5 -3
View File
@@ -8,17 +8,19 @@ on:
paths:
- "pyproject.toml"
permissions:
issues: write
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
# if this is a forked repository. Skipping the workflow.
if: github.event.repository.fork == false
if: ${{ github.repository_owner == 'ryanontheinside' }}
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
uses: Comfy-Org/publish-node-action@v1
with:
## Add your own personal access token to your Github Repository secrets and reference it here.
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
@@ -1,4 +1,6 @@
from ....src.realtimenodes.control_base import ControlNodeBase
import copy
from ....src.utils.general_utils import AlwaysEqualProxy
class StateResetNode(ControlNodeBase):
"""Node that resets all control node states when triggered"""
@@ -21,12 +23,8 @@ class StateResetNode(ControlNodeBase):
CATEGORY = "Realtime Nodes/control/utility"
def update(self, trigger, always_execute=True):
print(f"\n=== StateResetNode UPDATE - node_id: {self.node_id} ===")
print(f"States before potential reset: {self.state_manager._states}")
if trigger:
print("RESETTING ALL STATES")
self.state_manager.clear_all_states()
print(f"States after reset: {self.state_manager._states}")
return (True,)
return (False,)
@@ -52,19 +50,87 @@ class StateTestNode(ControlNodeBase):
CATEGORY = "Realtime Nodes/control/utility"
def update(self, increment, always_execute=True):
print(f"\n=== StateTestNode UPDATE - node_id: {self.node_id} ===")
print(f"All states before get: {self.state_manager._states}")
state = self.get_state({
"counter": 0
})
print(f"Retrieved state: {state}")
state["counter"] += increment
print(f"Updated state before save: {state}")
self.set_state(state)
print(f"All states after save: {self.state_manager._states}")
return (state["counter"],)
class GetStateNode(ControlNodeBase):
"""
Node that retrieves a value from the global state using a user-specified key.
"""
CATEGORY = "utils"
RETURN_TYPES = (AlwaysEqualProxy("*"),)
RETURN_NAMES = ("value",)
FUNCTION = "update"
DESCRIPTION = "Retrieve a value from the global state using the given key. If the key is not found, return the default value."
@classmethod
def INPUT_TYPES(cls):
inputs = super().INPUT_TYPES()
inputs["required"].update({
"key": ("STRING", {"default": "default_key", "tooltip": "The key to retrieve the value from. If not provided, the default value will be returned."}),
"default_value": (AlwaysEqualProxy("*"), {"tooltip": "The value to return if the key is not found."}),
"use_default": ("BOOLEAN", {"default": False, "tooltip": "If True, the default value will be returned if the key is not found."}),
})
return inputs
def update(self, key: str, default_value, use_default: bool, always_execute=True):
"""
Retrieve a value from the global state using the given key.
"""
if not key or use_default:
return (default_value,)
# Get the shared state dictionary
shared_state = self.state_manager.get_state("__shared_keys__", {})
# Check if the key exists
if key in shared_state:
return (shared_state[key],)
# Return default value if key not found
return (default_value,)
class SetStateNode(ControlNodeBase):
"""
Node that stores a value in the global state with a user-specified key.
The value will be accessible in future workflow runs through GetStateNode.
"""
CATEGORY = "utils"
RETURN_TYPES = (AlwaysEqualProxy("*"),)
RETURN_NAMES = ("value",)
FUNCTION = "update"
OUTPUT_NODE = True
DESCRIPTION = "Store a value in the global state with the given key. The value will be accessible in future workflow runs through GetStateNode."
@classmethod
def INPUT_TYPES(cls):
inputs = super().INPUT_TYPES()
inputs["required"].update({
"key": ("STRING", {"default": "default_key", "tooltip": "The key to store the value under. If not provided, the value will not be stored."}),
"value": (AlwaysEqualProxy("*"), {"tooltip": "The value to store in the global state."}),
})
return inputs
def update(self, key: str, value, always_execute=True):
"""
Store a value in the global state with the given key.
"""
if not key:
return (value,)
try:
shared_state = self.state_manager.get_state("__shared_keys__", {})
shared_state[key] = copy.deepcopy(value)
self.state_manager.set_state("__shared_keys__", shared_state)
except Exception as e:
print(f"[State Node] Error storing value: {str(e)}")
return (value,)
@@ -42,7 +42,7 @@ class HandTrackingNode:
RETURN_NAMES = ("debug_image", "hand_data")
FUNCTION = "track_hands"
CATEGORY = "image/processing"
DESCRIPTION = "(((EXPERIMENTAL))) Track hands in an image using MediaPipe. Returns a debug image with landmarks and a dictionary of hand data."
def __init__(self):
self.last_detection_conf = 0.5
self.last_tracking_conf = 0.5
+436
View File
@@ -3,6 +3,7 @@ import comfy.utils
import numpy as np
import base64
import re
import math
from io import BytesIO
from PIL import Image, ImageFont, ImageDraw, ImageOps
import nodes
@@ -574,4 +575,439 @@ class LoadImageFromPath_:
mask = mask.unsqueeze(0)
return (image, mask)
class FeedbackEffect:
"""
Ultra-fast real-time visual feedback processor designed for SetState/GetState loops.
Creates stunning, hypnotic visuals with optimized GPU-accelerated transformations.
For best results: connect to a SetState/GetState loop and adjust parameters in real-time.
"""
CATEGORY = "image/effects"
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "apply_effect"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE", {"tooltip": "Input image (typically from GetStateNode)"}),
"effect": (["fractal_zoom", "kaleidoscope", "plasma_field", "quantum_wave",
"neural_dream", "digital_flow", "hypershock"], {
"default": "fractal_zoom",
"tooltip": "Type of real-time effect to apply"
}),
"intensity": ("FLOAT", {
"default": 0.65,
"min": 0.1,
"max": 1.0,
"step": 0.05,
"tooltip": "Effect strength/intensity"
}),
"variation": ("INT", {
"default": 0,
"min": 0,
"max": 5,
"step": 1,
"tooltip": "Variation/mode of the selected effect"
}),
"speed": ("FLOAT", {
"default": 0.5,
"min": 0.05,
"max": 1.0,
"step": 0.05,
"tooltip": "Animation speed factor"
}),
"feedback_blend": ("FLOAT", {
"default": 0.95,
"min": 0.5,
"max": 0.999,
"step": 0.005,
"tooltip": "How much of previous frame to retain (higher = more trail effect)"
}),
"color_shift": ("FLOAT", {
"default": 0.2,
"min": 0.0,
"max": 1.0,
"step": 0.05,
"tooltip": "Amount of color transformation to apply"
}),
"reset": ("BOOLEAN", {
"default": False,
"tooltip": "Reset the feedback loop"
}),
}
}
def __init__(self):
self.frame_count = 0
self.time = 0
self.last_image = None
self.last_effect = None
self.effect_buffers = {}
def apply_effect(self, image, effect, intensity, variation, speed, feedback_blend, color_shift, reset):
batch_size, height, width, channels = image.shape
device = image.device
# Reset state if requested or if effect changes
if reset or effect != self.last_effect:
self.frame_count = 0
self.time = 0
self.effect_buffers = {}
# Update time and frame counter
self.frame_count += 1
frame_time = self.frame_count * speed * 0.03
self.time = frame_time
self.last_effect = effect
# Fast path for batch processing - we'll process each image separately
result_images = []
for b in range(batch_size):
img = image[b]
result = self._process_single_image(
img, effect, intensity, variation,
feedback_blend, color_shift, device,
width, height, frame_time, speed
)
result_images.append(result)
return (torch.stack(result_images),)
def _process_single_image(self, img, effect, intensity, variation,
feedback_blend, color_shift, device,
width, height, time, speed):
# Highly optimized processing path - calculate grid transformations in one go
# Coordinates grid (only calculate once and reuse)
if not hasattr(self, 'grid_cache') or self.grid_cache['size'] != (height, width):
y_norm, x_norm = torch.meshgrid(
torch.linspace(-1, 1, height, device=device),
torch.linspace(-1, 1, width, device=device),
indexing='ij'
)
self.grid_cache = {
'size': (height, width),
'x_norm': x_norm,
'y_norm': y_norm,
'r': torch.sqrt(x_norm**2 + y_norm**2),
'theta': torch.atan2(y_norm, x_norm)
}
# Get cached grids
x_norm = self.grid_cache['x_norm']
y_norm = self.grid_cache['y_norm']
r = self.grid_cache['r']
theta = self.grid_cache['theta']
# Initialize source coordinates to identity
source_x = x_norm.clone()
source_y = y_norm.clone()
# Apply selected effect transformation
if effect == "fractal_zoom":
# Fractal zoom with organic movement
zoom = 0.98 + math.sin(time * 2.5) * 0.02
# Variation affects spiral pattern
spiral = variation * 0.2 * intensity
angle = theta + r * spiral + time * speed
# Complex transform that creates evolving spiral patterns
dx = torch.sin(r * (3 + variation) + time * 3) * 0.05 * intensity
dy = torch.cos(r * (3 + variation) + time * 3.5) * 0.05 * intensity
# Apply zoom
source_x = (x_norm * zoom + dx) * (1 + 0.03 * intensity * math.sin(time))
source_y = (y_norm * zoom + dy) * (1 + 0.03 * intensity * math.cos(time))
# Add swirling effect
twist = r * (4.0 * intensity) + time * (1.5 + variation * 0.3)
source_x += torch.sin(twist + theta) * 0.05 * intensity
source_y += torch.cos(twist - theta) * 0.05 * intensity
elif effect == "kaleidoscope":
# Kaleidoscope effect with dynamic symmetry
segments = 4 + variation * 2
segment_angle = 2 * torch.pi / segments
# Rotate over time
rot_theta = theta + time * (0.2 + 0.1 * variation)
# Mirror segments
segment_id = torch.floor(rot_theta / segment_angle)
in_segment = rot_theta - segment_id * segment_angle
flip_mask = (segment_id % 2 == 0)
mirror_theta = torch.where(flip_mask, in_segment, segment_angle - in_segment)
# Apply transform with zoom breathing and ripples
zoom = 0.8 + 0.2 * math.sin(time * 1.5) + r * 0.1 * intensity
ripple = torch.sin(r * (8 + variation) - time * 3) * 0.06 * intensity
source_x = torch.cos(mirror_theta) * r * (zoom + ripple)
source_y = torch.sin(mirror_theta) * r * (zoom + ripple)
elif effect == "plasma_field":
# Plasma with flow dynamics
scale = 5.0 + variation * 2.0
speed_factor = 3.0 + variation * 0.5
# Create plasma-like distortion fields
plasma_x = torch.sin(x_norm * scale + time * speed_factor) * torch.cos(y_norm * (scale*0.8) + time)
plasma_y = torch.sin(y_norm * scale - time * (speed_factor*0.7)) * torch.cos(x_norm * (scale*0.9) - time * 1.3)
plasma_xy = torch.sin((x_norm + y_norm) * (scale*0.4) + time * 1.5) * 0.3
# Combine fields for rich movement
flow_x = (plasma_x + plasma_xy) * 0.15 * intensity
flow_y = (plasma_y + plasma_xy) * 0.15 * intensity
# Apply field distortion
source_x = x_norm + flow_x
source_y = y_norm + flow_y
elif effect == "quantum_wave":
# Quantum wave effect with interference patterns
time_factor = time * (2.0 + variation * 0.4)
# Dynamic wave parameters
wave_scale = (4.0 + variation) + math.sin(time) * 1.5
wave_speed = 1.5 + variation * 0.2
# Create interference pattern
wave1 = torch.sin(x_norm * wave_scale + time_factor * wave_speed)
wave2 = torch.sin(y_norm * wave_scale - time_factor * wave_speed)
wave3 = torch.sin((x_norm + y_norm) * wave_scale * 0.5 + time_factor * 1.3)
wave4 = torch.sin(r * wave_scale * 0.8 - time_factor)
# Combine wave patterns
interference = (wave1 + wave2 + wave3 + wave4) * 0.25
# Apply distortion with intensity scaling
dist_scale = 0.12 * intensity * (1.0 + 0.3 * math.sin(time * 0.5))
source_x = x_norm + interference * dist_scale * (1.0 + 0.5 * x_norm)
source_y = y_norm + interference * dist_scale * (1.0 + 0.5 * y_norm)
# Add subtle spiral
rotation = time * 0.3 * (1 + variation * 0.1)
rot_x = x_norm * torch.cos(torch.tensor(rotation, device=device)) - y_norm * torch.sin(torch.tensor(rotation, device=device))
rot_y = x_norm * torch.sin(torch.tensor(rotation, device=device)) + y_norm * torch.cos(torch.tensor(rotation, device=device))
# Blend rotation into final coordinates
source_x = source_x * 0.85 + rot_x * 0.15
source_y = source_y * 0.85 + rot_y * 0.15
elif effect == "neural_dream":
# Neural network-like patterns that evolve
# Create layered activation patterns
scale_factor = 3.0 + variation * 0.5
time_offset = time * (1.2 + variation * 0.2)
# First activation layer - positional encodings
act1 = torch.sin(x_norm * scale_factor + time_offset)
act2 = torch.sin(y_norm * scale_factor * 1.1 - time_offset * 0.7)
act3 = torch.sin((x_norm*0.5 + y_norm*1.5) * scale_factor * 0.7 + time_offset * 0.5)
# Second layer - non-linear combinations
layer2_1 = torch.tanh(act1 * act2 * 0.5 + act3 * 0.3)
layer2_2 = torch.tanh(act2 * act3 * 0.5 + act1 * 0.3)
# Output layer with residual connection
dx = layer2_1 * 0.15 * intensity
dy = layer2_2 * 0.15 * intensity
# Apply distortion with subtle spiral
angle = r * (variation * 0.2) + time * (0.2 + variation * 0.05)
source_x = x_norm + dx + torch.sin(angle) * r * 0.02 * intensity
source_y = y_norm + dy + torch.cos(angle) * r * 0.02 * intensity
elif effect == "digital_flow":
# Digital fluid-like motion with emergent patterns
# Base flow field
scale = 4.0 + variation
time_factor = time * (1.0 + variation * 0.2)
# Create flow vectors using simplex-like patterns
flow_x1 = torch.sin(x_norm * scale + time_factor) * torch.cos(y_norm * (scale*0.5) + time_factor * 0.6)
flow_y1 = torch.cos(x_norm * (scale*0.6) - time_factor * 0.7) * torch.sin(y_norm * scale - time_factor)
# Second flow field at different scale
flow_x2 = torch.sin((x_norm + y_norm) * (scale*0.4) + time_factor * 1.3)
flow_y2 = torch.cos((y_norm - x_norm) * (scale*0.4) - time_factor * 1.1)
# Combine flow fields
flow_x = (flow_x1 * 0.6 + flow_x2 * 0.4) * 0.12 * intensity
flow_y = (flow_y1 * 0.6 + flow_y2 * 0.4) * 0.12 * intensity
# Apply flow with subtle zoom pulse
zoom = 1.0 + math.sin(time * 1.5) * 0.01 * intensity
source_x = x_norm * zoom + flow_x
source_y = y_norm * zoom + flow_y
# Add digital quantization if higher variation
if variation >= 3:
# Quantize stronger for higher variations
quant_level = 15 + (5 - variation) * 5 # More divisions for lower variation
source_x = torch.round(source_x * quant_level) / quant_level
source_y = torch.round(source_y * quant_level) / quant_level
elif effect == "hypershock":
# High energy effect with shockwaves and energy fields
# Base frequencies
freq = 5.0 + variation
time_factor = time * (2.0 + variation * 0.3)
# Create pulsing shockwaves
shock1_radius = (time_factor * 0.7) % 2.0 - 1.0
shock1 = torch.exp(-((r - shock1_radius) * 10)**2) * 0.5
shock2_radius = (time_factor * 0.5 + 1.0) % 2.0 - 1.0
shock2 = torch.exp(-((r - shock2_radius) * 10)**2) * 0.4
# Energy field
energy_x = torch.sin(x_norm * freq + time_factor * 1.5) * 0.1
energy_y = torch.sin(y_norm * freq - time_factor * 1.3) * 0.1
# Combine effects
dx = (shock1 + shock2 * 0.7) * x_norm * 0.3 + energy_x
dy = (shock1 + shock2 * 0.7) * y_norm * 0.3 + energy_y
# Apply distortion with high energy scaling
source_x = x_norm + dx * intensity
source_y = y_norm + dy * intensity
# Add spiral warp for higher variations
if variation >= 2:
warp = torch.sin(r * (8 - variation) + time_factor) * 0.1 * intensity
source_x += torch.sin(theta + torch.tensor(time, device=device)) * warp
source_y += torch.cos(theta + torch.tensor(time, device=device)) * warp
# Ensure coordinates stay within bounds
source_x = torch.clamp(source_x, -1.98, 1.98)
source_y = torch.clamp(source_y, -1.98, 1.98)
# Convert from normalized coords to actual pixel sampling grid [-1, 1]
grid = torch.stack([source_x, source_y], dim=-1)
grid = grid.unsqueeze(0) # Add batch dimension
# Sample the image (fast tensor operation)
img_rgb = img[..., :3].permute(2, 0, 1).unsqueeze(0) # [1, C, H, W]
sampled = torch.nn.functional.grid_sample(
img_rgb, grid,
mode='bilinear',
padding_mode='reflection',
align_corners=False
).squeeze(0).permute(1, 2, 0) # Back to [H, W, C]
# Apply color transformations based on selected effect
if color_shift > 0:
# Optimize color processing by pushing calculations to the GPU
if effect == "fractal_zoom":
# Vibrant color amplification
color_factor = time * 0.1 % 1.0
hsv_rotation = torch.tensor([
[math.cos(color_factor * 6.28), -math.sin(color_factor * 6.28), 0],
[math.sin(color_factor * 6.28), math.cos(color_factor * 6.28), 0],
[0, 0, 1]
], device=device)
flat_rgb = sampled.reshape(-1, 3)
rotated_rgb = torch.matmul(flat_rgb, hsv_rotation)
sampled = rotated_rgb.reshape(sampled.shape)
# Vibrance enhancement
luma = sampled[..., 0] * 0.299 + sampled[..., 1] * 0.587 + sampled[..., 2] * 0.114
chroma = sampled - luma.unsqueeze(-1)
sampled = luma.unsqueeze(-1) + chroma * (1 + color_shift * 0.5)
elif effect in ["kaleidoscope", "hypershock"]:
# High contrast with glow
# Add bloom/glow to bright areas
bloom_threshold = 0.7
bloom_mask = (sampled.mean(dim=-1) > bloom_threshold).float().unsqueeze(-1)
bloom = sampled * bloom_mask * color_shift * 0.5
# Apply bloom with additive blending
sampled = torch.clamp(sampled + bloom, 0, 1)
# Edge enhancement for kaleidoscope
if effect == "kaleidoscope":
edge_x = torch.abs(sampled[:, 1:] - sampled[:, :-1]).mean(-1, keepdim=True)
edge_x = torch.cat([edge_x, edge_x[:, -1:]], dim=1)
edge_y = torch.abs(sampled[1:, :] - sampled[:-1, :]).mean(-1, keepdim=True)
edge_y = torch.cat([edge_y, edge_y[-1:, :]], dim=0)
edge = (edge_x + edge_y) * 0.5
sampled = torch.clamp(sampled + edge * color_shift, 0, 1)
elif effect in ["plasma_field", "neural_dream"]:
# Hue rotation with time
hue_shift = (time * 0.05) % 1.0
# RGB to HLS-like transform (simplified)
cmax, _ = torch.max(sampled, dim=-1, keepdim=True)
cmin, _ = torch.min(sampled, dim=-1, keepdim=True)
delta = cmax - cmin
# Calculate luminance
luma = (cmax + cmin) * 0.5
# Calculate saturation
sat = torch.where(delta > 0, delta / (1 - torch.abs(2 * luma - 1) + 1e-6), torch.zeros_like(delta))
# Enhance saturation
sat = torch.clamp(sat * (1 + color_shift), 0, 1)
# Apply new saturation while preserving luminance
normalized = (sampled - cmin) / (delta + 1e-6)
shifted = luma + (normalized - 0.5) * sat * (1 - torch.abs(2 * luma - 1))
sampled = torch.clamp(shifted, 0, 1)
elif effect == "quantum_wave":
# Quantum interference colors
# Shift RGB channels with interference pattern
shift = torch.sin(r * 4 + time * 2) * 0.1 * color_shift
# RGB channel shifting
r_shift = torch.roll(sampled[..., 0], shifts=int(shift[0,0].item() * width), dims=0)
r_shift = torch.roll(r_shift, shifts=int(shift[0,0].item() * height), dims=1)
b_shift = torch.roll(sampled[..., 2], shifts=-int(shift[0,0].item() * width), dims=0)
b_shift = torch.roll(b_shift, shifts=-int(shift[0,0].item() * height), dims=1)
sampled = torch.stack([r_shift, sampled[..., 1], b_shift], dim=-1)
elif effect == "digital_flow":
# Digital color quantization
if color_shift > 0.3:
quant_levels = max(3, int(10 * (1 - color_shift))) # Fewer levels with higher color_shift
sampled = torch.floor(sampled * quant_levels) / quant_levels
# Alpha handling
if img.shape[-1] == 4:
# Keep original alpha channel
alpha = torch.nn.functional.grid_sample(
img[..., 3:].permute(2, 0, 1).unsqueeze(0),
grid,
mode='bilinear',
padding_mode='reflection',
align_corners=False
).squeeze(0).permute(1, 2, 0)
# Recombine with RGB
sampled = torch.cat([sampled, alpha], dim=-1)
# Apply feedback blend if we have a last image
if self.last_image is not None and img.shape == self.last_image.shape:
# Optimize blend operation
result = sampled * (1.0 - feedback_blend) + self.last_image * feedback_blend
else:
result = sampled
# Store for next frame
self.last_image = result
return result
+2
View File
@@ -1,7 +1,9 @@
[project]
name = "comfyui_realtimenodes"
description = "These nodes are for real-time applications of ComfyUI."
version = "1.3.0"
license = {file = "LICENSE"}
dependencies = ["opencv-python"]
+409
View File
@@ -0,0 +1,409 @@
import { app } from "../../../scripts/app.js";
// Track all keys available
const stateKeys = new Set();
// Track connections to visualize data flow between set and get nodes
const connectionVisualizer = {
// Map of key -> {setNode, getNodes}
connections: {},
registerSetNode: function(node, key) {
if (!key) return;
// Initialize if not exists
if (!this.connections[key]) {
this.connections[key] = { setNode: null, getNodes: [] };
}
this.connections[key].setNode = node;
// Find corresponding get nodes
this.updateConnections(key);
},
registerGetNode: function(node, key) {
if (!key) return;
// Initialize if not exists
if (!this.connections[key]) {
this.connections[key] = { setNode: null, getNodes: [] };
}
// Add to getNodes if not already there
if (!this.connections[key].getNodes.includes(node)) {
this.connections[key].getNodes.push(node);
}
// Update connections
this.updateConnections(key);
},
unregisterNode: function(node, key) {
if (!key || !this.connections[key]) return;
// Remove from connections
if (this.connections[key].setNode === node) {
this.connections[key].setNode = null;
}
const getNodes = this.connections[key].getNodes;
const index = getNodes.indexOf(node);
if (index !== -1) {
getNodes.splice(index, 1);
}
// Remove key if no nodes left
if (!this.connections[key].setNode && this.connections[key].getNodes.length === 0) {
delete this.connections[key];
}
},
updateConnections: function(key) {
if (!key || !this.connections[key]) return;
const connection = this.connections[key];
// Update visual properties for set node
if (connection.setNode) {
connection.setNode.title = `Set State: ${key}`;
}
// Update visual properties for get nodes
connection.getNodes.forEach(getNode => {
getNode.title = `Get State: ${key}`;
});
},
getSetNodeForKey: function(key) {
return this.connections[key]?.setNode;
},
getNodesForKey: function(key) {
return this.connections[key]?.getNodes || [];
}
};
// Helper to show alerts to the user
function showAlert(message) {
app.extensionManager.toast.add({
severity: 'info',
summary: "State Nodes",
detail: `${message}`,
life: 3000,
});
}
// Helper to find all set nodes with a given key
function findSetNodesWithKey(graph, key) {
return graph._nodes.filter(node =>
node.type === "SetStateNode" &&
node.widgets &&
node.widgets.find(w => w.name === "key" && w.value === key)
);
}
// Helper to find all get nodes with a given key
function findGetNodesWithKey(graph, key) {
return graph._nodes.filter(node =>
node.type === "GetStateNode" &&
node.widgets &&
node.widgets.find(w => w.name === "key" && w.value === key)
);
}
// Helper to find all get nodes in the graph
function findAllGetNodes(graph) {
return graph._nodes.filter(node => node.type === "GetStateNode");
}
// Helper to update all get nodes with the current list of keys
function updateAllGetNodesDropdowns(graph) {
const getNodes = findAllGetNodes(graph);
getNodes.forEach(getNode => {
const keyWidget = getNode.widgets.find(w => w.name === "key");
if (keyWidget && keyWidget.options) {
// Update the values in the dropdown
keyWidget.options.values = [...stateKeys].sort();
// If the current key no longer exists, reset it
if (keyWidget.value && !stateKeys.has(keyWidget.value)) {
keyWidget.value = "";
getNode.title = "Get State";
}
}
});
}
// Register the SetStateNode for UI customization
app.registerExtension({
name: "SetStateNode",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "SetStateNode") return;
// Add custom widgets and behavior
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
if (onNodeCreated) {
onNodeCreated.apply(this, arguments);
}
// Store reference to the node
const node = this;
// Track key changes
const keyWidget = node.widgets.find(w => w.name === "key");
if (keyWidget) {
const originalCallback = keyWidget.callback;
keyWidget.callback = function(value) {
if (originalCallback) {
originalCallback.apply(this, arguments);
}
// Add to available keys
if (value && value.trim() !== "") {
stateKeys.add(value);
node.title = "Set State: " + value;
// Update all GetStateNodes
if (node.graph) {
updateAllGetNodesDropdowns(node.graph);
}
}
};
// Initialize if there's a default value
if (keyWidget.value && keyWidget.value.trim() !== "") {
stateKeys.add(keyWidget.value);
node.title = "Set State: " + keyWidget.value;
}
}
// Set node appearance
this.color = "#2a363b";
this.bgcolor = "#3f5159";
// Add extra context menu items
const getExtraMenuOptions = node.getExtraMenuOptions;
node.getExtraMenuOptions = function(_, options) {
if (getExtraMenuOptions) {
getExtraMenuOptions.apply(this, arguments);
}
const keyWidget = node.widgets.find(w => w.name === "key");
if (keyWidget && keyWidget.value) {
const key = keyWidget.value;
// Add menu to see all connected get nodes
const getNodes = findGetNodesWithKey(this.graph, key);
if (getNodes.length > 0) {
const getNodesSubmenu = getNodes.map(getNode => ({
content: `Get Node: ${getNode.id}`,
callback: () => {
app.canvas.centerOnNode(getNode);
app.canvas.selectNode(getNode);
}
}));
options.unshift({
content: "Connected Get Nodes",
has_submenu: true,
submenu: {
options: getNodesSubmenu
}
});
}
}
};
// When the node is removed
const onRemoved = node.onRemoved;
node.onRemoved = function() {
if (onRemoved) {
onRemoved.apply(this, arguments);
}
// Check if we need to remove the key from the available keys
const keyWidget = node.widgets.find(w => w.name === "key");
if (keyWidget && keyWidget.value) {
const key = keyWidget.value;
// Only remove if no other SetStateNodes are using this key
const otherSetNodes = findSetNodesWithKey(this.graph, key);
if (otherSetNodes.length <= 1) { // 1 because this node is still in the list
stateKeys.delete(key);
// Update the dropdowns in all GetStateNodes
updateAllGetNodesDropdowns(this.graph);
}
}
};
};
}
});
// Register the GetStateNode for UI customization
app.registerExtension({
name: "GetStateNode",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "GetStateNode") return;
// Add custom widgets and behavior
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
if (onNodeCreated) {
onNodeCreated.apply(this, arguments);
}
// Store reference to the node
const node = this;
// Set node appearance
this.color = "#2a363b";
this.bgcolor = "#3f5159";
// Find the key widget and enhance it
const keyWidget = node.widgets.find(w => w.name === "key");
if (keyWidget) {
// Replace widget with combo
const index = node.widgets.indexOf(keyWidget);
const keyValue = keyWidget.value; // Save current value
// Remove old widget
node.widgets.splice(index, 1);
// Add new combo widget
const newWidget = node.addWidget("combo", "key", keyValue, function(value) {
if (value) {
node.title = "Get State: " + value;
} else {
node.title = "Get State";
}
}, {
values: () => [...stateKeys].sort(),
name: "key", // Ensure name matches for server
});
// Move it to the correct position
if (index !== node.widgets.length - 1) {
const removed = node.widgets.pop();
node.widgets.splice(index, 0, removed);
}
// Initialize title
if (newWidget.value) {
node.title = "Get State: " + newWidget.value;
} else {
node.title = "Get State";
}
}
// Add extra context menu items
const getExtraMenuOptions = node.getExtraMenuOptions;
node.getExtraMenuOptions = function(_, options) {
if (getExtraMenuOptions) {
getExtraMenuOptions.apply(this, arguments);
}
const keyWidget = node.widgets.find(w => w.name === "key");
if (keyWidget && keyWidget.value) {
const key = keyWidget.value;
// Add menu to go to set nodes with this key
const setNodes = findSetNodesWithKey(this.graph, key);
if (setNodes.length > 0) {
const setNodesSubmenu = setNodes.map(setNode => ({
content: `Set Node: ${setNode.id}`,
callback: () => {
app.canvas.centerOnNode(setNode);
app.canvas.selectNode(setNode);
}
}));
options.unshift({
content: setNodes.length === 1 ? "Go to Set Node" : "Go to Set Nodes",
has_submenu: setNodes.length > 1,
callback: setNodes.length === 1 ? (() => {
app.canvas.centerOnNode(setNodes[0]);
app.canvas.selectNode(setNodes[0]);
}) : undefined,
submenu: setNodes.length > 1 ? {
options: setNodesSubmenu
} : undefined
});
}
// Add menu to see other get nodes with the same key
const getNodes = findGetNodesWithKey(this.graph, key).filter(n => n !== this);
if (getNodes.length > 0) {
const getNodesSubmenu = getNodes.map(getNode => ({
content: `Get Node: ${getNode.id}`,
callback: () => {
app.canvas.centerOnNode(getNode);
app.canvas.selectNode(getNode);
}
}));
options.unshift({
content: "Other Get Nodes",
has_submenu: true,
submenu: {
options: getNodesSubmenu
}
});
}
}
};
};
}
});
// Register the other state-related nodes
app.registerExtension({
name: "StateUtilityNodes",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "ClearStateNode" && nodeData.name !== "ListStatesNode") return;
// Add custom appearance
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
if (onNodeCreated) {
onNodeCreated.apply(this, arguments);
}
// Set node appearance
this.color = "#2a363b";
this.bgcolor = "#3f5159";
// When ClearStateNode is executed, also update the UI
if (nodeData.name === "ClearStateNode") {
const onExecuted = this.onExecuted;
this.onExecuted = function(message) {
if (onExecuted) {
onExecuted.apply(this, arguments);
}
// Check if all states were cleared
const action = this.widgets.find(w => w.name === "action");
if (action && action.value === "Clear All States") {
// Clear the state keys set
stateKeys.clear();
showAlert("All states cleared");
// Update all GetStateNodes
updateAllGetNodesDropdowns(this.graph);
} else {
// Clear just one key
const keyWidget = this.widgets.find(w => w.name === "key");
if (keyWidget && keyWidget.value) {
stateKeys.delete(keyWidget.value);
showAlert(`State '${keyWidget.value}' cleared`);
// Update all GetStateNodes
updateAllGetNodesDropdowns(this.graph);
}
}
};
}
};
}
});