From 284456eeea99ea8bef78b7b20228dcd7e49db58e Mon Sep 17 00:00:00 2001 From: snomiao Date: Sat, 25 Jan 2025 17:30:01 +0000 Subject: [PATCH 1/4] chore(publish): update workflow for node publishing - Add permissions for issue writing - Modify condition to check repository owner instead of fork status - Update action version from main to v1 for stability and consistency --- .github/workflows/publish.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a8719ae..c7b1eca 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 }} From bbb29df2df025a823bf80189e4af98e4158b3f8f Mon Sep 17 00:00:00 2001 From: RyanOnTheInside <7623207+ryanontheinside@users.noreply.github.com> Date: Sat, 19 Apr 2025 16:09:46 -0400 Subject: [PATCH 2/4] toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7c07091..e3bf406 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui_realtimenodes" description = "These nodes are for real-time applications of ComfyUI." -version = "1.2.1" +version = "1.2.2" license = {file = "LICENSE"} dependencies = ["opencv-python"] From d882b9fc16412cdfdabd617c60797a9e6a3e6349 Mon Sep 17 00:00:00 2001 From: RyanOnTheInside <7623207+ryanontheinside@users.noreply.github.com> Date: Sun, 20 Apr 2025 08:45:11 -0400 Subject: [PATCH 3/4] feat: setstate getstate This primarily adds two new nodes -GetState -SetState It allows for state to be persisted and passed from one workflow run to another between get and set nodes. Additionally, there is an feedback effect node for experimentation. --- __init__.py | 53 ++- .../controls/state_management_controls.py | 88 +++- node_wrappers/realtimenodes/logic/__init__.py | 0 .../realtimenodes/media/media_pipe_nodes.py | 2 +- node_wrappers/utils/misc_nodes.py | 436 ++++++++++++++++++ web/js/statenodes.js | 409 ++++++++++++++++ 6 files changed, 960 insertions(+), 28 deletions(-) create mode 100644 node_wrappers/realtimenodes/logic/__init__.py create mode 100644 web/js/statenodes.js diff --git a/__init__.py b/__init__.py index 0516b7c..2bbc2a8 100644 --- a/__init__.py +++ b/__init__.py @@ -5,7 +5,7 @@ import re import traceback # Directory containing node wrapper modules relative to this __init__.py -WRAPPER_DIR = "node_wrappers.realtimenodes" +WRAPPER_DIR = "node_wrappers" NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} @@ -13,7 +13,7 @@ print("\033[93m" + "[ComfyUI_RealtimeNodes] Loading nodes...") def load_nodes_from_directory(package_path): """Dynamically loads nodes from Python files in a directory.""" - loaded_nodes = {} + loaded_nodes = {} loaded_display_names = {} # Calculate absolute path to the package directory @@ -61,22 +61,43 @@ def load_nodes_from_directory(package_path): # --- Main Loading Logic --- -# Define the subdirectories within WRAPPER_DIR to scan -# Order might matter if there are dependencies between modules, though ideally there shouldn't be -subdirs_to_scan = [ - "controls", - "media", - "utils", - "specialized" # Add other subdirs like 'specialized' if needed -] +# Get base path for the node_wrappers directory +base_dir_path = os.path.join(os.path.dirname(__file__), WRAPPER_DIR) -# Iterate through specified subdirectories and load nodes -for subdir in subdirs_to_scan: - dir_path = f"{WRAPPER_DIR}.{subdir}" - nodes, display_names = load_nodes_from_directory(dir_path) - NODE_CLASS_MAPPINGS.update(nodes) - NODE_DISPLAY_NAME_MAPPINGS.update(display_names) +# Recursively scan all directories and subdirectories +def scan_for_modules(base_path, base_package): + """Recursively scan directories for module files""" + try: + for item in os.listdir(base_path): + item_path = os.path.join(base_path, item) + + # Skip __pycache__ and files/dirs starting with __ + if item.startswith('__'): + continue + + if os.path.isdir(item_path): + # Handle subdirectory + subdir_package = f"{base_package}.{item}" + + # First load .py files in this directory + try: + nodes, display_names = load_nodes_from_directory(subdir_package) + NODE_CLASS_MAPPINGS.update(nodes) + NODE_DISPLAY_NAME_MAPPINGS.update(display_names) + except Exception as e: + print(f"\033[91m" + f"[ComfyUI_RealtimeNodes] Error processing directory {subdir_package}: {e}") + traceback.print_exc() + + # Then recursively process subdirectories + subdir_path = os.path.join(base_path, item) + scan_for_modules(subdir_path, subdir_package) + except Exception as e: + print(f"\033[91m" + f"[ComfyUI_RealtimeNodes] Error scanning directory {base_path}: {e}") + traceback.print_exc() +# Start recursive scanning from the base directory +print(f"\033[92m" + f"[ComfyUI_RealtimeNodes] Scanning node_wrappers directory for modules...") +scan_for_modules(base_dir_path, WRAPPER_DIR) suffix = " 🕒🅡🅣🅝" diff --git a/node_wrappers/realtimenodes/controls/state_management_controls.py b/node_wrappers/realtimenodes/controls/state_management_controls.py index 02f915b..7eb336e 100644 --- a/node_wrappers/realtimenodes/controls/state_management_controls.py +++ b/node_wrappers/realtimenodes/controls/state_management_controls.py @@ -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 = "real-time/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 = "real-time/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,) + diff --git a/node_wrappers/realtimenodes/logic/__init__.py b/node_wrappers/realtimenodes/logic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/node_wrappers/realtimenodes/media/media_pipe_nodes.py b/node_wrappers/realtimenodes/media/media_pipe_nodes.py index b13bcee..fc8c489 100644 --- a/node_wrappers/realtimenodes/media/media_pipe_nodes.py +++ b/node_wrappers/realtimenodes/media/media_pipe_nodes.py @@ -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 diff --git a/node_wrappers/utils/misc_nodes.py b/node_wrappers/utils/misc_nodes.py index 25c3e00..3779896 100644 --- a/node_wrappers/utils/misc_nodes.py +++ b/node_wrappers/utils/misc_nodes.py @@ -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 diff --git a/web/js/statenodes.js b/web/js/statenodes.js new file mode 100644 index 0000000..fae9abc --- /dev/null +++ b/web/js/statenodes.js @@ -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); + } + } + }; + } + }; + } +}); \ No newline at end of file From 97d028bcab0c7361750b62a8044b0a7477cae62c Mon Sep 17 00:00:00 2001 From: ryanontheinside Date: Sun, 20 Apr 2025 14:48:58 -0400 Subject: [PATCH 4/4] pyproject (cherry picked from commit 284cd1ecc2b46e9c156766d96a42354c85e2856b) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e3bf406..7f20946 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui_realtimenodes" description = "These nodes are for real-time applications of ComfyUI." -version = "1.2.2" +version = "1.2.3" license = {file = "LICENSE"} dependencies = ["opencv-python"]