feat: add Kartel backend integration nodes (v2.3.0)
Add FL_KartelJobInput and FL_KartelJobOutput nodes for connecting ComfyUI workflows to the Kartel Unified Backend. Input node receives job metadata and parses standardized params_json with typed outputs (strings, ints, floats, bools, images from URLs). Output node POSTs generated images back to the backend webhook as multipart uploads. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
5f95113fc5
commit
12c5bcded7
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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),
|
||||
)
|
||||
+1
-1
@@ -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"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user