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:
Fillip
2026-02-17 18:58:12 -08:00
co-authored by Claude Opus 4.6
parent 5f95113fc5
commit 12c5bcded7
6 changed files with 380 additions and 1 deletions
+8
View File
@@ -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",
}
+133
View File
@@ -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"),
)
+108
View File
@@ -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")
+130
View File
@@ -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),
)
View File
+1 -1
View File
@@ -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"]