add workflows, etc

This commit is contained in:
Sirshendu Ganguly
2026-02-25 22:35:30 -05:00
parent dee0949f78
commit bc7b906bbd
12 changed files with 445 additions and 48 deletions
+20 -4
View File
@@ -8,13 +8,25 @@ from PIL import Image
from datetime import datetime
def _is_svg_url(url: str) -> bool:
"""True if URL points to SVG (e.g. from Runware Vectorize)."""
u = (url or "").split("?")[0].lower()
return u.endswith(".svg")
def _bytes_to_image_tensor(img_data: bytes):
"""Decode image bytes to ComfyUI IMAGE tensor [1, H, W, C] float32 0-1."""
"""Decode image bytes to ComfyUI IMAGE tensor [1, H, W, C] float32 0-1. Fails on SVG (use placeholder for SVG)."""
pil = Image.open(io.BytesIO(img_data)).convert("RGB")
arr = np.array(pil).astype(np.float32) / 255.0
return torch.from_numpy(arr).unsqueeze(0)
def _placeholder_image_tensor():
"""Return a 1x1 RGB placeholder tensor [1, 1, 1, 3] for non-raster outputs (e.g. SVG from Vectorize)."""
arr = np.ones((1, 1, 3), dtype=np.float32) * 0.5 # grey
return torch.from_numpy(arr).unsqueeze(0)
class RunwareSaveImage:
"""Runware Save Image: download from URL(s), save/preview to disk, and forward as IMAGE for chaining."""
@@ -23,7 +35,7 @@ class RunwareSaveImage:
return {
"required": {
"Images": ("STRING", {
"tooltip": "Image URL(s) from Runware Image Inference node. For multiple images, URLs should be comma-separated."
"tooltip": "Image URL(s) from Runware Image Inference or Runware Vectorize node. For multiple images, URLs should be comma-separated. SVG URLs (e.g. from Vectorize) are saved as .svg files; a placeholder tensor is passed for the IMAGE output."
}),
"filenamePrefix": ("STRING", {
"default": "ComfyUI",
@@ -69,10 +81,14 @@ class RunwareSaveImage:
response.raise_for_status()
img_data = response.content
tensors.append(_bytes_to_image_tensor(img_data))
# SVG (e.g. from Runware Vectorize) cannot be opened by PIL; save as file and use placeholder tensor
if _is_svg_url(image_url):
tensors.append(_placeholder_image_tensor())
else:
tensors.append(_bytes_to_image_tensor(img_data))
url_without_params = image_url.split("?")[0]
ext = url_without_params.split(".")[-1].lstrip(".") or "png"
ext = url_without_params.split(".")[-1].lstrip(".").lower() or "png"
extension = "." + ext
if len(image_urls) > 1:
filename = f"{filenamePrefix}_{timestamp}_{i+1:03}{extension}"
+165 -39
View File
@@ -1,24 +1,97 @@
import re
from .utils import runwareUtils as rwUtils
class vectorize:
"""Vectorize node for converting images to vector format"""
RUNWARE_VECTORIZE_MODELS = {
"recraft:1@1": "recraft:1@1",
"picsart:1@1": "picsart:1@1",
# Models grouped by architecture (provider), same pattern as video model search
VECTORIZE_MODELS = {
"Recraft": [
"recraft:1@1 (Recraft 1)",
"recraft:v4-pro@vector (Recraft V4 Pro Vector)",
"recraft:v4@vector (Recraft V4 Vector)",
],
"Picsart": [
"picsart:1@1 (Picsart 1)",
],
}
MODEL_ARCHITECTURES = [
"All",
"Recraft",
"Picsart",
]
@classmethod
def _getAllModels(cls):
"""Get all models from all architectures"""
all_models = []
for models in cls.VECTORIZE_MODELS.values():
all_models.extend(models)
return all_models
@classmethod
def INPUT_TYPES(cls):
all_models = cls._getAllModels()
default_model = all_models[0] if all_models else "recraft:1@1 (Recraft 1)"
return {
"required": {
"Image": ("IMAGE", {
"tooltip": "Input image to be vectorized."
"Model Search": ("STRING", {
"tooltip": "Search for a vectorization model by name or AIR code (e.g. recraft, picsart).",
}),
"Model": (list(cls.RUNWARE_VECTORIZE_MODELS.keys()), {
"tooltip": "Select the vectorization model to use.",
"default": "recraft:1@1",
"Model Architecture": (cls.MODEL_ARCHITECTURES, {
"tooltip": "Choose model architecture to filter the model list.",
"default": "Recraft",
}),
"Vector Model List": (all_models, {
"tooltip": "Select the vectorization model. Filter by Model Architecture above.",
"default": default_model,
}),
"Use Search Value": ("BOOLEAN", {
"tooltip": "When enabled, the value in Model Search is used as the model AIR code instead of the list selection.",
"default": False,
"label_on": "Enabled",
"label_off": "Disabled",
}),
},
"optional": {
"Image": ("IMAGE", {
"tooltip": "Optional input image to be vectorized."
}),
"dimensions": ([
"None", "Square HD (1024x1024)", "Custom"
], {
"default": "None",
"tooltip": "Output dimensions preset. Select 'None' for model default, or a preset to set width/height. Use 'Custom' with width/height inputs.",
}),
"width": ("INT", {
"tooltip": "Width in pixels. Used when dimensions is not 'None' (or with Custom).",
"default": 1024,
"min": 128,
"max": 6048,
"step": 1,
}),
"height": ("INT", {
"tooltip": "Height in pixels. Used when dimensions is not 'None' (or with Custom).",
"default": 1024,
"min": 128,
"max": 6048,
"step": 1,
}),
"Use positivePrompt": ("BOOLEAN", {
"tooltip": "When enabled, include positivePrompt in the vectorize request.",
"default": False,
"label_on": "Enabled",
"label_off": "Disabled",
}),
"positivePrompt": ("STRING", {
"multiline": True,
"tooltip": "Optional text prompt for vectorization. Only sent when Use Positive Prompt is enabled.",
}),
"providerSettings": ("RUNWAREPROVIDERSETTINGS", {
"tooltip": "Connect a Runware Provider Settings Node to configure provider-specific parameters (e.g. Recraft, Picsart).",
}),
},
}
@@ -29,51 +102,104 @@ class vectorize:
RETURN_NAMES = ("IMAGE",)
CATEGORY = "Runware"
@classmethod
def VALIDATE_INPUTS(cls, **kwargs):
return True
def vectorizeImage(self, **kwargs):
"""Vectorize image and return SVG URL"""
image = kwargs.get("Image")
modelName = kwargs.get("Model", "recraft:1@1")
model = self.RUNWARE_VECTORIZE_MODELS.get(modelName, "recraft:1@1")
imageUuid = rwUtils.convertTensor2IMG(image)
genConfig = self._buildGenConfig(model, imageUuid)
use_search_value = kwargs.get("Use Search Value", False)
search_input = kwargs.get("Model Search", "")
current_model = kwargs.get("Vector Model List", "recraft:1@1 (Recraft 1)")
if use_search_value and search_input and str(search_input).strip():
model_air_code = str(search_input).strip()
else:
# Extract AIR code from "airId (Display Name)" format
model_air_code = current_model.split(" (")[0].strip()
image_uuid = rwUtils.convertTensor2IMG(image) if image is not None else None
width = kwargs.get("width")
height = kwargs.get("height")
dimensions = kwargs.get("dimensions", "None")
use_positive_prompt = kwargs.get("Use positivePrompt", False)
positive_prompt = kwargs.get("positivePrompt")
provider_settings = kwargs.get("providerSettings")
gen_config = self._buildGenConfig(
model_air_code, image_uuid,
width=width, height=height, dimensions=dimensions,
use_positive_prompt=use_positive_prompt, positive_prompt=positive_prompt,
provider_settings=provider_settings,
)
print(f"[DEBUG] Sending Vectorize Request:")
print(f"[DEBUG] Request Payload: {rwUtils.safe_json_dumps(genConfig, indent=2)}")
genResult = rwUtils.inferenecRequest(genConfig)
print(f"[DEBUG] Request Payload: {rwUtils.safe_json_dumps(gen_config, indent=2)}")
gen_result = rwUtils.inferenecRequest(gen_config)
print(f"[DEBUG] Received Vectorize Response:")
print(f"[DEBUG] Response: {rwUtils.safe_json_dumps(genResult, indent=2)}")
self._validateResponse(genResult)
svg_url = rwUtils.extractImageURLs(genResult)
print(f"[DEBUG] Response: {rwUtils.safe_json_dumps(gen_result, indent=2)}")
self._validateResponse(gen_result)
svg_url = rwUtils.extractImageURLs(gen_result)
return (svg_url,)
def _buildGenConfig(self, model, imageUuid):
"""Build generation configuration for API request"""
return [{
@staticmethod
def _parseDimensionsPreset(dimensions):
"""Parse 'Label (WxH)' to (width, height) or None if not a preset."""
if not dimensions or dimensions in ("None", "Custom"):
return None, None
m = re.search(r"\((\d+)x(\d+)\)", dimensions)
if m:
return int(m.group(1)), int(m.group(2))
return None, None
def _buildGenConfig(self, model, image_uuid, width=None, height=None, dimensions="None",
use_positive_prompt=False, positive_prompt=None, provider_settings=None):
"""Build generation configuration for API request. image_uuid, width, height, positivePrompt, providerSettings are optional."""
inputs = {}
if image_uuid is not None:
inputs["image"] = image_uuid
payload = {
"taskType": "vectorize",
"taskUUID": rwUtils.genRandUUID(),
"model": model,
"inputs": {
"image": imageUuid
},
"inputs": inputs,
"outputType": "URL",
"outputFormat": "svg",
}]
}
if dimensions and dimensions != "None":
w, h = width, height
if w is None or h is None:
pw, ph = self._parseDimensionsPreset(dimensions)
if pw is not None and ph is not None:
w, h = pw, ph
if w is not None and h is not None:
payload["width"] = w
payload["height"] = h
if use_positive_prompt and positive_prompt is not None and str(positive_prompt).strip():
payload["positivePrompt"] = str(positive_prompt).strip()
# Handle providerSettings - extract provider name from model and merge (same as image inference)
if provider_settings is not None:
provider_name = model.split(":")[0] if ":" in model else model
if isinstance(provider_settings, dict):
if any(key in provider_settings for key in ["alibaba", "bytedance", "klingai", "openai", "pixverse",
"bria", "lightricks", "luma", "minimax", "runway", "vidu",
"elevenlabs", "bfl", "xai", "recraft", "picsart", "sourceful"]):
payload["providerSettings"] = provider_settings
else:
payload["providerSettings"] = {provider_name: provider_settings}
else:
payload["providerSettings"] = provider_settings
return [payload]
def _validateResponse(self, genResult):
def _validateResponse(self, gen_result):
"""Validate API response"""
if "errors" in genResult:
errorMessage = genResult["errors"][0]["message"]
raise Exception(f"Vectorization failed: {errorMessage}")
if "errors" in gen_result:
error_message = gen_result["errors"][0]["message"]
raise Exception(f"Vectorization failed: {error_message}")
# Node class mappings
NODE_CLASS_MAPPINGS = {
"RunwareVectorize": vectorize,
}
+8
View File
@@ -144,6 +144,9 @@ class txt2vid:
"Accelerator": ("RUNWAREACCELERATOR", {
"tooltip": "Connect a Runware Accelerator Options Node to configure caching and acceleration settings.",
}),
"settings": ("RUNWAREVIDEOSETTINGS", {
"tooltip": "Connect a Runware Video Inference Settings node to configure draft, audio, and promptUpsampling.",
}),
}
}
@@ -181,6 +184,7 @@ class txt2vid:
safetyInputs = kwargs.get("safetyInputs", None)
videoAdvancedFeatureInputs = kwargs.get("videoAdvancedFeatureInputs", None)
runwareAccelerator = kwargs.get("Accelerator", None)
settings = kwargs.get("settings", None)
useDuration = kwargs.get("useDuration", True)
duration = kwargs.get("duration", 5)
fps = kwargs.get("fps", 24)
@@ -350,6 +354,10 @@ class txt2vid:
# Add accelerator options if provided
if runwareAccelerator is not None and isinstance(runwareAccelerator, dict) and len(runwareAccelerator) > 0:
genConfig[0]["acceleratorOptions"] = runwareAccelerator
# Add settings if provided (draft, audio, promptUpsampling)
if settings is not None and isinstance(settings, dict) and len(settings) > 0:
genConfig[0]["settings"] = settings
# Add acceleration if not "none"
if acceleration and acceleration != "none":
+13 -3
View File
@@ -117,6 +117,9 @@ class videoModelSearch:
"VEED": [
"veed:fabric@1.0 (VEED Fabric 1.0)",
],
"Pruna": [
"prunaai:p-video@0 (P-Video)",
],
}
# Model dimensions mapping
@@ -234,6 +237,9 @@ class videoModelSearch:
# VEED Models (preserves input aspect ratio; resolution = shorter side)
"veed:fabric@1.0": {"width": 1280, "height": 720},
# Pruna Models (720p)
"prunaai:p-video@0": {"width": 1280, "height": 720},
}
# Model resolutions mapping
@@ -349,10 +355,13 @@ class videoModelSearch:
# xAI Models
"xai:grok-imagine@video": "480p",
# VEED Models
# VEED Models
"veed:fabric@1.0": "720p",
# Pruna Models
"prunaai:p-video@0": "720p",
}
MODEL_ARCHITECTURES = [
"All",
"KlingAI",
@@ -373,7 +382,8 @@ class videoModelSearch:
"Hunyuan",
"Kandinsky",
"xAI",
"VEED"
"VEED",
"Pruna",
]
DEFAULT_DIMENSIONS = {"width": 1024, "height": 576}
+83
View File
@@ -0,0 +1,83 @@
"""
Runware Video Inference Settings Node
Provides settings (draft, audio, promptUpsampling) for Runware Video Inference.
"""
from typing import Dict, Any
class RunwareVideoSettings:
"""Runware Video Inference Settings Node"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"optional": {
"useDraft": ("BOOLEAN", {
"tooltip": "Enable to include draft (lower-quality preview mode) in video inference settings.",
"default": False,
}),
"draft": ("BOOLEAN", {
"tooltip": "Draft mode for lower-quality preview. Only used when 'Use Draft' is enabled.",
"default": False,
"label_on": "Enabled",
"label_off": "Disabled",
}),
"useAudio": ("BOOLEAN", {
"tooltip": "Enable to include audio (save video with audio) in video inference settings.",
"default": True,
}),
"audio": ("BOOLEAN", {
"tooltip": "Save the video with audio. Only used when 'Use Audio' is enabled.",
"default": True,
"label_on": "Enabled",
"label_off": "Disabled",
}),
"usePromptUpsampling": ("BOOLEAN", {
"tooltip": "Enable to include promptUpsampling (enhance prompt automatically) in video inference settings.",
"default": True,
}),
"promptUpsampling": ("BOOLEAN", {
"tooltip": "Enhance prompt automatically for better results. Only used when 'Use Prompt Upsampling' is enabled.",
"default": True,
"label_on": "Enabled",
"label_off": "Disabled",
}),
}
}
RETURN_TYPES = ("RUNWAREVIDEOSETTINGS",)
RETURN_NAMES = ("settings",)
FUNCTION = "createSettings"
CATEGORY = "Runware"
DESCRIPTION = "Configure video inference settings (draft, audio, promptUpsampling) for Runware Video Inference. Connect to Runware Video Inference node."
def createSettings(self, **kwargs) -> tuple:
"""Create settings dict for Video Inference API"""
use_draft = kwargs.get("useDraft", False)
draft = kwargs.get("draft", False)
use_audio = kwargs.get("useAudio", True)
audio = kwargs.get("audio", True)
use_prompt_upsampling = kwargs.get("usePromptUpsampling", True)
prompt_upsampling = kwargs.get("promptUpsampling", True)
settings: Dict[str, Any] = {}
if use_draft:
settings["draft"] = bool(draft)
if use_audio:
settings["audio"] = bool(audio)
if use_prompt_upsampling:
settings["promptUpsampling"] = bool(prompt_upsampling)
return (settings,)
NODE_CLASS_MAPPINGS = {
"RunwareVideoSettings": RunwareVideoSettings,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RunwareVideoSettings": "Runware Video Inference Settings",
}