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,)