Merge main into fix-tiffNode/feature-outputNode

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sirshendu Ganguly
2026-02-02 13:37:05 -05:00
co-authored by Cursor
19 changed files with 634 additions and 21 deletions
@@ -47,6 +47,14 @@ class RunwareBytedanceProviderSettings:
"tooltip": "Enable audio generation support when available.",
"default": False,
}),
"useDraft": ("BOOLEAN", {
"tooltip": "Enable to include draft parameter in provider settings",
"default": False,
}),
"draft": ("BOOLEAN", {
"tooltip": "Enable draft mode for Bytedance provider",
"default": False,
}),
}
}
@@ -63,12 +71,14 @@ class RunwareBytedanceProviderSettings:
useMaxSequentialImages = kwargs.get("useMaxSequentialImages", False)
useFastMode = kwargs.get("useFastMode", False)
useAudio = kwargs.get("useAudio", False)
useDraft = kwargs.get("useDraft", False)
# Get value parameters
cameraFixed = kwargs.get("cameraFixed", False)
maxSequentialImages = kwargs.get("maxSequentialImages", 1)
fastMode = kwargs.get("fastMode", False)
audio = kwargs.get("audio", False)
draft = kwargs.get("draft", False)
# Build settings dictionary - only include what is enabled
settings = {}
@@ -88,6 +98,10 @@ class RunwareBytedanceProviderSettings:
# Add audio only if useAudio is enabled
if useAudio:
settings["audio"] = audio
# Add draft only if useDraft is enabled
if useDraft:
settings["draft"] = draft
# Clean up None values
settings = {k: v for k, v in settings.items() if v is not None}
+125
View File
@@ -0,0 +1,125 @@
from .utils import runwareUtils as rwUtils
import folder_paths
import os
import re
import requests
from datetime import datetime
class RunwareSave3D:
"""Runware Save 3D node for saving 3D model files from URL"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"3dObject": ("STRING", {
"tooltip": "3D file URL from Runware 3D Inference node."
}),
"filename_prefix": ("STRING", {
"default": "ComfyUI",
"tooltip": "Prefix for the filename."
}),
},
"optional": {
"filepath": ("STRING", {
"placeholder": "Saved file path will appear here after execution.",
"tooltip": "This field is automatically populated with the saved file path after save.",
}),
},
"hidden": {"node_id": "UNIQUE_ID"},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("filepath",)
FUNCTION = "save_3d"
OUTPUT_NODE = True
CATEGORY = "Runware"
DESCRIPTION = "Save 3D model files (GLB/PLY) from Runware 3D Inference to the output folder."
def save_3d(self, **kwargs):
"""Save 3D file from URL"""
file_url = kwargs.get("3dObject", "")
filename_prefix = kwargs.get("filename_prefix", "ComfyUI")
if not file_url or not file_url.strip():
raise Exception("No 3D URL provided. Please connect the '3dObject' output from Runware 3D Inference node.")
file_url = file_url.strip()
# Get output directory
output_dir = folder_paths.get_output_directory()
threed_output_dir = os.path.join(output_dir, "3d_models")
os.makedirs(threed_output_dir, exist_ok=True)
# Get extension from URL (handle query parameters)
url_without_params = file_url.split('?')[0]
extension = url_without_params.split('.')[-1].lower()
if extension not in ["glb", "ply"]:
raise Exception(f"Unsupported 3D file extension '{extension}' from URL. Expected .glb or .ply.")
# Sanitize filename_prefix to prevent path traversal or arbitrary subdir creation
prefix_basename = os.path.basename(
filename_prefix.replace(os.sep, "_").replace(os.altsep or os.sep, "_")
)
safe_prefix = re.sub(r"[^\w\-]", "", prefix_basename) or "ComfyUI"
# Generate filename with timestamp
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{safe_prefix}_{timestamp}.{extension}"
filepath = os.path.join(threed_output_dir, filename)
# Download with retry logic
max_retries = 10
retry_delays = [2, 5, 10, 15, 20]
for attempt in range(max_retries):
try:
response = requests.get(file_url, stream=True, timeout=60)
print(f"[Runware Save 3D] Download attempt {attempt + 1}: {response.status_code}")
if response.status_code == 422:
print(f"[Runware Save 3D] Server still processing, waiting...")
rwUtils.time.sleep(retry_delays[min(attempt, len(retry_delays) - 1)])
continue
if response.status_code == 502:
print(f"[Runware Save 3D] Server error (502), retrying...")
rwUtils.time.sleep(retry_delays[min(attempt, len(retry_delays) - 1)])
continue
response.raise_for_status()
with open(filepath, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
print(f"[Runware Save 3D] Successfully saved to: {filepath}")
# Send filepath to UI (like media upload mediaUUID)
rwUtils.sendSave3DFilepath(filepath, kwargs.get("node_id"))
return {"result": (filepath,), "ui": {"text": (f"Saved: {filename}",)}}
except requests.exceptions.RequestException as e:
print(f"[Runware Save 3D] Attempt {attempt + 1} failed: {e}")
if attempt < max_retries - 1:
rwUtils.time.sleep(retry_delays[min(attempt, len(retry_delays) - 1)])
continue
else:
raise Exception(f"Failed to download 3D file after {max_retries} attempts: {e}")
except Exception as e:
print(f"[Runware Save 3D] Unexpected error: {e}")
raise Exception(f"Failed to save 3D file: {e}")
raise Exception("Failed to download 3D file: max retries exceeded")
NODE_CLASS_MAPPINGS = {
"RunwareSave3D": RunwareSave3D,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RunwareSave3D": "Runware Save 3D",
}
+178
View File
@@ -0,0 +1,178 @@
from .utils import runwareUtils as rwUtils
import comfy.model_management
class threeDInference:
"""Runware 3D Inference node for generating 3D models from images"""
# Available 3D models
THREED_MODELS = {
"Meta SAM 3D": "meta:sam@3d",
}
# Output formats
OUTPUT_FORMATS = ["GLB", "PLY"]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"Model": (list(cls.THREED_MODELS.keys()), {
"tooltip": "Select the 3D generation model to use.",
"default": "Meta SAM 3D",
}),
"positivePrompt": ("STRING", {
"multiline": True,
"placeholder": "Describe the 3D object you want to generate...",
"tooltip": "A text prompt describing the 3D object to generate."
}),
"seed": ("INT", {
"tooltip": "Seed for reproducibility. Use the same seed to get the same result.",
"default": 1,
"min": 1,
"max": 2147483647,
}),
"outputFormat": (cls.OUTPUT_FORMATS, {
"tooltip": "Output format for the 3D model file.",
"default": "GLB",
}),
},
"optional": {
"inputs": ("RUNWARE3DINFERENCEINPUTS", {
"tooltip": "Connect a Runware 3D Inference Inputs node to provide image and mask inputs.",
}),
},
}
DESCRIPTION = "Generate 3D models from images using Runware's 3D Inference API. Connect the output to 'Runware Save 3D' node to save the file."
FUNCTION = "generate3D"
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("3dObject",)
CATEGORY = "Runware"
def generate3D(self, **kwargs):
"""Generate 3D model from inputs"""
modelName = kwargs.get("Model", "Meta SAM 3D")
positivePrompt = kwargs.get("positivePrompt", "")
seed = kwargs.get("seed", 1)
outputFormat = kwargs.get("outputFormat", "GLB")
inputs = kwargs.get("inputs", None)
# Get model AIR code
model = self.THREED_MODELS.get(modelName, "meta:sam@3d")
# Build generation config
genConfig = [
{
"taskType": "3dInference",
"taskUUID": rwUtils.genRandUUID(),
"model": model,
"numberResults": 1,
"outputType": "URL",
"includeCost": True,
"deliveryMethod": "async",
"outputFormat": outputFormat,
}
]
# Add positivePrompt if provided
if positivePrompt and positivePrompt.strip() != "":
genConfig[0]["positivePrompt"] = positivePrompt.strip()
# Add seed
genConfig[0]["seed"] = seed
# Handle inputs from 3D Inference Inputs node
if inputs is not None and isinstance(inputs, dict) and len(inputs) > 0:
genConfig[0]["inputs"] = inputs
try:
print(f"[DEBUG] Sending 3D Inference Request:")
print(f"[DEBUG] Request Payload: {rwUtils.safe_json_dumps(genConfig, indent=2)}")
# Send initial request
genResult = rwUtils.inferenecRequest(genConfig)
print(f"[DEBUG] Received 3D Inference Response:")
print(f"[DEBUG] Response: {rwUtils.safe_json_dumps(genResult, indent=2)}")
except Exception as e:
raise
# Extract task UUID for polling
taskUUID = genConfig[0]["taskUUID"]
# Poll for 3D generation completion
while True:
# Check for interrupt before each poll
comfy.model_management.throw_exception_if_processing_interrupted()
# Poll for result
pollResult = rwUtils.pollVideoResult(taskUUID) # Reuse existing poll function
print(f"[Debugging] Poll result: {rwUtils.safe_json_dumps(pollResult, indent=2) if isinstance(pollResult, (dict, list)) else pollResult}")
# Check for errors
if pollResult and "errors" in pollResult and len(pollResult["errors"]) > 0:
error_info = pollResult["errors"][0]
error_message = error_info.get("message", "Unknown error")
if "responseContent" in error_info:
response_content = error_info["responseContent"]
if isinstance(response_content, str):
detailed_message = response_content
elif isinstance(response_content, dict):
detailed_message = response_content.get("message", str(response_content))
else:
detailed_message = str(response_content)
if detailed_message:
error_message = f"{error_message}\nProvider Error: {detailed_message}"
task_uuid = error_info.get("taskUUID", "unknown")
raise Exception(f"3D generation failed (Task: {task_uuid}): {error_message}")
# Check for successful completion
if pollResult and "data" in pollResult and len(pollResult["data"]) > 0:
data = pollResult["data"][0]
# Check if outputs.files exists (3D inference response format)
outputs = data.get("outputs", {})
files = outputs.get("files", [])
if len(files) > 0:
file_info = files[0]
file_url = file_info.get("url", "")
if file_url:
print(f"[3D Inference] Generated 3D file URL: {file_url}")
return (file_url,)
# Check status if available
if "status" in data:
status = data["status"]
if status == "success":
# Try alternative response format
if "outputs" in data and "files" in data["outputs"]:
files = data["outputs"]["files"]
if len(files) > 0:
file_url = files[0].get("url", "")
if file_url:
print(f"[3D Inference] Generated 3D file URL: {file_url}")
return (file_url,)
# Check for interrupt before waiting
comfy.model_management.throw_exception_if_processing_interrupted()
# Wait before next poll
for _ in range(10): # 10 x 0.1 second = 1 second total
comfy.model_management.throw_exception_if_processing_interrupted()
rwUtils.time.sleep(0.1)
NODE_CLASS_MAPPINGS = {
"Runware3DInference": threeDInference,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Runware3DInference": "Runware 3D Inference",
}
+49
View File
@@ -0,0 +1,49 @@
from .utils import runwareUtils as rwUtils
class threeDInferenceInputs:
"""3D Inference Inputs node for configuring 3D generation inputs"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"optional": {
"image": ("IMAGE", {
"tooltip": "Input image for 3D inference. This image will be used as the source for generating the 3D model."
}),
"mask": ("IMAGE", {
"tooltip": "Optional mask image to specify a region of interest in the input image for 3D generation."
}),
}
}
DESCRIPTION = "Configure custom inputs for Runware 3D Inference, including image and mask inputs."
FUNCTION = "createInputs"
RETURN_TYPES = ("RUNWARE3DINFERENCEINPUTS",)
RETURN_NAMES = ("Inputs",)
CATEGORY = "Runware"
def createInputs(self, **kwargs):
"""Create 3D inference inputs from provided parameters"""
image = kwargs.get("image", None)
mask = kwargs.get("mask", None)
inputs = {}
if image is not None:
inputs["image"] = rwUtils.convertTensor2IMG(image)
if mask is not None:
inputs["mask"] = rwUtils.convertTensor2IMG(mask)
return (inputs,)
NODE_CLASS_MAPPINGS = {
"Runware3DInferenceInputs": threeDInferenceInputs,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Runware3DInferenceInputs": "Runware 3D Inference Inputs",
}
+25
View File
@@ -295,6 +295,18 @@ def sendMediaUUID(mediaUUID, nodeID):
},
)
def sendSave3DFilepath(filepath, nodeID):
PromptServer.instance.send_sync(
"runwareSave3DFilepath",
{
"success": True,
"filepath": filepath,
"nodeID": nodeID,
"widgetName": "filepath",
},
)
def sendImageCaption(captionText, nodeID):
PromptServer.instance.send_sync(
"runwareImageCaption",
@@ -317,6 +329,19 @@ def sendVideoTranscription(transcriptionText, nodeID):
},
)
def sendVideoOutputs(draftId, videoId, nodeID):
"""Send draftId and videoId to frontend for display in Video Inference Outputs node textboxes."""
PromptServer.instance.send_sync(
"runwareVideoOutputs",
{
"success": True,
"draftId": draftId or "",
"videoId": videoId or "",
"nodeID": nodeID,
},
)
def inferenecRequest(genConfig):
global RUNWARE_API_KEY, RUNWARE_API_BASE_URL, SESSION_TIMEOUT
RUNWARE_API_KEY = os.getenv("RUNWARE_API_KEY")
+2 -1
View File
@@ -125,7 +125,8 @@ class videoBgRemoval:
if status == "success":
if "videoURL" in videoData or "videoBase64Data" in videoData:
videos = rwUtils.convertVideoB64List(pollResult, 1920, 1080)
return videos
# SaveVideo expects a single video object, not a tuple
return (videos[0],) if len(videos) > 0 else (None,)
comfy.model_management.throw_exception_if_processing_interrupted()
+6 -4
View File
@@ -141,8 +141,8 @@ class txt2vid:
DESCRIPTION = "Generates Videos Lightning Fast With Runware Video Inference Engine."
FUNCTION = "generateVideo"
RETURN_TYPES = ("VIDEO", "RUNWARETASK")
RETURN_NAMES = ("VIDEO", "RW-Task")
RETURN_TYPES = ("VIDEO", "RUNWARETASK", "RUNWARETASK")
RETURN_NAMES = ("VIDEO", "RW-Task", "OUTPUT")
CATEGORY = "Runware"
def generateVideo(self, **kwargs):
@@ -325,7 +325,7 @@ class txt2vid:
genConfig[0]["acceleration"] = acceleration
if (multiInferenceMode):
return (None, genConfig)
return (None, genConfig, None)
else:
try:
# Debug: Print the request being sent
@@ -388,7 +388,9 @@ class txt2vid:
if status == "success":
if "videoURL" in video_data or "videoBase64Data" in video_data:
videos = rwUtils.convertVideoB64List(pollResult, width, height)
return videos
# SaveVideo expects a single video object, not a tuple
video_output = videos[0] if len(videos) > 0 else None
return (video_output, genConfig, pollResult)
# If status is "processing", continue polling
+18
View File
@@ -43,6 +43,16 @@ class videoInferenceInputs:
"Reference Voices": ("RUNWAREREFERENCEVOICES", {
"tooltip": "Connect the Reference Voices node to provide reference voice URLs or mediaUUIDs."
}),
"draftId": ("STRING", {
"default": "",
"multiline": False,
"tooltip": "Draft ID from a previous Bytedance video generation. Used when continuing or extending a draft output",
}),
"videoId": ("STRING", {
"default": "",
"multiline": False,
"tooltip": "Video ID from a previous generation. Used when extending or editing an existing OpenAI Sora video",
}),
}
}
@@ -65,6 +75,8 @@ class videoInferenceInputs:
references = kwargs.get("References", None)
referenceVideos = kwargs.get("Reference Videos", None)
referenceVoices = kwargs.get("Reference Voices", None)
draftId = kwargs.get("draftId", "")
videoId = kwargs.get("videoId", "")
inputs = {}
@@ -112,4 +124,10 @@ class videoInferenceInputs:
if referenceVoices is not None and len(referenceVoices) > 0:
inputs["referenceVoices"] = referenceVoices
if draftId is not None and draftId.strip() != "":
inputs["draftId"] = draftId.strip()
if videoId is not None and videoId.strip() != "":
inputs["videoId"] = videoId.strip()
return (inputs,)
+10 -13
View File
@@ -47,6 +47,7 @@ class videoModelSearch:
"pixverse:1@3 (PixVerse v4.5)",
"pixverse:1@5-fast (PixVerse v5 Fast)",
"pixverse:1@6 (PixVerse v5.5)",
"pixverse:1@7 (PixVerse v5.6)",
"pixverse:lipsync@1 (PixVerse LipSync)",
],
"Vidu": [
@@ -148,6 +149,7 @@ class videoModelSearch:
"pixverse:1@3": {"width": 640, "height": 360},
"pixverse:1@5-fast": {"width": 640, "height": 360},
"pixverse:1@6": {"width": 640, "height": 360},
"pixverse:1@7": {"width": 640, "height": 360},
"pixverse:lipsync@1": {"width": 640, "height": 360},
# Vidu Models
@@ -248,6 +250,7 @@ class videoModelSearch:
"pixverse:1@3": "360p",
"pixverse:1@5-fast": "360p",
"pixverse:1@6": "360p",
"pixverse:1@7": "360p",
"pixverse:lipsync@1": "360p",
# Vidu Models
@@ -390,8 +393,8 @@ class videoModelSearch:
DESCRIPTION = "Directly Search and Connect Video Models to Runware Video Inference Nodes In ComfyUI."
FUNCTION = "videoModelSearch"
RETURN_TYPES = ("RUNWAREVIDEOMODEL", "INT", "INT")
RETURN_NAMES = ("Runware Video Model", "Width", "Height")
RETURN_TYPES = ("RUNWAREVIDEOMODEL",)
RETURN_NAMES = ("Runware Video Model",)
CATEGORY = "Runware"
@classmethod
@@ -421,33 +424,27 @@ class videoModelSearch:
# Use custom dimensions from Width/Height widgets
width = customWidth
height = customHeight
returnWidth = width
returnHeight = height
elif useCustomDimensions == "Disabled":
# Don't pass width/height (use None in dict, but return 0 for INT output)
# Don't pass width/height (use None in dict)
width = None
height = None
returnWidth = 0
returnHeight = 0
else: # "Model Default"
# Use model default dimensions
width = dimensions["width"]
height = dimensions["height"]
returnWidth = width
returnHeight = height
resultDict = {
"model": modelAirCode,
"useCustomDimensions": useCustomDimensions,
"width": width,
"height": height,
}
# Add resolution if enabled and model supports it
if useResolution and modelResolution is not None:
resultDict["resolution"] = resolution
resultDict["useResolution"] = True
else:
resultDict["useResolution"] = False
return (resultDict, returnWidth, returnHeight)
return (resultDict,)
+75
View File
@@ -0,0 +1,75 @@
from .utils import runwareUtils as rwUtils
class RunwareVideoInferenceOutputs:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"Output": (
"RUNWARETASK",
{
"tooltip": "Connect the OUTPUT from Runware Video Inference.",
},
),
},
"optional": {
"draftId": ("STRING", {
"placeholder": "Draft ID will appear here after generation.",
"tooltip": "This field will be automatically populated with the draft ID from the video inference output.",
}),
"videoId": ("STRING", {
"placeholder": "Video ID will appear here after generation.",
"tooltip": "This field will be automatically populated with the video ID from the video inference output.",
}),
},
"hidden": {"node_id": "UNIQUE_ID"},
}
DESCRIPTION = "Extracts Runware video inference outputs such as draftId and videoId."
FUNCTION = "get_outputs"
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("draftID", "videoID")
CATEGORY = "Runware"
OUTPUT_NODE = True # Ensure node executes even if outputs aren't used
def get_outputs(self, Output, draftId=None, videoId=None, **kwargs):
draft_id = ""
video_id = ""
try:
data_list = None
# Handle different possible response shapes defensively
if isinstance(Output, dict):
if "data" in Output:
data_list = Output.get("data", [])
elif "response" in Output and isinstance(Output["response"], dict):
data_list = Output["response"].get("data", [])
if data_list and len(data_list) > 0:
first = data_list[0]
outputs = first.get("outputs") or {}
if isinstance(outputs, dict):
# draftId is used by some providers
draft_id = outputs.get("draftId", "") or outputs.get("draftID", "")
# videoId / videoID used by other providers
video_id = outputs.get("videoId", "") or outputs.get("videoID", "")
# Send to frontend so values appear in textboxes (like Media Upload)
rwUtils.sendVideoOutputs(draft_id, video_id, kwargs.get("node_id"))
except Exception as e:
print(f"[Runware Video Outputs] Failed to parse outputs: {e}")
return (draft_id, video_id)
NODE_CLASS_MAPPINGS = {
"RunwareVideoInferenceOutputs": RunwareVideoInferenceOutputs,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RunwareVideoInferenceOutputs": "Runware Video Inference Outputs",
}
+2 -1
View File
@@ -121,7 +121,8 @@ class videoUpscaler:
if status == "success":
if "mediaURL" in video_data or "videoURL" in video_data or "videoBase64Data" in video_data:
videos = rwUtils.convertVideoB64List(pollResult, 1920, 1080)
return videos
# SaveVideo expects a single video object, not a tuple
return (videos[0],) if len(videos) > 0 else (None,)
# If status is "processing", continue polling