Files

1388 lines
56 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from openai import OpenAI
import time
import json
import os
import re
import torch
from PIL import Image
import numpy as np
import base64
from .utils import generate_request_id, log_prepare, log_complete, log_error, ProgressBar
ENV_KEYS_API_KEY = ["COMFYUI_RN_API_KEY", "COMFLY_API_KEY", "RUNNODE_API_KEY", "RN_API_KEY", "LLM_API_KEY", "OPENAI_API_KEY", "DEEPSEEK_API_KEY"]
ENV_KEYS_BASE_URL = ["COMFYUI_RN_BASE_URL", "COMFLY_BASE_URL", "RUNNODE_BASE_URL", "RN_BASE_URL", "LLM_API_BASEURL", "OPENAI_BASE_URL", "OPENAI_API_BASE_URL", "DEEPSEEK_API_BASE_URL"]
MODEL_MAPPING = {
"Kimi-K2.5": "moonshotai/Kimi-K2.5",
"Kimi-K2-Thinking": "moonshotai/Kimi-K2-Thinking",
"Qwen2.5-32B-Instruct": "Qwen/Qwen2.5-32B-Instruct",
"Qwen2.5-72B-Instruct": "Qwen/Qwen2.5-72B-Instruct",
"Qwen2.5-VL-32B-Instruct": "Qwen/Qwen2.5-VL-32B-Instruct",
"Qwen2.5-VL-72B-Instruct": "Qwen/Qwen2.5-VL-72B-Instruct",
"Qwen3-32B": "Qwen/Qwen3-32B",
"Qwen3-Coder-480B-A35B-Instruct": "Qwen/Qwen3-Coder-480B-A35B-Instruct",
"Qwen3-VL-30B-A3B-Instruct": "Qwen/Qwen3-VL-30B-A3B-Instruct",
"DeepSeek-V4-Pro": "deepseek-ai/DeepSeek-V4-Pro",
"DeepSeek-V4-Flash": "deepseek-ai/DeepSeek-V4-Flash",
"DeepSeek-R1": "deepseek-ai/DeepSeek-R1",
"DeepSeek-R1-Distill-Qwen-32B": "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B",
"DeepSeek-R1-0528": "deepseek-ai/DeepSeek-R1-0528",
"DeepSeek-V3": "deepseek-ai/DeepSeek-V3",
"DeepSeek-V3-0324": "deepseek-ai/DeepSeek-V3-0324",
"DeepSeek-OCR": "deepseek-ai/DeepSeek-OCR",
"GD-DeepSeek-R1": "GD/DeepSeek-R1",
"GLM-5-Turbo": "GLM-5-Turbo",
"GLM-5.1": "GLM-5.1",
"glm-4-9b-chat": "glm-4-9b-chat",
"GLM-4-32B-0414": "ZhipuAI/GLM-4-32B-0414",
"GLM-4.1V-9B-Thinking": "ZhipuAI/GLM-4.1V-9B-Thinking",
"PaddleOCR-VL-0.9B": "PaddleOCR-VL-0.9B",
"MiniMax-M2.7": "MiniMax-M2.7",
"MiniMax-M2.7-highspeed": "MiniMax-M2.7-highspeed"
}
def get_first_env(names):
for n in names:
v = os.environ.get(n)
if v is not None and str(v).strip() != "":
return v
return None
def _normalize_baseurl(u):
if not u:
return u
s = u.rstrip("/")
if s.endswith("/v1") or "/v1/" in s:
return s
return s + "/v1"
def get_config():
try:
config_paths = [
os.path.join(os.path.dirname(os.path.realpath(__file__)), "config", 'ComfyUI_rn_prompter-config.json'),
os.path.join(os.path.dirname(os.path.realpath(__file__)), "config", 'comfyui_rn_prompter-config.json')
]
config_path = None
for path in config_paths:
if os.path.exists(path):
config_path = path
break
config = {}
if config_path and os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
llm_config = config.get('llm', {})
current_provider = llm_config.get('current_provider', 'quchi')
providers = llm_config.get('providers', {})
provider_config = providers.get(current_provider, {})
def _env(names):
for n in names:
v = os.environ.get(n)
if v is not None and str(v).strip() != "":
return v
return None
def _env_float(names, default):
v = _env(names)
if v is None:
return default
try:
return float(v)
except Exception:
return default
def _env_int(names, default):
v = _env(names)
if v is None:
return default
try:
return int(v)
except Exception:
return default
# 统一的 API Key 读取逻辑 (Env > Config)
api_key = _env(["COMFYUI_RN_API_KEY", "COMFLY_API_KEY", "RUNNODE_API_KEY", "RN_API_KEY", "LLM_API_KEY", "OPENAI_API_KEY", "DEEPSEEK_API_KEY"]) or provider_config.get('api_key', '')
base_url = _env(["COMFYUI_RN_BASE_URL", "COMFLY_BASE_URL", "RUNNODE_BASE_URL", "RN_BASE_URL", "LLM_API_BASEURL", "OPENAI_BASE_URL", "OPENAI_API_BASE_URL", "DEEPSEEK_API_BASE_URL"]) or provider_config.get('base_url', '')
model = _env(["COMFYUI_RN_MODEL", "COMFLY_MODEL", "RUNNODE_MODEL", "RN_MODEL", "LLM_MODEL", "OPENAI_MODEL", "DEEPSEEK_MODEL"]) or provider_config.get('model', '')
temperature = _env_float(["COMFYUI_RN_TEMPERATURE", "COMFLY_TEMPERATURE"], provider_config.get('temperature', 0.7))
max_tokens = _env_int(["COMFYUI_RN_MAX_TOKENS", "COMFLY_MAX_TOKENS"], provider_config.get('max_tokens', 1000))
top_p = _env_float(["COMFYUI_RN_TOP_P", "COMFLY_TOP_P"], provider_config.get('top_p', 0.9))
return {
'api_key': api_key,
'model': model,
'base_url': base_url,
'temperature': temperature,
'max_tokens': max_tokens,
'top_p': top_p
}
except Exception as e:
print(f"Error loading config: {e}")
return {}
def save_config(config):
# 禁用配置保存以防止密钥泄露
# raise PermissionError("save_config is disabled")
pass
def get_vllm_config():
try:
config_paths = [
os.path.join(os.path.dirname(os.path.realpath(__file__)), "config", 'ComfyUI_rn_prompter-config.json'),
os.path.join(os.path.dirname(os.path.realpath(__file__)), "config", 'comfyui_rn_prompter-config.json')
]
config_path = None
for path in config_paths:
if os.path.exists(path):
config_path = path
break
config = {}
if config_path and os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
vllm_config = config.get('vllm', {})
current_provider = vllm_config.get('current_provider', 'quchi')
providers = vllm_config.get('providers', {})
provider_config = providers.get(current_provider, {})
def _env(names):
for n in names:
v = os.environ.get(n)
if v is not None and str(v).strip() != "":
return v
return None
def _env_float(names, default):
v = _env(names)
if v is None:
return default
try:
return float(v)
except Exception:
return default
def _env_int(names, default):
v = _env(names)
if v is None:
return default
try:
return int(v)
except Exception:
return default
api_key = _env(["COMFYUI_RN_API_KEY", "COMFLY_API_KEY", "RUNNODE_API_KEY", "RN_API_KEY", "LLM_API_KEY", "OPENAI_API_KEY", "DEEPSEEK_API_KEY"]) or provider_config.get('api_key', '')
base_url = _env(["COMFYUI_RN_BASE_URL", "COMFLY_BASE_URL", "RUNNODE_BASE_URL", "RN_BASE_URL", "LLM_API_BASEURL", "OPENAI_BASE_URL", "OPENAI_API_BASE_URL", "DEEPSEEK_API_BASE_URL"]) or provider_config.get('base_url', '')
model = _env(["COMFYUI_RN_MODEL", "COMFLY_MODEL", "RUNNODE_MODEL", "RN_MODEL", "LLM_MODEL", "OPENAI_MODEL", "DEEPSEEK_MODEL"]) or provider_config.get('model', '')
temperature = _env_float(["COMFYUI_RN_TEMPERATURE", "COMFLY_TEMPERATURE"], provider_config.get('temperature', 0.7))
max_tokens = _env_int(["COMFYUI_RN_MAX_TOKENS", "COMFLY_MAX_TOKENS"], provider_config.get('max_tokens', 1000))
top_p = _env_float(["COMFYUI_RN_TOP_P", "COMFLY_TOP_P"], provider_config.get('top_p', 0.9))
return {
'api_key': api_key,
'model': model,
'base_url': base_url,
'temperature': temperature,
'max_tokens': max_tokens,
'top_p': top_p
}
except Exception:
return {}
def get_model_config(model_name):
"""
根据模型显示名称查找配置文件中的配置
Args:
model_name: 用户在界面中选择的模型名称(如"Qwen2.5-VL-32B-Instruct")
Returns:
包含该模型配置的字典,如果找不到则返回空字典
"""
try:
# 修改配置文件路径为新的配置文件
config_paths = [
os.path.join(os.path.dirname(os.path.realpath(__file__)), "config", 'ComfyUI_rn_prompter-config.json'),
os.path.join(os.path.dirname(os.path.realpath(__file__)), "config", 'comfyui_rn_prompter-config.json')
]
config_path = None
for path in config_paths:
if os.path.exists(path):
config_path = path
break
config = {}
if config_path and os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
# 模型名称映射字典(前端显示名称 -> 配置中的模型名称)
mapping = MODEL_MAPPING
# 获取映射后的模型名称
mapped_name = mapping.get(model_name, model_name)
# 直接在整个配置中查找匹配的model字段
def find_matching_config(config_dict, target_model):
"""递归查找匹配的配置"""
if isinstance(config_dict, dict):
# 检查当前字典是否有model字段且匹配
if 'model' in config_dict and config_dict['model'] == target_model:
# 找到了匹配的配置
return {
'api_key': config_dict.get('api_key', ''),
'model': config_dict.get('model', ''),
'base_url': config_dict.get('base_url', ''),
'temperature': config_dict.get('temperature', 0.7),
'max_tokens': config_dict.get('max_tokens', 1000),
'top_p': config_dict.get('top_p', 0.9)
}
# 递归查找子字典
for key, value in config_dict.items():
result = find_matching_config(value, target_model)
if result:
return result
elif isinstance(config_dict, list):
# 递归查找列表中的字典
for item in config_dict:
result = find_matching_config(item, target_model)
if result:
return result
return None
# 在整个配置中查找
result = find_matching_config(config, mapped_name)
if result:
print(f"Found config for {model_name} -> {mapped_name}")
return result
print(f"No matching config found for {model_name} -> {mapped_name}")
return {}
except Exception as e:
print(f"Error loading model config: {e}")
return {}
baseurl = get_config().get('base_url', '')
def encode_image_b64(ref_image):
i = 255. * ref_image.cpu().numpy()[0]
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
lsize = np.max(img.size)
factor = 1
while lsize / factor > 2048:
factor *= 2
img = img.resize((img.size[0] // factor, img.size[1] // factor))
image_path = f'{time.time()}.webp'
img.save(image_path, 'WEBP')
with open(image_path, "rb") as image_file:
base64_image = base64.b64encode(image_file.read()).decode('utf-8')
os.remove(image_path)
return base64_image
def _encode_frame_np_b64(frame_np, max_side=1024):
if frame_np is None:
return None
if hasattr(frame_np, "dtype"):
if frame_np.dtype != np.uint8:
frame_np = frame_np.astype(np.float32)
if frame_np.max() <= 1.0:
frame_np = frame_np * 255.0
frame_np = np.clip(frame_np, 0, 255).astype(np.uint8)
if getattr(frame_np, "ndim", None) == 3:
if frame_np.shape[0] in (1, 3, 4) and frame_np.shape[-1] not in (1, 3, 4):
frame_np = np.transpose(frame_np, (1, 2, 0))
frame_img = Image.fromarray(frame_np)
lsize = np.max(frame_img.size)
factor = 1
while lsize / factor > max_side:
factor *= 2
if factor > 1:
frame_img = frame_img.resize((frame_img.size[0] // factor, frame_img.size[1] // factor))
from io import BytesIO
buf = BytesIO()
frame_img.save(buf, format="WEBP")
return base64.b64encode(buf.getvalue()).decode("utf-8")
def extract_video_frames(video_tensor, max_frames=20):
"""
从视频张量中提取关键帧
Args:
video_tensor: 视频张量,形状为 (frames, height, width, channels)
max_frames: 最大提取帧数
Returns:
List[base64]: 编码后的图像列表
"""
import torch
frames = []
total_frames = video_tensor.shape[0]
# 计算采样间隔,均匀采样关键帧
if total_frames <= max_frames:
indices = range(total_frames)
else:
step = total_frames / max_frames
indices = [int(i * step) for i in range(max_frames)]
for idx in indices:
# 获取单帧图像
frame = video_tensor[idx]
# 转换为合适的格式 (height, width, channels)
if isinstance(frame, torch.Tensor):
frame_np = frame.cpu().numpy()
else:
frame_np = frame
frame_base64 = _encode_frame_np_b64(frame_np, max_side=1024)
if frame_base64:
frames.append(frame_base64)
return frames
def extract_video_frames_from_source(video_source, max_frames=20):
if video_source is None:
return []
if hasattr(video_source, "shape"):
try:
return extract_video_frames(video_source, max_frames=max_frames)
except Exception:
pass
import io as _io
if isinstance(video_source, _io.BytesIO):
try:
import av
video_source.seek(0)
with av.open(video_source, mode="r") as container:
video_stream = next((s for s in container.streams if s.type == "video"), None)
if video_stream is None:
return []
total = int(getattr(video_stream, "frames", 0) or 0)
indices = None
if total > 0:
if total <= max_frames:
indices = set(range(total))
else:
step = total / max_frames
indices = set(int(i * step) for i in range(max_frames))
frames = []
for i, frame in enumerate(container.decode(video_stream)):
if indices is not None:
if i not in indices:
continue
else:
if max_frames and len(frames) >= max_frames:
break
arr = frame.to_ndarray(format="rgb24")
b64 = _encode_frame_np_b64(arr, max_side=1024)
if b64:
frames.append(b64)
return frames
except Exception:
return []
if not isinstance(video_source, str):
raise TypeError(f"Unsupported video source type: {type(video_source)}")
src = video_source.strip()
if not src:
return []
import tempfile
import urllib.parse
import urllib.request
if src.startswith("file://"):
src = urllib.parse.unquote(src[len("file://"):])
if len(src) >= 3 and src[1] == ":" and (src[2] == "\\" or src[2] == "/"):
drive = src[0].lower()
rest = src[2:].replace("\\", "/").lstrip("/")
src = f"/mnt/{drive}/{rest}"
local_path = src
cleanup_path = None
if src.startswith("http://") or src.startswith("https://"):
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".mp4")
tmp.close()
urllib.request.urlretrieve(src, tmp.name)
local_path = tmp.name
cleanup_path = tmp.name
else:
if not os.path.exists(local_path):
raise FileNotFoundError(f"Video path not found: {local_path}")
try:
try:
import cv2
cap = cv2.VideoCapture(local_path)
if not cap.isOpened():
raise RuntimeError("cv2.VideoCapture failed")
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
if total > 0:
if total <= max_frames:
indices = list(range(total))
else:
step = total / max_frames
indices = [int(i * step) for i in range(max_frames)]
frames = []
for idx in indices:
cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
ok, frame = cap.read()
if not ok or frame is None:
continue
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
b64 = _encode_frame_np_b64(frame, max_side=1024)
if b64:
frames.append(b64)
cap.release()
return frames
frames = []
grabbed = 0
want = max_frames
stride = 1
if want > 0:
stride = max(1, int(30 / want))
idx = 0
while grabbed < want:
ok, frame = cap.read()
if not ok or frame is None:
break
if idx % stride == 0:
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
b64 = _encode_frame_np_b64(frame, max_side=1024)
if b64:
frames.append(b64)
grabbed += 1
idx += 1
cap.release()
return frames
except Exception:
import imageio.v3 as iio
frames = []
for i, frame in enumerate(iio.imiter(local_path)):
if max_frames and len(frames) >= max_frames:
break
b64 = _encode_frame_np_b64(frame, max_side=1024)
if b64:
frames.append(b64)
return frames
finally:
if cleanup_path:
try:
os.remove(cleanup_path)
except Exception:
pass
def _get_video_url(video):
if video is None:
return None
stream_source = getattr(video, "get_stream_source", None)
if callable(stream_source):
try:
v = stream_source()
if isinstance(v, str) and v.strip():
return v.strip()
import io as _io
if isinstance(v, _io.BytesIO):
return v
except Exception:
pass
if isinstance(video, dict):
for k in ("video_url", "url", "uri", "path", "file_path", "filepath", "video_path", "filename"):
v = video.get(k)
if isinstance(v, str) and v.strip():
return v.strip()
for attr in ("video_url", "url", "uri", "path", "file_path", "filepath", "video_path", "filename"):
v = getattr(video, attr, None)
if isinstance(v, str) and v.strip():
return v.strip()
return None
def _get_video_tensor(video):
if video is None:
return None
if isinstance(video, (list, tuple)) and len(video) > 0:
video = video[0]
if isinstance(video, dict):
for k in ("frames", "tensor", "video", "data", "images", "image", "pixels", "frames_tensor", "frame"):
v = video.get(k)
if v is not None:
return v
for attr in ("frames", "tensor", "video", "data", "images", "image", "pixels", "frames_tensor", "frame"):
v = getattr(video, attr, None)
if v is not None:
return v
return video
class RN_Translator():
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": "prompt"}),
"advanced_options": (["auto", "force_english", "force_chinese"], {"default": "auto"}),
},
"optional": {
"seed": ("INT", {"default": 100, "min": 0, "max": 0xffffffffffffffff}),
"apiBaseUrl": ("STRING", {"default": ""}),
"apiKey": ("STRING", {"default": ""}),
"model": ("STRING", {"default": ""}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("translation",)
FUNCTION = "translate_text"
CATEGORY = "RunNode/rn_prompter"
TITLE = "RunNode Translator"
def _split_text_into_chunks(self, text, max_chunk_size=400):
if len(text) <= max_chunk_size:
return [text]
sentences = re.split(r'[.!?。!?]\s*', text)
chunks = []
current = ""
for s in sentences:
if len(current + s) <= max_chunk_size:
current += (s + ". " if s else "")
else:
if current:
chunks.append(current.strip())
current = (s + ". " if s else "")
if current:
chunks.append(current.strip())
final = []
for c in chunks:
if len(c) <= max_chunk_size:
final.append(c)
else:
for i in range(0, len(c), max_chunk_size):
final.append(c[i:i + max_chunk_size])
return final
def _detect_direction(self, text, advanced_options):
if advanced_options == "force_English":
return ("源语言", "English")
if advanced_options == "force_Chinese":
return ("source", "Chinese")
if re.search(r"[\u4e00-\u9fff]", text):
return ("中文", "English")
return ("English", "Chinese")
def _translate_chunk(self, chunk, src_label, dst_label, temperature=None, apiBaseUrl=None, apiKey=None, model=None):
cfg = get_config()
if apiKey and str(apiKey).strip() and str(apiKey) != "default":
used_api_key = apiKey
else:
used_api_key = os.environ.get("COMFYUI_RN_API_KEY") or cfg.get("api_key", "")
if apiBaseUrl and str(apiBaseUrl).strip() and str(apiBaseUrl) != "default":
used_api_baseurl = _normalize_baseurl(apiBaseUrl)
else:
used_api_baseurl = _normalize_baseurl(os.environ.get("COMFYUI_RN_BASE_URL") or cfg.get("base_url") or "https://api.openai.com/v1")
if model and str(model).strip() and str(model) != "default":
used_model = model
else:
used_model = cfg.get("model") or "gpt-4o-mini"
if not used_api_key:
return "错误:请提供API密钥"
try:
client = OpenAI(api_key=used_api_key, base_url=used_api_baseurl)
system_prompt = "你是一个专业的翻译助手,负责准确翻译文本内容。"
user_prompt = f"""
请将以下{src_label}内容翻译成{dst_label}:
{chunk}
要求:
- 保持原意不变
- 语言自然流畅
- 只返回翻译结果,不要添加任何解释或额外文本
"""
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt.strip()},
]
cfg_temperature = cfg.get("temperature")
use_temperature = (
temperature if temperature is not None else (
cfg_temperature if cfg_temperature is not None else 0.3
)
)
cfg_max_tokens = cfg.get("max_tokens")
use_max_tokens = cfg_max_tokens if cfg_max_tokens is not None else 512
params = {
"model": used_model,
"messages": messages,
"temperature": use_temperature,
"max_tokens": use_max_tokens,
}
cfg_top_p = cfg.get("top_p")
if cfg_top_p is not None:
params["top_p"] = cfg_top_p
completion = client.chat.completions.create(**params)
if completion is not None and hasattr(completion, 'choices') and len(completion.choices) > 0:
return completion.choices[0].message.content.strip()
return "错误:API返回空结果"
except Exception as e:
return f"翻译错误:{str(e)}"
def translate_text(self, prompt, advanced_options, seed=0, apiBaseUrl="default", apiKey="default", model="default"):
request_id = generate_request_id("translate", "rn_prompter")
log_prepare("文本翻译", request_id, "RunNode/Prompter-", "Prompter")
rn_pbar = ProgressBar(request_id, "Prompter", streaming=True, task_type="文本翻译", source="RunNode/Prompter-")
cleaned = re.sub(r'[\x00\x01-\x08\x0b\x0c\x0e-\x1f\x7f]', '', prompt or "")
if not cleaned.strip():
rn_pbar.error("错误:请输入要翻译的文本")
return ("错误:请输入要翻译的文本",)
src_label, dst_label = self._detect_direction(cleaned, advanced_options)
chunks = self._split_text_into_chunks(cleaned, max_chunk_size=400)
if len(chunks) == 1:
res = self._translate_chunk(chunks[0], src_label, dst_label, temperature=None, apiBaseUrl=apiBaseUrl, apiKey=apiKey, model=model)
if isinstance(res, str) and (res.startswith("错误:") or res.startswith("翻译错误")):
rn_pbar.error(res)
else:
rn_pbar.done(char_count=len(res or ""))
return (res,)
translated = []
for c in chunks:
translated.append(self._translate_chunk(c, src_label, dst_label, temperature=None, apiBaseUrl=apiBaseUrl, apiKey=apiKey, model=model))
joined = ' '.join(translated)
if joined.startswith("错误:") or joined.startswith("翻译错误"):
rn_pbar.error(joined)
else:
rn_pbar.done(char_count=len(joined))
return (joined,)
@classmethod
def IS_CHANGED(cls, **kwargs):
return float(time.time())
class RN_Prompt_Translator():
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"advanced_options": (["auto", "force_English", "force_Chinese"], {"default": "auto"}),
},
"optional": {
"seed": ("INT", {"default": 100, "min": 0, "max": 0xffffffffffffffff}),
"apiBaseUrl": ("STRING", {"default": ""}),
"apiKey": ("STRING", {"default": ""}),
"model": ("STRING", {"default": ""}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("translation",)
FUNCTION = "translate_text"
CATEGORY = "RunNode/rn_prompter"
TITLE = "RunNode Prompt Translator"
def _split_text_into_chunks(self, text, max_chunk_size=400):
if len(text) <= max_chunk_size:
return [text]
sentences = re.split(r'[.!?。!?]\s*', text)
chunks = []
current = ""
for s in sentences:
if len(current + s) <= max_chunk_size:
current += (s + ". " if s else "")
else:
if current:
chunks.append(current.strip())
current = (s + ". " if s else "")
if current:
chunks.append(current.strip())
final = []
for c in chunks:
if len(c) <= max_chunk_size:
final.append(c)
else:
for i in range(0, len(c), max_chunk_size):
final.append(c[i:i + max_chunk_size])
return final
def _detect_direction(self, text, advanced_options):
if advanced_options == "force_English":
return ("源语言", "English")
if advanced_options == "force_Chinese":
return ("source", "Chinese")
if re.search(r"[\u4e00-\u9fff]", text):
return ("中文", "English")
return ("English", "Chinese")
def _translate_chunk(self, chunk, src_label, dst_label, temperature=None, apiBaseUrl=None, apiKey=None, model=None):
cfg = get_config()
if apiKey and str(apiKey).strip() and str(apiKey) != "default":
used_api_key = apiKey
else:
used_api_key = os.environ.get("COMFYUI_RN_API_KEY") or cfg.get("api_key", "")
if apiBaseUrl and str(apiBaseUrl).strip() and str(apiBaseUrl) != "default":
used_api_baseurl = _normalize_baseurl(apiBaseUrl)
else:
used_api_baseurl = _normalize_baseurl(os.environ.get("COMFYUI_RN_BASE_URL") or cfg.get("base_url") or "https://api.openai.com/v1")
if model and str(model).strip() and str(model) != "default":
used_model = model
else:
used_model = cfg.get("model") or "gpt-4o-mini"
if not used_api_key:
return "错误:请提供API密钥"
try:
client = OpenAI(api_key=used_api_key, base_url=used_api_baseurl)
system_prompt = "你是资深提示词工程师,负责将输入内容重写为用于生成式模型的标准化提示词,保持简洁、具象、可执行。"
user_prompt = f"""
Rewrite the following content as a standard {dst_label} prompt for generative models.
Requirements:
- concise, vivid, comma-separated phrases
- include subject, attributes, composition, style, lighting
- no explanations, headers or extra text
- return only the final prompt in {dst_label}
Content:
{chunk}
"""
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt.strip()},
]
cfg_temperature = cfg.get("temperature")
use_temperature = (
temperature if temperature is not None else (
cfg_temperature if cfg_temperature is not None else 0.3
)
)
cfg_max_tokens = cfg.get("max_tokens")
use_max_tokens = cfg_max_tokens if cfg_max_tokens is not None else 512
params = {
"model": used_model,
"messages": messages,
"temperature": use_temperature,
"max_tokens": use_max_tokens,
}
cfg_top_p = cfg.get("top_p")
if cfg_top_p is not None:
params["top_p"] = cfg_top_p
completion = client.chat.completions.create(**params)
if completion is not None and hasattr(completion, 'choices') and len(completion.choices) > 0:
return completion.choices[0].message.content.strip()
return "错误:API返回空结果"
except Exception as e:
return f"翻译错误:{str(e)}"
def translate_text(self, prompt, advanced_options, seed=0, apiBaseUrl="default", apiKey="default", model="default"):
request_id = generate_request_id("prompt_translate", "rn_prompter")
log_prepare("提示词翻译", request_id, "RunNode/Prompter-", "Prompter")
rn_pbar = ProgressBar(request_id, "Prompter", streaming=True, task_type="提示词翻译", source="RunNode/Prompter-")
cleaned = re.sub(r'[\x00\x01-\x08\x0b\x0c\x0e-\x1f\x7f]', '', prompt or "")
if not cleaned.strip():
rn_pbar.error("错误:请输入要翻译的文本")
return ("错误:请输入要翻译的文本",)
src_label, dst_label = self._detect_direction(cleaned, advanced_options)
chunks = self._split_text_into_chunks(cleaned, max_chunk_size=400)
if len(chunks) == 1:
res = self._translate_chunk(chunks[0], src_label, dst_label, temperature=None, apiBaseUrl=apiBaseUrl, apiKey=apiKey, model=model)
if isinstance(res, str) and (res.startswith("错误:") or res.startswith("翻译错误")):
rn_pbar.error(res)
else:
rn_pbar.done(char_count=len(res or ""))
return (res,)
translated = []
for c in chunks:
translated.append(self._translate_chunk(c, src_label, dst_label, temperature=None, apiBaseUrl=apiBaseUrl, apiKey=apiKey, model=model))
joined = ' '.join(translated)
if joined.startswith("错误:") or joined.startswith("翻译错误"):
rn_pbar.error(joined)
else:
rn_pbar.done(char_count=len(joined))
return (joined,)
@classmethod
def IS_CHANGED(cls, **kwargs):
return float(time.time())
class RN_Midjourney_Prompter():
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
},
"optional": {
"seed": ("INT", {"default": 100, "min": 0, "max": 0xffffffffffffffff}),
"temperature": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 2.0, "step": 0.1}),
"apiBaseUrl": ("STRING", {"default": "default"}),
"apiKey": ("STRING", {"default": "default"}),
"model": ("STRING", {"default": "default"}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("prompt opt",)
FUNCTION = "generate_mj_prompt"
CATEGORY = "RunNode/rn_prompter"
TITLE = "Midjourney Style Prompter"
def _split_text_into_chunks(self, text, max_chunk_size=400):
if len(text) <= max_chunk_size:
return [text]
sentences = re.split(r'[.!?。!?]\s*', text)
chunks = []
current = ""
for s in sentences:
if len(current + s) <= max_chunk_size:
current += (s + ". " if s else "")
else:
if current:
chunks.append(current.strip())
current = (s + ". " if s else "")
if current:
chunks.append(current.strip())
final = []
for c in chunks:
if len(c) <= max_chunk_size:
final.append(c)
else:
for i in range(0, len(c), max_chunk_size):
final.append(c[i:i + max_chunk_size])
return final
def _generate_chunk_mj(self, chunk, temperature=None, apiBaseUrl=None, apiKey=None, model=None):
cfg = get_config()
# 处理输入回退逻辑: Input -> Env -> Config
if apiKey and str(apiKey).strip() and str(apiKey) != "default":
used_api_key = apiKey
else:
used_api_key = cfg.get("api_key", "")
if apiBaseUrl and str(apiBaseUrl).strip() and str(apiBaseUrl) != "default":
used_api_baseurl = apiBaseUrl
else:
used_api_baseurl = cfg.get("base_url") or "https://ai.t8star.cn//v1"
if model and str(model).strip() and str(model) != "default":
used_model = model
else:
used_model = cfg.get("model") or "gpt-4o-mini"
if not used_api_key:
return "错误:请提供API密钥"
try:
client = OpenAI(api_key=used_api_key, base_url=used_api_baseurl)
system_prompt = "You are a professional Midjourney prompt engineer."
user_prompt = f"""
Rewrite the following description into a high-quality Midjourney prompt.
- Format: [Subject], [Action/Context], [Art Style/Medium], [Lighting], [Colors], [Composition], --ar 16:9 --v 6.0
- Keep it descriptive but concise.
- Use English only.
Content:
{chunk}
"""
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt.strip()},
]
cfg_temperature = cfg.get("temperature")
use_temperature = (
temperature if temperature is not None else (
cfg_temperature if cfg_temperature is not None else 0.3
)
)
cfg_max_tokens = cfg.get("max_tokens")
use_max_tokens = cfg_max_tokens if cfg_max_tokens is not None else 512
params = {
"model": used_model,
"messages": messages,
"temperature": use_temperature,
"max_tokens": use_max_tokens,
}
cfg_top_p = cfg.get("top_p")
if cfg_top_p is not None:
params["top_p"] = cfg_top_p
completion = client.chat.completions.create(**params)
if completion is not None and hasattr(completion, 'choices') and len(completion.choices) > 0:
return completion.choices[0].message.content.strip()
return "Error: Empty response"
except Exception as e:
return f"Error: {str(e)}"
def generate_mj_prompt(self, prompt, seed=0, temperature=0.3, apiBaseUrl="default", apiKey="default", model="default"):
request_id = generate_request_id("mj_prompt", "rn_prompter")
log_prepare("MJ 提示词", request_id, "RunNode/Prompter-", "Prompter")
rn_pbar = ProgressBar(request_id, "Prompter", streaming=True, task_type="MJ提示词", source="RunNode/Prompter-")
cleaned = re.sub(r'[\x00\x01-\x08\x0b\x0c\x0e-\x1f\x7f]', '', prompt or "")
if not cleaned.strip():
rn_pbar.error("错误:请输入要生成的文本")
return ("错误:请输入要生成的文本",)
chunks = self._split_text_into_chunks(cleaned, max_chunk_size=400)
if len(chunks) == 1:
res = self._generate_chunk_mj(chunks[0], temperature=temperature, apiBaseUrl=apiBaseUrl, apiKey=apiKey, model=model)
if isinstance(res, str) and (res.startswith("错误:") or res.startswith("Error")):
rn_pbar.error(res)
else:
rn_pbar.done(char_count=len(res or ""))
return (res,)
generated = []
for c in chunks:
generated.append(self._generate_chunk_mj(c, temperature=temperature, apiBaseUrl=apiBaseUrl, apiKey=apiKey, model=model))
joined = ' '.join(generated)
if joined.startswith("错误:") or joined.startswith("Error"):
rn_pbar.error(joined)
else:
rn_pbar.done(char_count=len(joined))
return (joined,)
@classmethod
def IS_CHANGED(cls, **kwargs):
return float(time.time())
class RN_LLMAPI_Node():
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"api_baseurl": ("STRING", {"multiline": True}),
"api_key": ("STRING", {"default": ""}),
"model": ("STRING", {"default": ""}),
"role": ("STRING", {"multiline": True, "default": "You are a helpful assistant"}),
"prompt": ("STRING", {"multiline": True, "default": "Hello"}),
"temperature": ("FLOAT", {"default": 0.6}),
"seed": ("INT", {"default": 100}),
# "max_video_frames": ("INT", {"default": 5, "min": 1, "max": 20, "step": 1}),
},
"optional": {
"ref_image": ("IMAGE",),
"video": ("VIDEO",),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("describe",)
FUNCTION = "rn_run_llmapi"
CATEGORY = "RunNode/rn_prompter"
def rn_run_llmapi(self, api_baseurl, api_key, model, role, prompt, temperature, seed,
max_video_frames=5, ref_image=None, video=None):
request_id = generate_request_id("llmapi", "rn_prompter")
log_prepare("通用LLM调用", request_id, "RunNode/Prompter-", "Prompter")
rn_pbar = ProgressBar(request_id, "Prompter", streaming=True, task_type="LLM调用", source="RunNode/Prompter-")
cfg = get_config()
vllm_cfg = get_vllm_config()
has_visual = (ref_image is not None) or (video is not None)
selected_cfg = vllm_cfg if has_visual else cfg
# 处理输入回退逻辑:只有当输入不为空(且不全是空格)时才使用输入值
used_api_baseurl = api_baseurl if api_baseurl and api_baseurl.strip() else None
used_api_key = api_key if api_key and api_key.strip() else None
used_api_baseurl = _normalize_baseurl(used_api_baseurl or os.environ.get("COMFYUI_RN_BASE_URL") or selected_cfg.get("base_url"))
used_api_key = (used_api_key or os.environ.get("COMFYUI_RN_API_KEY") or selected_cfg.get("api_key") or "")
used_model = (model or selected_cfg.get("model") or ("qwen25-vl-32b-instruct" if has_visual else "gpt-4o-mini"))
client = OpenAI(api_key=used_api_key, base_url=used_api_baseurl)
messages = [{'role': 'system', 'content': f'{role}'}]
user_content = [{"type": "text", "text": f"{prompt}"}]
# 处理图片输入
if ref_image is not None:
base64_image = encode_image_b64(ref_image)
user_content.append({
"type": "image_url",
"image_url": {
"url": f"data:image/webp;base64,{base64_image}",
"detail": "high"
}
})
# 处理视频输入
if video is not None:
try:
video_frames = []
get_components = getattr(video, "get_components", None)
if callable(get_components):
try:
components = get_components()
images = getattr(components, "images", None)
if images is not None:
video_frames = extract_video_frames(images, max_frames=max_video_frames)
except Exception:
pass
if not video_frames:
video_tensor = _get_video_tensor(video)
if hasattr(video_tensor, "shape"):
video_frames = extract_video_frames(video_tensor, max_frames=max_video_frames)
if not video_frames:
video_src = _get_video_url(video)
if video_src:
video_frames = extract_video_frames_from_source(video_src, max_frames=max_video_frames)
for frame_base64 in video_frames:
user_content.append({
"type": "image_url",
"image_url": {
"url": f"data:image/webp;base64,{frame_base64}",
"detail": "auto"
}
})
if len(video_frames) > 0:
prompt_addition = f"\nNote: I've provided {len(video_frames)} keyframes from the video. "
user_content[0]["text"] = prompt + prompt_addition
else:
video_src = _get_video_url(video)
video_tensor = _get_video_tensor(video)
tensor_shape = getattr(video_tensor, "shape", None)
keys = list(video.keys())[:30] if isinstance(video, dict) else None
rn_pbar.error(f"视频解析失败:未能从 VIDEO 中获得可用帧(type={type(video)} src={video_src} tensor_type={type(video_tensor)} tensor_shape={tensor_shape} keys={keys})")
except Exception as e:
rn_pbar.error(f"视频处理出错: {str(e)}")
# 构建消息
messages.append({
'role': 'user',
'content': user_content
})
try:
completion = client.chat.completions.create(
model=used_model,
messages=messages,
temperature=temperature,
seed=seed
)
if completion is not None and hasattr(completion, 'choices'):
response = completion.choices[0].message.content
else:
response = 'Error: No response from API'
except Exception as e:
response = f"API调用出错: {str(e)}"
if isinstance(response, str) and (response.startswith("Error") or response.startswith("API调用出错")):
rn_pbar.error(response)
else:
rn_pbar.done(char_count=len(response or ""))
return (response,)
class RN_LLMAPI_Pro_Node():
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": (list(MODEL_MAPPING.keys()), {"default": "Kimi-K2.5"}),
"role": ("STRING", {"multiline": True, "default": "You are a helpful assistant"}),
"prompt": ("STRING", {"multiline": True, "default": "Hello"}),
"temperature": ("FLOAT", {"default": 0.6}),
"seed": ("INT", {"default": -1}),
},
"optional": {
"ref_image1": ("IMAGE",),
"ref_image2": ("IMAGE",),
"ref_image3": ("IMAGE",),
"ref_image4": ("IMAGE",),
"ref_image5": ("IMAGE",),
"ref_image6": ("IMAGE",),
"ref_image7": ("IMAGE",),
"ref_image8": ("IMAGE",),
"ref_image9": ("IMAGE",),
"video": ("VIDEO",),
"max_video_frames": ("INT", {"default": 5, "min": 1, "max": 20, "step": 1}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("response",)
FUNCTION = "rn_run_llmapi_pro"
CATEGORY = "RunNode/rn_prompter"
def rn_run_llmapi_pro(
self,
model,
role,
prompt,
temperature,
seed,
api_baseurl='',
api_key='',
ref_image1=None,
ref_image2=None,
ref_image3=None,
ref_image4=None,
ref_image5=None,
ref_image6=None,
ref_image7=None,
ref_image8=None,
ref_image9=None,
video=None,
max_video_frames=5,
):
request_id = generate_request_id("llmapi_pro", "rn_prompter")
log_prepare("专业LLM调用", request_id, "RunNode/Prompter-", "Prompter")
rn_pbar = ProgressBar(request_id, "Prompter", streaming=True, task_type="LLM调用", source="RunNode/Prompter-")
# 获取环境变量
env_base_url = get_first_env(ENV_KEYS_BASE_URL)
env_api_key = get_first_env(ENV_KEYS_API_KEY)
# 获取模型对应的配置
model_config = get_model_config(model)
# 处理输入回退逻辑:只有当输入不为空(且不全是空格)时才使用输入值
used_api_baseurl = api_baseurl if api_baseurl and api_baseurl.strip() else None
used_api_key = api_key if api_key and api_key.strip() else None
# 优先级: 输入参数 > 环境变量 > 模型特定配置 > 全局配置
used_api_baseurl = used_api_baseurl or env_base_url or model_config.get('base_url', '') or get_config().get('base_url', '')
used_api_key = used_api_key or env_api_key or model_config.get('api_key', '') or get_config().get('api_key', '')
used_model = model_config.get('model', '') # 使用配置中的模型名称
# 如果配置中没有模型名称,使用映射后的名称
if not used_model:
# 模型名称映射字典
mapping = MODEL_MAPPING
used_model = mapping.get(model, model)
# 最终的检查,确保有base_url
if not used_api_baseurl:
return ("ERROR: No API base URL configured. Please check your configuration.",)
used_api_baseurl = _normalize_baseurl(used_api_baseurl)
print(f"Using base_url: {used_api_baseurl}")
print(f"Using model: {used_model}")
client = OpenAI(api_key=used_api_key, base_url=used_api_baseurl)
images_in = [ref_image1, ref_image2, ref_image3, ref_image4, ref_image5, ref_image6, ref_image7, ref_image8, ref_image9]
image_batches = [img for img in images_in if img is not None]
has_visual = bool(image_batches) or (video is not None)
if not has_visual:
messages = [
{'role': 'system', 'content': f'{role}'},
{'role': 'user', 'content': f'{prompt}'},
]
else:
user_content = [{"type": "text", "text": f"{prompt}"}]
max_images = 9
added_images = 0
for img in image_batches:
if added_images >= max_images:
break
try:
batch_size = int(getattr(img, "shape", [0])[0]) if getattr(img, "shape", None) is not None else 0
except Exception:
batch_size = 0
if batch_size <= 1:
base64_image = encode_image_b64(img)
user_content.append({
"type": "image_url",
"image_url": {
"url": f"data:image/webp;base64,{base64_image}",
"detail": "high"
}
})
added_images += 1
else:
for i in range(batch_size):
if added_images >= max_images:
break
base64_image = encode_image_b64(img[i:i+1])
user_content.append({
"type": "image_url",
"image_url": {
"url": f"data:image/webp;base64,{base64_image}",
"detail": "high"
}
})
added_images += 1
if video is not None:
try:
video_frames = []
get_components = getattr(video, "get_components", None)
if callable(get_components):
try:
components = get_components()
images = getattr(components, "images", None)
if images is not None:
video_frames = extract_video_frames(images, max_frames=max_video_frames)
except Exception:
pass
if not video_frames:
video_tensor = _get_video_tensor(video)
if hasattr(video_tensor, "shape"):
video_frames = extract_video_frames(video_tensor, max_frames=max_video_frames)
if not video_frames:
video_src = _get_video_url(video)
if video_src:
video_frames = extract_video_frames_from_source(video_src, max_frames=max_video_frames)
for frame_base64 in video_frames:
user_content.append({
"type": "image_url",
"image_url": {
"url": f"data:image/webp;base64,{frame_base64}",
"detail": "low"
}
})
if len(video_frames) > 0:
prompt_addition = f"\nNote: I've provided {len(video_frames)} keyframes from the video. "
user_content[0]["text"] = prompt + prompt_addition
else:
video_src = _get_video_url(video)
video_tensor = _get_video_tensor(video)
tensor_shape = getattr(video_tensor, "shape", None)
keys = list(video.keys())[:30] if isinstance(video, dict) else None
rn_pbar.error(f"视频解析失败:未能从 VIDEO 中获得可用帧(type={type(video)} src={video_src} tensor_type={type(video_tensor)} tensor_shape={tensor_shape} keys={keys})")
except Exception as e:
rn_pbar.error(f"视频处理出错: {str(e)}")
messages = [
{'role': 'system', 'content': f'{role}'},
{'role': 'user', 'content': user_content},
]
# 调用API
try:
completion = client.chat.completions.create(
model=used_model,
messages=messages,
temperature=temperature
)
if completion is not None and hasattr(completion, 'choices') and len(completion.choices) > 0:
prompt = completion.choices[0].message.content
else:
print(f"API Response invalid: {completion}")
prompt = 'Error: No response from API (Empty choices)'
except Exception as e:
error_msg = str(e)
if "not a multimodal model" in error_msg:
prompt = f"{used_model}不是多模态模型,无法识别图片,请重新选择模型"
rn_pbar.error(prompt)
return (prompt,)
print(f"API call error: {error_msg}")
# 诊断:如果是 JSON 解析错误,尝试获取原始响应内容帮助调试
if "Expecting value" in error_msg or "JSON" in error_msg:
try:
import urllib.request
import urllib.error
# 尝试构建完整 URL 进行测试
test_url = used_api_baseurl
if not test_url.endswith('/'):
test_url += '/'
test_url += 'chat/completions'
print(f"Diagnosing API connection to: {test_url}")
# 简单的测试请求头
headers = {
"Authorization": f"Bearer {used_api_key}",
"Content-Type": "application/json"
}
# 最小化的测试 Payload
payload = json.dumps({
"model": used_model,
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 1
}).encode('utf-8')
req = urllib.request.Request(test_url, data=payload, headers=headers, method='POST')
try:
with urllib.request.urlopen(req, timeout=10) as response:
print(f"Diagnostic Response Code: {response.getcode()}")
body = response.read().decode('utf-8', errors='ignore')
print(f"Diagnostic Response Body (first 500 chars): {body[:500]}")
except urllib.error.HTTPError as he:
print(f"Diagnostic HTTP Error: {he.code}")
error_body = he.read().decode('utf-8', errors='ignore')
print(f"Diagnostic Error Body: {error_body[:500]}")
error_msg += f" (HTTP {he.code}: {error_body[:200]})"
except Exception as de:
print(f"Diagnostic check failed: {de}")
except Exception as diag_e:
print(f"Failed to run diagnostics: {diag_e}")
prompt = f'Error: {error_msg}'
if isinstance(prompt, str) and prompt.startswith('Error'):
rn_pbar.error(prompt)
else:
rn_pbar.done(char_count=len(prompt or ""))
return (prompt,)