diff --git a/__init__.py b/__init__.py index e290ee9..c7ebb82 100644 --- a/__init__.py +++ b/__init__.py @@ -91,6 +91,10 @@ from .nodes.hugging_face.FL_HFHubModelUploader import FL_HFHubModelUploader from .nodes.hugging_face.FL_HF_Character import FL_HF_Character from .nodes.hugging_face.FL_HF_UploaderAbsolute import FL_HF_UploaderAbsolute +# KARTEL NODES +from .nodes.kartel.FL_KartelJobInput import FL_KartelJobInput +from .nodes.kartel.FL_KartelJobOutput import FL_KartelJobOutput + # IMAGE NODES from .nodes.image.FL_AnimeLineExtractor import FL_AnimeLineExtractor from .nodes.image.FL_ApplyMask import FL_ApplyMask @@ -408,6 +412,8 @@ NODE_CLASS_MAPPINGS = { "FL_AnimatedShapePatterns": FL_AnimatedShapePatterns, "FL_PathAnimator": FL_PathAnimator, "FL_RandomShapeGenerator": FL_RandomShapeGenerator, + "FL_KartelJobInput": FL_KartelJobInput, + "FL_KartelJobOutput": FL_KartelJobOutput, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -598,6 +604,8 @@ NODE_DISPLAY_NAME_MAPPINGS = { "FL_AnimatedShapePatterns": "FL Animated Shape Patterns", "FL_PathAnimator": "FL Path Animator", "FL_RandomShapeGenerator": "FL Random Shape Generator", + "FL_KartelJobInput": "FL Kartel Job Input", + "FL_KartelJobOutput": "FL Kartel Job Output", } diff --git a/nodes/kartel/FL_KartelJobInput.py b/nodes/kartel/FL_KartelJobInput.py new file mode 100644 index 0000000..5a609bc --- /dev/null +++ b/nodes/kartel/FL_KartelJobInput.py @@ -0,0 +1,133 @@ +import io +import json +import re +import numpy as np +from PIL import Image +import requests +import torch + + +# Standardized params_json schema: +# { +# "string_1": "", "string_2": "", "string_3": "", "string_4": "", +# "int_1": 0, "int_2": 0, "int_3": 0, "int_4": 0, +# "float_1": 0.0, "float_2": 0.0, "float_3": 0.0, "float_4": 0.0, +# "bool_1": false, "bool_2": false, "bool_3": false, "bool_4": false, +# "image_url_1": "", "image_url_2": "", "image_url_3": "", "image_url_4": "" +# } + + +class FL_KartelJobInput: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "job_id": ("STRING", {"default": ""}), + "user_id": ("STRING", {"default": ""}), + "user_email": ("STRING", {"default": ""}), + "app_name": ("STRING", {"default": ""}), + "callback_url": ("STRING", {"default": ""}), + "params_json": ("STRING", {"default": "{}", "multiline": True}), + } + } + + RETURN_TYPES = ( + "STRING", "STRING", "STRING", "STRING", "STRING", # job metadata + "STRING", "STRING", "STRING", "STRING", # string_1-4 + "INT", "INT", "INT", "INT", # int_1-4 + "FLOAT", "FLOAT", "FLOAT", "FLOAT", # float_1-4 + "BOOLEAN", "BOOLEAN", "BOOLEAN", "BOOLEAN", # bool_1-4 + "IMAGE", "IMAGE", "IMAGE", "IMAGE", # image_1-4 + ) + RETURN_NAMES = ( + "job_id", "user_id", "user_email", "app_name", "callback_url", + "string_1", "string_2", "string_3", "string_4", + "int_1", "int_2", "int_3", "int_4", + "float_1", "float_2", "float_3", "float_4", + "bool_1", "bool_2", "bool_3", "bool_4", + "image_1", "image_2", "image_3", "image_4", + ) + FUNCTION = "process" + CATEGORY = "🏵️Fill Nodes/Kartel" + + def process(self, job_id, user_id, user_email, app_name, callback_url, params_json): + print(f"[FL_KartelJobInput] === INCOMING DATA ===") + print(f"[FL_KartelJobInput] job_id='{job_id}'") + print(f"[FL_KartelJobInput] user_id='{user_id}'") + print(f"[FL_KartelJobInput] user_email='{user_email}'") + print(f"[FL_KartelJobInput] app_name='{app_name}'") + print(f"[FL_KartelJobInput] callback_url='{callback_url}'") + print(f"[FL_KartelJobInput] params_json='{params_json}'") + print(f"[FL_KartelJobInput] === END INCOMING DATA ===") + + # Parse JSON with trailing comma tolerance + try: + data = json.loads(params_json) + print(f"[FL_KartelJobInput] Parsed OK, keys: {list(data.keys())}") + print(f"[FL_KartelJobInput] Full parsed data: {data}") + except json.JSONDecodeError: + cleaned = re.sub(r',\s*([}\]])', r'\1', params_json) + try: + data = json.loads(cleaned) + print(f"[FL_KartelJobInput] Parsed after comma fix, keys: {list(data.keys())}") + print(f"[FL_KartelJobInput] Full parsed data: {data}") + except json.JSONDecodeError as e: + print(f"[FL_KartelJobInput] Invalid JSON: {e}") + data = {} + + # 1x1 black pixel placeholder for missing images + empty_image = torch.zeros(1, 1, 1, 3) + + def get_str(key): + val = data.get(key, "") + return str(val) if val else "" + + def get_int(key): + val = data.get(key, 0) + try: + return int(val) + except (ValueError, TypeError): + return 0 + + def get_float(key): + val = data.get(key, 0.0) + try: + return float(val) + except (ValueError, TypeError): + return 0.0 + + def get_bool(key): + val = data.get(key, False) + if isinstance(val, bool): + return val + if isinstance(val, str): + return val.lower() in ("true", "1", "yes") + return bool(val) + + def get_image(key): + url = data.get(key, "") + if not url: + return empty_image + try: + print(f"[FL_KartelJobInput] Downloading image '{key}': {url}") + response = requests.get(url, timeout=30) + response.raise_for_status() + pil_image = Image.open(io.BytesIO(response.content)) + if pil_image.mode != "RGB": + pil_image = pil_image.convert("RGB") + img_np = np.array(pil_image).astype(np.float32) / 255.0 + img_tensor = torch.from_numpy(img_np).unsqueeze(0) + print(f"[FL_KartelJobInput] Downloaded '{key}': {pil_image.width}x{pil_image.height}") + return img_tensor + except Exception as e: + print(f"[FL_KartelJobInput] Failed to download '{key}' ({url}): {e}") + return empty_image + + return ( + job_id, user_id, user_email, app_name, callback_url, + get_str("string_1"), get_str("string_2"), get_str("string_3"), get_str("string_4"), + get_int("int_1"), get_int("int_2"), get_int("int_3"), get_int("int_4"), + get_float("float_1"), get_float("float_2"), get_float("float_3"), get_float("float_4"), + get_bool("bool_1"), get_bool("bool_2"), get_bool("bool_3"), get_bool("bool_4"), + get_image("image_url_1"), get_image("image_url_2"), get_image("image_url_3"), get_image("image_url_4"), + ) diff --git a/nodes/kartel/FL_KartelJobOutput.py b/nodes/kartel/FL_KartelJobOutput.py new file mode 100644 index 0000000..5101b0c --- /dev/null +++ b/nodes/kartel/FL_KartelJobOutput.py @@ -0,0 +1,108 @@ +import io +import json +import numpy as np +from PIL import Image +import requests + + +class FL_KartelJobOutput: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE",), + "job_id": ("STRING", {"default": ""}), + "callback_url": ("STRING", {"default": ""}), + }, + "optional": { + "user_id": ("STRING", {"default": ""}), + "user_email": ("STRING", {"default": ""}), + "app_name": ("STRING", {"default": ""}), + } + } + + RETURN_TYPES = () + FUNCTION = "send_results" + CATEGORY = "🏵️Fill Nodes/Kartel" + OUTPUT_NODE = True + + def send_results(self, images, job_id, callback_url, + user_id="", user_email="", app_name=""): + if images is None: + print("[FL_KartelJobOutput] No images received (None), skipping POST.") + return () + + print(f"[FL_KartelJobOutput] Received images with shape: {images.shape}") + + if images.shape[1] <= 1 and images.shape[2] <= 1: + print("[FL_KartelJobOutput] Images are 1x1 placeholder, skipping POST.") + return () + + if not callback_url: + print("[FL_KartelJobOutput] No callback_url provided, skipping POST.") + return () + + # Convert each batch image to PNG bytes in-memory + png_buffers = [] + for i in range(images.shape[0]): + img_np = 255.0 * images[i].cpu().numpy() + img_pil = Image.fromarray(np.clip(img_np, 0, 255).astype(np.uint8)) + buf = io.BytesIO() + img_pil.save(buf, format="PNG") + buf.seek(0) + png_buffers.append((f"image_{i}.png", buf)) + + # Build metadata form field + metadata = { + "job_id": job_id, + "user_id": user_id, + "user_email": user_email, + "app_name": app_name, + "image_count": len(png_buffers), + "status": "completed", + } + data = {"metadata": json.dumps(metadata)} + + # POST with 1 retry on connection error + max_attempts = 2 + last_error = None + + for attempt in range(max_attempts): + # Build multipart files list (rebuild each attempt since streams are consumed) + files = [("images", (fn, buf, "image/png")) for fn, buf in png_buffers] + + try: + response = requests.post( + callback_url, + files=files, + data=data, + timeout=30, + ) + response.raise_for_status() + print( + f"[FL_KartelJobOutput] Successfully posted {len(png_buffers)} image(s) " + f"for job {job_id} -> {response.status_code}" + ) + return () + except requests.ConnectionError as e: + last_error = e + print( + f"[FL_KartelJobOutput] Connection error on attempt " + f"{attempt + 1}/{max_attempts}: {e}" + ) + # Reset buffer positions for retry + for _, buf in png_buffers: + buf.seek(0) + except requests.RequestException as e: + print(f"[FL_KartelJobOutput] POST failed for job {job_id}: {e}") + return () + + print( + f"[FL_KartelJobOutput] All {max_attempts} attempts failed " + f"for job {job_id}: {last_error}" + ) + return () + + @classmethod + def IS_CHANGED(cls, *args, **kwargs): + return float("NaN") diff --git a/nodes/kartel/FL_KartelParamsParser.py b/nodes/kartel/FL_KartelParamsParser.py new file mode 100644 index 0000000..c5e3c53 --- /dev/null +++ b/nodes/kartel/FL_KartelParamsParser.py @@ -0,0 +1,130 @@ +import io +import json +import re +import numpy as np +from PIL import Image +import requests +import torch + + +class FL_KartelParamsParser: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "params_json": ("STRING", {"default": "{}", "multiline": True}), + }, + "optional": { + "string_key_1": ("STRING", {"default": ""}), + "string_key_2": ("STRING", {"default": ""}), + "string_key_3": ("STRING", {"default": ""}), + "string_key_4": ("STRING", {"default": ""}), + "int_key_1": ("STRING", {"default": ""}), + "int_key_2": ("STRING", {"default": ""}), + "float_key_1": ("STRING", {"default": ""}), + "float_key_2": ("STRING", {"default": ""}), + "bool_key_1": ("STRING", {"default": ""}), + "image_url_key_1": ("STRING", {"default": ""}), + "image_url_key_2": ("STRING", {"default": ""}), + "image_url_key_3": ("STRING", {"default": ""}), + } + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "INT", "INT", "FLOAT", "FLOAT", "BOOLEAN", "IMAGE", "IMAGE", "IMAGE") + RETURN_NAMES = ("string_1", "string_2", "string_3", "string_4", "int_1", "int_2", "float_1", "float_2", "bool_1", "image_1", "image_2", "image_3") + FUNCTION = "parse" + CATEGORY = "🏵️Fill Nodes/Kartel" + + def parse(self, params_json, string_key_1="", string_key_2="", string_key_3="", + string_key_4="", int_key_1="", int_key_2="", float_key_1="", + float_key_2="", bool_key_1="", image_url_key_1="", image_url_key_2="", + image_url_key_3=""): + print(f"[FL_KartelParamsParser] Raw params_json ({len(params_json)} chars): {params_json[:500]}") + print(f"[FL_KartelParamsParser] image_url_key_1='{image_url_key_1}' image_url_key_2='{image_url_key_2}' image_url_key_3='{image_url_key_3}'") + + try: + data = json.loads(params_json) + print(f"[FL_KartelParamsParser] Parsed JSON OK, keys: {list(data.keys())}") + except json.JSONDecodeError: + # Strip trailing commas before } and ] then retry + cleaned = re.sub(r',\s*([}\]])', r'\1', params_json) + try: + data = json.loads(cleaned) + print(f"[FL_KartelParamsParser] Parsed JSON after stripping trailing commas, keys: {list(data.keys())}") + except json.JSONDecodeError as e: + print(f"[FL_KartelParamsParser] Invalid JSON: {e}") + data = {} + + def get_str(key): + if not key: + return "" + return str(data.get(key, "")) + + def get_int(key): + if not key: + return 0 + val = data.get(key, 0) + try: + return int(val) + except (ValueError, TypeError): + print(f"[FL_KartelParamsParser] Cannot convert '{key}' value '{val}' to INT, defaulting to 0") + return 0 + + def get_float(key): + if not key: + return 0.0 + val = data.get(key, 0.0) + try: + return float(val) + except (ValueError, TypeError): + print(f"[FL_KartelParamsParser] Cannot convert '{key}' value '{val}' to FLOAT, defaulting to 0.0") + return 0.0 + + def get_bool(key): + if not key: + return False + val = data.get(key, False) + if isinstance(val, bool): + return val + if isinstance(val, str): + return val.lower() in ("true", "1", "yes") + return bool(val) + + # 1x1 black pixel — valid IMAGE tensor placeholder when no image is available + empty_image = torch.zeros(1, 1, 1, 3) + + def get_image(key): + if not key: + return empty_image + url = data.get(key, "") + if not url: + return empty_image + try: + print(f"[FL_KartelParamsParser] Downloading image from '{key}': {url}") + response = requests.get(url, timeout=30) + response.raise_for_status() + pil_image = Image.open(io.BytesIO(response.content)) + if pil_image.mode != "RGB": + pil_image = pil_image.convert("RGB") + img_np = np.array(pil_image).astype(np.float32) / 255.0 + img_tensor = torch.from_numpy(img_np).unsqueeze(0) + print(f"[FL_KartelParamsParser] Downloaded image '{key}': {pil_image.width}x{pil_image.height}") + return img_tensor + except Exception as e: + print(f"[FL_KartelParamsParser] Failed to download image for '{key}' ({url}): {e}") + return empty_image + + return ( + get_str(string_key_1), + get_str(string_key_2), + get_str(string_key_3), + get_str(string_key_4), + get_int(int_key_1), + get_int(int_key_2), + get_float(float_key_1), + get_float(float_key_2), + get_bool(bool_key_1), + get_image(image_url_key_1), + get_image(image_url_key_2), + get_image(image_url_key_3), + ) diff --git a/nodes/kartel/__init__.py b/nodes/kartel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index c153337..cb18278 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui_fill-nodes" description = "Fill-Nodes is a versatile collection of custom nodes for ComfyUI that extends functionality across multiple domains. Features include advanced image processing (pixelation, slicing, masking), visual effects generation (glitch, halftone, pixel art), comprehensive file handling (PDF creation/extraction, Google Drive integration), AI model interfaces (GPT, DALL-E, Hugging Face), utility nodes for workflow enhancement, and specialized tools for video processing, captioning, and batch operations. The pack provides both practical workflow solutions and creative tools within a unified node collection." -version = "2.2.6" +version = "2.3.0" license = "LICENSE" dependencies = ["librosa", "sounddevice", "glitch_this", "PyOpenGL", "glfw", "scipy>=1.13.1", "requests", "aiohttp", "moviepy", "matplotlib", "reportlab", "openai", "PyPDF2", "pdf2image", "PyMuPDF", "reportlab", "PyPDF2", "ollama", "kornia", "opencv-python", "gdown", "open_clip_torch", "google-genai"]