diff --git a/__init__.py b/__init__.py index 253af2c..f0a193f 100644 --- a/__init__.py +++ b/__init__.py @@ -1,24 +1,92 @@ +from .node_configs import CombinedMeta +from collections import OrderedDict + +#allows for central management and inheritance of class variables for help documentation +class RyanOnTheInside(metaclass=CombinedMeta): + @classmethod + def get_description(cls): + + display_name = NODE_DISPLAY_NAME_MAPPINGS.get(cls.__name__, cls.__name__) + footer = "For more information, visit [RyanOnTheInside GitHub](https://github.com/ryanontheinside)." + display_name = display_name.replace(" | RyanOnTheInside", "") + + desc = f"# {display_name}\n\n" + + if hasattr(cls, 'DESCRIPTION'): + desc += f"{cls.DESCRIPTION}\n\n{footer}" + return desc + + if hasattr(cls, 'TOP_DESCRIPTION'): + desc += f"{cls.TOP_DESCRIPTION}\n\n" + + if hasattr(cls, "BASE_DESCRIPTION"): + desc += cls.BASE_DESCRIPTION + "\n\n" + + additional_info = OrderedDict() + for c in cls.mro()[::-1]: + if hasattr(c, 'ADDITIONAL_INFO'): + info = c.ADDITIONAL_INFO.strip() + + additional_info[c.__name__] = info + + if additional_info: + desc += "\n\n".join(additional_info.values()) + "\n\n" + + if hasattr(cls, 'BOTTOM_DESCRIPTION'): + desc += f"{cls.BOTTOM_DESCRIPTION}\n\n" + + desc += footer + return desc + from .nodes.masks.temporal_masks import ( MaskMorph, MaskTransform, MaskMath, MaskRings, MaskWarp, - #, MaskBlur, MaskThreshold, MaskTransform ) -from .nodes.masks.audio_masks import AudioControlledMaskMorph + +from .nodes.audio.audio_nodes import ( + AudioSeparator, + DownloadOpenUnmixModel, + AudioFeatureVisualizer, + FrequencyFilterCustom, + FrequencyFilterPreset, + AudioFilter, + +) + +from .nodes.flex.feature_extractors import( + + AudioFeatureExtractor, + TimeFeatureNode, + DepthFeatureNode, + ColorFeatureNode, + BrightnessFeatureNode, + MotionFeatureNode, +) + +from .nodes.flex.midi_feature_extractor import( + MIDILoadAndExtract, +) + +from .nodes.masks.flex_masks import ( + FlexMaskMorph, + FlexMaskWarp, + FlexMaskTransform, + FlexMaskMath +) + + from .nodes.masks.optical_flow_masks import ( OpticalFlowMaskModulation, OpticalFlowDirectionMask, - OpticalFlowParticleSystem + OpticalFlowParticleSystem, ) - - from .nodes.masks.particle_system_masks import ( ParticleEmissionMask, - #PulsatingParticleSystemMask, Vortex, GravityWell, ParticleEmitter, @@ -30,9 +98,56 @@ from .nodes.masks.particle_system_masks import ( ParticleSpeedModulation, ) -from .nodes.masks.utility_nodes import _mfc, TextMaskNode, MovingShape + + +from .nodes.masks.utility_nodes import ( + _mfc, + TextMaskNode, + MovingShape, +) + +###images + +from .nodes.images.image_utility_nodes import ( + DyeImage, +) +from .nodes.images.flex_images import ( + FlexImageEdgeDetect, + FlexImagePosterize, + FlexImageKaleidoscope, + FlexImageBloom, + FlexImageChromaticAberration, + FlexImageGlitch, + FlexImagePixelate, + FlexImageColorGrade, + FlexImageTiltShift, +) + +from .nodes.flex.feature_externals import ( + FeatureToWeightsStrategy, +) + +from .nodes.flex.feature_modulation import ( + FeatureMixer +) + + +import os +import folder_paths + +# Get the directory of the current file +current_dir = os.path.dirname(os.path.realpath(__file__)) + +# Register the midi_files directory +midi_path = os.path.join(current_dir, "data/midi_files") +folder_paths.add_model_folder_path("midi_files", midi_path) + +# Ensure the MIDI files directory exists +os.makedirs(midi_path, exist_ok=True) NODE_CLASS_MAPPINGS = { + + ###temporal "MaskMorph": MaskMorph, "MaskTransform":MaskTransform, "MaskMath":MaskMath, @@ -40,13 +155,13 @@ NODE_CLASS_MAPPINGS = { "MaskWarp":MaskWarp, + #optical flow "OpticalFlowMaskModulation": OpticalFlowMaskModulation, "OpticalFlowParticleSystem":OpticalFlowParticleSystem, - #"OpticalFlowDirectionMask":OpticalFlowDirectionMask, + "OpticalFlowDirectionMask":OpticalFlowDirectionMask, - + #particle simulation "ParticleEmissionMask":ParticleEmissionMask, - #"PulsatingParticleSystemMask":PulsatingParticleSystemMask, "Vortex":Vortex, "GravityWell":GravityWell, "EmitterMovement":EmitterMovement, @@ -56,39 +171,149 @@ NODE_CLASS_MAPPINGS = { "ParticleColorModulation":ParticleColorModulation, "ParticleSizeModulation":ParticleSizeModulation, "ParticleSpeedModulation":ParticleSpeedModulation, - # "AudioControlledMaskMorph": AudioControlledMaskMorph, + + + #flex masks + "FlexMaskMorph": FlexMaskMorph, + "FlexMaskWarp": FlexMaskWarp, + "FlexMaskTransform": FlexMaskTransform, + "FlexMaskMath": FlexMaskMath, + + #audio + "AudioSeparator": AudioSeparator, + "DownloadOpenUnmixModel":DownloadOpenUnmixModel, + "AudioFeatureVisualizer":AudioFeatureVisualizer, + "FrequencyFilterCustom": FrequencyFilterCustom, + "FrequencyFilterPreset": FrequencyFilterPreset, + "AudioFilter":AudioFilter, + + #features + "AudioFeatureExtractor":AudioFeatureExtractor, + "MIDILoadAndExtract":MIDILoadAndExtract, + "TimeFeatureNode":TimeFeatureNode, + "DepthFeatureNode": DepthFeatureNode, + "ColorFeatureNode":ColorFeatureNode, + "BrightnessFeatureNode":BrightnessFeatureNode, + "MotionFeatureNode":MotionFeatureNode, + "FeatureToWeightsStrategy": FeatureToWeightsStrategy, + "FeatureMixer":FeatureMixer, + 'FlexImageEdgeDetect':FlexImageEdgeDetect, + "FlexImagePosterize":FlexImagePosterize, + "FlexImageKaleidoscope":FlexImageKaleidoscope, + "FlexImageBloom":FlexImageBloom, + "FlexImageChromaticAberration":FlexImageChromaticAberration, + "FlexImageGlitch":FlexImageGlitch, + "FlexImagePixelate":FlexImagePixelate, + "FlexImageColorGrade":FlexImageColorGrade, + "FlexImageTiltShift":FlexImageTiltShift, + + + #garb + "DyeImage": DyeImage, "MovingShape": MovingShape, "_mfc":_mfc, "TextMaskNode":TextMaskNode, + + + + + + } +WEB_DIRECTORY = "./web/js" + NODE_DISPLAY_NAME_MAPPINGS = { - "MaskMorph": "Temporal Mask Morph | RyanOnTheInside", - "MaskTransform":"Temporal Mask Transform | RyanOnTheInside", - "MaskMath":"Temporal Mask Math | RyanOnTheInside", - "MaskRings":"Temporal Mask Rings | RyanOnTheInside", - "MaskWarp":"Mask Warp | RyanOnTheInside", + "MaskMorph": "Temporal Mask Morph", + "MaskTransform":"Temporal Mask Transform", + "MaskMath":"Temporal Mask Math", + "MaskRings":"Temporal Mask Rings", + "MaskWarp":"Temporal Mask Warp", - "OpticalFlowMaskModulation": "Optical Flow Mask Modulation | RyanOnTheInside", - "OpticalFlowParticleSystem":"Optical Flow Particle System | RyanOnTheInside", - #"OpticalFlowDirectionMask":"Optical Flow Direction Mask | RyanOnTheInside", + "OpticalFlowMaskModulation": "Optical Flow Mask Modulation", + "OpticalFlowParticleSystem":"Optical Flow Particle System", + #"OpticalFlowDirectionMask":"Optical Flow Direction Mask", - # "PulsatingParticleSystemMask":"Pulsating Particle System Mask | RyanOnTheInside", - "ParticleEmissionMask":"Particle Emission Mask | RyanOnTheInside", - "Vortex": "Vortex | RyanOnTheInside", - "GravityWell":"Gravity Well | RyanOnTheInside", - "ParticleEmitter": "Particle Emitter | RyanOnTheInside", - "EmitterMovement":"Emitter Movement | RyanOnTheInside", - "SpringJointSetting":"Spring Joint Setting | RyanOnTheInside", - "StaticBody":"Static Body | RyanOnTheInside", - "ParticleColorModulation":"Particle Color Modulation | RyanOnTheInside", - "ParticleSizeModulation": "Particle Size Modulation | RyanOnTheInside", - "ParticleSpeedModulation":"Particle Speed Modulation | RyanOnTheInside", + "ParticleEmissionMask":"Particle Emission Mask", + "Vortex": "Vortex", + "GravityWell":"Gravity Well", + "ParticleEmitter": "Particle Emitter", + "EmitterMovement":"Emitter Movement", + "SpringJointSetting":"Spring Joint Setting", + "StaticBody":"Static Body", + "ParticleColorModulation":"Particle Color Modulation", + "ParticleSizeModulation": "Particle Size Modulation", + "ParticleSpeedModulation":"Particle Speed Modulation", - # "AudioControlledMaskMorph": "Audio Controlled Mask Morph | RyanOnTheInside", + "AudioMaskMorph":"Audio Mask Morph", + "AudioMaskWarp": "Audio Mask Warp", + "AudioMaskTransform":"Audio Mask Transform", + "AudioMaskMath": "Audio Mask Math", + "AudioSeparator": "Audio Separator", + + "AudioFeatureVisualizer": "Audio Feature Visualizer" , + "Frequency Filter Custom": "Frequency Filter Custom", + "Frequency Filter Preset": "Frequency Filter Preset", + "AudioFilter": "Audio Filter", - "MovingShape": "Moving Shape | RyanOnTheInside", - "TextMaskNode":"Text Mask Node | RyanOnTheInside" -} \ No newline at end of file + "MIDILoadAndExtract": "MIDI Load & Feature Extract", + "AudioFeatureExtractor": "Audio Feature & Extractor", + "TimeFeatureNode": "Time Feature", + "DepthFeatureNode":"Depth Feature", + "BrightnessFeatureNode":"Brightness Feature", + "MotionFeatureNode":"Motion Feature", + + "MovingShape": "Moving Shape", + "TextMaskNode":"Text Mask Node", + + + "DyeImage" : "Dye Image", + # "FlexImageAdjustment":"Flex Image Adjustment", + # "FlexImageFilter":"Flex Image Filter", + # "FlexImageBlend":"Flex Image Blend", +} + + + + +import re + +suffix = " | RyanOnTheInside" + +for node_name in NODE_CLASS_MAPPINGS.keys(): + if node_name not in NODE_DISPLAY_NAME_MAPPINGS: + # Convert camelCase or snake_case to Title Case + display_name = ' '.join(word.capitalize() for word in re.findall(r'[A-Z]?[a-z]+|[A-Z]+(?=[A-Z][a-z]|\d|\W|$)|\d+', node_name)) + else: + display_name = NODE_DISPLAY_NAME_MAPPINGS[node_name] + + # Add the suffix if it's not already present + if not display_name.endswith(suffix): + display_name += suffix + + NODE_DISPLAY_NAME_MAPPINGS[node_name] = display_name + + +from aiohttp import web +from server import PromptServer +from pathlib import Path + +if hasattr(PromptServer, "instance"): + + # NOTE: we add an extra static path to avoid comfy mechanism + # that loads every script in web. + # + # Again credit to KJNodes and MTB nodes + + PromptServer.instance.app.add_routes( + [web.static("/ryanontheinside_web_async", (Path(__file__).parent.absolute() / "ryanontheinside_web_async").as_posix())] + ) + + + +for node_name, node_class in NODE_CLASS_MAPPINGS.items(): + if hasattr(node_class, 'get_description'): + desc = node_class.get_description() + node_class.DESCRIPTION = desc diff --git a/data/midi_files/sample_midi.mid b/data/midi_files/sample_midi.mid new file mode 100644 index 0000000..172176f Binary files /dev/null and b/data/midi_files/sample_midi.mid differ diff --git a/data/sample_midi.mp4 b/data/sample_midi.mp4 new file mode 100644 index 0000000..f71a9be Binary files /dev/null and b/data/sample_midi.mp4 differ diff --git a/node_configs.py b/node_configs.py new file mode 100644 index 0000000..d36bfe8 --- /dev/null +++ b/node_configs.py @@ -0,0 +1,718 @@ +#NOTE: this abstraction allows for both the documentation to be centrally managed and inherited +from abc import ABCMeta +class NodeConfigMeta(type): + def __new__(cls, name, bases, attrs): + new_class = super().__new__(cls, name, bases, attrs) + if name in NODE_CONFIGS: + for key, value in NODE_CONFIGS[name].items(): + setattr(new_class, key, value) + return new_class + +class CombinedMeta(NodeConfigMeta, ABCMeta): + pass + +def add_node_config(node_name, config): + NODE_CONFIGS[node_name] = config + +NODE_CONFIGS = {} + +add_node_config("MaskBase", { + "BASE_DESCRIPTION": """ +##Parameters +- `Masks`: Input mask or sequence of masks to be processed. (you can pass in a blank mask if you want) +- `Strength`: Controls the intensity of the effect (0.0 to 1.0). Higher values make the mask operation more pronounced. +- `Invert`: When enabled, reverses the mask, turning black areas white and vice versa. +- `Subtract Original`: Removes a portion of the original mask from the result (0.0 to 1.0). Higher values create more pronounced edge effects. +- `Grow with Blur`: Expands the mask edges (0.0 to 10.0). Higher values create softer, more expanded edges. +""" +}) + +add_node_config("TemporalMaskBase", { + "ADDITIONAL_INFO": """ +- `Start Frame`: The frame number where the effect begins (0 to 1000). +- `End Frame`: The frame number where the effect ends (0 to 1000). If set to 0, continues until the last frame. +- `Effect Duration`: Number of frames over which the effect is applied (0 to 1000). If 0, uses (End Frame - Start Frame). +- `Temporal Easing`: Controls how the effect strength changes over time, affecting the smoothness of transitions. + - Options: "ease_in_out", "linear", "bounce", "elastic", "none" +- `Palindrome`: When enabled, the effect plays forward then reverses within the specified duration, creating a back-and-forth motion. +""" +}) + +add_node_config("ParticleSystemMaskBase", { + "ADDITIONAL_INFO": """ +- `particle_count`: Total number of particles in the system (1 to 10000). More particles create denser effects. +- `particle_lifetime`: How long each particle exists in seconds (0.1 to 10.0). Longer lifetimes create more persistent effects. +- `wind_strength`: Power of the wind effect (-100.0 to 100.0). Positive values blow right, negative left. +- `wind_direction`: Angle of the wind in degrees (0.0 to 360.0). 0 is right, 90 is up, etc. +- `gravity`: Strength of downward pull (-1000.0 to 1000.0). Negative values make particles float up. +- `start_frame`: Frame to begin the particle effect (0 to 1000). +- `end_frame`: Frame to stop the particle effect (0 to 1000). +- `respect_mask_boundary`: When enabled, particles stay within the mask's shape. + +Optional inputs: +- `emitters`: Particle emitter configurations (PARTICLE_EMITTER type). Define where particles originate. +- `vortices`: Optional vortex configurations (VORTEX type). Create swirling effects. +- `wells`: Optional gravity well configurations (GRAVITY_WELL type). Create areas that attract or repel particles. +- `well_strength_multiplier`: Amplifies the power of gravity wells (0.0 to 10.0). Higher values create stronger attraction/repulsion. +""" +}) + +add_node_config("ParticleEmissionMask", { + "TOP_DESCRIPTION": "This is the main node for particle simulations. It creates dynamic, fluid-like effects through particle simulation. Supports multiple particle emitters, force fields (Gravity Well, Vortex), and allows for complex particle behaviors including boundary-respecting particles and static body interactions.", + "ADDITIONAL_INFO": """ +- `emission_strength`: Strength of particle emission effect (0.0 to 1.0), basically opacity +- `draw_modifiers`: Visibility of vortices and gravity wells (0.0 to 1.0) +""" +}) + +add_node_config("OpticalFlowMaskBase", { + "ADDITIONAL_INFO": """ +- `Images`: Input image sequence for optical flow calculation +- `Masks`: Input mask sequence to be processed +- `Strength`: Overall intensity of the effect (0.0 to 1.0). Higher values create more pronounced motion-based effects. +- `Flow Method`: Technique used for optical flow calculation. Each method has different speed/accuracy tradeoffs. + - Options: "Farneback", "LucasKanade", "PyramidalLK" +- `Flow Threshold`: Minimum motion required to trigger the effect (0.0 to 1.0). Higher values ignore subtle movements. +- `Magnitude Threshold`: Relative threshold for flow magnitude (0.0 to 1.0). Higher values focus on areas of stronger motion. +""" +}) + +add_node_config("OpticalFlowMaskModulation", { + "TOP_DESCRIPTION": "This is currently the main Optical Flow node. Use it to make motion based effects.", + "ADDITIONAL_INFO": """ +- `Modulation Strength`: Intensity of the modulation effect (0.0 to 5.0). Higher values create more pronounced motion trails. +- `Blur Radius`: Smoothing applied to the flow magnitude (0 to 20 pixels). Larger values create smoother trails. +- `Trail Length`: Number of frames for the trail effect (1 to 20). Longer trails last longer. +- `Decay Factor`: Rate of trail decay over time (0.1 to 1.0). Lower values make trails fade faster. +- `Decay Style`: Method of trail decay. + - Options: "fade" (opacity reduction), "thickness" (width reduction) +- `Max Thickness`: Maximum trail thickness for thickness-based decay (1 to 50 pixels). Larger values create thicker trails. +""" +}) + +add_node_config("OpticalFlowDirectionMask", { + "TOP_DESCRIPTION": "***WORK IN PROGRESS***" +}) + +add_node_config("OpticalFlowParticleSystem", { + "TOP_DESCRIPTION": "***WORK IN PROGRESS***" +}) + +add_node_config("MaskTransform", { + "TOP_DESCRIPTION": "Applies geometric transformations to the mask over time.", + "ADDITIONAL_INFO": """ +- `Transform Type`: The type of transformation to apply. + - Options: "translate", "rotate", "scale" +- `X Value`: Horizontal component of the transformation (-1000 to 1000). Positive values move right, negative left. +- `Y Value`: Vertical component of the transformation (-1000 to 1000). Positive values move up, negative down. +""" +}) + +add_node_config("MaskMorph", { + "TOP_DESCRIPTION": "Applies morphological operations to the mask, changing its shape over time.", + "ADDITIONAL_INFO": """ +- `Morph Type`: The type of morphological operation to apply. + - Options: "erode", "dilate", "open", "close" +- `Max Kernel Size`: Maximum size of the morphological kernel (3 to 21, odd numbers only). Larger values create more pronounced effects. +- `Max Iterations`: Maximum number of times to apply the operation (1 to 50). More iterations create more extreme effects. +""" +}) + +add_node_config("MaskMath", { + "TOP_DESCRIPTION": "Combines two masks using various mathematical operations.", + "ADDITIONAL_INFO": """ +- `Mask B`: Second mask to combine with the input mask. +- `Combination Method`: Mathematical operation to apply. + - Options: "add", "subtract", "multiply", "minimum", "maximum" +""" +}) + +add_node_config("MaskRings", { + "TOP_DESCRIPTION": "Creates concentric ring patterns based on the distance from the mask edges.", + "ADDITIONAL_INFO": """ +- `Num Rings`: Number of rings to generate (1 to 50). More rings create more detailed patterns. +- `Max Ring Width`: Maximum width of each ring as a fraction of the total distance (0.01 to 0.5). Larger values create wider rings. +""" +}) + +add_node_config("MaskWarp", { + "TOP_DESCRIPTION": "Applies various warping effects to the mask, creating distortions and movement.", + "ADDITIONAL_INFO": """ +- `Warp Type`: The type of warping effect to apply. Each creates a different distortion pattern. + - Options: "perlin" (noise-based), "radial" (circular), "swirl" (spiral) +- `Frequency`: Controls the scale of the warping effect (0.01 to 1.0). Higher values create more rapid changes in the warp pattern. +- `Amplitude`: Controls the strength of the warping effect (0.1 to 500.0). Higher values create more extreme distortions. +- `Octaves`: For noise-based warps, adds detail at different scales (1 to 8). More octaves create more complex, detailed patterns. +""" +}) + +add_node_config("BaseFeatureNode", { + "BASE_DESCRIPTION": "Provides a foundation for creating features that control mask modulation. Features are used to dynamically adjust mask operations over time or based on various inputs." +}) + +add_node_config("MIDILoadAndExtract", { + "TOP_DESCRIPTION": """ + Loads a MIDI file and extracts specified features for mask modulation. To use this, select the notes on the piano that you want to use to control modulations. + Many of the different types of information in the notes can be chosen as the driving feature.""", + "ADDITIONAL_INFO": """ +- `midi_file`: Path to the MIDI file to load and analyze +- `track_selection`: Which track(s) to analyze ("all" or specific track number) +- `attribute`: MIDI attribute to extract (e.g., "Note On/Off", "Pitchbend", "Pitch", "Aftertouch") +- `frame_rate`: Frame rate of the video to sync MIDI data with +- `video_frames`: Corresponding video frames (IMAGE type) +- `chord_only`: When true, only considers full chords (BOOLEAN) +- `notes`: Specific notes to consider (comma-separated string of MIDI note numbers, e.g., "60,64,67" for C major triad) +""" +}) + +add_node_config("AudioFilter", { + "TOP_DESCRIPTION": "Applies frequency filters to audio for targeted sound processing.", + "ADDITIONAL_INFO": """ +- `audio`: Input audio to be filtered (AUDIO type) +- `filters`: Frequency filters to be applied (FREQUENCY_FILTER type). These determine which frequencies are emphasized or reduced. +""" +}) + +add_node_config("FrequencyFilterPreset", { + "TOP_DESCRIPTION": "Creates preset filter chains for common audio processing tasks, simplifying complex audio manipulations.", + "ADDITIONAL_INFO": """ +- `preset`: Preset to use (e.g., "isolate_kick_drum" emphasizes low frequencies, "isolate_vocals" focuses on mid-range, "remove_rumble" cuts low frequencies) + +Optional inputs: +- `previous_filter`: Previous filter chain to append to, allowing for cumulative effects +""" +}) + +add_node_config("FrequencyFilterCustom", { + "TOP_DESCRIPTION": "Creates custom frequency filters.", + "ADDITIONAL_INFO": """ +- `filter_type`: Type of filter ("lowpass", "highpass", "bandpass") +- `order`: Filter order (1 to 10) +- `cutoff`: Cutoff frequency (20 to 20000 Hz) + +Optional inputs: +- `previous_filter`: Previous filter chain to append to +""" +}) + +add_node_config("AudioFeatureVisualizer", { + "TOP_DESCRIPTION": "***WORK IN PROGESS*** Visualizes various audio features, creating visual representations of sound characteristics.", + "ADDITIONAL_INFO": """ +- `audio`: Input audio to visualize (AUDIO type) +- `video_frames`: Corresponding video frames to overlay visualizations on (IMAGE type) +- `visualization_type`: Type of visualization to generate: + - "waveform": Shows amplitude over time + - "spectrogram": Displays frequency content over time + - "mfcc": Mel-frequency cepstral coefficients, useful for speech recognition + - "chroma": Represents pitch classes, useful for harmonic analysis + - "tonnetz": Tonal space representation + - "spectral_centroid": Shows the "center of mass" of the spectrum over time +- `frame_rate`: Frame rate of the video for synchronization +""" +}) + +add_node_config("AudioSeparator", { + "TOP_DESCRIPTION": "Separates an input audio track into its component parts using the Open-Unmix model.", + "ADDITIONAL_INFO": """ +- `audio`: Input audio to be separated (AUDIO type) +- `video_frames`: Corresponding video frames (IMAGE type) +- `frame_rate`: Frame rate of the video for synchronization + +Outputs: +- Original audio +- Isolated drums track +- Isolated vocals track +- Isolated bass track +- Isolated other instruments track +- FeaturePipe containing frame information + +This node uses the Open-Unmix model to separate the input audio into four stems: drums, vocals, bass, and other instruments. Each separated track is returned as an individual AUDIO type output, along with the original audio and a FeaturePipe for further processing. +""" +}) + +add_node_config("SpringJointSetting", { + "TOP_DESCRIPTION": "Defines the behavior of spring joints attached to particles.", + "ADDITIONAL_INFO": """ +- `stiffness`: Stiffness of the spring (0.0 to 1000.0). Higher values create stronger connections. +- `damping`: Damping factor of the spring (0.0 to 100.0). Higher values create more resistance to motion. +- `rest_length`: Rest length of the spring (0.0 to 100.0). Longer springs allow for more stretching. +- `max_distance`: Maximum distance the spring can stretch (0.0 to 500.0). Larger values allow for more elasticity. +""" +}) + +add_node_config("StaticBody", { + "TOP_DESCRIPTION": "Defines static bodies in the simulation that particles can interact with (think walls, barrier, ramps, etc.).", + "ADDITIONAL_INFO": """ +- `shape_type`: Type of shape ("segment" or "polygon") +- `x1`, `y1`, `x2`, `y2`: Coordinates defining the shape +- `elasticity`: Bounciness of the static body (0.0 to 1.0). Higher values create more bouncy collisions. +- `friction`: Friction of the static body (0.0 to 1.0). Higher values create more resistance to motion. +- `draw`: Whether to visualize the static body and how thick +- `color`: Color of the static body (RGB tuple) +""" +}) + +add_node_config("GravityWell", { + "TOP_DESCRIPTION": "An optional input for a simulation space. These can be chained together to add many to a simulation.", + "ADDITIONAL_INFO": """ +- `x`: X-coordinate of the gravity well (0.0 to 1.0) +- `y`: Y-coordinate of the gravity well (0.0 to 1.0) +- `strength`: Strength of the gravity well. Higher values create stronger attraction or repulsion. +- `radius`: Radius of effect for the gravity well. Larger values affect a wider area. +- `type`: Type of gravity well ('attract' or 'repel'). Attract pulls particles in, repel pushes them away. +- `color`: Color of the gravity well visualization (RGB tuple) +- `draw`: Thickness of the gravity well visualization (0.0 to 1.0) +""" +}) + +add_node_config("Vortex", { + "TOP_DESCRIPTION": "An optional input for a simulation space. These can be chained together to add many to a simulation", + "ADDITIONAL_INFO": """ +- `x`: X-coordinate of the vortex center (0.0 to 1.0) +- `y`: Y-coordinate of the vortex center (0.0 to 1.0) +- `strength`: Strength of the vortex effect (0.0 to 1000.0). Higher values create stronger swirling motion. +- `radius`: Radius of effect for the vortex (10.0 to 500.0). Larger values create wider swirling areas. +- `inward_factor`: Factor controlling how quickly particles are pulled towards the center (0.0 to 1.0). Higher values create tighter spirals. +- `movement_speed`: Speed of movement of the vortex object (0.0 to 10.0). Higher values make the vortex move faster in the simulation space. +- `color`: Color of the vortex visualization (RGB tuple) +- `draw`: Thickness of the vortex visualization (0.0 to 1.0) +""" +}) + +add_node_config("ParticleModulationBase", { + "TOP_DESCRIPTION": "Base class for particle modulation settings.", + "ADDITIONAL_INFO": """ +- `start_frame`: Frame to start the modulation effect (0 to 1000) +- `end_frame`: Frame to end the modulation effect (0 to 1000) +- `effect_duration`: Duration of the modulation effect in frames (0 to 1000) +- `temporal_easing`: Easing function for the modulation effect ("ease_in_out", "linear", "bounce", "elastic", "none") +- `palindrome`: Whether to reverse the modulation effect after completion (True/False) +""" +}) + +add_node_config("ParticleSizeModulation", { + "TOP_DESCRIPTION": "Modulates particle size over time.", + "ADDITIONAL_INFO": """ +- `target_size`: Target size for particles at the end of the modulation (0.0 to 400.0) +""" +}) + +add_node_config("ParticleSpeedModulation", { + "TOP_DESCRIPTION": "Modulates particle speed over time.", + "ADDITIONAL_INFO": """ +- `target_speed`: Target speed for particles at the end of the modulation (0.0 to 1000.0) +""" +}) + +add_node_config("ParticleColorModulation", { + "TOP_DESCRIPTION": "Modulates particle color over time.", + "ADDITIONAL_INFO": """ +- `target_color`: Target color for particles at the end of the modulation (RGB tuple) +""" +}) + +add_node_config("FlexMaskBase", { + "BASE_DESCRIPTION": """ +Provides a base for mask operations modulated by various features. + +## Common Parameters +- `feature`: The feature used to modulate the mask operation (FEATURE type) +- `feature_pipe`: The feature pipe containing frame information (FEATURE_PIPE type) +- `feature_threshold`: Threshold for feature activation (0.0 to 1.0) + +Optional inputs: +- `feature`: The feature used to modulate the mask operation (FEATURE type) +- `feature_pipe`: The feature pipe containing frame information (FEATURE_PIPE type) +""" +}) + +add_node_config("FlexMaskMorph", { + "TOP_DESCRIPTION": "Applies morphological operations to the mask, modulated by a selected feature.", + "ADDITIONAL_INFO": """ +- `morph_type`: The type of morphological operation to apply. + - Options: "erode", "dilate", "open", "close" +- `max_kernel_size`: Maximum size of the morphological kernel (3 to 21, odd numbers only). Larger values create more pronounced effects. +- `max_iterations`: Maximum number of times to apply the operation (1 to 50). More iterations create more extreme effects. + +The strength of the morphological operation is determined by the selected feature's value at each frame. +""" +}) + +add_node_config("FlexMaskWarp", { + "TOP_DESCRIPTION": "Applies warping effects to the mask, modulated by a selected feature.", + "ADDITIONAL_INFO": """ +- `warp_type`: The type of warping effect to apply. Each creates a different distortion pattern. + - Options: "perlin" (noise-based), "radial" (circular), "swirl" (spiral) +- `frequency`: Controls the scale of the warping effect (0.01 to 1.0). Higher values create more rapid changes in the warp pattern. +- `max_amplitude`: Maximum amplitude of the warping effect (0.1 to 500.0). Higher values create more extreme distortions. +- `octaves`: For noise-based warps, adds detail at different scales (1 to 8). More octaves create more complex, detailed patterns. + +The intensity of the warping effect is determined by the selected feature's value at each frame. +""" +}) + +add_node_config("FlexMaskTransform", { + "TOP_DESCRIPTION": "Applies geometric transformations to the mask, modulated by a selected feature.", + "ADDITIONAL_INFO": """ +- `transform_type`: The type of transformation to apply. + - Options: "translate", "rotate", "scale" +- `max_x_value`: Maximum horizontal component of the transformation (-1000.0 to 1000.0). Positive values move right, negative left. +- `max_y_value`: Maximum vertical component of the transformation (-1000.0 to 1000.0). Positive values move up, negative down. + +The extent of the transformation is determined by the selected feature's value at each frame. +""" +}) + +add_node_config("FlexMaskMath", { + "TOP_DESCRIPTION": "Performs mathematical operations between two masks, modulated by a selected feature.", + "ADDITIONAL_INFO": """ +- `mask_b`: Second mask to combine with the input mask. +- `combination_method`: Mathematical operation to apply. + - Options: "add", "subtract", "multiply", "minimum", "maximum" + +The strength of the combination is determined by the selected feature's value at each frame. +""" +}) + +add_node_config("BaseFeatureNode", { + "BASE_DESCRIPTION": """ + Features are used to modulate mask operations in FlexMask nodes. + +## Common Parameters +- `frame_rate`: Frame rate of the video +- `frame_count`: Total number of frames +""" +}) + +add_node_config("TimeFeatureNode", { + "TOP_DESCRIPTION": "Produces a feature that changes over time based on the selected effect type. This can be used to create dynamic, time-varying mask modulations.", + "ADDITIONAL_INFO": """ +- `effect_type`: Type of time-based pattern to apply. + - Options: "smooth" (gradual), "accelerate" (speeds up), "pulse" (rhythmic), "sawtooth" (repeating ramp), "bounce" (up and down) +- `speed`: How quickly the effect progresses (0.1 to 10.0, default: 1.0). Higher values create faster changes. +- `offset`: Shifts the starting point of the effect (0.0 to 1.0, default: 0.0). Useful for staggering multiple effects. + + +""" +}) + +add_node_config("AudioFeatureNode", { + "TOP_DESCRIPTION": "Analyzes the input audio to extract the specified feature. This feature can then be used to modulate masks based on audio characteristics.", + "ADDITIONAL_INFO": """ +- `audio`: Input audio to analyze (AUDIO type) +- `feature_type`: Type of audio feature to extract. + - Options: "amplitude_envelope", "rms_energy", "spectral_centroid", "onset_detection", "chroma_features" + +""" +}) + +add_node_config("DepthFeatureNode", { + "TOP_DESCRIPTION": "Analyzes the input depth maps to extract the specified depth-related feature. This feature can be used to modulate masks based on depth information in the scene.", + "ADDITIONAL_INFO": """ +- `depth_maps`: Input depth maps to analyze (IMAGE type) +- `feature_type`: Type of depth feature to extract. + - Options: "mean_depth", "depth_variance", "depth_range", "gradient_magnitude", "foreground_ratio", "midground_ratio", "background_ratio" + +""" +}) + +add_node_config("ColorFeatureNode", { + "TOP_DESCRIPTION": "Extracts color-related features from video frames for mask modulation.", + "ADDITIONAL_INFO": """ +- `feature_type`: Type of color feature to extract + - Options: "dominant_color" (most prevalent color), "color_variance" (variation in colors), "saturation" (color intensity), "red_ratio" (proportion of red), "green_ratio" (proportion of green), "blue_ratio" (proportion of blue) + +Analyzes the input video frames to extract the specified color-related feature. This feature can be used to modulate masks based on color information in the scene, creating effects that respond to color changes over time. +""" +}) + +add_node_config("BrightnessFeatureNode", { + "TOP_DESCRIPTION": "Extracts brightness-related features from video frames for mask modulation.", + "ADDITIONAL_INFO": """ +- `feature_type`: Type of brightness feature to extract + - Options: "mean_brightness" (average brightness), "brightness_variance" (variation in brightness), "dark_ratio" (proportion of dark areas), "mid_ratio" (proportion of mid-tone areas), "bright_ratio" (proportion of bright areas) + +Analyzes the input video frames to extract the specified brightness-related feature. This feature can be used to modulate masks based on lighting changes in the scene, allowing for effects that respond to overall brightness or specific tonal ranges. +""" +}) + +add_node_config("MotionFeatureNode", { + "TOP_DESCRIPTION": "Extracts motion-related features from video frames for mask modulation.", + "ADDITIONAL_INFO": """ +- `feature_type`: Type of motion feature to extract + - Options: "mean_motion" (average motion), "max_motion" (peak motion), "motion_direction" (overall direction), "horizontal_motion" (left-right movement), "vertical_motion" (up-down movement), "motion_complexity" (intricacy of motion) +- `flow_method`: Technique used for optical flow calculation + - Options: "Farneback" (dense flow), "LucasKanade" (sparse flow), "PyramidalLK" (multi-scale sparse flow) +- `flow_threshold`: Minimum motion magnitude to consider (0.0 to 10.0). Higher values ignore subtle movements. +- `magnitude_threshold`: Relative threshold for motion magnitude (0.0 to 1.0). Higher values focus on areas of stronger motion. + +Analyzes the input video frames to extract the specified motion-related feature using optical flow techniques. This feature can be used to modulate masks based on movement in the scene, creating effects that respond to motion intensity, direction, or complexity. +""" +}) + +add_node_config("AudioFeatureExtractor", { + "TOP_DESCRIPTION": "Analyzes the input audio to extract the specified feature. The resulting feature can be used to modulate masks based on audio characteristics.", + "ADDITIONAL_INFO": """ +- `audio`: Input audio to analyze (AUDIO type) +- `feature_pipe`: Feature pipe for frame information (FEATURE_PIPE type) +- `feature_type`: Type of audio feature to extract + - Options: "amplitude_envelope", "rms_energy", "spectral_centroid", "onset_detection", "chroma_features" + +""" +}) + +add_node_config("TimeFeatureNode", { + "TOP_DESCRIPTION": "Generates time-based features for mask modulation.", + "ADDITIONAL_INFO": """ +- `video_frames`: Input video frames (IMAGE type) +- `frame_rate`: Frame rate of the video +- `effect_type`: Type of time-based pattern to apply + - Options: "smooth" (gradual), "accelerate" (speeds up), "pulse" (rhythmic), "sawtooth" (repeating ramp), "bounce" (up and down) +- `speed`: How quickly the effect progresses (0.1 to 10.0, default: 1.0). Higher values create faster changes. +- `offset`: Shifts the starting point of the effect (0.0 to 1.0, default: 0.0). Useful for staggering multiple effects. + +Generates a feature that changes over time based on the selected effect type. This can be used to create dynamic, time-varying mask modulations. +""" +}) + +add_node_config("DepthFeatureNode", { + "TOP_DESCRIPTION": "Extracts depth-related features from depth maps for mask modulation.", + "ADDITIONAL_INFO": """ +- `video_frames`: Input video frames (IMAGE type) +- `frame_rate`: Frame rate of the video +- `depth_maps`: Input depth maps to analyze (IMAGE type) +- `feature_type`: Type of depth feature to extract + - Options: "mean_depth", "depth_variance", "depth_range", "gradient_magnitude", "foreground_ratio", "midground_ratio", "background_ratio" + +Analyzes the input depth maps to extract the specified depth-related feature. This feature can be used to modulate masks based on depth information in the scene. +""" +}) + +add_node_config("EmitterMovement", { + "TOP_DESCRIPTION": """These parameters work together to create complex, periodic movements for particle emitters. +By adjusting frequencies and amplitudes, you can achieve various patterns like circles, +figure-eights, or more chaotic motions. The direction parameters add extra dynamism by +altering the angle of particle emission over time.""", + "ADDITIONAL_INFO": """ +Position Control: +- `emitter_x_frequency`: How quickly the emitter moves horizontally (0.0 to 10.0). Higher values create faster side-to-side motion. +- `emitter_x_amplitude`: Maximum horizontal distance the emitter moves (0.0 to 0.5). Larger values create wider movements. +- `emitter_y_frequency`: How quickly the emitter moves vertically (0.0 to 10.0). Higher values create faster up-and-down motion. +- `emitter_y_amplitude`: Maximum vertical distance the emitter moves (0.0 to 0.5). Larger values create taller movements. +Direction Control: +- `direction_frequency`: How quickly the emission angle changes (0.0 to 10.0). Higher values create more rapid direction changes. +- `direction_amplitude`: Maximum angle change in degrees (0.0 to 180.0). Larger values allow for more extreme direction shifts. + + +""" +}) + +add_node_config("ParticleEmitter", { + "TOP_DESCRIPTION": "This node creates a particle emitter with the specified properties. It can be used in conjunction with particle system mask nodes to create complex particle effects. They can be chained together to add many to a given simulation.", + "ADDITIONAL_INFO": """ +- `emitter_x`: X-coordinate of the emitter (0.0 to 1.0, left to right) +- `emitter_y`: Y-coordinate of the emitter (0.0 to 1.0, up to down) +- `particle_direction`: Direction of particle emission in degrees (0.0 to 360.0, clockwise) +- `particle_spread`: Spread angle of particle emission in degrees (0.0 to 360.0, clockwise) +- `particle_size`: Size of emitted particles (1.0 to 400.0) +- `particle_speed`: Speed of emitted particles (1.0 to 1000.0) +- `emission_rate`: Rate of particle emission (0.1 to 100.0) +- `color`: Color of emitted particles (RGB string) +- `initial_plume`: Initial burst of particles (0.0 to 1.0) +- `start_frame`: Frame to start the emission (0 to 10000) +- `end_frame`: Frame to end the emission (0 to 10000) + +Optional inputs: +- `emitter_movement`: Movement settings for the emitter (EMITTER_MOVEMENT type) +- `spring_joint_setting`: Spring joint configuration for particles (SPRING_JOINT_SETTING type) +- `particle_modulation`: Modulation settings for particle properties over time (PARTICLE_MODULATION type) +""" +}) + +add_node_config("MovingShape", { + "TOP_DESCRIPTION": "Generate animated mask sequences featuring a moving shape with customizable parameters.", + "ADDITIONAL_INFO": """ +- `frame_width`: Width of each frame (1-3840 pixels) +- `frame_height`: Height of each frame (1-2160 pixels) +- `num_frames`: Number of frames in the sequence (1-120) +- `rgb`: Color of the shape in RGB format, e.g., "(255,255,255)" +- `shape`: Shape type ("square", "circle", or "triangle") +- `shape_width_percent`: Width of the shape as a percentage of frame width (0-100%) +- `shape_height_percent`: Height of the shape as a percentage of frame height (0-100%) +- `shape_start_position_x`: Starting X position of the shape (-100 to 100) +- `shape_start_position_y`: Starting Y position of the shape (-100 to 100) +- `shape_end_position_x`: Ending X position of the shape (-100 to 100) +- `shape_end_position_y`: Ending Y position of the shape (-100 to 100) +- `movement_type`: Type of movement ("linear", "ease_in_out", "bounce", or "elastic") +- `grow`: Growth factor of the shape during animation (0-100) +- `palindrome`: Whether to reverse the animation sequence (True/False) +- `delay`: Number of static frames at the start (0-60) + +This node creates a mask sequence with a moving shape, allowing for various animations and transformations. +""" +}) + +add_node_config("TextMaskNode", { + "TOP_DESCRIPTION": "Generate mask and image sequences featuring customizable text.", + "ADDITIONAL_INFO": """ +- `width`: Width of the output image (1-8192 pixels) +- `height`: Height of the output image (1-8192 pixels) +- `text`: The text to be rendered +- `font`: Font to be used (selectable from system fonts) +- `font_size`: Size of the font (1-1000) +- `font_color`: Color of the text in RGB format, e.g., "(255,255,255)" +- `background_color`: Color of the background in RGB format, e.g., "(0,0,0)" +- `x_position`: Horizontal position of the text (0.0-1.0, where 0.5 is center) +- `y_position`: Vertical position of the text (0.0-1.0, where 0.5 is center) +- `rotation`: Rotation angle of the text (0-360 degrees) +- `max_width_ratio`: Maximum width of text as a ratio of image width (0.1-1.0) +- `batch_size`: Number of images to generate in the batch (1-10000) +""" +}) + +add_node_config("_mfc", { + "TOP_DESCRIPTION": "Basic mask from color.", + "ADDITIONAL_INFO": """ +This is an abstract base class that provides common functionality for mask function components. +It is not meant to be used directly but serves as a foundation for other mask-related nodes. + +Key features: +- Implements common methods for mask operations +- Provides a structure for derived classes to follow +- Ensures consistency across different mask function components +""" +}) + +add_node_config("DownloadOpenUnmixModel", { + "TOP_DESCRIPTION": "Downloads and loads Open Unmix models for audio classification", + "ADDITIONAL_INFO": """ +-umxl (default) trained on private stems dataset of compressed stems. Note, that the weights are only licensed for non-commercial use (CC BY-NC-SA 4.0). + +-umxhq trained on MUSDB18-HQ which comprises the same tracks as in MUSDB18 but un-compressed which yield in a full bandwidth of 22050 Hz. + + +""" +}) + +add_node_config("FeatureMixer", { + "TOP_DESCRIPTION": "Advanced feature modulation node for fine-tuning and shaping feature values.", + "ADDITIONAL_INFO": """ +- `feature`: Input feature to be processed (FEATURE type) +- `base_gain`: Overall amplification of the feature values (0.0 to 10.0). Higher values increase the overall intensity. +- `floor`: Minimum value for the processed feature (0.0 to 1.0). Prevents values from going below this threshold. +- `ceiling`: Maximum value for the processed feature (0.0 to 10.0). Caps values at this upper limit. +- `peak_sharpness`: Sharpness of peaks in the feature curve (0.1 to 10.0). Higher values create more pronounced peaks. +- `valley_sharpness`: Sharpness of valleys in the feature curve (0.1 to 10.0). Higher values create deeper valleys. +- `attack`: Speed at which the envelope follower responds to increasing values (0.01 to 1.0). Lower values create slower attack. +- `release`: Speed at which the envelope follower responds to decreasing values (0.01 to 1.0). Lower values create slower release. +- `smoothing`: Amount of smoothing applied to the final curve (0.0 to 1.0). Higher values create smoother transitions. + +This node provides extensive control over feature modulation, allowing for complex shaping of feature values over time. It combines multiple processing stages including gain, waveshaping, envelope following, and smoothing to create highly customized feature curves for mask modulation. + +Outputs: +- Processed FEATURE +- Visualization of the processed feature (IMAGE type) +""" +}) + +add_node_config("FeatureToWeightsStrategy", { + "TOP_DESCRIPTION": "Converts a FEATURE input into a WEIGHTS_STRATEGY for use with IPAdapter nodes.", + "ADDITIONAL_INFO": """ +- `feature`: Input feature to be converted (FEATURE type) + +This node takes a FEATURE input and converts it into a WEIGHTS_STRATEGY that can be used with IPAdapter nodes. It creates a custom weights strategy based on the feature values for each frame. +This node is particularly useful for creating dynamic, feature-driven animations with IPAdapter, where the strength of the adaptation can vary over time based on extracted features from audio, video, or other sources. + +""" +}) + +NODE_CONFIGS["FlexImageBase"] = { + "BASE_DESCRIPTION": """ + +## Common Parameters +- `images`: Input image sequence (IMAGE type) +- `feature`: Feature used to modulate the effect (FEATURE type) +- `feature_pipe`: Feature pipe containing frame information (FEATURE_PIPE type) +- `strength`: Overall strength of the effect (0.0 to 1.0) +- `feature_threshold`: Minimum feature value to apply the effect (0.0 to 1.0) +- `modulate_param`: Parameter to be modulated by the feature +- `modulation_mode`: How the feature modulates the parameter ("relative" or "absolute") +""" +} + +add_node_config("FlexImagePosterize", { + "TOP_DESCRIPTION": "Applies a posterization effect to the image, reducing the number of colors.", + "ADDITIONAL_INFO": """ +- `max_levels`: Maximum number of color levels per channel (2 to 256) +- `dither_strength`: Intensity of dithering effect (0.0 to 1.0) +- `channel_separation`: Degree of separation between color channels (0.0 to 1.0) +- `gamma`: Gamma correction applied before posterization (0.1 to 2.2) +""" +}) + +add_node_config("FlexImageKaleidoscope", { + "TOP_DESCRIPTION": "Creates a kaleidoscope effect by mirroring and rotating segments of the image.", + "ADDITIONAL_INFO": """ +- `segments`: Number of mirror segments (2 to 32) +- `center_x`: X-coordinate of the effect center (0.0 to 1.0) +- `center_y`: Y-coordinate of the effect center (0.0 to 1.0) +- `zoom`: Zoom factor for the effect (0.1 to 2.0) +- `rotation`: Rotation angle of the effect (0.0 to 360.0 degrees) +- `precession`: Rate of rotation change over time (-1.0 to 1.0) +- `speed`: Speed of the effect animation (0.1 to 5.0) +""" +}) + +add_node_config("FlexImageColorGrade", { + "TOP_DESCRIPTION": "Applies color grading to the image using a Look-Up Table (LUT).", + "ADDITIONAL_INFO": """ +- `intensity`: Strength of the color grading effect (0.0 to 1.0) +- `mix`: Blend factor between original and graded image (0.0 to 1.0) +- `lut_file`: Path to the LUT file (optional) +""" +}) + +add_node_config("FlexImageGlitch", { + "TOP_DESCRIPTION": "Creates a glitch effect by applying horizontal shifts and color channel separation.", + "ADDITIONAL_INFO": """ +- `shift_amount`: Magnitude of horizontal shift (0.0 to 1.0) +- `scan_lines`: Number of scan lines to add (0 to 100) +- `color_shift`: Amount of color channel separation (0.0 to 1.0) +""" +}) + +add_node_config("FlexImageChromaticAberration", { + "TOP_DESCRIPTION": "Simulates chromatic aberration by shifting color channels.", + "ADDITIONAL_INFO": """ +- `shift_amount`: Magnitude of color channel shift (0.0 to 0.1) +- `angle`: Angle of the shift effect (0.0 to 360.0 degrees) +""" +}) + +add_node_config("FlexImagePixelate", { + "TOP_DESCRIPTION": "Applies a pixelation effect to the image.", + "ADDITIONAL_INFO": """ +- `pixel_size`: Size of each pixelated block (1 to 100 pixels) +""" +}) + +add_node_config("FlexImageBloom", { + "TOP_DESCRIPTION": "Adds a bloom effect to bright areas of the image.", + "ADDITIONAL_INFO": """ +- `threshold`: Brightness threshold for the bloom effect (0.0 to 1.0) +- `blur_amount`: Amount of blur applied to the bloom (0.0 to 50.0) +- `intensity`: Strength of the bloom effect (0.0 to 1.0) +""" +}) + +add_node_config("FlexImageTiltShift", { + "TOP_DESCRIPTION": "Creates a tilt-shift effect, simulating a shallow depth of field.", + "ADDITIONAL_INFO": """ +- `blur_amount`: Strength of the blur effect (0.0 to 50.0) +- `focus_position_x`: X-coordinate of the focus center (0.0 to 1.0) +- `focus_position_y`: Y-coordinate of the focus center (0.0 to 1.0) +- `focus_width`: Width of the focus area (0.0 to 1.0) +- `focus_height`: Height of the focus area (0.0 to 1.0) +- `focus_shape`: Shape of the focus area ("rectangle" or "ellipse") +""" +}) diff --git a/nodes/audio/audio_nodes.py b/nodes/audio/audio_nodes.py new file mode 100644 index 0000000..e5204ca --- /dev/null +++ b/nodes/audio/audio_nodes.py @@ -0,0 +1,321 @@ +import torch +import numpy as np +from scipy.ndimage import zoom +from .audio_utils import AudioVisualizer +from scipy import signal +import comfy.model_management as mm +import folder_paths +import os +from ... import RyanOnTheInside +from ..flex.feature_pipe import FeaturePipe +from ... import RyanOnTheInside + + + +class AudioNodeBase(RyanOnTheInside): + CATEGORY= "RyanOnTheInside/Audio" + +class DownloadOpenUnmixModel(AudioNodeBase): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "model_name": (["umxl", "umxhq"], {"default": "umxl"}), + } + } + + RETURN_TYPES = ("OPEN_UNMIX_MODEL",) + FUNCTION = "download_and_load_model" + CATEGORY = "RyanOnTheInside/Audio" + + def download_and_load_model(self, model_name): + device = mm.get_torch_device() + download_path = os.path.join(folder_paths.models_dir, "openunmix") + os.makedirs(download_path, exist_ok=True) + + model_file = f"{model_name}.pth" + model_path = os.path.join(download_path, model_file) + + if not os.path.exists(model_path): + print(f"Downloading {model_name} model...") + separator = torch.hub.load('sigsep/open-unmix-pytorch', model_name, device='cpu') + torch.save(separator.state_dict(), model_path) + print(f"Model saved to: {model_path}") + else: + print(f"Loading model from: {model_path}") + separator = torch.hub.load('sigsep/open-unmix-pytorch', model_name, device='cpu') + separator.load_state_dict(torch.load(model_path, map_location='cpu')) + + separator = separator.to(device) + separator.eval() + + return (separator,) + +class AudioSeparator(AudioNodeBase): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "model": ("OPEN_UNMIX_MODEL",), + "audio": ("AUDIO",), + "video_frames": ("IMAGE",), + "frame_rate": ("FLOAT", {"default": 30, "min": 0.1, "max": 120, "step": 0.1}), + } + } + + RETURN_TYPES = ("AUDIO", "AUDIO", "AUDIO", "AUDIO", "AUDIO", "FEATURE_PIPE") + RETURN_NAMES = ("audio", "drums_audio", "vocals_audio", "bass_audio", "other_audio", "feature_pipe") + FUNCTION = "process_audio" + + def process_audio(self, model, audio, video_frames, frame_rate): + waveform = audio['waveform'] + sample_rate = audio['sample_rate'] + + num_frames, height, width, _ = video_frames.shape + + if waveform.dim() == 3: + waveform = waveform.squeeze(0) + if waveform.dim() == 1: + waveform = waveform.unsqueeze(0) # Add channel dimension if mono + if waveform.shape[0] != 2: + waveform = waveform.repeat(2, 1) # Duplicate mono to stereo if necessary + + waveform = waveform.unsqueeze(0) + + # Determine the device + device = next(model.parameters()).device + waveform = waveform.to(device) + + estimates = model(waveform) + + # Create isolated audio objects for each target + isolated_audio = {} + target_indices = {'drums': 1, 'vocals': 0, 'bass': 2, 'other': 3} # Corrected indices + for target, index in target_indices.items(): + target_waveform = estimates[:, index, :, :] # Shape: (1, 2, num_samples) + + isolated_audio[target] = { + 'waveform': target_waveform.cpu(), # Move back to CPU + 'sample_rate': sample_rate, + 'frame_rate': frame_rate + } + + # Create FeaturePipe + feature_pipe = FeaturePipe(frame_rate, video_frames) + + return ( + audio, + isolated_audio['drums'], + isolated_audio['vocals'], + isolated_audio['bass'], + isolated_audio['other'], + feature_pipe, + ) + +class AudioFilter(AudioNodeBase): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "audio": ("AUDIO",), + "filters": ("FREQUENCY_FILTER",) + }, + } + + RETURN_TYPES = ("AUDIO",) + FUNCTION = "apply_filters" + DESCRIPTION = """Applies frequency filters to audio: +- `audio`: Input audio to be filtered +- `filters`: Frequency filters to be applied (FREQUENCY_FILTER type)""" + + def apply_filters(self, audio, filters): + audio_np = audio['waveform'].cpu().numpy().squeeze(0) + sample_rate = audio['sample_rate'] + + filtered_channels = [] + for channel in audio_np: + filtered_channel = channel + for filter_params in filters: + filtered_channel = self.apply_single_filter(filtered_channel, filter_params, sample_rate) + filtered_channels.append(filtered_channel) + + filtered_audio = np.stack(filtered_channels) + + max_val = np.max(np.abs(filtered_audio)) + if max_val > 1.0: + filtered_audio = filtered_audio / max_val + + filtered_tensor = torch.from_numpy(filtered_audio).unsqueeze(0).float() + + return ({"waveform": filtered_tensor, "sample_rate": sample_rate},) + + def apply_single_filter(self, audio, filter_params, sample_rate): + filter_type = filter_params['type'] + order = filter_params['order'] + cutoff = filter_params['cutoff'] + + if filter_type == 'lowpass': + b, a = signal.butter(order, cutoff, btype='low', fs=sample_rate) + elif filter_type == 'highpass': + b, a = signal.butter(order, cutoff, btype='high', fs=sample_rate) + elif filter_type == 'bandpass': + low = cutoff - 50 + high = cutoff + 50 + b, a = signal.butter(order, [low, high], btype='band', fs=sample_rate) + else: + raise ValueError(f"Unsupported filter type: {filter_type}") + + return signal.lfilter(b, a, audio) + +class FrequencyFilterPreset(AudioNodeBase): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "preset": ([ + "isolate_kick_drum", + "isolate_snare_drum", + "isolate_hi_hat", + "isolate_bass", + "isolate_vocals", + "remove_rumble", + "brighten_mix", + "remove_hiss" + ],), + }, + "optional": { + "previous_filter": ("FREQUENCY_FILTER",), + }, + } + + RETURN_TYPES = ("FREQUENCY_FILTER",) + FUNCTION = "create_preset_filter_chain" + + def create_preset_filter_chain(self, preset, previous_filter=None): + new_filters = self.get_preset_filters(preset) + + if previous_filter: + if isinstance(previous_filter, list): + return (previous_filter + new_filters,) + else: + return ([previous_filter] + new_filters,) + else: + return (new_filters,) + + def get_preset_filters(self, preset): + if preset == "isolate_kick_drum": + return [ + {"type": "highpass", "order": 2, "cutoff": 30}, + {"type": "lowpass", "order": 4, "cutoff": 200} + ] + elif preset == "isolate_snare_drum": + return [ + {"type": "bandpass", "order": 4, "cutoff": 200}, + {"type": "bandpass", "order": 4, "cutoff": 5000} + ] + elif preset == "isolate_hi_hat": + return [ + {"type": "highpass", "order": 4, "cutoff": 8000} + ] + elif preset == "isolate_bass": + return [ + {"type": "lowpass", "order": 4, "cutoff": 250} + ] + elif preset == "isolate_vocals": + return [ + {"type": "bandpass", "order": 2, "cutoff": 300}, + {"type": "bandpass", "order": 2, "cutoff": 4000} + ] + elif preset == "remove_rumble": + return [ + {"type": "highpass", "order": 4, "cutoff": 40} + ] + elif preset == "brighten_mix": + return [ + {"type": "highpass", "order": 2, "cutoff": 6000} + ] + elif preset == "remove_hiss": + return [ + {"type": "lowpass", "order": 6, "cutoff": 7500} + ] + else: + raise ValueError(f"Unknown preset: {preset}") + +class FrequencyFilterCustom(AudioNodeBase): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "filter_type": (["lowpass", "highpass", "bandpass"],), + "order": ("INT", {"default": 4, "min": 1, "max": 10, "step": 1}), + "cutoff": ("FLOAT", {"default": 1000, "min": 20, "max": 20000, "step": 1}), + }, + "optional": { + "previous_filter": ("FREQUENCY_FILTER",), + }, + } + + RETURN_TYPES = ("FREQUENCY_FILTER",) + FUNCTION = "create_filter" + + def create_filter(self, filter_type, order, cutoff, previous_filter=None): + filter_params = { + "type": filter_type, + "order": order, + "cutoff": cutoff, + } + + if previous_filter: + if isinstance(previous_filter, list): + previous_filter.append(filter_params) + return (previous_filter,) + else: + return ([previous_filter, filter_params],) + else: + return ([filter_params],) + +class AudioFeatureVisualizer(AudioNodeBase): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "audio": ("AUDIO",), + "video_frames": ("IMAGE",), + "visualization_type": ([ + "waveform", + "spectrogram", + "mfcc", + "chroma", + "tonnetz", + "spectral_centroid" + ],), + "frame_rate": ("FLOAT", {"default": 30, "min": 0.1, "max": 120, "step": 0.1}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "visualize_audio_feature" + CATEGORY = "audio" + + def visualize_audio_feature(self, audio, video_frames, visualization_type, frame_rate): + num_frames, height, width, _ = video_frames.shape + + visualizer = AudioVisualizer(audio, num_frames, height, width, frame_rate) + + if visualization_type == "waveform": + mask = visualizer.create_waveform() + elif visualization_type == "spectrogram": + mask = visualizer.create_spectrogram() + elif visualization_type == "mfcc": + mask = visualizer.create_mfcc() + elif visualization_type == "chroma": + mask = visualizer.create_chroma() + elif visualization_type == "tonnetz": + mask = visualizer.create_tonnetz() + elif visualization_type == "spectral_centroid": + mask = visualizer.create_spectral_centroid() + else: + raise ValueError(f"Unsupported visualization type: {visualization_type}") + + + return (mask,) \ No newline at end of file diff --git a/nodes/audio/audio_utils.py b/nodes/audio/audio_utils.py new file mode 100644 index 0000000..afb32f8 --- /dev/null +++ b/nodes/audio/audio_utils.py @@ -0,0 +1,188 @@ +import numpy as np +import torch +import librosa +import cv2 +import matplotlib.pyplot as plt + +import numpy as np +import librosa +import torch +import cv2 +import matplotlib.pyplot as plt + + + + +class BaseAudioProcessor: + def __init__(self, audio, num_frames, height, width, frame_rate): + self.audio = audio['waveform'].squeeze(0).mean(axis=0).cpu().numpy() # Convert to mono and numpy array + self.sample_rate = audio['sample_rate'] + self.num_frames = num_frames + self.height = height + self.width = width + self.frame_rate = frame_rate + + self.audio_duration = len(self.audio) / self.sample_rate + self.frame_duration = 1 / self.frame_rate if self.frame_rate > 0 else self.audio_duration / self.num_frames + + def _normalize(self, data): + return (data - data.min()) / (data.max() - data.min()) + + def _enhance_contrast(self, data, power=0.3): + return np.power(data, power) + + def _resize(self, data, new_width, new_height): + return cv2.resize(data, (new_width, new_height), interpolation=cv2.INTER_LINEAR) + + def _get_audio_frame(self, frame_index): + start_time = frame_index * self.frame_duration + end_time = (frame_index + 1) * self.frame_duration + start_sample = int(start_time * self.sample_rate) + end_sample = int(end_time * self.sample_rate) + return self.audio[start_sample:end_sample] + +class AudioVisualizer(BaseAudioProcessor): + def create_spectrogram(self): + + frames = [] + for i in range(self.num_frames): + audio_frame = self._get_audio_frame(i) + + n_fft = min(2048, len(audio_frame)) + S = librosa.stft(audio_frame, n_fft=n_fft) + S_db = librosa.amplitude_to_db(np.abs(S), ref=np.max) + + S_db_normalized = self._normalize(S_db) + S_db_enhanced = self._enhance_contrast(S_db_normalized) + + S_db_resized = self._resize(S_db_enhanced, self.width, self.height) + frames.append(S_db_resized) + + frames = np.stack(frames, axis=0) + + return torch.from_numpy(frames).float() + + def create_waveform(self): + + frames = [] + for i in range(self.num_frames): + audio_frame = self._get_audio_frame(i) + + plt.figure(figsize=(self.width / 100, self.height / 100), dpi=100) + plt.plot(audio_frame) + plt.axis('off') + plt.tight_layout(pad=0) + + plt.gcf().canvas.draw() + frame = np.frombuffer(plt.gcf().canvas.tostring_rgb(), dtype=np.uint8) + frame = frame.reshape(self.height, self.width, 3) + frames.append(frame) + plt.close() + + frames = np.stack(frames, axis=0) + + return torch.from_numpy(frames).float() / 255.0 + + def create_mfcc(self): + + frames = [] + for i in range(self.num_frames): + audio_frame = self._get_audio_frame(i) + + mfccs = librosa.feature.mfcc(y=audio_frame, sr=self.sample_rate, n_mfcc=self.height) + mfccs_normalized = self._normalize(mfccs) + mfccs_enhanced = self._enhance_contrast(mfccs_normalized) + + mfccs_resized = self._resize(mfccs_enhanced, self.width, self.height) + frames.append(mfccs_resized) + + frames = np.stack(frames, axis=0) + + return torch.from_numpy(frames).float() + + def create_chroma(self): + + frames = [] + for i in range(self.num_frames): + audio_frame = self._get_audio_frame(i) + + chroma = librosa.feature.chroma_stft(y=audio_frame, sr=self.sample_rate) + chroma_normalized = self._normalize(chroma) + chroma_enhanced = self._enhance_contrast(chroma_normalized) + + chroma_resized = self._resize(chroma_enhanced, self.width, self.height) + frames.append(chroma_resized) + + frames = np.stack(frames, axis=0) + + return torch.from_numpy(frames).float() + + def create_tonnetz(self): + + frames = [] + for i in range(self.num_frames): + audio_frame = self._get_audio_frame(i) + + tonnetz = librosa.feature.tonnetz(y=audio_frame, sr=self.sample_rate) + tonnetz_normalized = self._normalize(tonnetz) + tonnetz_enhanced = self._enhance_contrast(tonnetz_normalized) + + tonnetz_resized = self._resize(tonnetz_enhanced, self.width, self.height) + frames.append(tonnetz_resized) + + frames = np.stack(frames, axis=0) + + return torch.from_numpy(frames).float() + + def create_spectral_centroid(self): + + frames = [] + for i in range(self.num_frames): + audio_frame = self._get_audio_frame(i) + + centroid = librosa.feature.spectral_centroid(y=audio_frame, sr=self.sample_rate) + centroid_normalized = self._normalize(centroid) + centroid_enhanced = self._enhance_contrast(centroid_normalized) + + centroid_resized = self._resize(centroid_enhanced, self.width, self.height) + frames.append(centroid_resized) + + frames = np.stack(frames, axis=0) + + return torch.from_numpy(frames).float() + +class AudioFeatureExtractor(BaseAudioProcessor): + def __init__(self, audio, num_frames, frame_rate, feature_type='amplitude_envelope'): + super().__init__(audio, num_frames, None, None, frame_rate) + self.feature_type = feature_type + + def extract(self): + if self.feature_type == 'amplitude_envelope': + return self._amplitude_envelope() + elif self.feature_type == 'rms_energy': + return self._rms_energy() + elif self.feature_type == 'spectral_centroid': + return self._spectral_centroid() + elif self.feature_type == 'onset_detection': + return self._onset_detection() + elif self.feature_type == 'chroma_features': + return self._chroma_features() + else: + raise ValueError("Unsupported feature type") + +#TODO HANDLE NO MASKS +#TODO HANDLE FRAME MASK COUNT MISMATCH + def _amplitude_envelope(self): + return np.array([np.max(np.abs(self._get_audio_frame(i))) for i in range(self.num_frames)]) + + def _rms_energy(self): + return np.array([np.sqrt(np.mean(self._get_audio_frame(i)**2)) for i in range(self.num_frames)]) + + def _spectral_centroid(self): + return np.array([np.mean(librosa.feature.spectral_centroid(y=self._get_audio_frame(i), sr=self.sample_rate)[0]) for i in range(self.num_frames)]) + + def _onset_detection(self): + return np.array([np.mean(librosa.onset.onset_strength(y=self._get_audio_frame(i), sr=self.sample_rate)) for i in range(self.num_frames)]) + + def _chroma_features(self): + return np.array([np.mean(librosa.feature.chroma_stft(y=self._get_audio_frame(i), sr=self.sample_rate), axis=1) for i in range(self.num_frames)]) \ No newline at end of file diff --git a/nodes/audio/midi_nodes.py b/nodes/audio/midi_nodes.py new file mode 100644 index 0000000..503fa1d --- /dev/null +++ b/nodes/audio/midi_nodes.py @@ -0,0 +1 @@ +#TODO \ No newline at end of file diff --git a/nodes/audio/midi_utils.py b/nodes/audio/midi_utils.py new file mode 100644 index 0000000..503fa1d --- /dev/null +++ b/nodes/audio/midi_utils.py @@ -0,0 +1 @@ +#TODO \ No newline at end of file diff --git a/nodes/flex/feature_externals.py b/nodes/flex/feature_externals.py new file mode 100644 index 0000000..93df7ca --- /dev/null +++ b/nodes/flex/feature_externals.py @@ -0,0 +1,38 @@ +from ... import RyanOnTheInside + +class FlexExternalModulator(RyanOnTheInside): + CATEGORY = "RyanOnTheInside/FlexExternalMod" + +class FeatureToWeightsStrategy(FlexExternalModulator): + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "feature": ("FEATURE",), + } + } + + RETURN_TYPES = ("WEIGHTS_STRATEGY",) + RETURN_NAMES = ("WEIGHTS_STRATEGY",) + FUNCTION = "convert" + + def convert(self, feature): + frames = feature.frame_count + values = [feature.get_value_at_frame(i) for i in range(frames)] + + weights_str = ", ".join(map(lambda x: f"{x:.8f}", values)) + + weights_strategy = { + "weights": weights_str, + "timing": "custom", + "frames": frames, + "start_frame": 0, + "end_frame": frames, + "add_starting_frames": 0, + "add_ending_frames": 0, + "method": "full batch", + "frame_count": frames, + } + + + return (weights_strategy,) diff --git a/nodes/flex/feature_extractors.py b/nodes/flex/feature_extractors.py new file mode 100644 index 0000000..963f390 --- /dev/null +++ b/nodes/flex/feature_extractors.py @@ -0,0 +1,146 @@ +from .feature_pipe import FeaturePipe +from ... import RyanOnTheInside +from .features import AudioFeature, TimeFeature, DepthFeature, ColorFeature, BrightnessFeature, MotionFeature + +class FeatureExtractorBase(RyanOnTheInside): + CATEGORY="RyanOnTheInside/FlexFeatures" + +class AudioFeatureExtractor(FeatureExtractorBase): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "audio": ("AUDIO",), + "feature_pipe": ("FEATURE_PIPE",), + "feature_type": (["amplitude_envelope", "spectral_centroid", "onset_detection", "chroma_features"],), + } + } + + RETURN_TYPES = ("FEATURE", "FEATURE_PIPE") + FUNCTION = "extract_feature" + + + def extract_feature(self, audio, feature_pipe, feature_type): + feature = AudioFeature(feature_type, audio, feature_pipe.frame_count, feature_pipe.frame_rate, feature_type) + feature.extract() + return (feature, feature_pipe) + + +class FirstFeature(FeatureExtractorBase): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "video_frames": ("IMAGE",), + "frame_rate": ("FLOAT", {"default": 30.0, "min": 1.0, "max": 120.0, "step": 0.1}), + } + } + + RETURN_TYPES = ("FEATURE", "FEATURE_PIPE") + FUNCTION = "create_feature" + +class TimeFeatureNode(FirstFeature): + @classmethod + def INPUT_TYPES(cls): + return { + **super().INPUT_TYPES(), + "required": { + **super().INPUT_TYPES()["required"], + "effect_type": (["smooth", "accelerate", "pulse", "sawtooth", "bounce"],), + "speed": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 10.0, "step": 0.1}), + "offset": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + } + } + + + + def create_feature(self, effect_type, speed, offset, video_frames, frame_rate): + feature_pipe = FeaturePipe(frame_rate, video_frames) + time_feature = TimeFeature("time_effect", feature_pipe.frame_rate, feature_pipe.frame_count, + effect_type=effect_type, speed=speed, offset=offset) + time_feature.extract() + return (time_feature, feature_pipe) + +class DepthFeatureNode(FirstFeature): + @classmethod + def INPUT_TYPES(cls): + return { + **super().INPUT_TYPES(), + "required": { + **super().INPUT_TYPES()["required"], + "depth_maps": ("IMAGE",), + "feature_type": (["mean_depth", "depth_variance", "depth_range", "gradient_magnitude", "foreground_ratio", "midground_ratio", "background_ratio"],), + } + } + + def create_feature(self, depth_maps, frame_rate, video_frames, feature_type): + feature_pipe = FeaturePipe(frame_rate, video_frames) + depth_feature = DepthFeature("depth_feature", feature_pipe.frame_rate, feature_pipe.frame_count, depth_maps, feature_type) + depth_feature.extract() + return (depth_feature, feature_pipe) + +class ColorFeatureNode(FirstFeature): + @classmethod + def INPUT_TYPES(cls): + return { + **super().INPUT_TYPES(), + "required": { + **super().INPUT_TYPES()["required"], + "feature_type": (["dominant_color", "color_variance", "saturation", "red_ratio", "green_ratio", "blue_ratio"],), + } + } + + def create_feature(self, video_frames, frame_rate, feature_type): + feature_pipe = FeaturePipe(frame_rate, video_frames) + color_feature = ColorFeature("color_feature", feature_pipe.frame_rate, feature_pipe.frame_count, video_frames, feature_type) + color_feature.extract() + return (color_feature, feature_pipe) + +class BrightnessFeatureNode(FirstFeature): + @classmethod + def INPUT_TYPES(cls): + return { + **super().INPUT_TYPES(), + "required": { + **super().INPUT_TYPES()["required"], + "feature_type": (["mean_brightness", "brightness_variance", "dark_ratio", "mid_ratio", "bright_ratio"],), + } + } + + def create_feature(self, video_frames, frame_rate, feature_type): + feature_pipe = FeaturePipe(frame_rate, video_frames) + brightness_feature = BrightnessFeature("brightness_feature", feature_pipe.frame_rate, feature_pipe.frame_count, video_frames, feature_type) + brightness_feature.extract() + return (brightness_feature, feature_pipe) + +class MotionFeatureNode(FirstFeature): + @classmethod + def INPUT_TYPES(cls): + return { + **super().INPUT_TYPES(), + "required": { + **super().INPUT_TYPES()["required"], + "feature_type": (["mean_motion", "max_motion", "motion_direction", "horizontal_motion", "vertical_motion", "motion_complexity"],), + "flow_method": (["Farneback", "LucasKanade", "PyramidalLK"],), + "flow_threshold": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 10.0, "step": 0.1}), + "magnitude_threshold": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + } + } + + RETURN_TYPES = ("FEATURE", "FEATURE_PIPE") + FUNCTION = "create_feature" + + def create_feature(self, video_frames, frame_rate, feature_type, flow_method, flow_threshold, magnitude_threshold): + feature_pipe = FeaturePipe(frame_rate, video_frames) + motion_feature = MotionFeature( + "motion_feature", + feature_pipe.frame_rate, + feature_pipe.frame_count, + video_frames, + feature_type, + flow_method, + flow_threshold, + magnitude_threshold + ) + motion_feature.extract() + return (motion_feature, feature_pipe) \ No newline at end of file diff --git a/nodes/flex/feature_modulation.py b/nodes/flex/feature_modulation.py new file mode 100644 index 0000000..73fc917 --- /dev/null +++ b/nodes/flex/feature_modulation.py @@ -0,0 +1,173 @@ +from ... import RyanOnTheInside +import numpy as np +import matplotlib.pyplot as plt +import torch +from io import BytesIO +from PIL import Image + +class FeatureModulationBase(RyanOnTheInside): + CATEGORY = "RyanOnTheInside/FlexFeatures" + FUNCTION = "modulate" + + def visualize(self, feature, width=1920, height=1080): + values = [feature.get_value_at_frame(i) for i in range(feature.frame_count)] + frames = len(values) + + plt.figure(figsize=(width/100, height/100), dpi=100) + plt.style.use('dark_background') + + plt.plot(values, color='dodgerblue', linewidth=2) + + plt.xlabel('Frame', color='white', fontsize=14) + plt.ylabel('Value', color='white', fontsize=14) + + plt.grid(True, linestyle='--', alpha=0.3, color='gray') + + plt.tick_params(axis='both', colors='white', labelsize=12) + + max_ticks = 10 + step = max(1, frames // max_ticks) + x_ticks = range(0, frames, step) + plt.xticks(x_ticks, [str(x) for x in x_ticks]) + + y_min, y_max = min(values), max(values) + y_range = y_max - y_min + plt.ylim(y_min - 0.05*y_range, y_max + 0.05*y_range) + + plt.gca().spines['top'].set_visible(False) + plt.gca().spines['right'].set_visible(False) + plt.gca().spines['bottom'].set_color('white') + plt.gca().spines['left'].set_color('white') + + plt.title(f'Feature: {feature.name}', color='white', fontsize=16) + + plt.tight_layout(pad=0.5) + + buf = BytesIO() + plt.savefig(buf, format='png', facecolor='black', edgecolor='none') + buf.seek(0) + + img = Image.open(buf) + img_array = np.array(img) + img_tensor = torch.from_numpy(img_array).float() / 255.0 + if img_tensor.dim() == 3: + img_tensor = img_tensor.unsqueeze(0) + + plt.close() + buf.close() + + return img_tensor + +class FeatureMixer(FeatureModulationBase): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "feature": ("FEATURE",), + "base_gain": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.01}), + "floor": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "ceiling": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.01}), + "peak_sharpness": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 10.0, "step": 0.1}), + "valley_sharpness": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 10.0, "step": 0.1}), + "attack": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 1.0, "step": 0.01}), + "release": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 1.0, "step": 0.01}), + "smoothing": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "feature_threshold": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "rise_detection_threshold": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.05}), + "rise_smoothing_factor": ("FLOAT", {"default": 0.5, "min": 0.1, "max": 2.0, "step": 0.05}), + } + } + + RETURN_TYPES = ("FEATURE", "IMAGE") + RETURN_NAMES = ("FEATURE", "FEATURE_VISUALIZATION") + FUNCTION = "modulate" + + def modulate(self, feature, base_gain, floor, ceiling, peak_sharpness, valley_sharpness, attack, release, smoothing, feature_threshold, rise_detection_threshold, rise_smoothing_factor): + values = [feature.get_value_at_frame(i) for i in range(feature.frame_count)] + + # feature threshold + values = [v if v >= feature_threshold else 0 for v in values] + + # 0-1 range + min_val, max_val = min(values), max(values) + if min_val == max_val: + normalized = [0 for _ in values] # All values are the same, normalize to 0 + else: + normalized = [(v - min_val) / (max_val - min_val) for v in values] + + # multiply gain + gained = [v * base_gain for v in normalized] + + + def waveshape(v): + return v**peak_sharpness if v > 0.5 else 1 - (1-v)**valley_sharpness + waveshaped = [waveshape(v) for v in gained] + + + def apply_envelope(values, attack, release): + envelope = [] + current = values[0] + for v in values: + if v > current: + current += (v - current) * attack + else: + current += (v - current) * release + envelope.append(current) + return envelope + enveloped = apply_envelope(waveshaped, attack, release) + + + def smooth_values(values, smoothing_factor): + smoothed = values.copy() + for i in range(1, len(values)): + smoothed[i] = smoothed[i-1] + (values[i] - smoothed[i-1]) * (1 - smoothing_factor) + return smoothed + smoothed = smooth_values(enveloped, smoothing) + + # try to do look ahead + adjusted = self.apply_rise_time_adjustment(smoothed, rise_detection_threshold, rise_smoothing_factor) + + # chop + final_values = [max(floor, min(ceiling, v)) for v in adjusted] + + #we create a new feature with our values and override extract method + # NOTE: tested with several feature type, but not all + class ProcessedFeature(type(feature)): + def __init__(self, original_feature, processed_values): + self.__dict__.update(original_feature.__dict__) + self.name = f"Processed_{original_feature.name}" + self.frame_rate = original_feature.frame_rate + self.frame_count = original_feature.frame_count + self.data = processed_values + + def extract(self): + return self + + def get_value_at_frame(self, frame_index): + return self.data[frame_index] + + processed_feature = ProcessedFeature(feature, final_values) + return (processed_feature, self.visualize(processed_feature)) + + def apply_rise_time_adjustment(self, values, rise_detection_threshold, rise_smoothing_factor): + if all(v == 0 for v in values): + return values + + adjusted = values.copy() + window_size = 5 + + for i in range(len(values) - window_size): + if values[i + window_size] - values[i] > rise_detection_threshold: + + start_index = i + end_index = min(len(values), i + 2*window_size) + peak_index = start_index + np.argmax(values[start_index:end_index]) + peak_value = values[peak_index] + + + for j in range(start_index, peak_index): + progress = (j - start_index) / (peak_index - start_index) + smoothed_value = values[start_index] + (peak_value - values[start_index]) * (progress ** (1/rise_smoothing_factor)) + adjusted[j] = max(adjusted[j], smoothed_value) # Take the max to prevent lowering existing higher values + + return adjusted \ No newline at end of file diff --git a/nodes/flex/feature_pipe.py b/nodes/flex/feature_pipe.py new file mode 100644 index 0000000..6e0bce2 --- /dev/null +++ b/nodes/flex/feature_pipe.py @@ -0,0 +1,11 @@ +class FeaturePipe: + def __init__(self, frame_rate, video_frames): + self.frame_rate = frame_rate + self.frame_count = video_frames.shape[0] + self.height = video_frames.shape[1] + self.width = video_frames.shape[2] + + def update(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + return self \ No newline at end of file diff --git a/nodes/flex/features.py b/nodes/flex/features.py new file mode 100644 index 0000000..92843a4 --- /dev/null +++ b/nodes/flex/features.py @@ -0,0 +1,418 @@ +from abc import ABC, abstractmethod +import numpy as np +import librosa +import torch +import cv2 +from ..masks.mask_utils import calculate_optical_flow + +class BaseFeature(ABC): + def __init__(self, name, feature_type, frame_rate, frame_count): + self.name = name + self.type = feature_type + self.frame_rate = frame_rate + self.frame_count = frame_count + self.data = None + self.features = None + self.inverted = False + + @abstractmethod + def extract(self): + pass + + def get_value_at_frame(self, frame_index): + if self.data is not None: + return self.data[frame_index] + elif self.features is not None and hasattr(self, 'feature_name'): + return self.features[self.feature_name][frame_index] + else: + raise ValueError("No data or features available") + + def normalize(self): + if self.data is not None: + self.data = (self.data - np.min(self.data)) / (np.max(self.data) - np.min(self.data)) + return self + + def invert(self): + if self.data is not None: + self.data = 1 - self.data + if self.features is not None: + for key in self.features: + if isinstance(self.features[key], list): + self.features[key] = [1 - value for value in self.features[key]] + elif isinstance(self.features[key], np.ndarray): + self.features[key] = 1 - self.features[key] + elif isinstance(self.features[key], torch.Tensor): + self.features[key] = 1 - self.features[key] + self.inverted = not self.inverted + return self + +class TimeFeature(BaseFeature): + def __init__(self, name, frame_rate, frame_count, effect_type='smooth', speed=1.0, offset=0.0): + self.effect_type = effect_type + self.speed = speed + self.offset = offset + super().__init__(name, "time", frame_rate, frame_count) + + def extract(self): + t = np.linspace(0, self.frame_count / self.frame_rate, self.frame_count) + t = (t * self.speed + self.offset) % 1 + + if self.effect_type == 'smooth': + self.data = t + elif self.effect_type == 'accelerate': + self.data = t ** 2 + elif self.effect_type == 'pulse': + self.data = (np.sin(2 * np.pi * t) + 1) / 2 + elif self.effect_type == 'sawtooth': + self.data = t + elif self.effect_type == 'bounce': + self.data = 1 - np.abs(1 - 2 * t) + else: + raise ValueError("Unsupported effect type") + + return self.normalize() + +class AudioFeature(BaseFeature): + def __init__(self, name, audio, num_frames, frame_rate, feature_type='amplitude_envelope'): + self.audio = audio['waveform'].squeeze(0).mean(axis=0).cpu().numpy() + self.sample_rate = audio['sample_rate'] + self.num_frames = num_frames + self.feature_type = feature_type + super().__init__(name, "audio", frame_rate, num_frames) + self.frame_duration = 1 / self.frame_rate if self.frame_rate > 0 else len(self.audio) / (self.sample_rate * self.num_frames) + + def extract(self): + if self.feature_type == 'amplitude_envelope': + self.data = self._amplitude_envelope() + elif self.feature_type == 'rms_energy': + self.data = self._rms_energy() + elif self.feature_type == 'spectral_centroid': + self.data = self._spectral_centroid() + elif self.feature_type == 'onset_detection': + self.data = self._onset_detection() + elif self.feature_type == 'chroma_features': + self.data = self._chroma_features() + else: + raise ValueError("Unsupported feature type") + return self.normalize() + + def _get_audio_frame(self, frame_index): + start_time = frame_index * self.frame_duration + end_time = (frame_index + 1) * self.frame_duration + start_sample = int(start_time * self.sample_rate) + end_sample = int(end_time * self.sample_rate) + return self.audio[start_sample:end_sample] + + def _amplitude_envelope(self): + return np.array([np.max(np.abs(self._get_audio_frame(i))) for i in range(self.num_frames)]) + + def _rms_energy(self): + return np.array([np.sqrt(np.mean(self._get_audio_frame(i)**2)) for i in range(self.num_frames)]) + + def _spectral_centroid(self): + return np.array([np.mean(librosa.feature.spectral_centroid(y=self._get_audio_frame(i), sr=self.sample_rate)[0]) for i in range(self.num_frames)]) + + def _onset_detection(self): + return np.array([np.mean(librosa.onset.onset_strength(y=self._get_audio_frame(i), sr=self.sample_rate)) for i in range(self.num_frames)]) + + def _chroma_features(self): + return np.array([np.mean(librosa.feature.chroma_stft(y=self._get_audio_frame(i), sr=self.sample_rate), axis=1) for i in range(self.num_frames)]) + +class DepthFeature(BaseFeature): + def __init__(self, name, frame_rate, frame_count, depth_maps, feature_name='mean_depth'): + self.depth_maps = depth_maps + self.features = None + self.feature_name = feature_name + self.available_features = [ + "mean_depth", "depth_variance", "depth_range", "gradient_magnitude", + "foreground_ratio", "midground_ratio", "background_ratio" + ] + super().__init__(name, "depth", frame_rate, frame_count) + + def extract(self): + self.features = {self.feature_name: []} + + combined_depth = torch.mean(self.depth_maps, dim=-1) + + for depth_map in combined_depth: + depth_min = torch.min(depth_map) + depth_max = torch.max(depth_map) + if depth_max > depth_min: + normalized_depth = (depth_map - depth_min) / (depth_max - depth_min) + else: + normalized_depth = torch.zeros_like(depth_map) + + if self.feature_name == 'mean_depth': + self.features[self.feature_name].append(torch.mean(normalized_depth).item()) + elif self.feature_name == 'depth_variance': + self.features[self.feature_name].append(torch.var(normalized_depth).item()) + elif self.feature_name == 'depth_range': + self.features[self.feature_name].append((depth_max - depth_min).item()) + elif self.feature_name == 'gradient_magnitude': + grad_y, grad_x = torch.gradient(normalized_depth) + gradient_magnitude = torch.sqrt(grad_x**2 + grad_y**2) + self.features[self.feature_name].append(torch.mean(gradient_magnitude).item()) + elif self.feature_name in ['foreground_ratio', 'midground_ratio', 'background_ratio']: + total_pixels = normalized_depth.numel() + foreground = torch.sum(normalized_depth < 0.33).item() / total_pixels + midground = torch.sum((normalized_depth >= 0.33) & (normalized_depth < 0.66)).item() / total_pixels + background = torch.sum(normalized_depth >= 0.66).item() / total_pixels + if self.feature_name == 'foreground_ratio': + self.features[self.feature_name].append(foreground) + elif self.feature_name == 'midground_ratio': + self.features[self.feature_name].append(midground) + elif self.feature_name == 'background_ratio': + self.features[self.feature_name].append(background) + + feature_tensor = torch.tensor(self.features[self.feature_name]) + feature_min = torch.min(feature_tensor) + feature_max = torch.max(feature_tensor) + if feature_max > feature_min: + self.features[self.feature_name] = ((feature_tensor - feature_min) / (feature_max - feature_min)).tolist() + else: + self.features[self.feature_name] = torch.zeros_like(feature_tensor).tolist() + + return self + + def get_feature_sequence(self, feature_name=None): + if self.features is None: + self.extract() + if feature_name is None: + feature_name = self.feature_name + return self.features.get(feature_name, None) + + def set_active_feature(self, feature_name): + if feature_name in self.available_features: + self.feature_name = feature_name + else: + raise ValueError(f"Invalid feature name. Available features are: {', '.join(self.available_features)}") + +class ColorFeature(BaseFeature): + def __init__(self, name, frame_rate, frame_count, images, feature_name='dominant_color'): + if images.dim() != 4 or images.shape[-1] != 3: + raise ValueError(f"Expected images in BHWC format, but got shape {images.shape}") + self.images = images + self.feature_name = feature_name + self.available_features = [ + "dominant_color", "color_variance", "saturation", + "red_ratio", "green_ratio", "blue_ratio" + ] + super().__init__(name, "color", frame_rate, frame_count) + + def extract(self): + self.features = {self.feature_name: []} + + images = self.images.float() / 255.0 if self.images.dtype == torch.uint8 else self.images + + if self.feature_name in ['red_ratio', 'green_ratio', 'blue_ratio']: + color_sums = torch.sum(images, dim=(1, 2)) + totals = torch.sum(color_sums, dim=1, keepdim=True) + ratios = color_sums / totals + + if self.feature_name == 'red_ratio': + self.features[self.feature_name] = ratios[:, 0].tolist() + elif self.feature_name == 'green_ratio': + self.features[self.feature_name] = ratios[:, 1].tolist() + elif self.feature_name == 'blue_ratio': + self.features[self.feature_name] = ratios[:, 2].tolist() + else: + for image in images: + self._extract_features(image) + + self._normalize_features() + print("ColorFeature extraction completed") + return self + + def _extract_features(self, image): + if self.feature_name == 'dominant_color': + self.features[self.feature_name].append(self._get_dominant_color(image)) + elif self.feature_name == 'color_variance': + self.features[self.feature_name].append(torch.var(image.reshape(-1, 3), dim=0).mean().item()) + elif self.feature_name == 'saturation': + self.features[self.feature_name].append(self._calculate_saturation(image).item()) + + def _get_dominant_color(self, image): + print("Calculating dominant color") + quantized = (image * 31).long().reshape(-1, 3) + unique, counts = np.unique(quantized.cpu().numpy(), axis=0, return_counts=True) + dominant = unique[np.argmax(counts)] + return np.mean(dominant / 31) + + def _calculate_saturation(self, image): + print("Calculating saturation") + max_val, _ = torch.max(image, dim=-1) + min_val, _ = torch.min(image, dim=-1) + return torch.mean(max_val - min_val) + + def _normalize_features(self): + for key in self.features: + feature_tensor = torch.tensor(self.features[key]) + feature_min = torch.min(feature_tensor) + feature_max = torch.max(feature_tensor) + if feature_max > feature_min: + self.features[key] = ((feature_tensor - feature_min) / (feature_max - feature_min)).tolist() + else: + self.features[key] = torch.zeros_like(feature_tensor).tolist() + + def get_feature_sequence(self, feature_name=None): + if self.features is None: + self.extract() + if feature_name is None: + feature_name = self.feature_name + return self.features.get(feature_name, None) + + def set_active_feature(self, feature_name): + if feature_name in self.available_features: + self.feature_name = feature_name + else: + raise ValueError(f"Invalid feature name. Available features are: {', '.join(self.available_features)}") + +class BrightnessFeature(BaseFeature): + def __init__(self, name, frame_rate, frame_count, images, feature_name='mean_brightness'): + self.images = images + self.feature_name = feature_name + self.available_features = [ + "mean_brightness", "brightness_variance", "brightness_histogram", + "dark_ratio", "mid_ratio", "bright_ratio" + ] + super().__init__(name, "brightness", frame_rate, frame_count) + + def extract(self): + self.features = {self.feature_name: []} + + images = self.images.float() / 255.0 if self.images.dtype == torch.uint8 else self.images + + for image in images: + self._extract_features(image) + + self._normalize_features() + return self + + def _extract_features(self, image): + grayscale = 0.2989 * image[..., 0] + 0.5870 * image[..., 1] + 0.1140 * image[..., 2] + + if self.feature_name == 'mean_brightness': + self.features[self.feature_name].append(torch.mean(grayscale).item()) + elif self.feature_name == 'brightness_variance': + self.features[self.feature_name].append(torch.var(grayscale).item()) + elif self.feature_name == 'brightness_histogram': + histogram = torch.histc(grayscale, bins=10, min=0, max=1) + self.features[self.feature_name].append(histogram.tolist()) + else: + total_pixels = grayscale.numel() + if self.feature_name == 'dark_ratio': + self.features[self.feature_name].append((torch.sum(grayscale < 0.3) / total_pixels).item()) + elif self.feature_name == 'mid_ratio': + self.features[self.feature_name].append((torch.sum((grayscale >= 0.3) & (grayscale < 0.7)) / total_pixels).item()) + elif self.feature_name == 'bright_ratio': + self.features[self.feature_name].append((torch.sum(grayscale >= 0.7) / total_pixels).item()) + + def _normalize_features(self): + for key in self.features: + if key != 'brightness_histogram': + feature_tensor = torch.tensor(self.features[key]) + feature_min = torch.min(feature_tensor) + feature_max = torch.max(feature_tensor) + if feature_max > feature_min: + self.features[key] = ((feature_tensor - feature_min) / (feature_max - feature_min)).tolist() + else: + self.features[key] = torch.zeros_like(feature_tensor).tolist() + + def get_feature_sequence(self, feature_name=None): + if self.features is None: + self.extract() + if feature_name is None: + feature_name = self.feature_name + return self.features.get(feature_name, None) + + def set_active_feature(self, feature_name): + if feature_name in self.available_features: + self.feature_name = feature_name + else: + raise ValueError(f"Invalid feature name. Available features are: {', '.join(self.available_features)}") + +class MotionFeature(BaseFeature): + def __init__(self, name, frame_rate, frame_count, images, feature_name='mean_motion', flow_method='Farneback', flow_threshold=0.0, magnitude_threshold=0.0): + self.images = images + self.feature_name = feature_name + self.flow_method = flow_method + self.flow_threshold = flow_threshold + self.magnitude_threshold = magnitude_threshold + self.available_features = [ + "mean_motion", "max_motion", "motion_direction", + "horizontal_motion", "vertical_motion", "motion_complexity" + ] + super().__init__(name, "motion", frame_rate, frame_count) + + def extract(self): + print("Starting MotionFeature extraction") + self.features = {self.feature_name: []} + + images_np = (self.images.cpu().numpy() * 255).astype(np.uint8) + + for i in range(len(images_np) - 1): + print(f"Processing frames {i+1} and {i+2}") + frame1 = images_np[i] + frame2 = images_np[i+1] + + flow = calculate_optical_flow(frame1, frame2, self.flow_method) + self._extract_features(flow) + + self._pad_last_frame() + self._normalize_features() + print("MotionFeature extraction completed") + return self + + def _extract_features(self, flow): + flow_magnitude = np.sqrt(flow[..., 0]**2 + flow[..., 1]**2) + + if self.flow_threshold > 0: + flow_magnitude[flow_magnitude < self.flow_threshold] = 0 + if self.magnitude_threshold > 0: + flow_magnitude[flow_magnitude < self.magnitude_threshold * np.max(flow_magnitude)] = 0 + + if self.feature_name == 'mean_motion': + self.features[self.feature_name].append(np.mean(flow_magnitude)) + elif self.feature_name == 'max_motion': + self.features[self.feature_name].append(np.max(flow_magnitude)) + elif self.feature_name == 'motion_direction': + self.features[self.feature_name].append(np.mean(np.arctan2(flow[..., 1], flow[..., 0]))) + elif self.feature_name == 'horizontal_motion': + self.features[self.feature_name].append(np.mean(np.abs(flow[..., 0]))) + elif self.feature_name == 'vertical_motion': + self.features[self.feature_name].append(np.mean(np.abs(flow[..., 1]))) + elif self.feature_name == 'motion_complexity': + self.features[self.feature_name].append(np.std(flow_magnitude)) + + def _pad_last_frame(self): + for feature in self.features: + if feature != "motion_direction": + self.features[feature].append(self.features[feature][-1]) + else: + self.features[feature].append(0) + + def _normalize_features(self): + for key in self.features: + if key != "motion_direction": + feature_array = np.array(self.features[key]) + feature_min = np.min(feature_array) + feature_max = np.max(feature_array) + if feature_max > feature_min: + self.features[key] = ((feature_array - feature_min) / (feature_max - feature_min)).tolist() + else: + self.features[key] = np.zeros_like(feature_array).tolist() + else: + self.features[key] = (np.array(self.features[key]) / (2 * np.pi)).tolist() + + def get_feature_sequence(self, feature_name=None): + if self.features is None: + self.extract() + if feature_name is None: + feature_name = self.feature_name + return self.features.get(feature_name, None) + + def set_active_feature(self, feature_name): + if feature_name in self.available_features: + self.feature_name = feature_name + else: + raise ValueError(f"Invalid feature name. Available features are: {', '.join(self.available_features)}") \ No newline at end of file diff --git a/nodes/flex/midi_feature.py b/nodes/flex/midi_feature.py new file mode 100644 index 0000000..a808089 --- /dev/null +++ b/nodes/flex/midi_feature.py @@ -0,0 +1,204 @@ +from .features import BaseFeature +import numpy as np + +class MIDIFeature(BaseFeature): + ATTRIBUTE_MAP = { + "Velocity": "velocity", + "Pitch": "pitch", + "Note On/Off": "on_off", + "Note Duration": "duration", + "Note Density": "density", + "Pitchbend": "pitchbend", + "Aftertouch": "aftertouch", + "Poly Pressure": "poly_pressure", + "Modulation (CC1)": "cc1", + "Breath (CC2)": "cc2", + "Foot Controller (CC4)": "cc4", + "Volume (CC7)": "cc7", + "Balance (CC8)": "cc8", + "Pan (CC10)": "cc10", + "Expression (CC11)": "cc11", + "Sustain (CC64)": "cc64" + } + + def __init__(self, name, midi_data, attribute, frame_rate, frame_count, notes=None, chord_only=False): + self.midi_data = midi_data + self.attribute = attribute + self.notes = set(notes) if notes is not None else set() + self.chord_only = chord_only + self.total_time = self.calculate_total_time() + self.data = None + self.time_points = None + self.cc_data = {cc: [] for cc in [1, 2, 4, 7, 8, 10, 11, 64]} + self.pitchbend_data = [] + self.aftertouch_data = [] + self.poly_pressure_data = [] + super().__init__(name, "midi", frame_rate, frame_count) + + def extract(self): + try: + self.data = self.extract_attribute() + except ValueError as e: + print(f"Warning: {str(e)}. Using default values.") + self.data = np.zeros(self.frame_count) + return self.normalize() + + def calculate_total_time(self): + total_time = sum(msg.time for msg in self.midi_data if not msg.is_meta) + return max(total_time, self.frame_count / self.frame_rate) + + def extract_attribute(self): + attribute_values = [] + time_points = [] + current_time = 0 + note_on_times = {} + active_notes = set() + note_counts = np.zeros(self.frame_count) + modulation_data = [] + + for msg in self.midi_data: + if msg.is_meta: + continue + + current_time += msg.time + frame_index = int(current_time * self.frame_rate) + if frame_index >= self.frame_count: + break + + value = self.process_message(msg, current_time, note_on_times, active_notes, note_counts, modulation_data) + if value is not None: + attribute_values.append(value) + time_points.append(current_time) + + return self.finalize_attribute(attribute_values, time_points, note_counts, modulation_data) + + def process_message(self, msg, current_time, note_on_times, active_notes, note_counts, modulation_data): + frame_index = int(current_time * self.frame_rate) + + if msg.type == 'control_change': + if msg.control in self.cc_data: + self.cc_data[msg.control].append((current_time, msg.value / 127.0)) + if msg.control == 1: + modulation_data.append((current_time, msg.value / 127.0)) + + elif msg.type == 'pitchwheel': + self.pitchbend_data.append((current_time, (msg.pitch + 8192) / 16383.0)) + + elif msg.type == 'aftertouch': + self.aftertouch_data.append((current_time, msg.value / 127.0)) + + elif msg.type == 'polytouch': + self.poly_pressure_data.append((current_time, msg.value / 127.0)) + + if msg.type == 'note_on' and (not self.notes or msg.note in self.notes): + note_on_times[msg.note] = current_time + active_notes.add(msg.note) + note_counts[frame_index:] += 1 + + if not self.chord_only or (self.chord_only and active_notes == self.notes): + return self.get_attribute_value_for_message(msg, 'on') + + elif msg.type == 'note_off' and msg.note in note_on_times: + if msg.note in active_notes: + active_notes.remove(msg.note) + + if not self.chord_only or (self.chord_only and len(active_notes) == 0): + value = self.get_attribute_value_for_message(msg, 'off', current_time - note_on_times[msg.note]) + note_counts[frame_index:] -= 1 + del note_on_times[msg.note] + return value + + return None + + def get_attribute_value_for_message(self, msg, state, duration=None): + if self.attribute == 'velocity' and state == 'on': + return msg.velocity / 127.0 + elif self.attribute == 'pitch' and state == 'on': + return msg.note / 127.0 + elif self.attribute == 'on_off': + return 1.0 if state == 'on' else 0.0 + elif self.attribute == 'duration' and state == 'off': + return duration + elif self.attribute == 'pitchbend': + return (msg.pitch + 8192) / 16383.0 if msg.type == 'pitchwheel' else None + elif self.attribute == 'aftertouch': + return msg.value / 127.0 if msg.type == 'aftertouch' else None + elif self.attribute == 'poly_pressure': + return msg.value / 127.0 if msg.type == 'polytouch' else None + elif self.attribute.startswith('cc'): + cc_number = int(self.attribute[2:]) + return msg.value / 127.0 if msg.type == 'control_change' and msg.control == cc_number else None + return None + + def finalize_attribute(self, attribute_values, time_points, note_counts, modulation_data): + if not time_points: + return np.zeros(self.frame_count) + + self.time_points = np.array(time_points) + attribute_values = np.array(attribute_values) + + if len(self.time_points) < 2: + self.time_points = np.array([0, self.total_time]) + attribute_values = np.array([0, 0]) + + frame_times = np.linspace(0, self.total_time, self.frame_count) + + if self.attribute == 'density': + max_count = np.max(note_counts) + return note_counts / max_count if max_count > 0 else np.zeros(self.frame_count) + elif self.attribute.startswith('cc'): + cc_number = int(self.attribute[2:]) + return self.interpolate_cc_data(cc_number, frame_times) + elif self.attribute == 'pitchbend': + return self.interpolate_data(self.pitchbend_data, frame_times) + elif self.attribute == 'aftertouch': + return self.interpolate_data(self.aftertouch_data, frame_times) + elif self.attribute == 'poly_pressure': + return self.interpolate_data(self.poly_pressure_data, frame_times) + elif self.attribute == 'modulation': + if modulation_data: + mod_times, mod_values = zip(*modulation_data) + return np.interp(frame_times, mod_times, mod_values) + return np.zeros(self.frame_count) + else: + interpolated = np.interp(frame_times, self.time_points, attribute_values) + interpolated[note_counts == 0] = 0 + return self.apply_modulation(interpolated, frame_times, modulation_data) + + def interpolate_cc_data(self, cc_number, frame_times): + if self.cc_data[cc_number]: + cc_times, cc_values = zip(*self.cc_data[cc_number]) + return np.interp(frame_times, cc_times, cc_values) + return np.zeros(self.frame_count) + + def interpolate_data(self, data, frame_times): + if data: + times, values = zip(*data) + return np.interp(frame_times, times, values) + return np.zeros(self.frame_count) + + def apply_modulation(self, interpolated, frame_times, modulation_data): + if modulation_data and self.attribute != 'modulation': + mod_times, mod_values = zip(*modulation_data) + modulation = np.interp(frame_times, mod_times, mod_values) + interpolated = interpolated * (1 + modulation * 0.2) + return np.clip(interpolated, 0, 1) + return interpolated + + def normalize(self): + if self.data is not None and len(self.data) > 0: + min_val = np.min(self.data) + max_val = np.max(self.data) + if max_val > min_val: + self.data = (self.data - min_val) / (max_val - min_val) + else: + self.data = np.zeros_like(self.data) + return self + + @classmethod + def get_attribute_names(cls): + return list(cls.ATTRIBUTE_MAP.keys()) + + @classmethod + def get_attribute_value(cls, friendly_name): + return cls.ATTRIBUTE_MAP.get(friendly_name) \ No newline at end of file diff --git a/nodes/flex/midi_feature_extractor.py b/nodes/flex/midi_feature_extractor.py new file mode 100644 index 0000000..b065939 --- /dev/null +++ b/nodes/flex/midi_feature_extractor.py @@ -0,0 +1,163 @@ +import mido +import os +from .feature_pipe import FeaturePipe +import folder_paths +from server import PromptServer +from aiohttp import web +import shutil +from .midi_feature import MIDIFeature +from .feature_extractors import FeatureExtractorBase + +class MIDILoadAndExtract(FeatureExtractorBase): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "midi_file": (folder_paths.get_filename_list("midi_files"),), + "track_selection": (["all"],), + "attribute": (MIDIFeature.get_attribute_names(), {"default": "Note On/Off"}), + "frame_rate": ("FLOAT", {"default": 30, "min": 0.1, "max": 120, "step": 0.1}), + "video_frames": ("IMAGE",), + "chord_only": ("BOOLEAN", {"default": False}), + "notes": ("STRING", {"default": ""}), + }, + } + + RETURN_TYPES = ("MIDI", "FEATURE", "FEATURE_PIPE") + FUNCTION = "process_midi" + CATEGORY = "RyanOnTheInside/Audio" + + def process_midi(self, midi_file, track_selection, notes, attribute, frame_rate, video_frames, chord_only=False): + try: + midi_path = folder_paths.get_full_path("midi_files", midi_file) + if not midi_path or not os.path.exists(midi_path): + raise FileNotFoundError(f"MIDI file not found: {midi_file}") + + midi_data = mido.MidiFile(midi_path) + + selected_notes = [int(n.strip()) for n in notes.split(',') if n.strip().isdigit()] + feature_pipe = FeaturePipe(frame_rate, video_frames) + + # Convert friendly attribute name to internal attribute name + internal_attribute = MIDIFeature.get_attribute_value(attribute) + + feature = MIDIFeature(f"midi_{internal_attribute}", midi_data, internal_attribute, + feature_pipe.frame_rate, feature_pipe.frame_count, + notes=selected_notes, chord_only=chord_only) + + feature.extract() + + return (midi_data, feature, feature_pipe) + + except Exception as e: + # error_msg = f"Error in MIDILoadAndExtract.process_midi: {type(e).__name__}: {str(e)}\n" + # error_msg += traceback.format_exc() + # print(error_msg) + raise RuntimeError(f"Error processing MIDI file: {type(e).__name__}: {str(e)}") + + @classmethod + def analyze_midi(cls, midi_path): + midi_data = mido.MidiFile(midi_path) + tracks = ["all"] + all_notes = set() + track_notes = {} + for i, track in enumerate(midi_data.tracks): + track_notes[str(i)] = set() + for msg in track: + if msg.type == 'note_on': + track_notes[str(i)].add(msg.note) + all_notes.add(msg.note) + if len(track_notes[str(i)]) == 0: + tracks.append(f"{i}: (Empty)") + else: + tracks.append(f"{i}: {track.name or f'Track {i}'}") + + return { + "tracks": tracks, + "all_notes": ",".join(map(str, sorted(set(all_notes)))), + "track_notes": {k: ",".join(map(str, sorted(v))) for k, v in track_notes.items()} + } + + @classmethod + def VALIDATE_INPUTS(cls, midi_file, track_selection, notes, attribute, frame_rate, video_frames): + if not folder_paths.exists_and_is_file(midi_file): + return "MIDI file not found: {}".format(midi_file) + + if notes != "all": + try: + note_list = [int(n.strip()) for n in notes.split(',') if n.strip()] + if not all(0 <= n <= 127 for n in note_list): + return "Invalid note value. All notes must be between 0 and 127." + except ValueError: + return "Invalid notes format. Please provide comma-separated integers or 'all'." + + return True + + +routes = PromptServer.instance.routes +@PromptServer.instance.routes.post('/get_track_notes') +async def get_track_notes(request): + data = await request.json() + midi_file = data.get('midi_file') + + if not midi_file: + return web.json_response({"error": "Missing required parameters"}, status=400) + + midi_path = folder_paths.get_full_path("midi_files", midi_file) + if not midi_path or not os.path.exists(midi_path): + return web.json_response({"error": "MIDI file not found"}, status=404) + + analysis = MIDILoadAndExtract.analyze_midi(midi_path) + return web.json_response(analysis) + +@routes.post('/upload_midi') +async def upload_midi(request): + data = await request.post() + midi_file = data['file'] + + if midi_file and midi_file.filename: + safe_filename = os.path.basename(midi_file.filename) + + midi_dir = folder_paths.get_folder_paths("midi_files")[0] + + if not os.path.exists(midi_dir): + os.makedirs(midi_dir, exist_ok=True) + + midi_path = os.path.join(midi_dir, safe_filename) + + with open(midi_path, 'wb') as f: + shutil.copyfileobj(midi_file.file, f) + + midi_files = folder_paths.get_filename_list("midi_files") + analysis = MIDILoadAndExtract.analyze_midi(midi_path) + + return web.json_response({ + "status": "success", + "uploaded_file": safe_filename, + "midi_files": midi_files, + "analysis": analysis + }) + else: + return web.json_response({"status": "error", "message": "No file uploaded"}, status=400) + +@PromptServer.instance.routes.post('/refresh_midi_data') +async def refresh_midi_data(request): + data = await request.json() + midi_file = data.get('midi_file') + track_selection = data.get('track_selection') + + if not midi_file: + return web.json_response({"error": "Missing required parameters"}, status=400) + + midi_path = folder_paths.get_full_path("midi_files", midi_file) + if not midi_path or not os.path.exists(midi_path): + return web.json_response({"error": "MIDI file not found"}, status=404) + + analysis = MIDILoadAndExtract.analyze_midi(midi_path) + + # Filter notes based on track selection + if track_selection != "all": + track_index = track_selection.split(':')[0] + analysis['all_notes'] = analysis['track_notes'].get(track_index, "") + + return web.json_response(analysis) \ No newline at end of file diff --git a/nodes/images/flex_images.py b/nodes/images/flex_images.py new file mode 100644 index 0000000..c642b74 --- /dev/null +++ b/nodes/images/flex_images.py @@ -0,0 +1,373 @@ +import cv2 +import torch +import numpy as np +from .image_base import FlexImageBase +from scipy.ndimage import gaussian_filter +import torch.nn.functional as F + +class FlexImageEdgeDetect(FlexImageBase): + @classmethod + def INPUT_TYPES(cls): + return { + **super().INPUT_TYPES(), + "required": { + **super().INPUT_TYPES()["required"], + "low_threshold": ("FLOAT", {"default": 100, "min": 0, "max": 255, "step": 1}), + "high_threshold": ("FLOAT", {"default": 200, "min": 0, "max": 255, "step": 1}), + } + } + + @classmethod + def get_modifiable_params(cls): + return ["low_threshold", "high_threshold"] + + def apply_effect_internal(self, image: np.ndarray, low_threshold: float, high_threshold: float, **kwargs) -> np.ndarray: + gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) + + edges = cv2.Canny(np.uint8(gray * 255), low_threshold, high_threshold) + + # Convert back to RGB + edges_rgb = cv2.cvtColor(edges, cv2.COLOR_GRAY2RGB) + + return edges_rgb.astype(float) / 255.0 + +class FlexImagePosterize(FlexImageBase): + @classmethod + def INPUT_TYPES(cls): + return { + **super().INPUT_TYPES(), + "required": { + **super().INPUT_TYPES()["required"], + "max_levels": ("INT", {"default": 8, "min": 2, "max": 256, "step": 1}), + "dither_strength": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01}), + "channel_separation": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01}), + "gamma": ("FLOAT", {"default": 1.2, "min": 0.1, "max": 2.2, "step": 0.1}), + } + } + + @classmethod + def get_modifiable_params(cls): + return ["max_levels", "dither_strength", "channel_separation", "gamma"] + + def apply_effect_internal(self, image: np.ndarray, max_levels: int, dither_strength: float, + channel_separation: float, gamma: float, **kwargs) -> np.ndarray: + # Apply gamma correction + image_gamma = np.power(image, gamma) + + # Convert image to uint8 + image_uint8 = (image_gamma * 255).astype(np.uint8) + + posterized = np.zeros_like(image_uint8) + + for c in range(3): # RGB channels + # Calculate levels for each channel + channel_levels = int(np.clip(2 + (max_levels - 2) * (1 + channel_separation * (c - 1)), 2, max_levels)) + + # Posterize + div = 256 // channel_levels + posterized[:,:,c] = (image_uint8[:,:,c] // div) * div + + if dither_strength > 0: + # Apply Floyd-Steinberg dithering with adjustable strength + error = (image_uint8.astype(np.float32) - posterized) * dither_strength + h, w = image.shape[:2] + for c in range(3): # RGB channels + for i in range(h - 1): + for j in range(w - 1): + old_pixel = posterized[i, j, c] + new_pixel = np.clip(old_pixel + error[i, j, c], 0, 255) + quant_error = old_pixel - new_pixel + posterized[i, j, c] = new_pixel + error[i, j+1, c] += quant_error * 7 / 16 + error[i+1, j-1, c] += quant_error * 3 / 16 + error[i+1, j, c] += quant_error * 5 / 16 + error[i+1, j+1, c] += quant_error * 1 / 16 + + # Convert back to float32 and apply gamma correction + return np.power(posterized.astype(np.float32) / 255.0, 1/gamma) + +class FlexImageKaleidoscope(FlexImageBase): + @classmethod + def INPUT_TYPES(cls): + return { + **super().INPUT_TYPES(), + "required": { + **super().INPUT_TYPES()["required"], + "segments": ("INT", {"default": 8, "min": 2, "max": 32, "step": 1}), + "center_x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "center_y": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "zoom": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 2.0, "step": 0.1}), + "rotation": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 360.0, "step": 1.0}), + "precession": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}), + "speed": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 5.0, "step": 0.1}), + } + } + + @classmethod + def get_modifiable_params(cls): + return ["segments", "zoom", "rotation", "precession", "speed"] + + def apply_effect_internal(self, image: np.ndarray, segments: int, center_x: float, center_y: float, + zoom: float, rotation: float, precession: float, speed: float, **kwargs) -> np.ndarray: + h, w = image.shape[:2] + center = (int(w * center_x), int(h * center_y)) + + # Ensure segments is an integer + segments = max(2, int(segments)) + + # Create the kaleidoscope effect + segment_angle = 360 / segments + result = np.zeros_like(image) + + for i in range(segments): + angle = i * segment_angle + rotation * speed + # Apply precession effect + precession_factor = 1 + precession * (i / segments) * speed + matrix = cv2.getRotationMatrix2D(center, angle, zoom * precession_factor) + rotated = cv2.warpAffine(image, matrix, (w, h), borderMode=cv2.BORDER_REFLECT) + + mask = np.zeros((h, w), dtype=np.float32) + pts = np.array([center, (0, 0), (w, 0), (w, h), (0, h)], dtype=np.int32) + cv2.fillPoly(mask, [pts], 1.0) + + segment_mask = cv2.warpAffine(mask, matrix, (w, h)) + result = np.maximum(result, rotated * segment_mask[:,:,None]) + + # Ensure the result is not all black + if np.max(result) == 0: + print("Warning: Kaleidoscope effect resulted in a black image") + return image # Return original image if result is all black + + return result.clip(0, 1) + +class FlexImageColorGrade(FlexImageBase): + @classmethod + def INPUT_TYPES(cls): + return { + **super().INPUT_TYPES(), + "required": { + **super().INPUT_TYPES()["required"], + "intensity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "mix": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + }, + "optional": { + "lut_file": ("STRING", {"default": None}), + } + } + + @classmethod + def get_modifiable_params(cls): + return ["intensity", "mix"] + + def __init__(self): + super().__init__() + self.lut_cache = {} + + def load_lut(self, lut_file): + if lut_file is None: + return None + if lut_file not in self.lut_cache: + lut = cv2.imread(lut_file, cv2.IMREAD_UNCHANGED) + if lut is None: + print(f"Warning: Failed to load LUT file: {lut_file}") + return None + self.lut_cache[lut_file] = lut + return self.lut_cache[lut_file] + + def apply_effect_internal(self, image: np.ndarray, intensity: float, mix: float, + lut_file: str = None, **kwargs) -> np.ndarray: + # Load the LUT + lut = self.load_lut(lut_file) + + # Apply color grading if LUT is available + if lut is not None: + graded = cv2.LUT(image, lut) + + # Apply intensity + graded = cv2.addWeighted(image, 1 - intensity, graded, intensity, 0) + + # Mix with original + result = cv2.addWeighted(image, 1 - mix, graded, mix, 0) + else: + result = image # If no LUT is available, return the original image + + return np.clip(result, 0, 1) + +class FlexImageGlitch(FlexImageBase): + @classmethod + def INPUT_TYPES(cls): + base_inputs = super().INPUT_TYPES() + base_inputs["required"].update({ + "shift_amount": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}), + "scan_lines": ("INT", {"default": 10, "min": 0, "max": 100, "step": 1}), + "color_shift": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}), + }) + return base_inputs + + @classmethod + def get_modifiable_params(cls): + return ["shift_amount", "scan_lines", "color_shift"] + + def apply_effect_internal(self, image: np.ndarray, shift_amount: float, scan_lines: int, color_shift: float, **kwargs) -> np.ndarray: + h, w = image.shape[:2] + + # Apply horizontal shift + shift = int(w * shift_amount) + result = np.roll(image, shift, axis=1) + + # Add scan lines + if scan_lines > 0: + scan_line_mask = np.zeros((h, w)) + scan_line_mask[::scan_lines] = 1 + result = result * (1 - scan_line_mask)[:,:,np.newaxis] + scan_line_mask[:,:,np.newaxis] + + # Apply color channel shift + if color_shift > 0: + color_shift_amount = int(w * color_shift) + result[:,:,0] = np.roll(result[:,:,0], color_shift_amount, axis=1) + result[:,:,2] = np.roll(result[:,:,2], -color_shift_amount, axis=1) + + return np.clip(result, 0, 1) + +class FlexImageChromaticAberration(FlexImageBase): + @classmethod + def INPUT_TYPES(cls): + return { + **super().INPUT_TYPES(), + "required": { + **super().INPUT_TYPES()["required"], + "shift_amount": ("FLOAT", {"default": 0.01, "min": 0.0, "max": 0.1, "step": 0.001}), + "angle": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 360.0, "step": 1.0}), + } + } + + @classmethod + def get_modifiable_params(cls): + return ["shift_amount", "angle"] + + def apply_effect_internal(self, image: np.ndarray, shift_amount: float, angle: float, **kwargs) -> np.ndarray: + h, w = image.shape[:2] + + # Calculate shift vectors + dx = int(w * shift_amount * np.cos(np.radians(angle))) + dy = int(h * shift_amount * np.sin(np.radians(angle))) + + # Shift red and blue channels + result = np.zeros_like(image) + result[:,:,0] = np.roll(image[:,:,0], (dy, dx), (0, 1)) # Red channel + result[:,:,1] = image[:,:,1] # Green channel (no shift) + result[:,:,2] = np.roll(image[:,:,2], (-dy, -dx), (0, 1)) # Blue channel + + return np.clip(result, 0, 1) + + +class FlexImagePixelate(FlexImageBase): + @classmethod + def INPUT_TYPES(cls): + return { + **super().INPUT_TYPES(), + "required": { + **super().INPUT_TYPES()["required"], + "pixel_size": ("INT", {"default": 10, "min": 1, "max": 100, "step": 1}), + } + } + + @classmethod + def get_modifiable_params(cls): + return ["pixel_size"] + + def apply_effect_internal(self, image: np.ndarray, pixel_size: int, **kwargs) -> np.ndarray: + h, w = image.shape[:2] + + # Ensure pixel_size is an integer and greater than 0 + pixel_size = max(1, int(pixel_size)) + + # Calculate new dimensions + new_h, new_w = h // pixel_size, w // pixel_size + + # Ensure new dimensions are at least 1x1 + new_h, new_w = max(1, new_h), max(1, new_w) + + # Resize down + small = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_LINEAR) + + # Resize up + result = cv2.resize(small, (w, h), interpolation=cv2.INTER_NEAREST) + + return result + +class FlexImageBloom(FlexImageBase): + @classmethod + def INPUT_TYPES(cls): + return { + **super().INPUT_TYPES(), + "required": { + **super().INPUT_TYPES()["required"], + "threshold": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 1.0, "step": 0.01}), + "blur_amount": ("FLOAT", {"default": 10.0, "min": 0.0, "max": 50.0, "step": 0.1}), + "intensity": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + } + } + + @classmethod + def get_modifiable_params(cls): + return ["threshold", "blur_amount", "intensity"] + + def apply_effect_internal(self, image: np.ndarray, threshold: float, blur_amount: float, + intensity: float, **kwargs) -> np.ndarray: + # Extract bright areas + bright_areas = np.maximum(image - threshold, 0) / (1 - threshold) + + # Apply gaussian blur + blurred = gaussian_filter(bright_areas, sigma=blur_amount) + + # Combine with original image + result = image + blurred * intensity + + return np.clip(result, 0, 1) + +class FlexImageTiltShift(FlexImageBase): + @classmethod + def INPUT_TYPES(cls): + return { + **super().INPUT_TYPES(), + "required": { + **super().INPUT_TYPES()["required"], + "blur_amount": ("FLOAT", {"default": 10.0, "min": 0.0, "max": 50.0, "step": 0.1}), + "focus_position_x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "focus_position_y": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "focus_width": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01}), + "focus_height": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01}), + "focus_shape": (["rectangle", "ellipse"], {"default": "rectangle"}), + } + } + + @classmethod + def get_modifiable_params(cls): + return ["blur_amount", "focus_position_x", "focus_position_y", "focus_width", "focus_height"] + + def apply_effect_internal(self, image: np.ndarray, blur_amount: float, focus_position_x: float, + focus_position_y: float, focus_width: float, focus_height: float, + focus_shape: str, **kwargs) -> np.ndarray: + h, w = image.shape[:2] + mask = np.zeros((h, w), dtype=np.float32) + center_x, center_y = int(w * focus_position_x), int(h * focus_position_y) + width, height = int(w * focus_width), int(h * focus_height) + + if focus_shape == "rectangle": + cv2.rectangle(mask, + (center_x - width//2, center_y - height//2), + (center_x + width//2, center_y + height//2), + 1, -1) + else: # ellipse + cv2.ellipse(mask, + (center_x, center_y), + (width//2, height//2), + 0, 0, 360, 1, -1) + + mask = cv2.GaussianBlur(mask, (0, 0), sigmaX=min(width, height) / 6) + blurred = cv2.GaussianBlur(image, (0, 0), sigmaX=blur_amount) + result = image * mask[:,:,np.newaxis] + blurred * (1 - mask[:,:,np.newaxis]) + + return np.clip(result, 0, 1) + diff --git a/nodes/images/image_base.py b/nodes/images/image_base.py new file mode 100644 index 0000000..f89eef1 --- /dev/null +++ b/nodes/images/image_base.py @@ -0,0 +1,278 @@ +# import torch +# import numpy as np +# from abc import ABC, abstractmethod +# from tqdm import tqdm +# from comfy.utils import ProgressBar +# from .image_utils import preserve_original_colors, apply_blur +# from ... import RyanOnTheInside + +# class ImageBase(RyanOnTheInside, ABC): +# @classmethod +# def INPUT_TYPES(cls): +# return { +# "required": { +# "images": ("IMAGE",), +# "strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), +# "blend_mode": (["normal", "multiply", "screen", "overlay", "soft_light"],), +# "preserve_colors": ("BOOLEAN", {"default": False}), +# "apply_to_alpha": ("BOOLEAN", {"default": False}), +# } +# } + +# CATEGORY = "RyanOnTheInside/Images" + +# def __init__(self): +# self.pre_processors = [] +# self.post_processors = [] +# self.progress_bar = None +# self.tqdm_bar = None +# self.current_progress = 0 +# self.total_steps = 0 + +# def add_pre_processor(self, func): +# self.pre_processors.append(func) +# return self + +# def add_post_processor(self, func): +# self.post_processors.append(func) +# return self + +# def pre_process(self, image): +# for processor in self.pre_processors: +# image = processor(image) +# return image + +# def post_process(self, image): +# for processor in self.post_processors: +# image = processor(image) +# return image + +# def start_progress(self, total_steps, desc="Processing"): +# self.progress_bar = ProgressBar(total_steps) +# self.tqdm_bar = tqdm(total=total_steps, desc=desc, leave=False) +# self.current_progress = 0 +# self.total_steps = total_steps + +# def update_progress(self, step=1): +# self.current_progress += step +# if self.progress_bar: +# self.progress_bar.update(step) +# if self.tqdm_bar: +# self.tqdm_bar.update(step) + +# def end_progress(self): +# if self.tqdm_bar: +# self.tqdm_bar.close() +# self.progress_bar = None +# self.tqdm_bar = None +# self.current_progress = 0 +# self.total_steps = 0 + +# @abstractmethod +# def process_image(self, image: np.ndarray, strength: float, **kwargs) -> np.ndarray: +# """ +# Process a single image. This method must be implemented by child classes. +# """ +# pass + +# def apply_image_operation(self, processed_images: torch.Tensor, original_images: torch.Tensor, strength: float, blend_mode: str, preserve_colors: bool, apply_to_alpha: bool, **kwargs) -> torch.Tensor: +# processed_images_np = processed_images.cpu().numpy() if isinstance(processed_images, torch.Tensor) else processed_images +# original_images_np = original_images.cpu().numpy() if isinstance(original_images, torch.Tensor) else original_images +# num_frames = processed_images_np.shape[0] + +# self.start_progress(num_frames, desc="Applying image operation") + +# result = [] +# for processed_image, original_image in zip(processed_images_np, original_images_np): +# # Pre-processing +# processed_image = self.pre_process(processed_image) + +# if preserve_colors: +# processed_image = preserve_original_colors(original_image, processed_image) + +# if not apply_to_alpha and original_image.shape[-1] == 4: +# processed_image[..., -1] = original_image[..., -1] + +# # Post-processing +# processed_image = self.post_process(processed_image) + +# # Ensure the final image is clipped between 0 and 1 +# processed_image = np.clip(processed_image, 0, 1) + +# result.append(processed_image) +# self.update_progress() + +# self.end_progress() + +# return torch.from_numpy(np.stack(result)).float() + +# @abstractmethod +# def main_function(self, *args, **kwargs) -> torch.Tensor: +# """ +# Main entry point for the node. This method must be implemented by child classes. +# It should call apply_image_operation with the appropriate arguments. +# """ +# pass + + + +# import torch +# import numpy as np +# from abc import abstractmethod +# from .image_base import ImageBase + +# class FlexImageBase(ImageBase): + +# BLEND_MODES = ["multiply", "add", "subtract", "override"] +# @classmethod +# def INPUT_TYPES(cls): +# return { +# "required": { +# **super().INPUT_TYPES()["required"], +# "feature": ("FEATURE",), +# "feature_pipe": ("FEATURE_PIPE",), +# "feature_threshold": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), +# "feature_strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.01}), +# "feature_blend_mode": (cls.BLEND_MODES,), +# } +# } + +# CATEGORY = "RyanOnTheInside/FlexImages" +# RETURN_TYPES = ("IMAGE",) +# FUNCTION = "main_function" + +# @abstractmethod +# def process_image(self, image: np.ndarray, feature_value: float, strength: float, **kwargs) -> np.ndarray: +# pass + +# def apply_image_operation(self, images, feature, feature_pipe, strength, blend_mode, preserve_colors, apply_to_alpha, feature_threshold, feature_strength, feature_blend_mode, **kwargs): +# num_frames = feature_pipe.frame_count +# original_images = images.clone() + +# self.start_progress(num_frames, desc="Applying flex image operation") + +# result = [] +# for i in range(num_frames): +# image = images[i].numpy() +# feature_value = feature.get_value_at_frame(i) + +# if feature_value >= feature_threshold: +# # Apply the effect with the feature value directly controlling the intensity +# processed_image = self.process_image(image, feature_value * feature_strength, strength, **kwargs) +# else: +# processed_image = image + +# result.append(processed_image) +# self.update_progress() + +# self.end_progress() + +# processed_images = torch.from_numpy(np.stack(result)).float() +# return super().apply_image_operation(processed_images, original_images, strength, blend_mode, preserve_colors, apply_to_alpha, **kwargs) + +# @abstractmethod +# def main_function(self, images, feature, feature_pipe, strength, blend_mode, preserve_colors, apply_to_alpha, feature_threshold, feature_strength, feature_blend_mode, **kwargs): +# pass + + + +import torch +import numpy as np +from abc import ABC, abstractmethod +from tqdm import tqdm +from comfy.utils import ProgressBar +from ... import RyanOnTheInside + +class FlexImageBase(RyanOnTheInside, ABC): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE",), + "feature": ("FEATURE",), + "feature_pipe": ("FEATURE_PIPE",), + "strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "feature_threshold": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "modulate_param": (cls.get_modifiable_params(), {"default": cls.get_modifiable_params()[0]}), + "modulation_mode": (["relative", "absolute"], {"default": "relative"}), + } + } + + CATEGORY = "RyanOnTheInside/ImageEffects" + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_effect" + + def __init__(self): + self.progress_bar = None + + def start_progress(self, total_steps, desc="Processing"): + self.progress_bar = ProgressBar(total_steps) + + def update_progress(self): + if self.progress_bar: + self.progress_bar.update(1) + + def end_progress(self): + self.progress_bar = None + + @classmethod + @abstractmethod + def get_modifiable_params(cls): + """Return a list of parameter names that can be modulated.""" + return [] + + def modulate_param(self, param_name, param_value, feature_value, strength, mode): + if mode == "relative": + return param_value * (1 + (feature_value - 0.5) * strength) + else: # absolute + return param_value * feature_value * strength + + def apply_effect(self, images, feature, feature_pipe, strength, feature_threshold, **kwargs): + num_frames = feature_pipe.frame_count + images_np = images.cpu().numpy() + + self.start_progress(num_frames, desc=f"Applying {self.__class__.__name__}") + + result = [] + for i in range(num_frames): + image = images_np[i] + feature_value = feature.get_value_at_frame(i) + + if feature_value >= feature_threshold: + processed_image = self.process_image(image, feature_value, strength, **kwargs) + else: + processed_image = image + + result.append(processed_image) + self.update_progress() + + self.end_progress() + + # Convert the list of numpy arrays to a single numpy array + result_np = np.stack(result) + + # Convert the numpy array to a PyTorch tensor and ensure it's in BHWC format + result_tensor = torch.from_numpy(result_np).float() + + # If the tensor is not in BHWC format, transpose it + if result_tensor.shape[1] == 3: # If it's in BCHW format + result_tensor = result_tensor.permute(0, 2, 3, 1) + + return (result_tensor,) + + def process_image(self, image: np.ndarray, feature_value: float, strength: float, + modulate_param: str, modulation_mode: str, **kwargs) -> np.ndarray: + # Modulate the selected parameter + for param_name in self.get_modifiable_params(): + if param_name in kwargs: + if param_name == modulate_param: + kwargs[param_name] = self.modulate_param(param_name, kwargs[param_name], + feature_value, strength, modulation_mode) + + # Call the child class's implementation + return self.apply_effect_internal(image, **kwargs) + + @abstractmethod + def apply_effect_internal(self, image: np.ndarray, **kwargs) -> np.ndarray: + """Apply the effect to the image. To be implemented by child classes.""" + pass \ No newline at end of file diff --git a/nodes/images/image_utility_nodes.py b/nodes/images/image_utility_nodes.py new file mode 100644 index 0000000..0df560e --- /dev/null +++ b/nodes/images/image_utility_nodes.py @@ -0,0 +1,31 @@ +import torch +from ..node_utilities import string_to_rgb + +class DyeImage: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "image": ("IMAGE",), + "source_rgb": ("STRING", {"default": "255,255,255"}), + "target_rgb": ("STRING", {"default": "0,0,0"}), + "tolerance": ("FLOAT", {"default": 0.01, "min": 0.0, "max": 1.0, "step": 0.001}) + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "dye_image" + CATEGORY = "image/color" + + def dye_image(self, image, source_rgb, target_rgb, tolerance): + + source = torch.tensor(string_to_rgb(source_rgb), dtype=torch.float32, device=image.device) + target = torch.tensor(string_to_rgb(target_rgb), dtype=torch.float32, device=image.device) + + color_distance = torch.sum((image - source.view(1, 1, 1, 3)).abs(), dim=-1) + mask = color_distance <= (tolerance * 3) + + result = image.clone() + result[mask] = target.view(1, 1, 1, 3).expand_as(result)[mask] + + return (result,) diff --git a/nodes/images/image_utils.py b/nodes/images/image_utils.py new file mode 100644 index 0000000..3e27719 --- /dev/null +++ b/nodes/images/image_utils.py @@ -0,0 +1,198 @@ +import numpy as np +import cv2 + +def apply_blend_mode(base_image: np.ndarray, blend_image: np.ndarray, mode: str, opacity: float) -> np.ndarray: + """ + Apply various blend modes to combine two images. + + :param base_image: The bottom layer image + :param blend_image: The top layer image to blend + :param mode: The blending mode to use + :param opacity: The opacity of the blend (0.0 to 1.0) + :return: The blended image + """ + if mode == "normal": + blended = base_image * (1 - opacity) + blend_image * opacity + elif mode == "multiply": + blended = base_image * blend_image + elif mode == "screen": + blended = 1 - (1 - base_image) * (1 - blend_image) + elif mode == "overlay": + mask = base_image >= 0.5 + blended = np.where(mask, 1 - 2 * (1 - base_image) * (1 - blend_image), 2 * base_image * blend_image) + elif mode == "soft_light": + mask = blend_image <= 0.5 + blended = np.where(mask, + base_image - (1 - 2 * blend_image) * base_image * (1 - base_image), + base_image + (2 * blend_image - 1) * (np.sqrt(base_image) - base_image)) + else: + raise ValueError(f"Unsupported blend mode: {mode}") + + return base_image * (1 - opacity) + blended * opacity + +def preserve_original_colors(original_image: np.ndarray, processed_image: np.ndarray) -> np.ndarray: + """ + Preserve the colors of the original image while keeping the luminance of the processed image. + + :param original_image: The original image + :param processed_image: The processed image whose luminance we want to keep + :return: An image with original colors and processed luminance + """ + def rgb_to_hsl(rgb): + r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2] + max_rgb = np.max(rgb, axis=-1) + min_rgb = np.min(rgb, axis=-1) + diff = max_rgb - min_rgb + + l = (max_rgb + min_rgb) / 2 + + s = np.zeros_like(l) + mask = diff != 0 + s[mask] = diff[mask] / (1 - np.abs(2 * l[mask] - 1)) + + h = np.zeros_like(l) + mask_r = (max_rgb == r) & (diff != 0) + h[mask_r] = ((g[mask_r] - b[mask_r]) / diff[mask_r]) % 6 + mask_g = (max_rgb == g) & (diff != 0) + h[mask_g] = ((b[mask_g] - r[mask_g]) / diff[mask_g]) + 2 + mask_b = (max_rgb == b) & (diff != 0) + h[mask_b] = ((r[mask_b] - g[mask_b]) / diff[mask_b]) + 4 + h /= 6 + + return np.stack([h, s, l], axis=-1) + + def hsl_to_rgb(hsl): + h, s, l = hsl[..., 0], hsl[..., 1], hsl[..., 2] + c = (1 - np.abs(2 * l - 1)) * s + x = c * (1 - np.abs((h * 6) % 2 - 1)) + m = l - c / 2 + + rgb = np.zeros_like(hsl) + mask = (h < 1/6) + rgb[mask] = [c[mask], x[mask], 0] + mask = (1/6 <= h) & (h < 2/6) + rgb[mask] = [x[mask], c[mask], 0] + mask = (2/6 <= h) & (h < 3/6) + rgb[mask] = [0, c[mask], x[mask]] + mask = (3/6 <= h) & (h < 4/6) + rgb[mask] = [0, x[mask], c[mask]] + mask = (4/6 <= h) & (h < 5/6) + rgb[mask] = [x[mask], 0, c[mask]] + mask = (5/6 <= h) + rgb[mask] = [c[mask], 0, x[mask]] + + return rgb + m[..., np.newaxis] + + original_hsl = rgb_to_hsl(original_image[..., :3]) + processed_hsl = rgb_to_hsl(processed_image[..., :3]) + + result_hsl = np.copy(original_hsl) + result_hsl[..., 2] = processed_hsl[..., 2] # Use luminance from processed image + + result_rgb = hsl_to_rgb(result_hsl) + + if original_image.shape[-1] == 4: # If there's an alpha channel + result = np.dstack((result_rgb, original_image[..., 3])) + else: + result = result_rgb + + return result + + + +def normalize_array(arr: np.ndarray) -> np.ndarray: + """ + Normalize an array to the range [0, 1]. + + :param arr: Input array + :return: Normalized array + """ + min_val = np.min(arr) + max_val = np.max(arr) + if max_val > min_val: + return (arr - min_val) / (max_val - min_val) + return arr + +def apply_blur(image: np.ndarray, intensity: float, kernel_size: int, sigma: float = 0) -> np.ndarray: + blurred = cv2.GaussianBlur(image, (kernel_size, kernel_size), sigma) + return blurred + +def apply_sharpen(image: np.ndarray, intensity: float, kernel_size: int, sigma: float = 1.0) -> np.ndarray: + blurred = cv2.GaussianBlur(image, (kernel_size, kernel_size), sigma) + sharpened = cv2.addWeighted(image, 1 + intensity, blurred, -intensity, 0) + return np.clip(sharpened, 0, 1) + +def apply_edge_detect(image: np.ndarray, intensity: float, kernel_size: int) -> np.ndarray: + gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) + edges = cv2.Canny(np.uint8(gray * 255), 100, 200) + edges = edges.astype(float) / 255.0 + return np.clip(image * (1 - intensity) + np.dstack([edges] * 3) * intensity, 0, 1) + +def apply_emboss(image: np.ndarray, intensity: float, kernel_size: int) -> np.ndarray: + kernel = np.array([[-1,-1,0], [-1,0,1], [0,1,1]]) + if kernel_size > 3: + kernel = cv2.resize(kernel, (kernel_size, kernel_size), interpolation=cv2.INTER_LINEAR) + gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) + embossed = cv2.filter2D(gray, -1, kernel * intensity) + embossed = (embossed - np.min(embossed)) / (np.max(embossed) - np.min(embossed)) + return np.clip(image * (1 - intensity) + np.dstack([embossed] * 3) * intensity, 0, 1) + +def apply_posterize(image: np.ndarray, intensity: float, levels: int, dither: bool = False) -> np.ndarray: + effective_levels = int(np.clip(levels * (1 - intensity) + 2 * intensity, 2, levels)) + quantized = np.round(image * (effective_levels - 1)) / (effective_levels - 1) + if dither: + error = image - quantized + quantized += np.random.uniform(-0.5/effective_levels, 0.5/effective_levels, image.shape) + return np.clip(quantized, 0, 1) + +def apply_brightness(image: np.ndarray, intensity: float, midpoint: float = 0.5, preserve_luminosity: bool = False) -> np.ndarray: + adjusted = image + intensity + + if preserve_luminosity: + original_luminosity = np.mean(image) + adjusted_luminosity = np.mean(adjusted) + adjusted *= original_luminosity / adjusted_luminosity + + return np.clip(adjusted, 0, 1) + +def apply_contrast(image: np.ndarray, intensity: float, midpoint: float = 0.5, preserve_luminosity: bool = False) -> np.ndarray: + factor = 1 + intensity + adjusted = (image - midpoint) * factor + midpoint + + if preserve_luminosity: + original_luminosity = np.mean(image) + adjusted_luminosity = np.mean(adjusted) + adjusted *= original_luminosity / adjusted_luminosity + + return np.clip(adjusted, 0, 1) + +def apply_saturation(image: np.ndarray, intensity: float, preserve_luminosity: bool = True) -> np.ndarray: + hsv = cv2.cvtColor(image, cv2.COLOR_RGB2HSV) + hsv[..., 1] = np.clip(hsv[..., 1] * (1 + intensity), 0, 1) + adjusted = cv2.cvtColor(hsv, cv2.COLOR_HSV2RGB) + + if preserve_luminosity: + original_luminosity = np.mean(image) + adjusted_luminosity = np.mean(adjusted) + adjusted *= original_luminosity / adjusted_luminosity + + return np.clip(adjusted, 0, 1) + +def apply_hue_shift(image: np.ndarray, intensity: float, preserve_luminosity: bool = True, hue_method: str = "circular") -> np.ndarray: + hsv = cv2.cvtColor(image, cv2.COLOR_RGB2HSV) + + if hue_method == "linear": + hsv[..., 0] = np.clip(hsv[..., 0] + intensity, 0, 1) + elif hue_method == "circular": + hsv[..., 0] = (hsv[..., 0] + intensity) % 1.0 + else: + raise ValueError(f"Unsupported hue shift method: {hue_method}") + + adjusted = cv2.cvtColor(hsv, cv2.COLOR_HSV2RGB) + + if preserve_luminosity: + original_luminosity = np.mean(image) + adjusted_luminosity = np.mean(adjusted) + adjusted *= original_luminosity / adjusted_luminosity + + return np.clip(adjusted, 0, 1) \ No newline at end of file diff --git a/nodes/masks/audio_masks.py b/nodes/masks/audio_masks.py deleted file mode 100644 index f260866..0000000 --- a/nodes/masks/audio_masks.py +++ /dev/null @@ -1,62 +0,0 @@ -import numpy as np -from .mask_base import MaskBase -from .mask_utils import normalize_array, morph_mask - -class AudioControlledMaskBase(MaskBase): - @classmethod - def INPUT_TYPES(cls): - return { - **super().INPUT_TYPES(), - "required": { - **super().INPUT_TYPES()["required"], - "audio": ("AUDIO",), - "bass_freq_range": ("STRING", {"default": "20,150"}), - "reactivity_curve": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 5.0, "step": 0.1}), - } - } - - CATEGORY = "/Masks/Audio" - - def apply_mask_operation(self, masks, audio, bass_freq_range, reactivity_curve, **kwargs): - audio_np = audio['waveform'].cpu().numpy().squeeze() - sample_rate = audio['sample_rate'] - - if audio_np.ndim > 1: - audio_np = np.mean(audio_np, axis=0) - - num_frames = len(masks) - samples_per_frame = len(audio_np) // num_frames - - frame_energy = np.array([ - np.sqrt(np.mean(audio_np[i*samples_per_frame:(i+1)*samples_per_frame]**2)) - for i in range(num_frames) - ]) - - energy_min, energy_max = np.percentile(frame_energy, [5, 95]) - normalized_energy = np.clip((frame_energy - energy_min) / (energy_max - energy_min), 0, 1) - - smoothed_energy = np.convolve(normalized_energy, np.ones(5)/5, mode='same') - enhanced_energy = np.tanh(3 * (smoothed_energy - smoothed_energy.mean())) - enhanced_energy = normalize_array(enhanced_energy) - - enhanced_energy = np.power(enhanced_energy, reactivity_curve) - enhanced_energy = normalize_array(enhanced_energy) - - return super().apply_mask_operation(masks, enhanced_energy, **kwargs) - -class AudioControlledMaskMorph(AudioControlledMaskBase): - @classmethod - def INPUT_TYPES(cls): - return { - **super().INPUT_TYPES(), - "required": { - **super().INPUT_TYPES()["required"], - "morph_type": (["dilate", "erode", "open", "close"],), - "kernel_size": ("INT", {"default": 5, "min": 3, "max": 21, "step": 2}), - "growth_size": ("INT", {"default": 1, "min": 1, "max": 10, "step": 1}), - } - } - - def process_mask(self, mask, strength, morph_type, kernel_size, growth_size, **kwargs): - iterations = max(1, int(growth_size * strength * 5)) - return morph_mask(mask, morph_type, kernel_size, iterations) \ No newline at end of file diff --git a/nodes/masks/flex_masks.py b/nodes/masks/flex_masks.py new file mode 100644 index 0000000..1d3b78f --- /dev/null +++ b/nodes/masks/flex_masks.py @@ -0,0 +1,89 @@ +from .mask_base import FlexMaskBase +from .mask_utils import morph_mask, warp_mask, transform_mask, combine_masks +import numpy as np + +class FlexMaskMorph(FlexMaskBase): + @classmethod + def INPUT_TYPES(cls): + return { + **super().INPUT_TYPES(), + "required": { + **super().INPUT_TYPES()["required"], + "morph_type": (["erode", "dilate", "open", "close"],), + "max_kernel_size": ("INT", {"default": 5, "min": 3, "max": 21, "step": 2}), + "max_iterations": ("INT", {"default": 10, "min": 1, "max": 50, "step": 1}), + } + } + + def process_mask(self, mask: np.ndarray, feature_value: float, strength: float, morph_type: str, max_kernel_size: int, max_iterations: int, **kwargs) -> np.ndarray: + kernel_size = max(3, int(3 + (max_kernel_size - 3) * feature_value * strength)) + iterations = max(1, int(max_iterations * feature_value * strength)) + + return morph_mask(mask, morph_type, kernel_size, iterations) + + def main_function(self, masks, feature, feature_pipe, strength, feature_threshold, invert, subtract_original, grow_with_blur, morph_type, max_kernel_size, max_iterations, **kwargs): + return (self.apply_mask_operation(masks, feature, feature_pipe, strength, feature_threshold, invert, subtract_original, grow_with_blur, + morph_type=morph_type, max_kernel_size=max_kernel_size, max_iterations=max_iterations, **kwargs),) + +class FlexMaskWarp(FlexMaskBase): + @classmethod + def INPUT_TYPES(cls): + return { + **super().INPUT_TYPES(), + "required": { + **super().INPUT_TYPES()["required"], + "warp_type": (["perlin", "radial", "swirl"],), + "frequency": ("FLOAT", {"default": 0.1, "min": 0.01, "max": 1.0, "step": 0.01}), + "max_amplitude": ("FLOAT", {"default": 30.0, "min": 0.1, "max": 500.0, "step": 0.1}), + "octaves": ("INT", {"default": 3, "min": 1, "max": 8, "step": 1}), + } + } + + def process_mask(self, mask: np.ndarray, feature_value: float, strength: float, warp_type: str, frequency: float, max_amplitude: float, octaves: int, **kwargs) -> np.ndarray: + amplitude = max_amplitude * feature_value * strength + return warp_mask(mask, warp_type, frequency, amplitude, octaves) + + def main_function(self, masks, feature, feature_pipe, strength, feature_threshold, invert, subtract_original, grow_with_blur, warp_type, frequency, max_amplitude, octaves, **kwargs): + return (self.apply_mask_operation(masks, feature, feature_pipe, strength, feature_threshold, invert, subtract_original, grow_with_blur, + warp_type=warp_type, frequency=frequency, max_amplitude=max_amplitude, octaves=octaves, **kwargs),) + +class FlexMaskTransform(FlexMaskBase): + @classmethod + def INPUT_TYPES(cls): + return { + **super().INPUT_TYPES(), + "required": { + **super().INPUT_TYPES()["required"], + "transform_type": (["translate", "rotate", "scale"],), + "max_x_value": ("FLOAT", {"default": 10.0, "min": -1000.0, "max": 1000.0, "step": 0.1}), + "max_y_value": ("FLOAT", {"default": 10.0, "min": -1000.0, "max": 1000.0, "step": 0.1}), + } + } + + def process_mask(self, mask: np.ndarray, feature_value: float, strength: float, transform_type: str, max_x_value: float, max_y_value: float, **kwargs) -> np.ndarray: + x_value = max_x_value * feature_value * strength + y_value = max_y_value * feature_value * strength + return transform_mask(mask, transform_type, x_value, y_value) + + def main_function(self, masks, feature, feature_pipe, strength, feature_threshold, invert, subtract_original, grow_with_blur, transform_type, max_x_value, max_y_value, **kwargs): + return (self.apply_mask_operation(masks, feature, feature_pipe, strength, feature_threshold, invert, subtract_original, grow_with_blur, + transform_type=transform_type, max_x_value=max_x_value, max_y_value=max_y_value, **kwargs),) + +class FlexMaskMath(FlexMaskBase): + @classmethod + def INPUT_TYPES(cls): + return { + **super().INPUT_TYPES(), + "required": { + **super().INPUT_TYPES()["required"], + "mask_b": ("MASK",), + "combination_method": (["add", "subtract", "multiply", "minimum", "maximum"],), + } + } + + def process_mask(self, mask: np.ndarray, feature_value: float, strength: float, mask_b: np.ndarray, combination_method: str, **kwargs) -> np.ndarray: + return combine_masks(mask, mask_b, combination_method, feature_value * strength) + + def main_function(self, masks, feature, feature_pipe, strength, feature_threshold, invert, subtract_original, grow_with_blur, mask_b, combination_method, **kwargs): + return (self.apply_mask_operation(masks, feature, feature_pipe, strength, feature_threshold, invert, subtract_original, grow_with_blur, + mask_b=mask_b, combination_method=combination_method, **kwargs),) \ No newline at end of file diff --git a/nodes/masks/mask_base.py b/nodes/masks/mask_base.py index 578c1c3..0d27f60 100644 --- a/nodes/masks/mask_base.py +++ b/nodes/masks/mask_base.py @@ -18,9 +18,11 @@ import random from typing import List, Tuple import pymunk import cv2 +from ..audio.audio_utils import AudioFeatureExtractor +from ... import RyanOnTheInside -class MaskBase(ABC): +class MaskBase(RyanOnTheInside, ABC): @classmethod def INPUT_TYPES(cls): return { @@ -33,8 +35,9 @@ class MaskBase(ABC): } } - CATEGORY = "/RyanOnTheInside/masks/" + CATEGORY = "RyanOnTheInside/Masks" + def __init__(self): self.pre_processors = [] self.post_processors = [] @@ -150,6 +153,8 @@ class TemporalMaskBase(MaskBase, ABC): "palindrome": ("BOOLEAN", {"default": False}), } } + + CATEGORY="RyanOnTheInside/TemporalMasks" def __init__(self): super().__init__() @@ -221,6 +226,7 @@ class ParticleSystemMaskBase(MaskBase, ABC): "wind_strength": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0}), "wind_direction": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 360.0, "step": 1.0}), "gravity": ("FLOAT", {"default": 0.0, "min": -1000.0, "max": 1000.0, "step": 1.0}), + "warmup_period": ("INT", {"default": 0, "min": 0, "max": 1000, "step": 1}), "start_frame": ("INT", {"default": 0, "min": 0, "max": 1000, "step": 1}), "end_frame": ("INT", {"default": 0, "min": 0, "max": 1000, "step": 1}), "respect_mask_boundary": ("BOOLEAN", {"default": False}), @@ -235,7 +241,7 @@ class ParticleSystemMaskBase(MaskBase, ABC): RETURN_TYPES = ("MASK", "IMAGE") FUNCTION = "main_function" - CATEGORY = "/RyanOnTheInside/masks/" + CATEGORY="RyanOnTheInside/ParticleSystemMasks" def __init__(self): super().__init__() @@ -273,12 +279,18 @@ class ParticleSystemMaskBase(MaskBase, ABC): start_frame = kwargs.get('start_frame', 0) end_frame = kwargs.get('end_frame', num_frames) end_frame = end_frame if end_frame > 0 else num_frames + warmup_period = kwargs.get('warmup_period', 0) self.setup_particle_system(width, height, **kwargs) respect_mask_boundary = kwargs.get('respect_mask_boundary', False) - self.start_progress(num_frames, desc="Processing particle system mask") + self.start_progress(num_frames + warmup_period, desc="Processing particle system mask") + + # Run warmup period + for _ in range(warmup_period): + self.update_particle_system(1.0 / 30.0, masks_np[0], respect_mask_boundary, -1) + self.update_progress() mask_result = [] image_result = [] @@ -301,6 +313,7 @@ class ParticleSystemMaskBase(MaskBase, ABC): return torch.from_numpy(np.stack(mask_result)).float(), torch.from_numpy(np.stack(image_result)).float() + def setup_particle_system(self, width: int, height: int, **kwargs): self.space.gravity = pymunk.Vec2d(float(kwargs['wind_strength']), float(kwargs['gravity'])) @@ -573,15 +586,13 @@ class ParticleSystemMaskBase(MaskBase, ABC): elif modulation['type'] == 'ParticleColorModulation': self.apply_color_modulation(particle, modulation, progress) - def calculate_modulation_progress(self, current_frame, modulation, particle_age): + def calculate_modulation_progress(self, current_frame, modulation, particle_creation_frame): start_frame = modulation['start_frame'] duration = modulation['effect_duration'] - - progress = (current_frame - start_frame) / duration + progress = (max(0, current_frame) - start_frame) / duration if modulation['palindrome']: - # For plindrome, we need to adjust the progress if progress <= 0.5: progress = progress * 2 else: @@ -635,14 +646,16 @@ class ParticleSystemMaskBase(MaskBase, ABC): for _ in range(sub_steps): for i, emitter in enumerate(self.emitters): - emission_rate = emitter['emission_rate'] * 10 # Increased sensitivity - self.particles_to_emit[i] += emission_rate * sub_dt - while self.particles_to_emit[i] >= 1 and self.total_particles_emitted < self.max_particles: - self.emit_particle(emitter, height, width, i, frame_index) - self.particles_to_emit[i] -= 1 + # Check if the emitter is active in the current frame + if emitter['start_frame'] <= max(0, frame_index) and (emitter['end_frame'] == 0 or max(0, frame_index) < emitter['end_frame']): + emission_rate = emitter['emission_rate'] * 10 # Increased sensitivity + self.particles_to_emit[i] += emission_rate * sub_dt + while self.particles_to_emit[i] >= 1 and self.total_particles_emitted < self.max_particles: + self.emit_particle(emitter, height, width, i, frame_index) + self.particles_to_emit[i] -= 1 particles_before = len(self.particles) - self.particles = [p for p in self.particles if frame_index - p.creation_frame < self.particle_lifetime * 30] # Assuming 30 fps + self.particles = [p for p in self.particles if max(0, frame_index) - p.creation_frame < self.particle_lifetime * 30] # Assuming 30 fps particles_after = len(self.particles) particles_removed = particles_before - particles_after @@ -651,7 +664,7 @@ class ParticleSystemMaskBase(MaskBase, ABC): self.apply_gravity_well_force(particle) # Apply modulations before updating position - self.apply_particle_modulations(particle, frame_index) + self.apply_particle_modulations(particle, max(0, frame_index)) old_pos = particle.position new_pos = old_pos + particle.velocity * sub_dt @@ -700,12 +713,11 @@ class ParticleSystemMaskBase(MaskBase, ABC): particle = pymunk.Body(mass, moment) particle.position = emitter_pos particle.velocity = velocity - #particle.creation_time = self.total_time particle.lifetime = self.particle_lifetime particle.size = particle_size particle.color = emitter['color'] particle.emitter_index = emitter_index - particle.creation_frame = frame_index + particle.creation_frame = max(0, frame_index) # Ensure non-negative creation frame shape = pymunk.Circle(particle, radius) shape.elasticity = 0.9 @@ -784,7 +796,7 @@ class ParticleSystemMaskBase(MaskBase, ABC): def main_function(self, masks, strength, invert, subtract_original, grow_with_blur, emitters, **kwargs): self.initialize() - self.total_frames = masks.shape[0] + self.total_frames = masks.shape[0] + kwargs.get('warmup_period', 0) # Include warmup period in total frames for emitter in emitters: emitter['initial_x'] = emitter['emitter_x'] emitter['initial_y'] = emitter['emitter_y'] @@ -794,10 +806,22 @@ class ParticleSystemMaskBase(MaskBase, ABC): original_masks = masks processed_masks, processed_images = self.process_mask(masks, emitters=self.emitters, **kwargs) + + if subtract_original > 0: + original_masks_np = original_masks.cpu().numpy() + for i in range(self.total_frames): + mask_8bit = (original_masks_np[i] * 255).astype(np.uint8) + dist_transform = create_distance_transform(mask_8bit) + dist_transform = normalize_array(dist_transform) + threshold = 1 - subtract_original + subtraction_mask = dist_transform > threshold + + processed_images[i][subtraction_mask] = 0 + result_masks = self.apply_mask_operation(processed_masks, original_masks, strength, invert, subtract_original, grow_with_blur, **kwargs) + return (result_masks, processed_images,) - class OpticalFlowMaskBase(MaskBase, ABC): @classmethod def INPUT_TYPES(cls): @@ -812,7 +836,7 @@ class OpticalFlowMaskBase(MaskBase, ABC): } } - CATEGORY = "/RyanOnTheInside/masks/" + CATEGORY="RyanOnTheInside/OpticalFlowMasks" def __init__(self): super().__init__() @@ -860,5 +884,52 @@ class OpticalFlowMaskBase(MaskBase, ABC): processed_masks = np.stack(result) return self.apply_mask_operation(processed_masks, masks, strength, **kwargs) +#TODO check if input mask is blank and just skip, then check children +class FlexMaskBase(MaskBase): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + **super().INPUT_TYPES()["required"], + "feature": ("FEATURE",), + "feature_pipe": ("FEATURE_PIPE",), + "feature_threshold": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + } + } + CATEGORY = "RyanOnTheInside/AudioMasks" + RETURN_TYPES = ("MASK",) + FUNCTION = "main_function" + + @abstractmethod + def process_mask(self, mask: np.ndarray, feature_value: float, strength: float, **kwargs) -> np.ndarray: + pass + + def apply_mask_operation(self, masks, feature, feature_pipe, strength, feature_threshold, invert, subtract_original, grow_with_blur, **kwargs): + num_frames = feature_pipe.frame_count + original_masks = masks.clone() + + self.start_progress(num_frames, desc="Applying flex mask operation") + + result = [] + for i in range(num_frames): + mask = masks[i].numpy() + feature_value = feature.get_value_at_frame(i) + + if feature_value >= feature_threshold: + processed_mask = self.process_mask(mask, feature_value, strength, **kwargs) + else: + processed_mask = mask + + result.append(processed_mask) + self.update_progress() + + self.end_progress() + + processed_masks = torch.from_numpy(np.stack(result)).float() + return super().apply_mask_operation(processed_masks, original_masks, strength, invert, subtract_original, grow_with_blur, **kwargs) + + @abstractmethod + def main_function(self, masks, feature, feature_pipe, strength, feature_threshold, invert, subtract_original, grow_with_blur, **kwargs): + pass diff --git a/nodes/masks/mask_utils.py b/nodes/masks/mask_utils.py index 78d65c2..8489695 100644 --- a/nodes/masks/mask_utils.py +++ b/nodes/masks/mask_utils.py @@ -2,7 +2,7 @@ import numpy as np import cv2 from scipy import ndimage from scipy.ndimage import gaussian_filter - +from ..node_utilities import string_to_rgb def apply_easing(t, easing_type): if easing_type == 'linear': @@ -55,9 +55,23 @@ def translate_mask(mask: np.ndarray, x_value: float, y_value: float) -> np.ndarr def rotate_mask(mask: np.ndarray, angle: float) -> np.ndarray: height, width = mask.shape[:2] - center = (width // 2, height // 2) - M = cv2.getRotationMatrix2D(center, angle, 1) - return warp_affine(mask, M) + + rows = np.any(mask, axis=1) + cols = np.any(mask, axis=0) + ymin, ymax = np.where(rows)[0][[0, -1]] if np.any(rows) else (0, height-1) + xmin, xmax = np.where(cols)[0][[0, -1]] if np.any(cols) else (0, width-1) + + center_y = int((ymin + ymax) / 2) + center_x = int((xmin + xmax) / 2) + + M = cv2.getRotationMatrix2D((center_x, center_y), angle, 1.0) + + rotated = np.zeros_like(mask) + + mask_rotated = cv2.warpAffine(mask, M, (width, height), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT) + rotated = np.maximum(rotated, mask_rotated) + + return rotated def scale_mask(mask: np.ndarray, scale_x: float, scale_y: float) -> np.ndarray: height, width = mask.shape[:2] @@ -215,9 +229,3 @@ def calculate_optical_flow(frame1, frame2, flow_method): else: raise ValueError(f"Unknown flow method: {flow_method}") - -def string_to_rgb(color_string): - if isinstance(color_string, tuple): - return color_string - color_values = color_string.strip('()').split(',') - return tuple(int(value.strip()) / 255.0 for value in color_values) \ No newline at end of file diff --git a/nodes/masks/particle_system_masks.py b/nodes/masks/particle_system_masks.py index 88a3b69..78c4247 100644 --- a/nodes/masks/particle_system_masks.py +++ b/nodes/masks/particle_system_masks.py @@ -2,7 +2,7 @@ import numpy as np from .mask_base import ParticleSystemMaskBase from typing import List, Tuple import cv2 -import inspect +from ... import RyanOnTheInside class ParticleEmissionMask(ParticleSystemMaskBase): @classmethod @@ -22,8 +22,8 @@ class ParticleEmissionMask(ParticleSystemMaskBase): RETURN_TYPES = ("MASK","IMAGE") FUNCTION = "main_function" - CATEGORY = "Masks/Particle Systems" + def process_single_mask(self, mask: np.ndarray, frame_index: int, emission_strength: float, **kwargs) -> Tuple[np.ndarray, np.ndarray]: self.modulate_parameters(frame_index, mask) @@ -57,7 +57,10 @@ class ParticleEmissionMask(ParticleSystemMaskBase): emission_strength=emission_strength, static_bodies=static_bodies, **kwargs) -class Vortex: +class ParticleSystemModulatorBase(RyanOnTheInside): + CATEGORY="RyanOnTheInside/ParticleSystemMasks" + +class Vortex(ParticleSystemModulatorBase): @classmethod def INPUT_TYPES(cls): return { @@ -78,7 +81,6 @@ class Vortex: RETURN_TYPES = ("VORTEX",) FUNCTION = "create_vortex" - CATEGORY = "RyanOnTheInside/ParticleSystems" def create_vortex(self, x, y, strength, radius, inward_factor, movement_speed, color, draw, previous_vortex=None): vortex = { @@ -99,7 +101,7 @@ class Vortex: return (vortex_list,) -class GravityWell: +class GravityWell(ParticleSystemModulatorBase): @classmethod def INPUT_TYPES(cls): return { @@ -119,7 +121,6 @@ class GravityWell: RETURN_TYPES = ("GRAVITY_WELL",) FUNCTION = "create_gravity_well" - CATEGORY = "RyanOnTheInside/ParticleSystems" def create_gravity_well(self, x, y, strength, radius, type, color, draw, previous_well=None): well = { @@ -139,7 +140,7 @@ class GravityWell: return (well_list,) -class ParticleEmitter: +class ParticleEmitter(ParticleSystemModulatorBase): @classmethod def INPUT_TYPES(cls): return { @@ -153,6 +154,8 @@ class ParticleEmitter: "emission_rate": ("FLOAT", {"default": 10.0, "min": 0.1, "max": 100.0, "step": 0.1}), "color": ("STRING", {"default": "(255,255,255)"}), "initial_plume": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "start_frame": ("INT", {"default": 0, "min": 0, "max": 10000, "step": 1}), + "end_frame": ("INT", {"default": 0, "min": 0, "max": 10000, "step": 1}), }, "optional": { "previous_emitter": ("PARTICLE_EMITTER",), @@ -164,11 +167,11 @@ class ParticleEmitter: RETURN_TYPES = ("PARTICLE_EMITTER",) FUNCTION = "create_emitter" - CATEGORY = "RyanOnTheInside/ParticleSystems" def create_emitter(self, emitter_x, emitter_y, particle_direction, particle_spread, particle_size, particle_speed, emission_rate, color, initial_plume, - previous_emitter=None, emitter_movement=None, spring_joint_setting=None, particle_modulation=None): + start_frame, end_frame, previous_emitter=None, emitter_movement=None, + spring_joint_setting=None, particle_modulation=None): emitter = { "emitter_x": emitter_x, "emitter_y": emitter_y, @@ -179,6 +182,8 @@ class ParticleEmitter: "emission_rate": emission_rate, "color": color, "initial_plume": initial_plume, + "start_frame": start_frame, + "end_frame": end_frame, } if emitter_movement: @@ -197,7 +202,7 @@ class ParticleEmitter: return (emitter_list,) -class SpringJointSetting: +class SpringJointSetting(ParticleSystemModulatorBase): @classmethod def INPUT_TYPES(cls): return { @@ -211,7 +216,6 @@ class SpringJointSetting: RETURN_TYPES = ("SPRING_JOINT_SETTING",) FUNCTION = "create_setting" - CATEGORY = "RyanOnTheInside/ParticleSystems" def create_setting(self, stiffness, damping, rest_length, max_distance): return ({ @@ -221,7 +225,7 @@ class SpringJointSetting: "max_distance": max_distance, },) -class EmitterMovement: +class EmitterMovement(ParticleSystemModulatorBase): @classmethod def INPUT_TYPES(cls): return { @@ -237,7 +241,6 @@ class EmitterMovement: RETURN_TYPES = ("EMITTER_MOVEMENT",) FUNCTION = "create_movement" - CATEGORY = "RyanOnTheInside/ParticleSystems" def create_movement(self, emitter_x_frequency, emitter_y_frequency, direction_frequency, emitter_x_amplitude, emitter_y_amplitude, direction_amplitude): @@ -251,7 +254,7 @@ class EmitterMovement: } return (movement,) -class StaticBody: +class StaticBody(ParticleSystemModulatorBase): @classmethod def INPUT_TYPES(cls): return { @@ -274,7 +277,6 @@ class StaticBody: RETURN_TYPES = ("STATIC_BODY",) FUNCTION = "create_static_body" - CATEGORY = "RyanOnTheInside/ParticleSystems" def create_static_body(self, shape_type, x1, y1, x2, y2, elasticity, friction, draw, color, previous_body=None): body = { @@ -297,7 +299,7 @@ class StaticBody: return (body_list,) -class ParticleModulationBase: +class ParticleModulationBase(ParticleSystemModulatorBase): @classmethod def INPUT_TYPES(cls): return { @@ -312,8 +314,7 @@ class ParticleModulationBase: "previous_modulation": ("PARTICLE_MODULATION",), } } - - CATEGORY = "RyanOnTheInside/ParticleSystems" + def create_modulation(self, start_frame, end_frame, effect_duration, temporal_easing, palindrome, previous_modulation=None): modulation = { diff --git a/nodes/masks/structure.txt b/nodes/masks/structure.txt deleted file mode 100644 index 8813bdf..0000000 --- a/nodes/masks/structure.txt +++ /dev/null @@ -1,76 +0,0 @@ -""" -Mask Operation Inheritance Structure - -Other types can be extrapolated from this temporal mask structure (ie, the particle system nodes). - -1. MaskBase (Abstract Base Class) - - Location: - - Inherits from: ABC (Abstract Base Class) - - Key features: - - Defines basic structure for all mask operations - - Provides common methods: pre_process, post_process, start_progress, update_progress, end_progress - - Defines abstract method: process_mask - - Implements apply_mask_operation method - - Abstract methods that must be implemented by subclasses: - - process_mask - - main_function - -2. TemporalMaskBase (Abstract Base Class) - - Location: - - Inherits from: MaskBase, ABC - - Key features: - - Adds temporal-specific inputs (start_frame, end_frame, effect_duration, temporal_easing, palindrome) - - Implements apply_temporal_mask_operation method - - Overrides process_mask to delegate to process_single_mask - - Implements main_function to handle temporal operations - - Abstract methods that must be implemented by subclasses: - - process_single_mask - -3. Specific Mask Operation Classes (e.g., MaskMorph, MaskTransform) - - Location: - - Inherit from: TemporalMaskBase - - Key features: - - Implement process_single_mask method with specific mask operation logic - - May override main_function if additional processing is needed - - Define operation-specific inputs - -Inheritance Structure: -ABC - | - +--> MaskBase (ABC) - | - +--> TemporalMaskBase (ABC) - | - +--> MaskMorph - | - +--> MaskTransform - | - +--> (Other temporal mask operations) - -Responsibilities: - -1. MaskBase: - - Defines the basic structure for all mask operations - - Handles progress tracking and reporting - - Provides pre- and post-processing capabilities - - Defines the interface for mask processing (process_mask) - -2. TemporalMaskBase: - - Adds temporal functionality to mask operations - - Handles frame-by-frame processing with temporal effects - - Provides easing and palindrome capabilities - - Defines the interface for single frame processing (process_single_mask) - -3. Specific Mask Operation Classes: - - Implement the actual mask processing logic - - Define operation-specific parameters - - May provide additional customization of the temporal processing - - -When creating a new mask operation: -- For non-temporal operations, inherit directly from MaskBase -- For temporal operations, inherit from TemporalMaskBase -- Implement the required abstract methods -- Add any operation-specific inputs and logic - -""" \ No newline at end of file diff --git a/nodes/masks/temporal_masks.py b/nodes/masks/temporal_masks.py index b12505a..7ab0544 100644 --- a/nodes/masks/temporal_masks.py +++ b/nodes/masks/temporal_masks.py @@ -5,6 +5,7 @@ import torch import cv2 from scipy.ndimage import distance_transform_edt + class MaskMorph(TemporalMaskBase): @classmethod def INPUT_TYPES(cls): @@ -21,6 +22,7 @@ class MaskMorph(TemporalMaskBase): RETURN_TYPES = ("MASK",) FUNCTION = "apply_mask_morph" + def process_single_mask(self, mask: np.ndarray, strength: float, morph_type: str, max_kernel_size: int, max_iterations: int, **kwargs) -> np.ndarray: # Scale kernel size and iterations based on strength kernel_size = max(3, int(3 + (max_kernel_size - 3) * strength)) @@ -47,6 +49,7 @@ class MaskTransform(TemporalMaskBase): RETURN_TYPES = ("MASK",) FUNCTION = "apply_mask_transform" + def process_single_mask(self, mask: np.ndarray, strength: float, transform_type: str, x_value: float, y_value: float, **kwargs) -> np.ndarray: transformed_mask = transform_mask(mask, transform_type, x_value * strength, y_value * strength) return transformed_mask @@ -69,6 +72,7 @@ class MaskMath(TemporalMaskBase): RETURN_TYPES = ("MASK",) FUNCTION = "apply_mask_math" + def process_single_mask(self, mask: np.ndarray, strength: float, mask_b: np.ndarray, combination_method: str, frame_index: int, **kwargs) -> np.ndarray: mask_b_frame = mask_b[frame_index] return combine_masks(mask, mask_b_frame, combination_method, strength) @@ -77,6 +81,7 @@ class MaskMath(TemporalMaskBase): mask_b_np = mask_b.cpu().numpy() if isinstance(mask_b, torch.Tensor) else mask_b return super().main_function(masks, strength, mask_b=mask_b_np, combination_method=combination_method, **kwargs) + #TODO CONFIRM THAT NOTHING HAPPENS WITH EMPTY MASK class MaskRings(TemporalMaskBase): @classmethod def INPUT_TYPES(cls): @@ -92,6 +97,7 @@ class MaskRings(TemporalMaskBase): RETURN_TYPES = ("MASK",) FUNCTION = "apply_mask_rings" + def process_single_mask(self, mask: np.ndarray, strength: float, num_rings: int, max_ring_width: float, **kwargs) -> np.ndarray: distance = distance_transform_edt(1 - mask) max_distance = np.max(distance) diff --git a/nodes/masks/utility_nodes.py b/nodes/masks/utility_nodes.py index fc79d9e..08eba38 100644 --- a/nodes/masks/utility_nodes.py +++ b/nodes/masks/utility_nodes.py @@ -19,8 +19,8 @@ class MovingShape: "num_frames": ("INT", {"default": 30, "min": 1, "max": 120, "step": 1}), "rgb": ("STRING", {"default": "(255,255,255)"}), "shape": (["square", "circle", "triangle"],), - "shape_width_percent": ("FLOAT", {"default": 100, "min": 0, "max": 100, "step": 0.1}), - "shape_height_percent": ("FLOAT", {"default": 100, "min": 0, "max": 100, "step": 0.1}), + "shape_width_percent": ("FLOAT", {"default": 50, "min": 0, "max": 100, "step": 0.1}), + "shape_height_percent": ("FLOAT", {"default": 50, "min": 0, "max": 100, "step": 0.1}), "shape_start_position_x": ("FLOAT", {"default": 0, "min": -100, "max": 100, "step": 0.1}), "shape_start_position_y": ("FLOAT", {"default": 0, "min": -100, "max": 100, "step": 0.1}), "shape_end_position_x": ("FLOAT", {"default": 0, "min": -100, "max": 100, "step": 0.1}), @@ -247,7 +247,7 @@ class _mfc: "threshold": ("INT", { "default": 0, "min": 0, "max": 127, "step": 1, }), } } - + #TODO add image RETURN_TYPES = ("MASK",) FUNCTION = "execute" CATEGORY = "/RyanOnTheInside/masks/" diff --git a/nodes/node_utilities.py b/nodes/node_utilities.py new file mode 100644 index 0000000..cea0acf --- /dev/null +++ b/nodes/node_utilities.py @@ -0,0 +1,5 @@ +def string_to_rgb(color_string): + if isinstance(color_string, tuple): + return color_string + color_values = color_string.strip('()').split(',') + return tuple(int(value.strip()) / 255.0 for value in color_values) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 5e97362..c0509e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,9 @@ [project] name = "comfyui_ryanonyheinside" -description = "Custom nodes introducing particle simulations, optical flow, and temporal masks" -version = "1.2.0" +description = "Custom nodes introducing particle simulations, optical flow, audio manipulation & reactivity, and temporal masks" +version = "1.3.0" license = {file = "LICENSE"} -dependencies = ["opencv-python==4.10.0","scipy","pillow","librosa","pymunk==6.8.1","matplotlib"] +dependencies = ["opencv-python==4.10.0","scipy","pillow","librosa","pymunk==6.8.1","matplotlib","openunmix","mido"] [project.urls] Repository = "https://github.com/ryanontheinside/ComfyUI_RyanOnTheInside" diff --git a/readme.md b/readme.md index 9aab703..0453806 100644 --- a/readme.md +++ b/readme.md @@ -1,10 +1,13 @@ # ComfyUI RyanOnTheInside Node Pack ## Overview -RyanOnTheInside node pack introduces the following (so far): -- Particle Systems -- Optical Flow -- Temporal Masks +RyanOnTheInside node pack introduces dynamic features to ComfyUI: + +- 🎆 **Particle Systems**: Create mesmerizing, fluid-like effects +- 🎵 **Audio & MIDI Processing**: Separate instruments and create audio-reactive visuals +- 🎛️ **Flex Features**: Dynamic control over IPAdapters, Masks, and Images +- 🌊 **Optical Flow**: Generate masks based on motion in videos +- ⏳ **Temporal Effects**: Apply time-based transformations to masks and images
-
- *A few examples showcasing object-aware particles, initial plume effects, vortex interactions, gravity wells, and directional emitters.*
-
-#### General Parameters for particle system
-These settings apply to all particle system mask nodes, and generally affect the simulation space level.
-- `particle_count`: Total number of particles in the system (1 to 10000)
-- `particle_lifetime`: Lifetime of each particle in seconds (0.1 to 10.0)
-- `wind_strength`: Strength of the wind effect (-100.0 to 100.0)
-- `wind_direction`: Direction of the wind in degrees (0.0 to 360.0)
-- `gravity`: Strength of gravity (-1000.0 to 1000.0)
-- `start_frame`: Frame to start the effect (0 to 1000)
-- `end_frame`: Frame to end the effect (0 to 1000)
-- `respect_mask_boundary`: Whether particles should respect the mask boundary
-- `emitters`: Particle emitter configurations (PARTICLE_EMITTER type)
-- `vortices`: Optional vortex configurations (VORTEX type)
-- `wells`: Optional gravity well configurations (GRAVITY_WELL type)
-- `well_strength_multiplier`: Multiplier for gravity well strength (0.0 to 10.0)
-
-#### Particle Emission Mask
-This is the main node for particle simulations.
-This node is particularly useful for creating complex, animated mask effects that can simulate natural phenomena like smoke, water, or energy fields...bowling balls, planets, whatever. The resulting masks can be used in various image processing and generation tasks within ComfyUI workflows.
-
-##### Parameters:
-- `masks`: Input mask(s). Optionally interact with the particle system
-- `emission_strength`: Strength of particle emission effect (0.0 to 1.0), basically opacity
-- `draw_modifiers`: Visibility of vortices and gravity wells (0.0 to 1.0)
-
-##### Outputs:
-- `MASK`: The resulting particle system binary mask
-- `IMAGE`: A visualization of the particle system - useful for masking different colors for different particle systems
-
-
-#### ParticleEmitter Parameters
-The Particle Emitter node...creates a particle emitter. They can be chained together to feed N to a particle based node.
-- `emitter_x`: X-coordinate of the emitter (0.0 to 1.0)
-- `emitter_y`: Y-coordinate of the emitter (0.0 to 1.0)
-- `particle_direction`: Direction of particle emission in degrees
-- `particle_spread`: Spread angle of particle emission in degrees
-- `particle_speed`: Speed of emitted particles
-- `particle_size`: Size of emitted particles
-- `color`: Color of emitted particles (RGB tuple)
-- `emission_rate`: Rate of particle emission
-- `initial_plume`: Initial burst of particles (0.0 to 1.0)
-- `emitter_movement`: (optional) Movement settings for the emitter
-- `spring_joint_setting`: (optional) Spring joint configuration for particles
-- `particle_modulation`: (optional) Modulation settings for particle properties over time
-
-
-#### EmitterMovement Parameters
-These settings are encapulated in a node that can optionally be fed to particle emitters.
-
-Position Control:
-- `emitter_y_frequency`: Rate of vertical oscillation (higher values = faster up-down movement)
-- `emitter_y_amplitude`: Maximum vertical distance from center (larger values = taller movement)
-- `emitter_x_frequency`: Rate of horizontal oscillation (higher values = faster left-right movement)
-- `emitter_x_amplitude`: Maximum horizontal distance from center (larger values = wider movement)
-
-Direction Control:
-- `direction_frequency`: Rate of emission angle change (higher values = faster direction shifts)
-- `direction_amplitude`: Maximum angle change in degrees (up to 180° in either direction)
-
-These parameters work together to create complex, periodic movements for particle emitters.
-By adjusting frequencies and amplitudes, you can achieve various patterns like circles,
-figure-eights, or more chaotic motions. The direction parameters add extra dynamism by
-altering the angle of particle emission over time.
-
-#### Particle Modulation
-These settings define how particle properties change over time:
-
-##### Common Parameters for All Modulations:
-- `start_frame`: Frame to start the modulation effect (0 to 1000)
-- `end_frame`: Frame to end the modulation effect (0 to 1000)
-- `effect_duration`: Duration of the modulation effect in frames (0 to 1000)
-- `temporal_easing`: Easing function for the modulation effect ("ease_in_out", "linear", "bounce", "elastic", "none")
-- `palindrome`: Whether to reverse the modulation effect after completion (True/False)
-
-##### ParticleSizeModulation:
-- `target_size`: Target size for particles at the end of the modulation (0.0 to 400.0)
-
-##### ParticleSpeedModulation:
-- `target_speed`: Target speed for particles at the end of the modulation (0.0 to 1000.0)
-
-##### ParticleColorModulation:
-- `target_color`: Target color for particles at the end of the modulation (RGB tuple)
-
-These modulation settings can be chained together to create complex, dynamic particle behaviors over time.
-
-#### SpringJointSetting Parameters
-These settings define the behavior of spring joints attached to particles:
-- `stiffness`: Stiffness of the spring (0.0 to 1000.0)
-- `damping`: Damping factor of the spring (0.0 to 100.0)
-- `rest_length`: Rest length of the spring (0.0 to 100.0)
-- `max_distance`: Maximum distance the spring can stretch (0.0 to 500.0)
-
-#### StaticBody Parameters
-These settings define static bodies in the simulation that particles can interact with. It allows you to draw solid objects, like walls for instance. You can chain them together to add N to a given simulation:
-- `shape_type`: Type of shape ("segment" or "polygon")
-- `x1`, `y1`, `x2`, `y2`: Coordinates defining the shape
-- `elasticity`: Bounciness of the static body (0.0 to 1.0)
-- `friction`: Friction of the static body (0.0 to 1.0)
-- `draw`: Whether to visualize the static body and how thick
-- `color`: Color of the static body (RGB tuple)
-
-#### GravityWell Parameters
-These are optional inputs for a simulation space. They can be chained together to have N in a given simulation.
-- `x`: X-coordinate of the gravity well (0.0 to 1.0)
-- `y`: Y-coordinate of the gravity well (0.0 to 1.0)
-- `strength`: Strength of the gravity well
-- `radius`: Radius of effect for the gravity well
-- `type`: Type of gravity well ('attract' or 'repel')
-- `color`: Color of the gravity well visualization (RGB tuple)
-- `draw`: Visibility of the gravity well (0.0 to 1.0)
-
-#### Vortex Parameters
-These are optional inputs for a simulation space. They can be chained together to have N in a given simulation.
-- `x`: X-coordinate of the vortex center (0.0 to 1.0)
-- `y`: Y-coordinate of the vortex center (0.0 to 1.0)
-- `strength`: Strength of the vortex effect (0.0 to 1000.0)
-- `radius`: Radius of effect for the vortex (10.0 to 500.0)
-- `inward_factor`: Factor controlling how quickly particles are pulled towards the center (0.0 to 1.0)
-- `movement_speed`: Speed of movement of the vortex object (0.0 to 10.0)
-- `color`: Color of the vortex visualization (RGB tuple)
-- `draw`: Visibility of the vortex (0.0 to 1.0)
-
-## Optical Flow Masks
-
-Motion-based effects:
-
-- Multiple optical flow algorithms (Farneback, Lucas-Kanade, Pyramidal Lucas-Kanade)
-- Particle simulations reactive to optical flow - work in progress
-- Directional flow isolation and emphasis - in progress
-- Advanced blending and modulation options
-
-
-
- *Motion generated trails*
-
-### Optical Flow Mask Nodes
-
-#### General optical mask settings
-These settings apply across the different optical flow masks, and modulate the input mask based on optical flow, creating dynamic mask animations.
-
-- `Images`: Input image sequence for optical flow calculation
-- `Masks`: Input mask sequence to be processed
-- `Strength`: Overall strength of the effect (0.0 to 1.0)
-- `Flow Method`: Technique used for optical flow calculation
- - Options: "Farneback", "LucasKanade", "PyramidalLK"
-- `Flow Threshold`: Minimum flow magnitude to consider (0.0 to 1.0)
-- `Magnitude Threshold`: Relative threshold for flow magnitude (0.0 to 1.0)
-
-
-#### OpticalFlowMaskModulation
-This is currently the main Optical Flow node. Use it to make motion based effects.
-
-- `Modulation Strength`: Intensity of the modulation effect (0.0 to 5.0)
-- `Blur Radius`: Smoothing applied to the flow magnitude (0 to 20 pixels)
-- `Trail Length`: Number of frames for the trail effect (1 to 20)
-- `Decay Factor`: Rate of trail decay over time (0.1 to 1.0)
-- `Decay Style`: Method of trail decay
- - Options: "fade" (opacity reduction), "thickness" (width reduction)
-- `Max Thickness`: Maximum trail thickness for thickness-based decay (1 to 50 pixels)
-
-#### Optical Flow Direction Mask - work in progress
-This node will create masks based on the direction of optical flow, allowing for selective masking of moving areas.
-
-#### Optical Flow Particle System - work in progess
-This node will generate a particle system animation driven by optical flow, creating dynamic visual effects.
-
-
-## Temporal Masks
-
-Implement time-based transformations and animations:
-
-- Morphing and transformation effects (erosion, dilation, translation, rotation, scaling)
-- Various mask combination methods
-- Radial and pulsating effects
-- Warp effects using Perlin noise, radial distortion, and swirling patterns
-- Customizable easing functions and palindrome support
-
-
-
- *Examples of various combinations of temporal mask effects including morphing, transformations, math , and warping.*
-
-
-- `Start Frame`: The frame number where the effect begins (0 to 1000).
-- `End Frame`: The frame number where the effect ends (0 to 1000). If set to 0, processes until the last frame.
-- `Effect Duration`: Number of frames over which the effect is applied (0 to 1000). If 0, uses (End Frame - Start Frame).
-- `Temporal Easing`: Determines how the effect strength changes over time.
- - Options: "ease_in_out", "linear", "bounce", "elastic", "none"
-- `Palindrome`: When enabled, the effect plays forward then backward within the specified duration.
-
-
-#### Mask Morph
-Applies morphological operations to the mask, changing its shape over time.
-
-- `Morph Type`: The type of morphological operation to apply.
- - Options: "erode", "dilate", "open", "close"
-- `Max Kernel Size`: Maximum size of the morphological kernel (3 to 21, odd numbers only).
-- `Max Iterations`: Maximum number of times to apply the operation (1 to 50).
-
-#### Mask Transform
-Applies geometric transformations to the mask over time.
-
-- `Transform Type`: The type of transformation to apply.
- - Options: "translate", "rotate", "scale"
-- `X Value`: Horizontal component of the transformation (-1000 to 1000).
-- `Y Value`: Vertical component of the transformation (-1000 to 1000).
-
-#### Mask Math
-Combines two masks using various mathematical operations.
-
-- `Mask B`: Second mask to combine with the input mask.
-- `Combination Method`: Mathematical operation to apply.
- - Options: "add", "subtract", "multiply", "minimum", "maximum"
-
-#### Mask Rings
-Creates concentric ring patterns based on the distance from the mask edges.
-
-- `Num Rings`: Number of rings to generate (1 to 50).
-- `Max Ring Width`: Maximum width of each ring as a fraction of the total distance (0.01 to 0.5).
-
-#### Mask Warp
-Applies various warping effects to the mask.
-
-- `Warp Type`: The type of warping effect to apply.
- - Options: "perlin", "radial", "swirl"
-- `Frequency`: Frequency of the warping effect (0.01 to 1.0).
-- `Amplitude`: Intensity of the warping effect (0.1 to 500.0).
-- `Octaves`: Number of octaves for noise-based warps (1 to 8).
-
-All these nodes inherit the common parameters from MaskBase and TemporalMaskBase, allowing for precise control over the timing and strength of the effects across multiple frames.
-
-
-### Miscellaneous Nodes
-
-#### MovingShape
-
-Generate animated mask sequences featuring a moving shape with customizable parameters.
-
-Parameters:
-- `frame_width`: Width of each frame (1-3840 pixels)
-- `frame_height`: Height of each frame (1-2160 pixels)
-- `num_frames`: Number of frames in the sequence (1-120)
-- `rgb`: Color of the shape in RGB format, e.g., "(255,255,255)"
-- `shape`: Shape type ("square", "circle", or "triangle")
-- `shape_width_percent`: Width of the shape as a percentage of frame width (0-100%)
-- `shape_height_percent`: Height of the shape as a percentage of frame height (0-100%)
-- `shape_start_position_x`: Starting X position of the shape (-100 to 100)
-- `shape_start_position_y`: Starting Y position of the shape (-100 to 100)
-- `shape_end_position_x`: Ending X position of the shape (-100 to 100)
-- `shape_end_position_y`: Ending Y position of the shape (-100 to 100)
-- `movement_type`: Type of movement ("linear", "ease_in_out", "bounce", or "elastic")
-- `grow`: Growth factor of the shape during animation (0-100)
-- `palindrome`: Whether to reverse the animation sequence (True/False)
-- `delay`: Number of static frames at the start (0-60)
-
-This node creates a mask sequence with a moving shape, allowing for various animations and transformations.
-
-#### TextMaskNode
-
-Generate mask and image sequences featuring customizable text.
-
-Parameters:
-- `width`: Width of the output image (1-8192 pixels)
-- `height`: Height of the output image (1-8192 pixels)
-- `text`: The text to be rendered
-- `font`: Font to be used (selectable from system fonts)
-- `font_size`: Size of the font (1-1000)
-- `font_color`: Color of the text in RGB format, e.g., "(255,255,255)"
-- `background_color`: Color of the background in RGB format, e.g., "(0,0,0)"
-- `x_position`: Horizontal position of the text (0.0-1.0, where 0.5 is center)
-- `y_position`: Vertical position of the text (0.0-1.0, where 0.5 is center)
-- `rotation`: Rotation angle of the text (0-360 degrees)
-- `max_width_ratio`: Maximum width of text as a ratio of image width (0.1-1.0)
-- `batch_size`: Number of images to generate in the batch (1-10000)
-
-This node creates both a mask and an image with the specified text, allowing for various text-based effects and animations. The text automatically wraps to fit within the specified maximum width.
-
-
## Contributing
Contributions are welcome! Both to the code and EXAMPLE WORKFLOWS!!! If you'd like to contribute:
@@ -388,4 +253,4 @@ For more details, see the [full license text](https://creativecommons.org/licens
## Support
-For issues, questions, or suggestions, please open an issue on the GitHub repository.
+For issues, questions, or suggestions, please open an issue on the GitHub repository.
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index cf04099..d5e8409 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,3 +4,5 @@ pillow
librosa
pymunk==6.8.1
matplotlib
+openunmix
+mido
\ No newline at end of file
diff --git a/ryanontheinside_web_async/marked.min.js b/ryanontheinside_web_async/marked.min.js
new file mode 100644
index 0000000..2e66c36
--- /dev/null
+++ b/ryanontheinside_web_async/marked.min.js
@@ -0,0 +1,6 @@
+/**
+ * marked v12.0.1 - a markdown parser
+ * Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed)
+ * https://github.com/markedjs/marked
+ */
+!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s=/[&<>"']/,r=new RegExp(s.source,"g"),i=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,l=new RegExp(i.source,"g"),o={"&":"&","<":"<",">":">",'"':""","'":"'"},a=e=>o[e];function c(e,t){if(t){if(s.test(e))return e.replace(r,a)}else if(i.test(e))return e.replace(l,a);return e}const h=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;function p(e){return e.replace(h,((e,t)=>"colon"===(t=t.toLowerCase())?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):""))}const u=/(^|[^\[])\^/g;function k(e,t){let n="string"==typeof e?e:e.source;t=t||"";const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(u,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}function g(e){try{e=encodeURI(e).replace(/%25/g,"%")}catch(e){return null}return e}const f={exec:()=>null};function d(e,t){const n=e.replace(/\|/g,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(/ \|/);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n[n.length-1].trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length'+(n?e:c(e,!0))+"\n":""+(n?e:c(e,!0))+"\n"}blockquote(e){return`\n${e}\n`}html(e,t){return e}heading(e,t,n){return`
${e}
\n`}table(e,t){return t&&(t=`${t}`),"${e}`}br(){return"An error occurred:
"+c(n.message+"",!0)+"";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const ae=new oe;function ce(e,t){return ae.parse(e,t)}ce.options=ce.setOptions=function(e){return ae.setOptions(e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.getDefaults=t,ce.defaults=e.defaults,ce.use=function(...e){return ae.use(...e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.walkTokens=function(e,t){return ae.walkTokens(e,t)},ce.parseInline=ae.parseInline,ce.Parser=ie,ce.parser=ie.parse,ce.Renderer=se,ce.TextRenderer=re,ce.Lexer=ne,ce.lexer=ne.lex,ce.Tokenizer=w,ce.Hooks=le,ce.parse=ce;const he=ce.options,pe=ce.setOptions,ue=ce.use,ke=ce.walkTokens,ge=ce.parseInline,fe=ce,de=ie.parse,xe=ne.lex;e.Hooks=le,e.Lexer=ne,e.Marked=oe,e.Parser=ie,e.Renderer=se,e.TextRenderer=re,e.Tokenizer=w,e.getDefaults=t,e.lexer=xe,e.marked=ce,e.options=he,e.parse=fe,e.parseInline=ge,e.parser=de,e.setOptions=pe,e.use=ue,e.walkTokens=ke})); diff --git a/ryanontheinside_web_async/protovis.min.js b/ryanontheinside_web_async/protovis.min.js new file mode 100644 index 0000000..dfb8416 --- /dev/null +++ b/ryanontheinside_web_async/protovis.min.js @@ -0,0 +1,277 @@ +var a;if(!Array.prototype.map)Array.prototype.map=function(b,c){for(var d=this.length,f=new Array(d),g=0;g