Merge branch 'main' into feature-ACE-1-5-Step

Resolve conflicts in audioInference.py (keep 8/16 kbps bitrate with main's OGG tooltip) and settings.py (restore renderingSpeed/magicPrompt handling from main).
This commit is contained in:
Sirshendu Ganguly
2026-05-15 17:32:47 +05:30
17 changed files with 506 additions and 61 deletions
+10
View File
@@ -53,6 +53,11 @@ from .modules.textInferenceMessages import RunwareTextInferenceMessages
from .modules.textInferenceMessage import RunwareTextInferenceMessage
from .modules.textInference import RunwareTextInference
from .modules.textInferenceSettings import RunwareTextInferenceSettings
from .modules.textInferenceToolChoice import RunwareTextInferenceToolChoice
from .modules.textInferenceSettingsCache import RunwareTextInferenceSettingsCache
from .modules.textInferenceInputs import RunwareTextInferenceInputs
from .modules.textInferenceInputsImages import RunwareTextInferenceInputsImages
from .modules.textInferenceInputsVideos import RunwareTextInferenceInputsVideos
from .modules.audioSections import RunwareAudioSections
from .modules.audioInferenceInputs import audioInferenceInputs
from .modules.audioSettings import RunwareAudioSettings
@@ -177,6 +182,11 @@ NODE_CLASS_MAPPINGS = {
"Runware Text Inference Message": RunwareTextInferenceMessage,
"Runware Text Inference": RunwareTextInference,
"Runware Text Inference Settings": RunwareTextInferenceSettings,
"Runware Text Inference Tool Choice": RunwareTextInferenceToolChoice,
"Runware Text Inference Settings Cache": RunwareTextInferenceSettingsCache,
"Runware Text Inference Inputs": RunwareTextInferenceInputs,
"Runware Text Inference Inputs Images": RunwareTextInferenceInputsImages,
"Runware Text Inference Inputs Videos": RunwareTextInferenceInputsVideos,
"Runware Audio Sections": RunwareAudioSections,
"Runware Audio Inference Inputs": audioInferenceInputs,
"Runware Audio Inference Settings": RunwareAudioSettings,
+25
View File
@@ -89,6 +89,11 @@ const RUNWARE_NODE_TYPES = {
TEXTINFERENCEMESSAGES: "Runware Text Inference Messages",
TEXTINFERENCEMESSAGE: "Runware Text Inference Message",
TEXTINFERENCESETTINGS: "Runware Text Inference Settings",
TEXTINFERENCETOOLCHOICE: "Runware Text Inference Tool Choice",
TEXTINFERENCESETTINGSCACHE: "Runware Text Inference Settings Cache",
TEXTINFERENCEINPUTS: "Runware Text Inference Inputs",
TEXTINFERENCEINPUTSIMAGES: "Runware Text Inference Inputs Images",
TEXTINFERENCEINPUTSVIDEOS: "Runware Text Inference Inputs Videos",
AUDIOSECTIONS: "Runware Audio Sections",
AUDIOINFERENCEINPUTS: "Runware Audio Inference Inputs",
AUDIOSETTINGS: "Runware Audio Inference Settings",
@@ -328,6 +333,26 @@ const RUNWARE_NODE_PROPS = {
bgColor: DEFAULT_BGCOLOR,
colorModeOnly: true,
},
[RUNWARE_NODE_TYPES.TEXTINFERENCETOOLCHOICE]: {
bgColor: DEFAULT_BGCOLOR,
colorModeOnly: true,
},
[RUNWARE_NODE_TYPES.TEXTINFERENCESETTINGSCACHE]: {
bgColor: DEFAULT_BGCOLOR,
colorModeOnly: true,
},
[RUNWARE_NODE_TYPES.TEXTINFERENCEINPUTSIMAGES]: {
bgColor: DEFAULT_BGCOLOR,
colorModeOnly: true,
},
[RUNWARE_NODE_TYPES.TEXTINFERENCEINPUTSVIDEOS]: {
bgColor: DEFAULT_BGCOLOR,
colorModeOnly: true,
},
[RUNWARE_NODE_TYPES.TEXTINFERENCEINPUTS]: {
bgColor: DEFAULT_BGCOLOR,
colorModeOnly: true,
},
[RUNWARE_NODE_TYPES.TEXTINFERENCESETTINGS]: {
bgColor: DEFAULT_BGCOLOR,
colorModeOnly: true,
+19 -3
View File
@@ -1360,8 +1360,6 @@ function textInferenceSettingsToggleHandler(settingsNode) {
const frequencyPenaltyWidget = settingsNode.widgets.find(w => w && w.name === "frequencyPenalty");
const useToolsWidget = settingsNode.widgets.find(w => w && w.name === "useTools");
const toolsWidget = settingsNode.widgets.find(w => w && w.name === "tools");
const useToolChoiceWidget = settingsNode.widgets.find(w => w && w.name === "useToolChoice");
const toolChoiceWidget = settingsNode.widgets.find(w => w && w.name === "toolChoice");
function toggleWidgetState(useWidget, paramWidget) {
if (!useWidget || !paramWidget) return;
@@ -1388,7 +1386,6 @@ function textInferenceSettingsToggleHandler(settingsNode) {
if (usePresencePenaltyWidget && presencePenaltyWidget) toggleWidgetState(usePresencePenaltyWidget, presencePenaltyWidget);
if (useFrequencyPenaltyWidget && frequencyPenaltyWidget) toggleWidgetState(useFrequencyPenaltyWidget, frequencyPenaltyWidget);
if (useToolsWidget && toolsWidget) toggleWidgetState(useToolsWidget, toolsWidget);
if (useToolChoiceWidget && toolChoiceWidget) toggleWidgetState(useToolChoiceWidget, toolChoiceWidget);
}
function videoSettingsToggleHandler(settingsNode) {
@@ -3281,9 +3278,17 @@ function textModelSearchFilterHandler(textModelSearchNode) {
"openai:gpt@5.4-mini (GPT-5.4 Mini)",
"openai:gpt@5.4-nano (GPT-5.4 Nano)",
],
"Anthropic": [
"anthropic:claude@opus-4.7 (Claude Opus 4.7)",
"anthropic:claude@sonnet-4.6 (Claude Sonnet 4.6)",
"anthropic:claude@haiku-4.5 (Claude Haiku 4.5)",
],
"Google": [
"google:gemini@3-flash (Gemini 3 Flash)",
],
"MoonshotAI": [
"moonshotai:kimi@k2.6 (Kimi K2.6)",
],
"Zai": [
"zai-glm-5-1 (ZAI GLM 5.1)",
],
@@ -3359,6 +3364,7 @@ function audioModelSearchFilterHandler(audioModelSearchNode) {
"minimax:music@2.6 (MiniMax Music 2.6)",
],
"Inworld": [
"inworld:tts@2 (Inworld Realtime TTS 2)",
"inworld:tts@1.5-mini (Inworld TTS-1.5 Mini)",
"inworld:tts@1.5-max (Inworld TTS-1.5 Max)",
],
@@ -5137,6 +5143,10 @@ function settingsToggleHandler(settingsNode) {
const thinkingLevelWidget = settingsNode.widgets.find(w => w.name === "thinkingLevel");
const useSequentialWidget = settingsNode.widgets.find(w => w.name === "useSequential");
const sequentialWidget = settingsNode.widgets.find(w => w.name === "sequential");
const useRenderingSpeedWidget = settingsNode.widgets.find(w => w.name === "useRenderingSpeed");
const renderingSpeedWidget = settingsNode.widgets.find(w => w.name === "renderingSpeed");
const useMagicPromptWidget = settingsNode.widgets.find(w => w.name === "useMagicPrompt");
const magicPromptWidget = settingsNode.widgets.find(w => w.name === "magicPrompt");
// Helper function to toggle widget enabled state
function toggleWidgetState(useWidget, paramWidget, paramName) {
@@ -5223,6 +5233,12 @@ function settingsToggleHandler(settingsNode) {
if (useSequentialWidget && sequentialWidget) {
toggleWidgetState(useSequentialWidget, sequentialWidget, "sequential");
}
if (useRenderingSpeedWidget && renderingSpeedWidget) {
toggleWidgetState(useRenderingSpeedWidget, renderingSpeedWidget, "renderingSpeed");
}
if (useMagicPromptWidget && magicPromptWidget) {
toggleWidgetState(useMagicPromptWidget, magicPromptWidget, "magicPrompt");
}
}
function safetyInputsToggleHandler(safetyNode) {
+90 -36
View File
@@ -5,6 +5,7 @@ import numpy as np
import librosa
import tempfile
import os
from urllib.parse import urlparse
import comfy.model_management
from .utils import runwareUtils as rwUtils
@@ -22,10 +23,6 @@ class RunwareAudioInference:
"model": ("RUNWAREAUDIOMODEL", {
"tooltip": "AI model to use for audio generation"
}),
"usePositivePrompt": ("BOOLEAN", {
"default": True,
"tooltip": "Enable/disable positivePrompt parameter in API request"
}),
"positivePrompt": ("STRING", {
"multiline": True,
"default": "classical piano piece, gentle and melodic",
@@ -51,17 +48,17 @@ class RunwareAudioInference:
"default": False,
"tooltip": "Enable/disable sampleRate parameter in API request"
}),
"sampleRate": ([8000, 16000, 22050, 24000, 32000, 44100], {
"sampleRate": ([8000, 16000, 22050, 24000, 32000, 44100, 48000], {
"default": 32000,
"tooltip": "Audio sample rate in Hz. Supported: 8000, 16000, 22050, 24000, 32000, 44100."
"tooltip": "Audio sample rate in Hz. Supported: 8000, 16000, 22050, 24000, 32000, 44100, 48000.",
}),
"useBitrate": ("BOOLEAN", {
"default": False,
"tooltip": "Enable/disable bitrate parameter in API request (MP3 only)"
"tooltip": "Enable/disable bitrate parameter in API request"
}),
"bitrate": ([8, 16, 32, 64, 128, 256], {
"default": 128,
"tooltip": "Audio bitrate in kbps. Allowed: 8, 16, 32, 64, 128, 256. Only applies to MP3 format."
"tooltip": "Audio bitrate in kbps. Allowed: 8, 16, 32, 64, 128, 256. Applies to compressed formats (e.g. MP3, OGG).",
}),
"useChannels": ("BOOLEAN", {
"default": False,
@@ -74,10 +71,6 @@ class RunwareAudioInference:
"step": 1,
"tooltip": "Number of audio channels (1=mono, 2=stereo)"
}),
"outputType": (["URL", "dataURI", "base64Data"], {
"default": "URL",
"tooltip": "Output type for the generated audio"
}),
"outputFormat": (["MP3", "MP4", "WAV", "FLAC", "OGG"], {
"default": "MP3",
"tooltip": "Format of the output audio"
@@ -219,13 +212,16 @@ class RunwareAudioInference:
status = audioData["status"]
if status == "success":
audioUrl = self._extractAudioUrl(pollResult)
hasAudio = audioUrl is not None
audioUrls = self._extractAudioUrls(pollResult)
hasAudio = len(audioUrls) > 0
hasVideo = bool(audioData.get("videoURL") or audioData.get("videoBase64Data", False))
if hasAudio:
audioObj = self._downloadAndProcessAudio(audioUrl, params["sampleRate"])
print(f"[DEBUG] Audio URL found, returning audio. Audio URL: {audioUrl}")
audioObjects = []
for audioUrl in audioUrls:
audioObjects.append(self._downloadAndProcessAudio(audioUrl, params["sampleRate"], params["outputFormat"]))
audioObj = self._mergeAudioObjects(audioObjects, params["sampleRate"])
print(f"[DEBUG] Audio URL(s) found, returning batched audio. Count: {len(audioUrls)}")
# Return empty video object when only audio is present (prevents errors in downstream nodes)
emptyVideoObj = rwUtils.VideoObject("", width=0, height=0)
return (audioObj, emptyVideoObj)
@@ -260,7 +256,6 @@ class RunwareAudioInference:
"""Extract and validate parameters from kwargs"""
return {
"positivePrompt": kwargs.get("positivePrompt", ""),
"usePositivePrompt": kwargs.get("usePositivePrompt", True),
"model": kwargs.get("model", ""),
"duration": kwargs.get("duration", 30),
"useDuration": kwargs.get("useDuration", True),
@@ -270,7 +265,6 @@ class RunwareAudioInference:
"useBitrate": kwargs.get("useBitrate", False),
"channels": kwargs.get("channels", 2),
"useChannels": kwargs.get("useChannels", False),
"outputType": kwargs.get("outputType", "URL"),
"outputFormat": kwargs.get("outputFormat", "MP3"),
"negativePrompt": kwargs.get("negativePrompt", ""),
"numberResults": kwargs.get("numberResults", 1),
@@ -310,7 +304,7 @@ class RunwareAudioInference:
"taskType": "audioInference",
"taskUUID": taskUuid,
"model": params["model"],
"outputType": params["outputType"],
"outputType": "URL",
"outputFormat": params["outputFormat"],
"deliveryMethod": "async",
"includeCost": True,
@@ -338,8 +332,8 @@ class RunwareAudioInference:
params["useDuration"] = False
print(f"[DEBUG] Disabled duration because sections are provided")
# Add prompts conditionally
if params["usePositivePrompt"]:
# Add positivePrompt only when non-empty
if len(params["positivePrompt"]) > 0:
genConfig[0]["positivePrompt"] = params["positivePrompt"]
print(f"[DEBUG] Added positivePrompt: '{params['positivePrompt']}'")
else:
@@ -447,26 +441,68 @@ class RunwareAudioInference:
if not hasAudio and not hasVideo:
raise Exception("No audio or video data received from API")
def _extractAudioUrl(self, genResult):
"""Extract audio URL from API response. Returns None if no audio URL is present (e.g., when only video is returned)."""
audioData = genResult["data"][0]
audioUrl = audioData.get("audioURL", "")
if not audioUrl:
audioUrl = audioData.get("audioDataURI", "")
if not audioUrl:
audioUrl = audioData.get("audioBase64Data", "")
def _extractAudioUrls(self, genResult):
"""Extract all available audio URLs from API response."""
audioUrls = []
for item in genResult.get("data", []):
audioUrl = item.get("audioURL", "") or item.get("audioDataURI", "") or item.get("audioBase64Data", "")
if audioUrl:
audioUrls.append(audioUrl)
return audioUrls
return audioUrl if audioUrl else None
def _mergeAudioObjects(self, audioObjects, targetSampleRate):
"""Merge multiple audio objects into a single batched audio object."""
if not audioObjects:
return {
"waveform": torch.zeros((1, 1, 1)),
"sample_rate": targetSampleRate,
"format": "wav"
}
def _downloadAndProcessAudio(self, audioUrl, targetSampleRate):
waveforms = []
maxChannels = 0
maxSamples = 0
for audioObj in audioObjects:
waveform = audioObj["waveform"]
if waveform.dim() == 2:
waveform = waveform.unsqueeze(0)
waveforms.append(waveform)
maxChannels = max(maxChannels, waveform.shape[1])
maxSamples = max(maxSamples, waveform.shape[2])
paddedWaveforms = []
for waveform in waveforms:
batchSize, channels, samples = waveform.shape
if channels < maxChannels:
channelPadding = torch.zeros(
(batchSize, maxChannels - channels, samples),
dtype=waveform.dtype,
device=waveform.device
)
waveform = torch.cat([waveform, channelPadding], dim=1)
if samples < maxSamples:
waveform = torch.nn.functional.pad(waveform, (0, maxSamples - samples))
paddedWaveforms.append(waveform)
outputFormat = audioObjects[0].get("format", "wav")
return {
"waveform": torch.cat(paddedWaveforms, dim=0),
"sample_rate": targetSampleRate,
"format": outputFormat
}
def _downloadAndProcessAudio(self, audioUrl, targetSampleRate, outputFormat):
"""Download audio file and process it for ComfyUI"""
try:
response = requests.get(audioUrl, timeout=30)
response.raise_for_status()
with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as tempFile:
tempSuffix = self._inferAudioSuffix(audioUrl, outputFormat)
with tempfile.NamedTemporaryFile(suffix=tempSuffix, delete=False) as tempFile:
tempFile.write(response.content)
tempFilePath = tempFile.name
@@ -477,7 +513,8 @@ class RunwareAudioInference:
return {
"waveform": waveformTensor,
"sample_rate": targetSampleRate
"sample_rate": targetSampleRate,
"format": tempSuffix.lstrip(".")
}
finally:
if os.path.exists(tempFilePath):
@@ -485,6 +522,23 @@ class RunwareAudioInference:
except Exception as e:
raise Exception(f"Failed to download audio: {e}")
def _inferAudioSuffix(self, audioUrl, outputFormat):
"""Infer temporary audio file extension from URL or requested format."""
parsedPath = urlparse(audioUrl).path if audioUrl else ""
ext = os.path.splitext(parsedPath)[1].lower()
allowed = {".mp3", ".mp4", ".wav", ".flac", ".ogg"}
if ext in allowed:
return ext
formatMap = {
"MP3": ".mp3",
"MP4": ".mp4",
"WAV": ".wav",
"FLAC": ".flac",
"OGG": ".ogg",
}
return formatMap.get(str(outputFormat).upper(), ".wav")
def _loadAudioFile(self, filePath):
"""Load audio file using librosa"""
waveformNp, originalSampleRate = librosa.load(filePath, sr=None, mono=False)
+1
View File
@@ -40,6 +40,7 @@ class RunwareAudioModelSearch:
"minimax:music@2.6 (MiniMax Music 2.6)",
],
"Inworld": [
"inworld:tts@2 (Inworld Realtime TTS 2)",
"inworld:tts@1.5-mini (Inworld TTS-1.5 Mini)",
"inworld:tts@1.5-max (Inworld TTS-1.5 Max)",
],
+28 -3
View File
@@ -2,7 +2,8 @@
Runware Image Inference Settings (registered as Runware Settings for workflow compatibility).
Provides settings for image generation including temperature, systemPrompt, topP, layers,
quality, background, style, search, promptExtend, editRegions, thinking (boolean),
thinkingLevel (high/medium/low), sequential, and colorPalette
thinkingLevel (high/medium/low), sequential, renderingSpeed (TURBO/DEFAULT/QUALITY),
magicPrompt (AUTO/ON/OFF), and colorPalette
(from Runware Image Inference Settings Color Palette).
"""
@@ -155,6 +156,22 @@ class RunwareSettings:
"label_on": "true",
"label_off": "false",
}),
"useRenderingSpeed": ("BOOLEAN", {
"tooltip": "Enable to include renderingSpeed in settings.",
"default": False,
}),
"renderingSpeed": (["TURBO", "DEFAULT", "QUALITY"], {
"default": "DEFAULT",
"tooltip": "The rendering speed to use. Only used when 'Use Rendering Speed' is enabled.",
}),
"useMagicPrompt": ("BOOLEAN", {
"tooltip": "Enable to include magicPrompt in settings.",
"default": False,
}),
"magicPrompt": (["AUTO", "ON", "OFF"], {
"default": "AUTO",
"tooltip": "Determine if MagicPrompt should be used. Only used when 'Use Magic Prompt' is enabled.",
}),
}
}
@@ -164,7 +181,8 @@ class RunwareSettings:
CATEGORY = "Runware"
DESCRIPTION = (
"Configure general settings for image generation: temperature, system prompt, top-p, layers, quality, background, style, search, "
"promptExtend, editRegions (JSON), thinking (boolean), thinkingLevel (high/medium/low), sequential, and optional colorPalette from the Color Palette node."
"promptExtend, editRegions (JSON), thinking (boolean), thinkingLevel (high/medium/low), sequential, "
"renderingSpeed (TURBO/DEFAULT/QUALITY), magicPrompt (AUTO/ON/OFF), and optional colorPalette from the Color Palette node."
)
def createSettings(self, **kwargs) -> tuple[Dict[str, Any]]:
@@ -186,6 +204,8 @@ class RunwareSettings:
useThinking = kwargs.get("useThinking", False)
useThinkingLevel = kwargs.get("useThinkingLevel", False)
useSequential = kwargs.get("useSequential", False)
useRenderingSpeed = kwargs.get("useRenderingSpeed", False)
useMagicPrompt = kwargs.get("useMagicPrompt", False)
# Get value parameters
temperature = kwargs.get("temperature", 1.0)
@@ -196,6 +216,8 @@ class RunwareSettings:
quality = kwargs.get("quality", "medium")
background = kwargs.get("background", "auto")
style = kwargs.get("style", "auto")
renderingSpeed = kwargs.get("renderingSpeed", "DEFAULT")
magicPrompt = kwargs.get("magicPrompt", "AUTO")
# Build settings dictionary - only include what is enabled
settings: Dict[str, Any] = {}
@@ -246,7 +268,10 @@ class RunwareSettings:
if palette is not None and isinstance(palette, list) and len(palette) > 0:
settings["colorPalette"] = palette
if useRenderingSpeed:
settings["renderingSpeed"] = str(renderingSpeed)
if useMagicPrompt:
settings["magicPrompt"] = str(magicPrompt)
# Clean up None values
settings = {k: v for k, v in settings.items() if v is not None}
+15 -1
View File
@@ -25,6 +25,12 @@ class RunwareTextInference:
"providerSettings": ("RUNWAREPROVIDERSETTINGS", {
"tooltip": "Optional provider-specific settings (wrapped by provider prefix from the model AIR).",
}),
"toolChoice": ("RUNWARETEXTINFERENCETOOLCHOICE", {
"tooltip": "Connect Runware Text Inference Tool Choice for tool-calling strategy (auto / any / tool / none).",
}),
"inputs": ("RUNWARETEXTINFERENCEINPUTS", {
"tooltip": "Connect Runware Text Inference Inputs for inputs.images / inputs.videos (URLs or mediaUUIDs).",
}),
},
}
@@ -32,7 +38,7 @@ class RunwareTextInference:
RETURN_NAMES = ("text",)
FUNCTION = "infer_text"
CATEGORY = "Runware/Text"
DESCRIPTION = "Run text/chat inference using Runware textInference. Connect Text Model and Text Inference Messages."
DESCRIPTION = "Run text/chat inference using Runware textInference. Connect Text Model and Text Inference Messages. Optional: Settings, provider settings, tool choice, inputs (images/videos)."
@staticmethod
def _extract_text_from_data_item(item: Dict[str, Any]) -> Optional[str]:
@@ -70,6 +76,8 @@ class RunwareTextInference:
messages: List[Dict[str, str]] = kwargs.get("messages") or []
text_settings = kwargs.get("settings")
provider_settings = kwargs.get("providerSettings")
tool_choice = kwargs.get("toolChoice")
text_inputs = kwargs.get("inputs")
if not model:
raise Exception("model is empty.")
@@ -94,6 +102,12 @@ class RunwareTextInference:
provider_name = model.split(":")[0] if ":" in model else model
task["providerSettings"] = {provider_name: provider_settings}
if tool_choice is not None and isinstance(tool_choice, dict) and len(tool_choice) > 0:
task["toolChoice"] = tool_choice
if text_inputs is not None and isinstance(text_inputs, dict) and len(text_inputs) > 0:
task["inputs"] = dict(text_inputs)
gen_config = [task]
print(f"[Runware Text Inference] Request: {rwUtils.safe_json_dumps(gen_config, indent=2)}")
+52
View File
@@ -0,0 +1,52 @@
"""Runware Text Inference Inputs — merge Images + Videos blocks into task.inputs for textInference."""
from typing import Any, Dict, Tuple
class RunwareTextInferenceInputs:
"""Merge Runware Text Inference Inputs Images and/or Videos into a single inputs dict."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"optional": {
"images": ("RUNWARETEXTINFERENCEINPUTSIMAGES", {
"tooltip": "Connect Runware Text Inference Inputs Images for inputs.images[].",
}),
"videos": ("RUNWARETEXTINFERENCEINPUTSVIDEOS", {
"tooltip": "Connect Runware Text Inference Inputs Videos for inputs.videos[].",
}),
},
}
RETURN_TYPES = ("RUNWARETEXTINFERENCEINPUTS",)
RETURN_NAMES = ("inputs",)
FUNCTION = "merge_inputs"
CATEGORY = "Runware/Text"
DESCRIPTION = (
"Combine Runware Text Inference Inputs Images and/or Videos into inputs for Runware Text Inference "
"(inputs.images, inputs.videos)."
)
def merge_inputs(self, **kwargs) -> Tuple[Dict[str, Any], ...]:
images_block = kwargs.get("images")
videos_block = kwargs.get("videos")
out: Dict[str, Any] = {}
if isinstance(images_block, dict) and images_block.get("images"):
imgs = images_block["images"]
if isinstance(imgs, list) and len(imgs) > 0:
filtered_imgs = [str(x).strip() for x in imgs if x is not None and str(x).strip()]
if filtered_imgs:
out["images"] = filtered_imgs
if isinstance(videos_block, dict) and videos_block.get("videos"):
vids = videos_block["videos"]
if isinstance(vids, list) and len(vids) > 0:
filtered_vids = [str(x).strip() for x in vids if x is not None and str(x).strip()]
if filtered_vids:
out["videos"] = filtered_vids
return (out,)
+50
View File
@@ -0,0 +1,50 @@
"""Runware Text Inference Inputs Images — builds inputs.images from ComfyUI IMAGE tensors."""
from typing import Any, Dict, List, Tuple
from .utils import runwareUtils as rwUtils
class RunwareTextInferenceInputsImages:
"""Collect IMAGE tensors and convert them for textInference task.inputs.images.
Each connected IMAGE is converted via runwareUtils.convertTensor2IMG, which returns
either a cached mediaUUID (when caching is enabled) or a base64 data URI.
"""
_MAX_IMAGES = 8
@classmethod
def INPUT_TYPES(cls):
optional: Dict[str, tuple] = {}
for i in range(1, cls._MAX_IMAGES + 1):
ordinal = rwUtils.getOrdinal(i)
optional[f"Image {i}"] = ("IMAGE", {
"tooltip": (
f"{ordinal.capitalize()} image for the model. Connect a Load Image (or any IMAGE source) node. "
"Tensor is converted to base64 / mediaUUID before being sent to the API."
),
})
return {"required": {}, "optional": optional}
RETURN_TYPES = ("RUNWARETEXTINFERENCEINPUTSIMAGES",)
RETURN_NAMES = ("images",)
FUNCTION = "createImages"
CATEGORY = "Runware/Text"
DESCRIPTION = (
"Collect one or more IMAGE tensors (Load Image, generated images, etc.) for Runware Text Inference "
"inputs.images. Connect the output to Runware Text Inference Inputs."
)
def createImages(self, **kwargs) -> Tuple[Dict[str, Any], ...]:
urls: List[str] = []
for i in range(1, self._MAX_IMAGES + 1):
tensor = kwargs.get(f"Image {i}", None)
if tensor is None:
continue
urls.append(rwUtils.convertTensor2IMG(tensor))
if not urls:
return ({},)
return ({"images": urls},)
+51
View File
@@ -0,0 +1,51 @@
"""Runware Text Inference Inputs Videos — builds inputs.videos (URL or mediaUUID per item)."""
from typing import Any, Dict, List, Tuple
from .utils import runwareUtils as rwUtils
class RunwareTextInferenceInputsVideos:
"""Collect video references for textInference task.inputs.videos."""
_MAX_VIDEOS = 4
@classmethod
def INPUT_TYPES(cls):
optional: Dict[str, tuple] = {
"useVideos": ("BOOLEAN", {
"default": False,
"tooltip": "Enable to include inputs.videos in the text inference request.",
}),
}
for i in range(1, cls._MAX_VIDEOS + 1):
ordinal = rwUtils.getOrdinal(i)
optional[f"Video{i}"] = ("STRING", {
"default": "",
"tooltip": f"Video URL or mediaUUID for the {ordinal} video. Only used when 'Use Videos' is enabled.",
})
return {"required": {}, "optional": optional}
RETURN_TYPES = ("RUNWARETEXTINFERENCEINPUTSVIDEOS",)
RETURN_NAMES = ("videos",)
FUNCTION = "createVideos"
CATEGORY = "Runware/Text"
DESCRIPTION = (
"Collect one or more video references (URL or mediaUUID) for Runware Text Inference "
"inputs.videos. Connect the output to Runware Text Inference Inputs."
)
def createVideos(self, **kwargs) -> Tuple[Dict[str, Any], ...]:
if not kwargs.get("useVideos", False):
return ({},)
urls: List[str] = []
for i in range(1, self._MAX_VIDEOS + 1):
raw = kwargs.get(f"Video{i}", "")
if raw is not None and str(raw).strip():
urls.append(str(raw).strip())
if not urls:
return ({},)
return ({"videos": urls},)
+15 -18
View File
@@ -70,9 +70,13 @@ class RunwareTextInferenceSettings:
"tooltip": f"Top-k sampling (1–{cls._MAX_TOP_K}).",
}),
# Keep this in the original slot to avoid shifting legacy widgets_values.
"thinkingLevel": (["none", "low", "medium", "high"], {
"thinkingLevel": (["none", "low", "medium", "high", "max", "adaptive"], {
"default": "none",
"tooltip": "Extended reasoning level: none, low, medium, high.",
"tooltip": (
"Extended reasoning level: none, low, medium, high, max, adaptive. "
"none -> disabled. low/medium/high/max -> adaptive thinking with the chosen effort. "
"adaptive -> model picks budget. Legacy 'off' is mapped to 'none'."
),
}),
"useStopSequences": ("BOOLEAN", {
"default": False,
@@ -114,14 +118,8 @@ class RunwareTextInferenceSettings:
"default": "[]",
"tooltip": "JSON array for tools.",
}),
"useToolChoice": ("BOOLEAN", {
"default": False,
"tooltip": "Include toolChoice in settings.",
}),
"toolChoice": ("STRING", {
"multiline": True,
"default": "{}",
"tooltip": "JSON object for toolChoice.",
"cache": ("RUNWARETEXTINFERENCESETTINGSCACHE", {
"tooltip": "Connect Runware Text Inference Settings Cache for settings.cache.{scope, ttl}.",
}),
},
}
@@ -132,8 +130,8 @@ class RunwareTextInferenceSettings:
CATEGORY = "Runware/Text"
DESCRIPTION = (
"Optional text inference settings (systemPrompt, maxTokens, temperature, topP, topK, "
"stopSequences, presencePenalty, frequencyPenalty, tools, toolChoice, thinkingLevel). "
"Connect to Runware Text Inference."
"stopSequences, presencePenalty, frequencyPenalty, tools, thinkingLevel, cache). "
"Connect to Runware Text Inference. For tool calling, use Runware Text Inference Tool Choice on the inference node."
)
@staticmethod
@@ -191,7 +189,7 @@ class RunwareTextInferenceSettings:
"minimal": "low",
}
value = aliases.get(value, value)
if value not in ("none", "low", "medium", "high"):
if value not in ("none", "low", "medium", "high", "max", "adaptive"):
return "none"
return value
@@ -233,15 +231,14 @@ class RunwareTextInferenceSettings:
if parsed_tools:
out["tools"] = parsed_tools
if kwargs.get("useToolChoice", False):
parsed_choice = self._parse_json_setting(kwargs.get("toolChoice"), dict, "toolChoice")
if parsed_choice:
out["toolChoice"] = parsed_choice
tl = self._normalize_thinking_level(kwargs.get("thinkingLevel", "none"))
if tl != "none":
out["thinkingLevel"] = tl
cache_cfg = kwargs.get("cache")
if cache_cfg is not None and isinstance(cache_cfg, dict) and len(cache_cfg) > 0:
out["cache"] = cache_cfg
if not out:
return ({},)
+64
View File
@@ -0,0 +1,64 @@
"""
Runware Text Inference Settings Cache Node
Builds the cache object for Runware Text Inference (settings.cache.{scope, ttl}).
Maps cache_control placement (Anthropic):
- scope: where cache_control is attached
* system -> system block only
* system+history -> system block + last user message block (default)
- ttl: cache lifetime (5m or 1h)
"""
from typing import Dict, Any
class RunwareTextInferenceSettingsCache:
"""Runware Text Inference Settings Cache node."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"scope": (["system", "system+history"], {
"default": "system+history",
"tooltip": (
"Where cache_control is attached.\n"
"- system: cache the system block only.\n"
"- system+history: cache the system block and the last user message block."
),
}),
"ttl": (["5m", "1h"], {
"default": "5m",
"tooltip": "Cache lifetime: 5m (5 minutes) or 1h (1 hour).",
}),
}
}
RETURN_TYPES = ("RUNWARETEXTINFERENCESETTINGSCACHE",)
RETURN_NAMES = ("cache",)
FUNCTION = "createCache"
CATEGORY = "Runware/Text"
DESCRIPTION = (
"Build a settings.cache object (scope + ttl) and connect it to Runware Text Inference. "
"Merged into the request as settings.cache.{scope, ttl}."
)
def createCache(self, **kwargs) -> tuple[Dict[str, Any]]:
scope = (kwargs.get("scope") or "system+history").strip().lower()
if scope not in ("system", "system+history"):
scope = "system+history"
ttl = (kwargs.get("ttl") or "5m").strip().lower()
if ttl not in ("5m", "1h"):
ttl = "5m"
return ({"scope": scope, "ttl": ttl},)
NODE_CLASS_MAPPINGS = {
"RunwareTextInferenceSettingsCache": RunwareTextInferenceSettingsCache,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RunwareTextInferenceSettingsCache": "Runware Text Inference Settings Cache",
}
+73
View File
@@ -0,0 +1,73 @@
"""
Runware Text Inference Tool Choice Node
Builds the toolChoice object for Runware Text Inference (textInference).
Strategies (type):
- auto: model decides whether to call a tool (recommended default)
- any: model must call at least one tool but chooses which one
- tool: model must call the specific tool identified by `name`
- none: model will not call any tool
"""
from typing import Dict, Any
class RunwareTextInferenceToolChoice:
"""Runware Text Inference Tool Choice node."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"type": (["auto", "any", "tool", "none"], {
"default": "auto",
"tooltip": (
"Strategy the model uses to decide when and which tools to call.\n"
"- auto: model decides (recommended default).\n"
"- any: model must call at least one tool, of its choosing.\n"
"- tool: model must call the specific tool named below.\n"
"- none: model will not call any tool."
),
}),
"name": ("STRING", {
"default": "",
"tooltip": "Name of the specific tool the model must call. Required when type is 'tool'; ignored otherwise.",
}),
}
}
RETURN_TYPES = ("RUNWARETEXTINFERENCETOOLCHOICE",)
RETURN_NAMES = ("toolChoice",)
FUNCTION = "createToolChoice"
CATEGORY = "Runware/Text"
DESCRIPTION = (
"Build a toolChoice object (type + optional tool name) and connect it to "
"Runware Text Inference. Required when type is 'tool'."
)
def createToolChoice(self, **kwargs) -> tuple[Dict[str, Any]]:
choice_type = (kwargs.get("type") or "auto").strip().lower()
if choice_type not in ("auto", "any", "tool", "none"):
choice_type = "auto"
name = (kwargs.get("name") or "").strip()
result: Dict[str, Any] = {"type": choice_type}
if choice_type == "tool":
if not name:
raise Exception(
"Tool Choice: 'name' is required when type is 'tool'. "
"Provide the exact tool name the model must call."
)
result["name"] = name
return (result,)
NODE_CLASS_MAPPINGS = {
"RunwareTextInferenceToolChoice": RunwareTextInferenceToolChoice,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RunwareTextInferenceToolChoice": "Runware Text Inference Tool Choice",
}
+10
View File
@@ -13,9 +13,17 @@ class RunwareTextModelSearch:
"openai:gpt@5.4-mini (GPT-5.4 Mini)",
"openai:gpt@5.4-nano (GPT-5.4 Nano)",
],
"Anthropic": [
"anthropic:claude@opus-4.7 (Claude Opus 4.7)",
"anthropic:claude@sonnet-4.6 (Claude Sonnet 4.6)",
"anthropic:claude@haiku-4.5 (Claude Haiku 4.5)",
],
"Google": [
"google:gemini@3-flash (Gemini 3 Flash)",
],
"MoonshotAI": [
"moonshotai:kimi@k2.6 (Kimi K2.6)",
],
"Zai": [
"zai-glm-5-1 (ZAI GLM 5.1)",
],
@@ -23,8 +31,10 @@ class RunwareTextModelSearch:
MODEL_PROVIDERS = [
"All",
"Anthropic",
"Google",
"MiniMax",
"MoonshotAI",
"OpenAI",
"Zai",
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long