Add custom nodes, Civitai loras (LFS), and vast.ai setup script
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
Python Linting / Run Pylint (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Has been cancelled
Execution Tests / test (macos-latest) (push) Has been cancelled
Execution Tests / test (ubuntu-latest) (push) Has been cancelled
Execution Tests / test (windows-latest) (push) Has been cancelled
Test server launches without errors / test (push) Has been cancelled
Unit Tests / test (macos-latest) (push) Has been cancelled
Unit Tests / test (ubuntu-latest) (push) Has been cancelled
Unit Tests / test (windows-2022) (push) Has been cancelled

Includes 30 custom nodes committed directly, 7 Civitai-exclusive
loras stored via Git LFS, and a setup script that installs all
dependencies and downloads HuggingFace-hosted models on vast.ai.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 00:55:26 +00:00
parent 2b70ab9ad0
commit f09734b0ee
2274 changed files with 748556 additions and 3 deletions

View File

@@ -0,0 +1,67 @@
import numpy as np
from scipy.ndimage import map_coordinates, spline_filter
from scipy.sparse.linalg import factorized
from .numerical import difference, operator
class Fluid:
def __init__(self, shape, *quantities, pressure_order=1, advect_order=3):
self.shape = shape
self.dimensions = len(shape)
# Prototyping is simplified by dynamically
# creating advected quantities as needed.
self.quantities = quantities
for q in quantities:
setattr(self, q, np.zeros(shape))
self.indices = np.indices(shape)
self.velocity = np.zeros((self.dimensions, *shape))
laplacian = operator(shape, difference(2, pressure_order))
self.pressure_solver = factorized(laplacian)
self.advect_order = advect_order
def step(self):
# Advection is computed backwards in time as described in Stable Fluids.
advection_map = self.indices - self.velocity
# SciPy's spline filter introduces checkerboard divergence.
# A linear blend of the filtered and unfiltered fields based
# on some value epsilon eliminates this error.
def advect(field, filter_epsilon=10e-2, mode='constant'):
filtered = spline_filter(field, order=self.advect_order, mode=mode)
field = filtered * (1 - filter_epsilon) + field * filter_epsilon
return map_coordinates(field, advection_map, prefilter=False, order=self.advect_order, mode=mode)
# Apply advection to each axis of the
# velocity field and each user-defined quantity.
for d in range(self.dimensions):
self.velocity[d] = advect(self.velocity[d])
for q in self.quantities:
setattr(self, q, advect(getattr(self, q)))
# Compute the jacobian at each point in the
# velocity field to extract curl and divergence.
jacobian_shape = (self.dimensions,) * 2
partials = tuple(np.gradient(d) for d in self.velocity)
jacobian = np.stack(partials).reshape(*jacobian_shape, *self.shape)
divergence = jacobian.trace()
# If this curl calculation is extended to 3D, the y-axis value must be negated.
# This corresponds to the coefficients of the levi-civita symbol in that dimension.
# Higher dimensions do not have a vector -> scalar, or vector -> vector,
# correspondence between velocity and curl due to differing isomorphisms
# between exterior powers in dimensions != 2 or 3 respectively.
curl_mask = np.triu(np.ones(jacobian_shape, dtype=bool), k=1)
curl = (jacobian[curl_mask] - jacobian[curl_mask.T]).squeeze()
# Apply the pressure correction to the fluid's velocity field.
pressure = self.pressure_solver(divergence.flatten()).reshape(self.shape)
self.velocity -= np.gradient(pressure)
return divergence, curl, pressure

View File

@@ -0,0 +1,95 @@
"""Generates psychedelic color textures in the spirit of Blender's magic texture shader using Python/Numpy
https://github.com/cheind/magic-texture
"""
from typing import Tuple, Optional
import numpy as np
def coordinate_grid(shape: Tuple[int, int], dtype=np.float32):
"""Returns a three-dimensional coordinate grid of given shape for use in `magic`."""
x = np.linspace(-1, 1, shape[1], endpoint=True, dtype=dtype)
y = np.linspace(-1, 1, shape[0], endpoint=True, dtype=dtype)
X, Y = np.meshgrid(x, y)
XYZ = np.stack((X, Y, np.ones_like(X)), -1)
return XYZ
def random_transform(coords: np.ndarray, rng: np.random.Generator = None):
"""Returns randomly transformed coordinates"""
H, W = coords.shape[:2]
rng = rng or np.random.default_rng()
m = rng.uniform(-1.0, 1.0, size=(3, 3)).astype(coords.dtype)
return (coords.reshape(-1, 3) @ m.T).reshape(H, W, 3)
def magic(
coords: np.ndarray,
depth: Optional[int] = None,
distortion: Optional[int] = None,
rng: np.random.Generator = None,
):
"""Returns color magic color texture.
The implementation is based on Blender's (https://www.blender.org/) magic
texture shader. The following adaptions have been made:
- we exchange the nested if-cascade by a probabilistic iterative approach
Kwargs
------
coords: HxWx3 array
Coordinates transformed into colors by this method. See
`magictex.coordinate_grid` to generate the default.
depth: int (optional)
Number of transformations applied. Higher numbers lead to more
nested patterns. If not specified, randomly sampled.
distortion: float (optional)
Distortion of patterns. Larger values indicate more distortion,
lower values tend to generate smoother patterns. If not specified,
randomly sampled.
rng: np.random.Generator
Optional random generator to draw samples from.
Returns
-------
colors: HxWx3 array
Three channel color image in range [0,1]
"""
rng = rng or np.random.default_rng()
if distortion is None:
distortion = rng.uniform(1, 4)
if depth is None:
depth = rng.integers(1, 5)
H, W = coords.shape[:2]
XYZ = coords
x = np.sin((XYZ[..., 0] + XYZ[..., 1] + XYZ[..., 2]) * distortion)
y = np.cos((-XYZ[..., 0] + XYZ[..., 1] - XYZ[..., 2]) * distortion)
z = -np.cos((-XYZ[..., 0] - XYZ[..., 1] + XYZ[..., 2]) * distortion)
if depth > 0:
x *= distortion
y *= distortion
z *= distortion
y = -np.cos(x - y + z)
y *= distortion
xyz = [x, y, z]
fns = [np.cos, np.sin]
for _ in range(1, depth):
axis = rng.choice(3)
fn = fns[rng.choice(2)]
signs = rng.binomial(n=1, p=0.5, size=4) * 2 - 1
xyz[axis] = signs[-1] * fn(
signs[0] * xyz[0] + signs[1] * xyz[1] + signs[2] * xyz[2]
)
xyz[axis] *= distortion
x, y, z = xyz
x /= 2 * distortion
y /= 2 * distortion
z /= 2 * distortion
c = 0.5 - np.stack((x, y, z), -1)
np.clip(c, 0, 1.0)
return c

View File

@@ -0,0 +1,25 @@
from functools import reduce
from itertools import cycle
from math import factorial
import numpy as np
import scipy.sparse as sp
def difference(derivative, accuracy=1):
# Central differences implemented based on the article here:
# http://web.media.mit.edu/~crtaylor/calculator.html
derivative += 1
radius = accuracy + derivative // 2 - 1
points = range(-radius, radius + 1)
coefficients = np.linalg.inv(np.vander(points))
return coefficients[-derivative] * factorial(derivative - 1), points
def operator(shape, *differences):
# Credit to Philip Zucker for figuring out
# that kronsum's argument order is reversed.
# Without that bit of wisdom I'd have lost it.
differences = zip(shape, cycle(differences))
factors = (sp.diags(*diff, shape=(dim,) * 2) for dim, diff in differences)
return reduce(lambda a, f: sp.kronsum(f, a, format='csc'), factors)

View File

@@ -0,0 +1,88 @@
import torch
import numpy as np
from PIL import Image, ImageColor
from typing import Union, List
import logging
# Utility functions from mtb nodes: https://github.com/melMass/comfy_mtb
def pil2tensor(image: Union[Image.Image, List[Image.Image]]) -> torch.Tensor:
if isinstance(image, list):
return torch.cat([pil2tensor(img) for img in image], dim=0)
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
def np2tensor(img_np: Union[np.ndarray, List[np.ndarray]]) -> torch.Tensor:
if isinstance(img_np, list):
return torch.cat([np2tensor(img) for img in img_np], dim=0)
return torch.from_numpy(img_np.astype(np.float32) / 255.0).unsqueeze(0)
def tensor2np(tensor: torch.Tensor):
if len(tensor.shape) == 3: # Single image
return np.clip(255.0 * tensor.cpu().numpy(), 0, 255).astype(np.uint8)
else: # Batch of images
return [np.clip(255.0 * t.cpu().numpy(), 0, 255).astype(np.uint8) for t in tensor]
def tensor2pil(image: torch.Tensor) -> List[Image.Image]:
batch_count = image.size(0) if len(image.shape) > 3 else 1
if batch_count > 1:
out = []
for i in range(batch_count):
out.extend(tensor2pil(image[i]))
return out
return [
Image.fromarray(
np.clip(255.0 * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)
)
]
def string_to_color(color_string: str) -> List[int]:
color_list = [0, 0, 0] # Default fallback (black)
if ',' in color_string:
# Handle CSV format (e.g., "255, 0, 0" or "255, 0, 0, 128" or "1.0, 0.5, 0.0")
try:
values = [float(channel.strip()) for channel in color_string.split(',')]
# Convert to 0-255 range if values are in 0-1 range
if all(0 <= v <= 1 for v in values):
color_list = [int(v * 255) for v in values]
else:
color_list = [int(v) for v in values]
except ValueError:
logging.warning(f"Invalid color format: {color_string}. Using default black.")
elif color_string.lstrip('#').isalnum() and not color_string.lstrip('#').replace('.', '', 1).isdigit():
# Could be Hex format or color name
color_string_stripped = color_string.lstrip('#')
# Try hex first
if len(color_string_stripped) in [6, 8] and all(c in '0123456789ABCDEFabcdef' for c in color_string_stripped):
if len(color_string_stripped) == 6: # #RRGGBB
color_list = [int(color_string_stripped[i:i+2], 16) for i in (0, 2, 4)]
elif len(color_string_stripped) == 8: # #RRGGBBAA
color_list = [int(color_string_stripped[i:i+2], 16) for i in (0, 2, 4, 6)]
else:
# Try color name (e.g., "red", "blue", "cyan")
try:
rgb = ImageColor.getrgb(color_string)
color_list = list(rgb)
except ValueError:
logging.warning(f"Invalid color name or hex format: {color_string}. Using default black.")
else:
# Handle single value (grayscale) - can be int or float
try:
value = float(color_string.strip())
# Convert to 0-255 range if it's a float between 0-1
if 0 <= value <= 1:
value = int(value * 255)
else:
value = int(value)
color_list = [value, value, value]
except ValueError:
logging.warning(f"Invalid color format: {color_string}. Using default black.")
# Clip values to valid range
color_list = np.clip(color_list, 0, 255).tolist()
return color_list