diff --git a/__init__.py b/__init__.py index fa47ed4..1d3644c 100644 --- a/__init__.py +++ b/__init__.py @@ -1,74 +1,49 @@ -from .utils import log +import traceback +from .log import log +from .utils import here +from pathlib import Path +import importlib NODE_CLASS_MAPPINGS = {} -try: - from .nodes.deep_bump import DeepBump - NODE_CLASS_MAPPINGS["Deep Bump (mtb)"] = DeepBump -except Exception: - log.error("DeepBump nodes failed to load.") -from .nodes.latent_processing import LatentLerp -from .nodes.roop import Roop -# from .nodes.geometries import LoadGeometry, GeometryInfo -try: - from .nodes.fun import QRNode - NODE_CLASS_MAPPINGS["QR Code (mtb)"] = QRNode -except Exception: - log.error("QRNode failed to load.") +NODE_CLASS_MAPPINGS_DEBUG = {} -from .nodes.image_processing import ( - ImageCompare, - Denoise, - Blur, - HSVtoRGB, - RGBtoHSV, - ColorCorrect, - MaskToImage, - ColoredImage, - ImagePremultiply -) -try: - from .nodes.image_processing import DeglazeImage -except Exception: - log.error("DeglazeImage failed to load. This is probably an opencv mismatch. This node requires opencv-python-contrib.") -from .nodes.crop import Crop, Uncrop, BoundingBox, BBoxFromMask -from .nodes.conditions import ( - SmartStep, - StylesLoader, - TextToImage -) -from .nodes.video import LoadImageSequence, SaveImageSequence -from .nodes.mask import ImageRemoveBackgroundRembg -# from .nodes.videopose import MMPoseEstimation -# NODE MAPPING -NODE_CLASS_MAPPINGS = { - "Latent Lerp (mtb) [DEPRECATED]": LatentLerp, - "Deep Bump (mtb)": DeepBump, +def load_nodes(): + errors = [] + nodes = [] + for filename in (here / "nodes").iterdir(): + if filename.suffix == '.py': + module_name = filename.stem + module_path = filename.resolve().as_posix() + + try: + module = importlib.import_module(f".nodes.{module_name}",package=__package__) + _nodes = getattr(module, '__nodes__') + + nodes.extend(_nodes) + # Use the `nodes` variable here as needed + log.debug(f"Imported __nodes__ from {module_name}") + + except Exception: + error_message = traceback.format_exc().splitlines()[-1] + errors.append(f"Failed to import {module_name}. {error_message}") + # log.error(f"Failed to import {module_name}. {error_message}") + + if errors: + log.error(f"Some nodes failed to load:\n\t" + "\n\t".join(errors) + "\n\n" + "Check that you properly installed the dependencies.\n" + "If you think this is a bug, please report it on the github page (https://github.com/melMass/comfy_mtb/issues)") + + return nodes +nodes = load_nodes() + +for node_class in nodes: + class_name = node_class.__name__ + class_name = node_class.__name__ - "Int to Number (mtb)": IntToNumber, - "Bounding Box (mtb)": BoundingBox, - "Bounding Box From Mask (mtb)": BBoxFromMask, - "Crop (mtb)": Crop, - "Uncrop (mtb)": Uncrop, - "ImageBlur (mtb)": Blur, - "Denoise (mtb)": Denoise, - "ImageCompare (mtb)": ImageCompare, - "RGB to HSV (mtb)": RGBtoHSV, - "HSV to RGB (mtb)": HSVtoRGB, - "Color Correct (mtb)": ColorCorrect, - "Modulo (mtb)": Modulo, - "Deglaze Image (mtb)": DeglazeImage, - "Smart Step (mtb)": SmartStep, - "Styles Loader (mtb)": StylesLoader, - "Text to Image (mtb)": TextToImage, - "Load Image Sequence (mtb)": LoadImageSequence, - "Save Image Sequence (mtb)": SaveImageSequence, - "Mask to Image (mtb)": MaskToImage, - "Image Remove Background RemBG (mtb)": ImageRemoveBackgroundRembg, - "Colored Image (mtb)": ColoredImage, - "Image Premultiply (mtb)": ImagePremultiply, - "Face Swap [roop] (mtb)": Roop, - # "MMPose Estimation (mtb)": MMPoseEstimation, - # "Load Geometry (mtb)": LoadGeometry, - # "Geometry Info (mtb)": GeometryInfo, -} + NODE_CLASS_MAPPINGS[class_name] = node_class + NODE_CLASS_MAPPINGS_DEBUG[class_name] = node_class.__doc__ + + +def get_summary(docstring): + return docstring.strip().split('\n\n', 1)[0] + +log.debug(f"Loaded the following nodes:\n\t" + "\n\t".join(f"{k}: {get_summary(doc) if doc else '-'}" for k,doc in NODE_CLASS_MAPPINGS_DEBUG.items())) diff --git a/log.py b/log.py new file mode 100644 index 0000000..02133cd --- /dev/null +++ b/log.py @@ -0,0 +1,48 @@ +from logging import getLogger +import logging + +class Formatter(logging.Formatter): + + grey = "\x1b[38;20m" + yellow = "\x1b[33;20m" + red = "\x1b[31;20m" + bold_red = "\x1b[31;1m" + reset = "\x1b[0m" + # format = "%(asctime)s - [%(name)s] - %(levelname)s - %(message)s (%(filename)s:%(lineno)d)" + format = "[%(name)s] | %(levelname)s -> %(message)s" + + FORMATS = { + logging.DEBUG: grey + format + reset, + logging.INFO: grey + format + reset, + logging.WARNING: yellow + format + reset, + logging.ERROR: red + format + reset, + logging.CRITICAL: bold_red + format + reset + } + + def format(self, record): + log_fmt = self.FORMATS.get(record.levelno) + formatter = logging.Formatter(log_fmt) + return formatter.format(record) + + + +def mklog(name, level=logging.DEBUG): + logger = logging.getLogger(name) + logger.setLevel(level) + # create console handler with a higher log level + ch = logging.StreamHandler() + ch.setLevel(logging.DEBUG) + + ch.setFormatter(Formatter()) + + logger.addHandler(ch) + return logger + + +#- The main app logger +log = mklog(__package__) + + + +def log_user(arg): + print("\033[34mComfy MTB Utils:\033[0m {arg}") \ No newline at end of file diff --git a/nodes/conditions.py b/nodes/conditions.py index 4cb59d4..bc5bfbb 100644 --- a/nodes/conditions.py +++ b/nodes/conditions.py @@ -1,6 +1,15 @@ -from ..utils import log, pil2tensor +from ..utils import pil2tensor +from ..utils import here +from ..log import log +import folder_paths +from pathlib import Path +import shutil +import csv + class SmartStep: + """Utils to control the steps start/stop of the KAdvancedSampler in percentage""" + def __init__(self): pass @@ -8,54 +17,54 @@ class SmartStep: def INPUT_TYPES(cls): return { "required": { - "step": ("INT",{"default": 20, "min": 1, "max": 10000, "step": 1},), + "step": ( + "INT", + {"default": 20, "min": 1, "max": 10000, "step": 1}, + ), "start_percent": ( "INT", {"default": 0, "min": 0, "max": 100, "step": 1}, ), - "end_percent": ( + "end_percent": ( "INT", {"default": 0, "min": 0, "max": 100, "step": 1}, ), - } } - RETURN_TYPES = ("INT","INT","INT") - RETURN_NAMES = ("step","start","end") + RETURN_TYPES = ("INT", "INT", "INT") + RETURN_NAMES = ("step", "start", "end") FUNCTION = "do_step" CATEGORY = "conditioning" - def do_step(self, step,start_percent,end_percent): - + def do_step(self, step, start_percent, end_percent): + start = int(step * start_percent / 100) end = int(step * end_percent / 100) - - return (step,start, end) - -import folder_paths -from pathlib import Path -import shutil -from ..utils import here + return (step, start, end) + + def install_default_styles(): styles_dir = Path(folder_paths.base_path) / "styles" styles_dir.mkdir(parents=True, exist_ok=True) default_style = here / "styles.csv" dest_style = styles_dir / "default.csv" - print("\n\n\n\tINSTALLING DEFAULT STYLE\n\n\n") + log.debug("\n\n\n\tINSTALLING DEFAULT STYLE\n\n\n") shutil.copy2(default_style.as_posix(), dest_style.as_posix()) - print("\n\n\n\tDEFAULT STYLE INSTALLED\n\n\n") - + log.debug("\n\n\n\tDEFAULT STYLE INSTALLED\n\n\n") + return dest_style -import csv -# class that load csv files and populate a dropdown from the rows + class StylesLoader: + """Load csv files and populate a dropdown from the rows (à la A111)""" + options = {} + def __init__(self): pass - + @classmethod def INPUT_TYPES(cls): input_dir = Path(folder_paths.base_path) / "styles" @@ -63,9 +72,13 @@ class StylesLoader: install_default_styles() if not (files := [f for f in input_dir.iterdir() if f.suffix == ".csv"]): - log.error("No styles found in the styles folder, place at least one csv file in the styles folder") - return {"required": - {"style_name": (["error"],), } + log.error( + "No styles found in the styles folder, place at least one csv file in the styles folder" + ) + return { + "required": { + "style_name": (["error"],), + } } for file in files: with open(file, "r", encoding="utf8") as f: @@ -73,49 +86,82 @@ class StylesLoader: for row in parsed: log.debug(f"Adding style {row[0]}") cls.options[row[0]] = (row[1], row[2]) - return {"required": - {"style_name": (list(cls.options.keys()),), } + return { + "required": { + "style_name": (list(cls.options.keys()),), } - - + } + CATEGORY = "conditioning" - RETURN_TYPES = ("STRING","STRING") - RETURN_NAMES = ("positive","negative") + RETURN_TYPES = ("STRING", "STRING") + RETURN_NAMES = ("positive", "negative") FUNCTION = "load_style" + def load_style(self, style_name): - return (self.options[style_name][0],self.options[style_name][1]) - + return (self.options[style_name][0], self.options[style_name][1]) + class TextToImage: - fonts= {} + """Utils to convert text to image using a font + + + The tool looks for any .ttf file in the Comfy folder hierarchy. + """ + + fonts = {} + def __init__(self): pass @classmethod def INPUT_TYPES(cls): - + fonts = list(Path(folder_paths.base_path).glob("**/*.ttf")) if not fonts: - log.error("No fonts found in the fonts folder, place at least one ttf file in the fonts folder") - return {"required": - {"font": (["error"],), } + log.error( + "No fonts found in the fonts folder, place at least one ttf file in the fonts folder" + ) + return { + "required": { + "font": (["error"],), + } } for font in fonts: log.debug(f"Adding font {font}") cls.fonts[font.stem] = font.as_posix() - return { "required": { - "text": ("STRING", {"default": "Hello world!"},), + "text": ( + "STRING", + {"default": "Hello world!"}, + ), "font": ((sorted(cls.fonts.keys())),), - "wrap": ("INT", {"default": 120, "min": 0, "max": 8096, "step": 1},), - "font_size": ("INT", {"default": 12, "min": 1, "max": 100, "step": 1},), - "width": ("INT", {"default": 512, "min": 1, "max": 1000, "step": 1},), - "height": ("INT", {"default": 512, "min": 1, "max": 8096, "step": 1},), + "wrap": ( + "INT", + {"default": 120, "min": 0, "max": 8096, "step": 1}, + ), + "font_size": ( + "INT", + {"default": 12, "min": 1, "max": 100, "step": 1}, + ), + "width": ( + "INT", + {"default": 512, "min": 1, "max": 1000, "step": 1}, + ), + "height": ( + "INT", + {"default": 512, "min": 1, "max": 8096, "step": 1}, + ), # "position": (["INT"], {"default": 0, "min": 0, "max": 100, "step": 1}), - "color": ("STRING",{"default": "black"},), # TODO: add color picker - "background": ("STRING",{"default": "white"},), # TODO: add color picker + "color": ( + "COLOR", + {"default": "black"}, + ), + "background": ( + "COLOR", + {"default": "white"}, + ), } } @@ -124,29 +170,32 @@ class TextToImage: FUNCTION = "text_to_image" CATEGORY = "utils" - def text_to_image(self, text, font, wrap,font_size,width,height, color, background): + def text_to_image( + self, text, font, wrap, font_size, width, height, color, background + ): from PIL import Image, ImageDraw, ImageFont import textwrap - # import uuid - # import os + font = self.fonts[font] font = ImageFont.truetype(font, font_size) if wrap == 0: wrap = width / font_size - lines = textwrap.wrap(text, width= wrap ) + lines = textwrap.wrap(text, width=wrap) log.debug(f"Lines: {lines}") - line_height = font.getsize('hg')[1] - img_height = height # line_height * len(lines) - img_width = width # max(font.getsize(line)[0] for line in lines) + line_height = font.getsize("hg")[1] + img_height = height # line_height * len(lines) + img_width = width # max(font.getsize(line)[0] for line in lines) - img = Image.new('RGBA', (img_width, img_height), background) + img = Image.new("RGBA", (img_width, img_height), background) draw = ImageDraw.Draw(img) y_text = 0 for line in lines: width, height = font.getsize(line) draw.text((0, y_text), line, color, font=font) y_text += height - # img.save(os.path.join(folder_paths.base_path, f'{str(uuid.uuid4())}.png')) - return (pil2tensor(img),) \ No newline at end of file + return (pil2tensor(img),) + + +__nodes__ = [SmartStep, TextToImage, StylesLoader] diff --git a/nodes/crop.py b/nodes/crop.py index bef8d36..2aa03f0 100644 --- a/nodes/crop.py +++ b/nodes/crop.py @@ -5,6 +5,7 @@ import numpy as np class BoundingBox: + """The bounding box (BBOX) custom type used by other nodes""" def __init__(self): pass @@ -34,6 +35,7 @@ class BoundingBox: class BBoxFromMask: + """From a mask extract the bounding box""" def __init__(self): pass @@ -85,6 +87,11 @@ class BBoxFromMask: class Crop: + """Crops an image and an optional mask to a given bounding box + + The bounding box can be given as a tuple of (x, y, width, height) or as a BBOX type + The BBOX input takes precedence over the tuple input + """ def __init__(self): pass @@ -93,9 +100,9 @@ class Crop: return { "required": { "image": ("IMAGE",), - "mask": ("MASK",), }, "optional": { + "mask": ("MASK",), "x": ("INT", {"default": 0, "max": 10000000, "min": 0, "step": 1}), "y": ("INT", {"default": 0, "max": 10000000, "min": 0, "step": 1}), "width": ( @@ -116,27 +123,32 @@ class Crop: CATEGORY = "image/crop" def do_crop( - self, image: torch.Tensor, mask, x=0, y=0, width=256, height=256, bbox=None + self, image: torch.Tensor, mask=None, x=0, y=0, width=256, height=256, bbox=None ): image = image.numpy() - mask = mask.numpy() + if mask: + mask = mask.numpy() if bbox != None: x, y, width, height = bbox cropped_image = image[:, y : y + height, x : x + width, :] - cropped_mask = mask[y : y + height, x : x + width] + cropped_mask = mask[y : y + height, x : x + width] if mask != None else None crop_data = (x, y, width, height) return ( torch.from_numpy(cropped_image), - torch.from_numpy(cropped_mask), + torch.from_numpy(cropped_mask) if mask != None else None, crop_data, ) class Uncrop: + """Uncrops an image to a given bounding box + + The bounding box can be given as a tuple of (x, y, width, height) or as a BBOX type + The BBOX input takes precedence over the tuple input""" def __init__(self): pass @@ -190,7 +202,6 @@ class Uncrop: mask_block = Image.new("L", (bb_width, bb_height), 255) mask_block = inset_border(mask_block, int(blend_ratio / 2), (0)) - print(bbox) mask.paste(mask_block, (bb_x, bb_y, bb_x + bb_width, bb_y + bb_height)) blend.paste(crop_img, (bb_x, bb_y, bb_x + bb_width, bb_y + bb_height)) @@ -201,3 +212,11 @@ class Uncrop: image = Image.alpha_composite(image.convert("RGBA"), blend) return (pil2tensor(image.convert("RGB")),) + + +__nodes__ = [ + BBoxFromMask, + BoundingBox, + Crop, + Uncrop +] \ No newline at end of file diff --git a/nodes/deep_bump.py b/nodes/deep_bump.py index 652b2f2..fefcd1e 100644 --- a/nodes/deep_bump.py +++ b/nodes/deep_bump.py @@ -2,8 +2,9 @@ import onnxruntime as ort import numpy as np import pathlib import onnxruntime as ort -import torch +import numpy as np from .. import utils as utils_inference +from ..log import log # Disable MS telemetry ort.disable_telemetry_events() @@ -17,7 +18,7 @@ def color_to_normals(color_img, overlap, progress_callback): img = np.mean(color_img[:3], axis=0, keepdimss=True) # Split image in tiles - print("DeepBump Color → Normals : tilling") + log.debug("DeepBump Color → Normals : tilling") tile_size = 256 overlaps = { "SMALL": tile_size // 6, @@ -30,18 +31,18 @@ def color_to_normals(color_img, overlap, progress_callback): ) # Load model - print("DeepBump Color → Normals : loading model") + log.debug("DeepBump Color → Normals : loading model") addon_path = str(pathlib.Path(__file__).parent.absolute()) ort_session = ort.InferenceSession(f"{addon_path}/models/deepbump256.onnx") # Predict normal map for each tile - print("DeepBump Color → Normals : generating") + log.debug("DeepBump Color → Normals : generating") pred_tiles = utils_inference.tiles_infer( tiles, ort_session, progress_callback=progress_callback ) # Merge tiles - print("DeepBump Color → Normals : merging") + log.debug("DeepBump Color → Normals : merging") pred_img = utils_inference.tiles_merge( pred_tiles, (stride_size, stride_size), @@ -235,11 +236,6 @@ def normals_to_height(normals_img, seamless, progress_callback): # - ADDON -import numpy as np - -# import imageio.v3 as iio - - class DeepBump: def __init__(self): pass @@ -287,7 +283,7 @@ class DeepBump: in_img = np.transpose(image, (2, 0, 1)) / 255 - print(f"Input image shape: {in_img.shape}") + log.debug(f"Input image shape: {in_img.shape}") # Apply processing if mode == "Color to Normals": @@ -304,3 +300,6 @@ class DeepBump: out_img = (np.transpose(out_img, (1, 2, 0)) * 255).astype(np.uint8) return (utils_inference.pil2tensor(out_img),) + + +__nodes__ = [DeepBump] \ No newline at end of file diff --git a/nodes/fun.py b/nodes/fun.py index ad3231d..040c1ee 100644 --- a/nodes/fun.py +++ b/nodes/fun.py @@ -57,4 +57,7 @@ class QRNode: # that we now resize without filtering code = code.resize((width, height), Image.NEAREST) - return (pil2tensor(code),) \ No newline at end of file + return (pil2tensor(code),) + + +__nodes__ = [QRNode] \ No newline at end of file diff --git a/nodes/graph_utils.py b/nodes/graph_utils.py index e69de29..dabb09c 100644 --- a/nodes/graph_utils.py +++ b/nodes/graph_utils.py @@ -0,0 +1,69 @@ +import torch +import folder_paths +import os + + +class SaveTensors: + """Debug node that will probably be removed in the future""" + + def __init__(self): + self.output_dir = folder_paths.get_output_directory() + self.type = "output" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "filename_prefix": ("STRING", {"default": "ComfyPickle"}), + }, + "optional": { + "image": ("IMAGE",), + "mask": ("MASK",), + "latent": ("LATENT",), + }, + } + + FUNCTION = "save" + OUTPUT_NODE = True + RETURN_TYPES = () + CATEGORY = "utils" + + def save( + self, + filename_prefix, + image: torch.Tensor = None, + mask: torch.Tensor = None, + latent: torch.Tensor = None, + ): + ( + full_output_folder, + filename, + counter, + subfolder, + filename_prefix, + ) = folder_paths.get_save_image_path(filename_prefix, self.output_dir) + + if image is not None: + image_file = f"{filename}_image_{counter:05}.pt" + torch.save(image, os.path.join(full_output_folder, image_file)) + # np.save(os.path.join(full_output_folder, image_file), image.cpu().numpy()) + + if mask is not None: + mask_file = f"{filename}_mask_{counter:05}.pt" + torch.save(mask, os.path.join(full_output_folder, mask_file)) + # np.save(os.path.join(full_output_folder, mask_file), mask.cpu().numpy()) + + if latent is not None: + # for latent we must use pickle + latent_file = f"{filename}_latent_{counter:05}.pt" + torch.save(latent, os.path.join(full_output_folder, latent_file)) + # pickle.dump(latent, open(os.path.join(full_output_folder, latent_file), "wb")) + + # np.save(os.path.join(full_output_folder, latent_file), latent[""].cpu().numpy()) + + return f"{filename_prefix}_{counter:05}" + + +__nodes__ = [ + SaveTensors, +] diff --git a/nodes/image_processing.py b/nodes/image_processing.py index 6c553ee..ee71081 100644 --- a/nodes/image_processing.py +++ b/nodes/image_processing.py @@ -9,11 +9,16 @@ from PIL import Image, ImageChops from ..utils import tensor2pil, pil2tensor, img_np_to_tensor, img_tensor_to_np import cv2 import torch +from ..log import log +import folder_paths +from PIL.PngImagePlugin import PngInfo +import json +import os try: from cv2.ximgproc import guidedFilter except ImportError: - print("guidedFilter not found") + log.error("guidedFilter not found, use opencv-contrib-python") class ColorCorrect: @@ -160,7 +165,6 @@ class ColorCorrect: saturation: float = 1.0, value: float = 1.0, ): - # Apply color correction operations image = self.gamma_correction_tensor(image, gamma) image = self.contrast_adjustment_tensor(image, contrast) @@ -221,12 +225,10 @@ class RGBtoHSV: def convert(self, image): image = image.numpy() - # image = image.transpose(1,2,3,0) image = np.squeeze(image) image = rgb2hsv(image) image = np.expand_dims(image, axis=0) - # image = image.transpose(3,0,1,2) return (torch.from_numpy(image),) @@ -286,11 +288,9 @@ class Denoise: def denoise(self, image: torch.Tensor, weight): image = image.numpy() - # image = image.transpose(1,2,3,0) image = image.squeeze() image = denoise_tv_chambolle(image, weight=weight) - # image = image.transpose(3,0,1,2) image = np.expand_dims(image, axis=0) return (torch.from_numpy(image),) @@ -322,17 +322,12 @@ class Blur: def blur(self, image: torch.Tensor, sigmaX, sigmaY): image = image.numpy() image = image.transpose(1, 2, 3, 0) - # image = ndimage.gaussian_filter(image, sigma) image = gaussian(image, sigma=(sigmaX, sigmaY, 0, 0)) - # (image, sigma=sigma, multichannel=True) image = image.transpose(3, 0, 1, 2) return (torch.from_numpy(image),) - - - -#https://github.com/lllyasviel/AdverseCleaner/blob/main/clean.py +# https://github.com/lllyasviel/AdverseCleaner/blob/main/clean.py def deglaze_np_img(np_img): y = np_img.copy() for _ in range(64): @@ -341,33 +336,34 @@ def deglaze_np_img(np_img): y = guidedFilter(np_img, y, 4, 16) return y + class DeglazeImage: @classmethod def INPUT_TYPES(cls): - return {"required": {"image": ("IMAGE", ) } } + return {"required": {"image": ("IMAGE",)}} CATEGORY = "image" - RETURN_TYPES = ("IMAGE", ) + RETURN_TYPES = ("IMAGE",) FUNCTION = "deglaze_image" + def deglaze_image(self, image): return (img_np_to_tensor(deglaze_np_img(img_tensor_to_np(image))),) class MaskToImage: - def __init__(self): pass @classmethod def INPUT_TYPES(cls): return { - "required": { - "mask": ("MASK",), - "color": ("COLOR",), - "background": ("COLOR", {"default": "#000000"}) - } - } + "required": { + "mask": ("MASK",), + "color": ("COLOR",), + "background": ("COLOR", {"default": "#000000"}), + } + } CATEGORY = "image/mask" @@ -375,37 +371,38 @@ class MaskToImage: FUNCTION = "render_mask" - def render_mask(self, mask,color, background): + def render_mask(self, mask, color, background): mask = img_tensor_to_np(mask) mask = Image.fromarray(mask).convert("L") - + image = Image.new("RGBA", mask.size, color=color) # apply the mask - image = Image.composite(image, Image.new("RGBA", mask.size, color=background), mask) - + image = Image.composite( + image, Image.new("RGBA", mask.size, color=background), mask + ) + # image = ImageChops.multiply(image, mask) # apply over background # image = Image.alpha_composite(Image.new("RGBA", image.size, color=background), image) - + image = pil2tensor(image.convert("RGB")) - print(image.shape) - + return (image,) + class ColoredImage: def __init__(self) -> None: pass - + @classmethod def INPUT_TYPES(cls): return { - "required": { - "color": ("COLOR",), - "width": ("INT",{"default": 512, "min": 16, "max": 8160}), - "height": ("INT",{"default": 512, "min": 16, "max": 8160}), - - } - } + "required": { + "color": ("COLOR",), + "width": ("INT", {"default": 512, "min": 16, "max": 8160}), + "height": ("INT", {"default": 512, "min": 16, "max": 8160}), + } + } CATEGORY = "image" @@ -413,55 +410,59 @@ class ColoredImage: FUNCTION = "render_img" - def render_img(self, color,width,height): - - image = Image.new("RGB", (width,height), color=color) - + def render_img(self, color, width, height): + image = Image.new("RGB", (width, height), color=color) + image = pil2tensor(image) - # print(image.shape) - + return (image,) - - + + class ImagePremultiply: def __init__(self): pass - - + @classmethod def INPUT_TYPES(cls): return { - "required": { - "image": ("IMAGE",), - "mask": ("MASK",), - "invert": (["True","False"], {"default": "False"}) - } - } - + "required": { + "image": ("IMAGE",), + "mask": ("MASK",), + "invert": (["True", "False"], {"default": "False"}), + } + } + CATEGORY = "image" RETURN_TYPES = ("IMAGE",) FUNCTION = "premultiply" - - def premultiply(self, image, mask, invert): - - invert = invert == "True" - - image = tensor2pil(image) + def premultiply(self, image, mask, invert): + invert = invert == "True" + image = tensor2pil(image) mask = tensor2pil(mask).convert("L") - # apply the mask as premultiplied alpha over a transparent image - if invert: mask = ImageChops.invert(mask) - + image.putalpha(mask) - - + # if invert: # image = Image.composite(image,Image.new("RGBA", image.size, color=(0,0,0,0)), mask) # else: # image = Image.composite(Image.new("RGBA", image.size, color=(0,0,0,0)), image, mask) - + return (pil2tensor(image),) - \ No newline at end of file + + +__nodes__ = [ + ColorCorrect, + HSVtoRGB, + RGBtoHSV, + ImageCompare, + Denoise, + Blur, + DeglazeImage, + MaskToImage, + ColoredImage, + ImagePremultiply, +] diff --git a/nodes/latent_processing.py b/nodes/latent_processing.py index b63e603..93d35dd 100644 --- a/nodes/latent_processing.py +++ b/nodes/latent_processing.py @@ -1,6 +1,7 @@ import torch class LatentLerp: + """Linear interpolation (blend) between two latent vectors""" def __init__(self): pass @@ -26,3 +27,7 @@ class LatentLerp: torch.lerp(a["samples"], b["samples"], t, out=a["samples"]) return (a,) + +__nodes__ = [ + LatentLerp, +] \ No newline at end of file diff --git a/nodes/mask.py b/nodes/mask.py index e8632c5..828478d 100644 --- a/nodes/mask.py +++ b/nodes/mask.py @@ -7,7 +7,7 @@ class ImageRemoveBackgroundRembg: pass @classmethod - def INPUT_TYPES(s): + def INPUT_TYPES(cls): return { "required": { "image": ("IMAGE",), @@ -27,7 +27,6 @@ class ImageRemoveBackgroundRembg: CATEGORY = "image" # bgcolor: Optional[Tuple[int, int, int, int]] def remove_background(self, image, alpha_matting, alpha_matting_foreground_threshold, alpha_matting_background_threshold, alpha_matting_erode_size, post_process_mask, bgcolor): - print(f"Background Color: {bgcolor}") image = remove( data=tensor2pil(image), alpha_matting=alpha_matting == "True", @@ -51,3 +50,8 @@ class ImageRemoveBackgroundRembg: return (pil2tensor(image), pil2tensor(mask), pil2tensor(image_on_bg)) + + +__nodes__ = [ + ImageRemoveBackgroundRembg, +] \ No newline at end of file diff --git a/nodes/number.py b/nodes/number.py new file mode 100644 index 0000000..cf6c6a1 --- /dev/null +++ b/nodes/number.py @@ -0,0 +1,26 @@ +class IntToNumber: + """Node addon for the WAS Suite. Converts a "comfy" INT to a NUMBER.""" + + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "int": ("INT", {"default": 0, "min": 0, "max": 1e9, "step": 1}), + } + } + + RETURN_TYPES = ("NUMBER",) + FUNCTION = "int_to_number" + CATEGORY = "number" + + def int_to_number(self, int): + + return (int,) + +__nodes__ = [ + IntToNumber, + +] \ No newline at end of file diff --git a/nodes/video.py b/nodes/video.py index 1d322da..86b25f9 100644 --- a/nodes/video.py +++ b/nodes/video.py @@ -9,8 +9,12 @@ import folder_paths from pathlib import Path import json -from ..utils import log +from ..log import log class LoadImageSequence: + """Load an image sequence from a folder. The current frame is used to determine which image to load. + + Usually used in conjunction with the `Primitive` node set to increment to load a sequence of images from a folder. + """ @classmethod def INPUT_TYPES(cls): return { @@ -69,6 +73,10 @@ def resolve_path(path, frame): return re.sub("#+", padded_number, path) class SaveImageSequence: + """Save an image sequence to a folder. The current frame is used to determine which image to save. + + This is merely a wrapper around the `save_images` function with formatting for the output folder and filename. + """ def __init__(self): self.output_dir = folder_paths.get_output_directory() self.type = "output" @@ -135,3 +143,7 @@ class SaveImageSequence: +__nodes__ = [ + LoadImageSequence, + SaveImageSequence, +] \ No newline at end of file diff --git a/scripts/download_models.py b/scripts/download_models.py new file mode 100644 index 0000000..54a8f01 --- /dev/null +++ b/scripts/download_models.py @@ -0,0 +1,148 @@ +import os +import requests +from rich.console import Console +from tqdm import tqdm + +try: + import folder_paths +except ModuleNotFoundError: + import sys + + sys.path.append(os.path.join(os.path.dirname(__file__), "../../..")) + import folder_paths + +models_to_download = { + "DeepBump": { + "size": 25.5, + "download_url": "https://github.com/HugoTini/DeepBump/raw/master/deepbump256.onnx", + "destination": "deepbump", + }, + "Roop": { + "size": 660, + "download_url": [ + "https://github.com/xinntao/facexlib/releases/download/v0.1.0/detection_mobilenet0.25_Final.pth", + "https://github.com/xinntao/facexlib/releases/download/v0.1.0/detection_Resnet50_Final.pth", + "https://huggingface.co/deepinsight/inswapper/resolve/main/inswapper_128.onnx", + ], + "destination": "roop", + }, +} + +console = Console() + +from urllib.parse import urlparse +from pathlib import Path + + +def download_model(download_url, destination): + + if isinstance(download_url, list): + for url in download_url: + download_model(url, destination) + return + + filename = os.path.basename(urlparse(download_url).path) + response = requests.get(download_url, stream=True) + total_size = int(response.headers.get("content-length", 0)) + + destination_path = os.path.join(destination, filename) + with open(destination_path, "wb") as file: + with tqdm( + total=total_size, unit="B", unit_scale=True, desc=destination_path, ncols=80 + ) as progress_bar: + for data in response.iter_content(chunk_size=4096): + file.write(data) + progress_bar.update(len(data)) + + console.print( + f"Downloaded model from {download_url} to {destination_path}", + style="bold green", + ) + + +def ask_user_for_downloads(models_to_download): + console.print("Choose models to download:") + choices = {} + for i, model_name in enumerate(models_to_download.keys(), start=1): + choices[str(i)] = model_name + console.print(f"{i}. {model_name}") + + console.print( + "Enter the numbers of the models you want to download (comma-separated):" + ) + user_input = console.input(">> ") + selected_models = user_input.split(",") + models_to_download_selected = {} + + for choice in selected_models: + choice = choice.strip() + + if choice in choices: + model_name = choices[choice] + models_to_download_selected[model_name] = models_to_download[model_name] + + elif choice == "": + # download all + models_to_download_selected = models_to_download + else: + console.print(f"Invalid choice: {choice}. Skipping.") + + return models_to_download_selected + + +def handle_interrupt(): + console.print("Interrupted by user.", style="bold red") + + +def main(models_to_download): + try: + models_to_download_selected = {} + + def check_destination(urls, destination): + + if isinstance(urls, list): + for url in urls: + check_destination(url, destination) + return + + filename = os.path.basename(urlparse(urls).path) + destination = os.path.join(folder_paths.models_dir, destination) + + if not os.path.exists(destination): + os.makedirs(destination) + + destination_path = os.path.join(destination, filename) + if os.path.exists(destination_path): + url_name = os.path.basename(urlparse(urls).path) + console.print( + f"Checkpoint '{url_name}' for {model_name} already exists in '{destination}'" + ) + else: + model_details["destination"] = destination + models_to_download_selected[model_name] = model_details + + for model_name, model_details in models_to_download.items(): + destination = model_details["destination"] + download_url = model_details["download_url"] + + check_destination(download_url, destination) + + if not models_to_download_selected: + console.print("No new models to download.") + return + + models_to_download_selected = ask_user_for_downloads( + models_to_download_selected + ) + + for model_name, model_details in models_to_download_selected.items(): + download_url = model_details["download_url"] + destination = model_details["destination"] + download_model(download_url, destination) + + except KeyboardInterrupt: + handle_interrupt() + + +if __name__ == "__main__": + main(models_to_download) diff --git a/get_deps.py b/scripts/get_deps.py similarity index 78% rename from get_deps.py rename to scripts/get_deps.py index 390a2d8..0f166c0 100644 --- a/get_deps.py +++ b/scripts/get_deps.py @@ -8,8 +8,9 @@ from rich.progress import Progress console = Console(stderr=True) + def get_imported_modules(filename): - with open(filename, 'r') as file: + with open(filename, "r") as file: tree = ast.parse(file.read()) imported_modules = [] @@ -21,7 +22,9 @@ def get_imported_modules(filename): ) elif isinstance(node, ast.ImportFrom): if node.module: - imported_modules.append((node.module, node.module in sys.builtin_module_names)) + imported_modules.append( + (node.module, node.module in sys.builtin_module_names) + ) return imported_modules @@ -36,7 +39,7 @@ def list_imported_modules(folder): for root, _, files in os.walk(folder): for file in files: - if file.endswith('.py'): + if file.endswith(".py"): file_path = os.path.join(root, file) imported_modules = get_imported_modules(file_path) modules.extend(imported_modules) @@ -47,9 +50,11 @@ def list_imported_modules(folder): return modules -if __name__ == '__main__': +if __name__ == "__main__": if len(sys.argv) < 2: - console.print("[bold red]Please provide the folder path as a command-line argument.[/bold red]") + console.print( + "[bold red]Please provide the folder path as a command-line argument.[/bold red]" + ) sys.exit(1) # folder_path = input("Enter the folder path: ") @@ -78,5 +83,11 @@ if __name__ == '__main__': console.print(table) - json_data = json.dumps([{"module": module, "type": "Built-in" if is_builtin else "External"} for module, is_builtin in imported_modules], indent=4) - print(json_data) \ No newline at end of file + json_data = json.dumps( + [ + {"module": module, "type": "Built-in" if is_builtin else "External"} + for module, is_builtin in imported_modules + ], + indent=4, + ) + print(json_data) diff --git a/utils.py b/utils.py index 69f1015..f8b6bc2 100644 --- a/utils.py +++ b/utils.py @@ -3,12 +3,24 @@ import numpy as np import torch from pathlib import Path import sys -from logging import getLogger -import logging - -log = getLogger(__package__) -log.setLevel(logging.DEBUG) +def add_path(path, prepend=False): + + if isinstance(path, list): + for p in path: + add_path(p, prepend) + return + + if isinstance(path, Path): + path = path.resolve().as_posix() + + if path not in sys.path: + if prepend: + sys.path.insert(0, path) + else: + sys.path.append(path) + + # Get the absolute path of the parent directory of the current script here = Path(__file__).parent.resolve() @@ -19,11 +31,12 @@ comfy_dir = here.parent.parent font_path = here / "font.ttf" # Add extern folder to path -extern = (here / "extern", here / "extern" / "SadTalker") -sys.path.extend([ x.as_posix() for x in extern]) +add_path(here / "extern") +add_path(here / "extern" / "SadTalker") -# Add the ComfyUI directory path to the sys.path list -sys.path.append(comfy_dir.resolve().as_posix()) +# Add the ComfyUI directory and custom nodes path to the sys.path list +add_path(comfy_dir) +add_path((comfy_dir / "custom_nodes")) # Tensor to PIL (grabbed from WAS Suite) def tensor2pil(image: torch.Tensor) -> Image.Image: diff --git a/web/colors.js b/web/color_widget.js similarity index 85% rename from web/colors.js rename to web/color_widget.js index 40d5c4e..fb41117 100644 --- a/web/colors.js +++ b/web/color_widget.js @@ -65,6 +65,17 @@ const custom = (key,val) => { ctx.fillText(this.name, widgetWidth * 0.5, widgetY + 14); + + // ctx.strokeStyle = "#fff"; + // ctx.strokeRect(border, widgetY + border, widgetWidth - border * 2, height - border * 2); + + + // ctx.fillStyle = "#000"; + // ctx.fillRect(widgetWidth/2 - border / 2 , widgetY + border / 2 , widgetWidth/2 + border / 2, height + border / 2); + // ctx.fillStyle = this.value; + // ctx.fillRect(widgetWidth/2, widgetY, widgetWidth/2, height); + + } widget.mouse = function (e, pos, node) { if (e.type === "pointerdown") { console.log({e,pos,node}) @@ -80,7 +91,18 @@ const custom = (key,val) => { const picker = document.createElement("input"); picker.type = "color"; picker.value = this.value; - + // picker.style.position = "absolute"; + // picker.style.left = ( pos[0]) + "px"; + // picker.style.top = ( pos[1]) + "px"; + + // place at screen center + // picker.style.position = "absolute"; + // picker.style.left = (window.innerWidth / 2) + "px"; + // picker.style.top = (window.innerHeight / 2) + "px"; + // picker.style.transform = "translate(-50%, -50%)"; + // picker.style.zIndex = 1000; + + document.body.appendChild(picker); diff --git a/web/extern/parse-css.js b/web/extern/parse-css.js new file mode 100644 index 0000000..b77811f --- /dev/null +++ b/web/extern/parse-css.js @@ -0,0 +1,362 @@ + +// #region patterns +const float = '-?\\d*(?:\\.\\d+)'; +export const number = `(${float}?)`; +export const percentage = `(${float}?%)`; +export const numberOrPercentage = `(${float}?%?)`; +const clamp = (num, min, max) => Math.min(Math.max(min, num), max); + +const hexCharacters = 'a-f\\d'; +const match3or4Hex = `#?[${hexCharacters}]{3}[${hexCharacters}]?`; +const match6or8Hex = `#?[${hexCharacters}]{6}([${hexCharacters}]{2})?`; +const nonHexChars = new RegExp(`[^#${hexCharacters}]`, 'gi'); +const validHexSize = new RegExp(`^${match3or4Hex}$|^${match6or8Hex}$`, 'i'); + + +export const hex_pattern = new RegExp(/^#([a-f0-9]{3,4}|[a-f0-9]{4}(?:[a-f0-9]{2}){1,2})\b$/, "i"); + +export const hsl3_pattern = new RegExp(`^ + hsla?\\( + \\s*(-?\\d*(?:\\.\\d+)?(?:deg|rad|turn)?)\\s*, + \\s*${percentage}\\s*, + \\s*${percentage}\\s* + (?:,\\s*${numberOrPercentage}\\s*)? + \\) + $ +`.replace(/\n|\s/g, '')) + +export const hsl4_pattern = new RegExp(`^ + hsla?\\( + \\s*(-?\\d*(?:\\.\\d+)?(?:deg|rad|turn)?)\\s* + \\s+${percentage} + \\s+${percentage} + \\s*(?:\\s*\\/\\s*${numberOrPercentage}\\s*)? + \\) + $ +`.replace(/\n|\s/g, '')) + +export const rgb3_pattern = new RegExp(`^ + rgba?\\( + \\s*${number}\\s*, + \\s*${number}\\s*, + \\s*${number}\\s* + (?:,\\s*${numberOrPercentage}\\s*)? + \\) + $ +`.replace(/\n|\s/g, '')) + +export const rgb4_pattern = new RegExp(`^ + rgba?\\( + \\s*${number} + \\s+${number} + \\s+${number} + \\s*(?:\\s*\\/\\s*${numberOrPercentage}\\s*)? + \\) +$ +`.replace(/\n|\s/g, '')); + +export const transparent_pattern = new RegExp(/^transparent$/, 'i'); +// #endregion + + +// #region utils + + +/* 500 => 255, -10 => 0, 128 => 128 */ +const parseRGB = (num) => { + let n = num; + if (typeof n !== 'number') { + n = n.endsWith('%') ? (parseFloat(n) * 255) / 100 : parseFloat(n); + } + return clamp(Math.round(n), 0, 255); +}; + +/* 200 => 100, -100 => 0, 50 => 50 */ +const parsePercentage = (percentage) => clamp(parseFloat(percentage), 0, 100); + +/* '50%' => 5.0, 200 => 1, -10 => 0 */ +function parseAlpha(alpha) { + let a = alpha; + if (typeof a !== 'number') { + a = a.endsWith('%') ? parseFloat(a) / 100 : parseFloat(a); + } + return clamp(a, 0, 1); +} + +export function getHEX(hex) { + const [r, g, b, a] = hex2Rgb(hex, { format: 'array' }); + return getRGB([null, ...[r, g, b, a]]); +} + +export function getHSL([, h, s, l, a = 1]) { + let hh = h; + if (hh.endsWith('turn')) { + hh = (parseFloat(hh) * 360) / 1; + } else if (hh.endsWith('rad')) { + hh = Math.round((parseFloat(hh) * 180) / Math.PI); + } else { + hh = parseFloat(hh); + } + return { + type: 'hsl', + values: [hh, parsePercentage(s), parsePercentage(l)], + alpha: parseAlpha(a === null ? 1 : a) + }; +} + +export function getRGB([, r, g, b, a = 1]) { + return { + type: 'rgb', + values: [r, g, b].map(parseRGB), + alpha: parseAlpha(a === null ? 1 : a) + }; +} +export function hex2Rgb(hex, options = {}) { + if (typeof hex !== 'string' || nonHexChars.test(hex) || !validHexSize.test(hex)) { + throw new TypeError('Expected a valid hex string'); + } + + hex = hex.replace(/^#/, ''); + let alphaFromHex = 1; + + if (hex.length === 8) { + alphaFromHex = Number.parseInt(hex.slice(6, 8), 16) / 255; + hex = hex.slice(0, 6); + } + + if (hex.length === 4) { + alphaFromHex = Number.parseInt(hex.slice(3, 4).repeat(2), 16) / 255; + hex = hex.slice(0, 3); + } + + if (hex.length === 3) { + hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; + } + + const number = Number.parseInt(hex, 16); + const red = number >> 16; + const green = (number >> 8) & 255; + const blue = number & 255; + const alpha = typeof options.alpha === 'number' ? options.alpha : alphaFromHex; + + if (options.format === 'array') { + return [red, green, blue, alpha]; + } + + if (options.format === 'css') { + const alphaString = alpha === 1 ? '' : ` / ${Number((alpha * 100).toFixed(2))}%`; + return `rgb(${red} ${green} ${blue}${alphaString})`; + } + + return {red, green, blue, alpha}; +} +// #endregion + + +// #region colorNames +export const colorName = { + aliceblue: [240, 248, 255], + antiquewhite: [250, 235, 215], + aqua: [0, 255, 255], + aquamarine: [127, 255, 212], + azure: [240, 255, 255], + beige: [245, 245, 220], + bisque: [255, 228, 196], + black: [0, 0, 0], + blanchedalmond: [255, 235, 205], + blue: [0, 0, 255], + blueviolet: [138, 43, 226], + brown: [165, 42, 42], + burlywood: [222, 184, 135], + cadetblue: [95, 158, 160], + chartreuse: [127, 255, 0], + chocolate: [210, 105, 30], + coral: [255, 127, 80], + cornflowerblue: [100, 149, 237], + cornsilk: [255, 248, 220], + crimson: [220, 20, 60], + cyan: [0, 255, 255], + darkblue: [0, 0, 139], + darkcyan: [0, 139, 139], + darkgoldenrod: [184, 134, 11], + darkgray: [169, 169, 169], + darkgreen: [0, 100, 0], + darkgrey: [169, 169, 169], + darkkhaki: [189, 183, 107], + darkmagenta: [139, 0, 139], + darkolivegreen: [85, 107, 47], + darkorange: [255, 140, 0], + darkorchid: [153, 50, 204], + darkred: [139, 0, 0], + darksalmon: [233, 150, 122], + darkseagreen: [143, 188, 143], + darkslateblue: [72, 61, 139], + darkslategray: [47, 79, 79], + darkslategrey: [47, 79, 79], + darkturquoise: [0, 206, 209], + darkviolet: [148, 0, 211], + deeppink: [255, 20, 147], + deepskyblue: [0, 191, 255], + dimgray: [105, 105, 105], + dimgrey: [105, 105, 105], + dodgerblue: [30, 144, 255], + firebrick: [178, 34, 34], + floralwhite: [255, 250, 240], + forestgreen: [34, 139, 34], + fuchsia: [255, 0, 255], + gainsboro: [220, 220, 220], + ghostwhite: [248, 248, 255], + gold: [255, 215, 0], + goldenrod: [218, 165, 32], + gray: [128, 128, 128], + green: [0, 128, 0], + greenyellow: [173, 255, 47], + grey: [128, 128, 128], + honeydew: [240, 255, 240], + hotpink: [255, 105, 180], + indianred: [205, 92, 92], + indigo: [75, 0, 130], + ivory: [255, 255, 240], + khaki: [240, 230, 140], + lavender: [230, 230, 250], + lavenderblush: [255, 240, 245], + lawngreen: [124, 252, 0], + lemonchiffon: [255, 250, 205], + lightblue: [173, 216, 230], + lightcoral: [240, 128, 128], + lightcyan: [224, 255, 255], + lightgoldenrodyellow: [250, 250, 210], + lightgray: [211, 211, 211], + lightgreen: [144, 238, 144], + lightgrey: [211, 211, 211], + lightpink: [255, 182, 193], + lightsalmon: [255, 160, 122], + lightseagreen: [32, 178, 170], + lightskyblue: [135, 206, 250], + lightslategray: [119, 136, 153], + lightslategrey: [119, 136, 153], + lightsteelblue: [176, 196, 222], + lightyellow: [255, 255, 224], + lime: [0, 255, 0], + limegreen: [50, 205, 50], + linen: [250, 240, 230], + magenta: [255, 0, 255], + maroon: [128, 0, 0], + mediumaquamarine: [102, 205, 170], + mediumblue: [0, 0, 205], + mediumorchid: [186, 85, 211], + mediumpurple: [147, 112, 219], + mediumseagreen: [60, 179, 113], + mediumslateblue: [123, 104, 238], + mediumspringgreen: [0, 250, 154], + mediumturquoise: [72, 209, 204], + mediumvioletred: [199, 21, 133], + midnightblue: [25, 25, 112], + mintcream: [245, 255, 250], + mistyrose: [255, 228, 225], + moccasin: [255, 228, 181], + navajowhite: [255, 222, 173], + navy: [0, 0, 128], + oldlace: [253, 245, 230], + olive: [128, 128, 0], + olivedrab: [107, 142, 35], + orange: [255, 165, 0], + orangered: [255, 69, 0], + orchid: [218, 112, 214], + palegoldenrod: [238, 232, 170], + palegreen: [152, 251, 152], + paleturquoise: [175, 238, 238], + palevioletred: [219, 112, 147], + papayawhip: [255, 239, 213], + peachpuff: [255, 218, 185], + peru: [205, 133, 63], + pink: [255, 192, 203], + plum: [221, 160, 221], + powderblue: [176, 224, 230], + purple: [128, 0, 128], + rebeccapurple: [102, 51, 153], + red: [255, 0, 0], + rosybrown: [188, 143, 143], + royalblue: [65, 105, 225], + saddlebrown: [139, 69, 19], + salmon: [250, 128, 114], + sandybrown: [244, 164, 96], + seagreen: [46, 139, 87], + seashell: [255, 245, 238], + sienna: [160, 82, 45], + silver: [192, 192, 192], + skyblue: [135, 206, 235], + slateblue: [106, 90, 205], + slategray: [112, 128, 144], + slategrey: [112, 128, 144], + snow: [255, 250, 250], + springgreen: [0, 255, 127], + steelblue: [70, 130, 180], + tan: [210, 180, 140], + teal: [0, 128, 128], + thistle: [216, 191, 216], + tomato: [255, 99, 71], + turquoise: [64, 224, 208], + violet: [238, 130, 238], + wheat: [245, 222, 179], + white: [255, 255, 255], + whitesmoke: [245, 245, 245], + yellow: [255, 255, 0], + yellowgreen: [154, 205, 50] +} +// #endregion + + +export const parseCSSColor = (str, debug=false) => { + if (typeof str !== 'string') { + console.error(`parseCSSColor: expected a string found ${typeof str}`,str); + return null; + } + + const hex = hex_pattern.exec(str); + if (hex) { + if (debug){ + console.debug('parseCSSColor: hex', hex); + } + return getHEX(hex[0]); + } + + const hsl = hsl4_pattern.exec(str) || hsl3_pattern.exec(str); + if (hsl) { + if (debug){ + console.debug('parseCSSColor: hsl', hsl); + } + return getHSL(hsl); + } + + const rgb = + rgb4_pattern.exec(str) || + rgb3_pattern.exec(str) + if (rgb) { + if (debug){ + console.debug('parseCSSColor: rgb', rgb); + } + return getRGB(rgb); + } + + if (transparent_pattern.exec(str)) { + if (debug){ + console.debug('parseCSSColor: transparent'); + } + return getRGB([null, 0, 0, 0, 0]); + } + + const cn = colorName[str.toLowerCase()]; + if (cn) { + if (debug){ + console.debug('parseCSSColor: colorName', cn); + } + return getRGB([null, cn[0], cn[1], cn[2], 1]); + } + + console.error('parseCSSColor: unknown color', str); + return null; +}; + +export default parseCSSColor; +