├── .gitignore ├── LICENSE ├── README.md ├── comfyuifanyi.code-workspace ├── config.template.json ├── main.py ├── project_structure.txt ├── requirements.txt ├── run.bat ├── src ├── __init__.py ├── diff_tab.py ├── file_utils.py ├── node_diff.py ├── node_parser.py ├── prompts.py ├── translation_config.py ├── translator.py └── web │ ├── static │ ├── css │ │ └── style.css │ └── js │ │ └── main.js │ └── templates │ └── index.html ├── translation_mapping.json └── version_notes.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | env/ 6 | venv/ 7 | ENV/ 8 | .venv/ 9 | 10 | # 配置文件 11 | config.json 12 | 13 | # 输出目录 14 | output/ 15 | output/*/ 16 | 17 | # IDE 18 | .vscode/ 19 | .idea/ 20 | *.swp 21 | .DS_Store 22 | 23 | # 临时文件 24 | *.bak 25 | *.tmp 26 | 27 | # 忽略工作目录 28 | workspace/ 29 | 30 | # 忽略翻译结果和日志 31 | *_translated.json 32 | all_translations.json 33 | nodes_to_translate.json 34 | detection_detail.log 35 | 36 | # 虚拟环境 37 | venv/ 38 | ENV/ 39 | .env 40 | .venv 41 | 42 | # Node modules (if applicable) 43 | node_modules/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Your Name 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 声明:作者纯开发小白一枚,本项目全程由cursor开发,本人只是一个提问的工具人,如果遇到BUG,请轻喷。 2 | 3 | 程序配套的视频讲解: 4 | 【comfyui插件翻译自由啦,直接中文搜索节点,继承辣椒酱的意志~】 https://www.bilibili.com/video/BV1hmAneGEbZ/?share_source=copy_web&vd_source=af996d4f530575a9634db534351104a6 5 | 6 | # ComfyUI 节点翻译工具 7 | 8 | 一个用于翻译 ComfyUI 插件节点信息的工具,支持批量检测和翻译节点信息。 9 | 需要配合AIGODLIKE-ComfyUI-Translation使用 10 | 将最终翻译的json直接放入AIGODLIKE-ComfyUI-Translation/zh-CN\Nodes中即可 11 | 12 | # ComfyUI 节点翻译工具 - V2(2025/02/21) 13 | 14 | ## 更新内容 15 | - 增加了拖放文件夹的支持,用户可以直接将插件文件夹拖入程序。 16 | - 优化了操作说明,提供了更清晰的使用步骤。 17 | - 检测到的待翻译 JSON 文件现在会存放在时间戳文件夹下。 18 | - 点击“查看待翻译 JSON”按钮时,打开对应的时间戳文件夹。 19 | - 翻译工作结束后,自动删除待翻译的 JSON 文件。 20 | ## 功能特点 21 | 22 | - 自动检测插件目录中的节点信息 23 | - 使用火山引擎 Doubao-1.5-pro-32k API 进行中文翻译 24 | - 支持批量翻译和进度显示 25 | - 提供节点对比功能 26 | - 详细的翻译日志和费用统计 27 | 28 | ## API 配置说明 29 | 30 | ### 火山引擎 API 配置 31 | 详细教学见:https://www.bilibili.com/video/BV1LCN2eZEAX/?spm_id_from=333.1387.homepage.video_card.click&vd_source=ae85ec1de21e4084d40c5d4eec667b8f 32 | 1. 访问[火山引擎控制台](https://console.volcengine.com/) 33 | 2. 创建 API 密钥,获取 `API Key` 34 | 3. 创建翻译模型,获取 `Model ID` 35 | 4. 在程序中填入 API 密钥和模型 ID 36 | 5. 点击"测试 API"确认连接正常 37 | 38 | ### 费率说明 39 | - 输入 tokens:0.0008元/千tokens 40 | - 输出 tokens:0.0020元/千tokens 41 | - 程序会自动计算并显示预估费用 42 | 43 | ## 使用说明 44 | 45 | ### 快速开始 46 | 1. 运行 `run.bat` 或 `run(国内源).bat` 启动程序(国内用户建议使用后者) 47 | 2. 在主界面填入 API 密钥和模型 ID 48 | 3. 点击"保存配置"保存 API 信息 49 | 4. 点击"测试 API"验证连接 50 | 51 | ### 翻译功能 52 | 1. 点击"选择文件夹"选择要翻译的插件目录 53 | 2. 点击"检测节点"扫描插件中的节点 54 | 3. 可以点击"查看待翻译 JSON"查看检测到的节点 55 | 4. 设置批次大小(建议:5-8) 56 | 5. 点击"开始翻译"开始翻译过程 57 | 6. 翻译完成后可点击"查看结果"查看翻译结果 58 | 59 | ### 对比功能 60 | 1. 切换到"对比功能"标签页 61 | 2. 选择旧版本节点文件 62 | 3. 选择新版本节点文件 63 | 4. 点击"比较节点"进行对比 64 | 5. 可以点击"打开结果文件"查看详细对比结果 65 | 66 | ## 功能说明 67 | 68 | ### 主要功能 69 | - **节点检测**:扫描插件目录,识别所有 ComfyUI 节点 70 | - **节点翻译**:将节点信息翻译成中文 71 | - **节点对比**:对比两个版本的节点差异 72 | - **结果查看**:支持查看待翻译内容和翻译结果 73 | 74 | ### 辅助功能 75 | - **API 测试**:验证 API 连接是否正常 76 | - **配置保存**:保存 API 密钥和模型 ID 77 | - **进度显示**:显示翻译进度和详细日志 78 | - **费用统计**:统计 tokens 使用量和预估费用 79 | 80 | ### 输出文件 81 | 所有文件都保存在程序目录下的 `output/插件名/` 文件夹中: 82 | - `temp/`: 临时文件目录 83 | - `translations/`: 翻译结果目录 84 | - `logs/`: 日志文件目录 85 | - `debug/`: 调试信息目录 86 | 87 | ## 注意事项 88 | 89 | 1. 确保有稳定的网络连接 90 | 2. API 密钥请妥善保管,不要泄露 91 | 3. 建议先用小批量测试翻译效果 92 | 4. 如遇到问题,查看日志获取详细信息 93 | 5. 翻译费用以实际计费为准 94 | 95 | ## 更新日志 96 | 97 | ### v1.0.0 98 | - 初始版本发布 99 | - 支持火山引擎翻译服务 100 | - 实现基本的节点检测和翻译功能 101 | - 添加节点对比功能 102 | -------------------------------------------------------------------------------- /comfyuifanyi.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | }, 6 | { 7 | "path": "../cmyui/ComfyUI/custom_nodes/comfyui_fill-nodes" 8 | } 9 | ], 10 | "settings": {} 11 | } -------------------------------------------------------------------------------- /config.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_keys": { 3 | "aliyun": "your-aliyun-api-key", 4 | "siliconflow": "your-siliconflow-api-key" 5 | }, 6 | "api_configs": { 7 | "aliyun": { 8 | "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", 9 | "models": { 10 | "qwen-plus": { 11 | "temperature": 0.3, 12 | "max_tokens": 4096, 13 | "timeout": 30 14 | }, 15 | "deepseek-v3": { 16 | "temperature": 0.3, 17 | "max_tokens": 4096, 18 | "timeout": 30 19 | } 20 | } 21 | }, 22 | "siliconflow": { 23 | "base_url": "https://api.siliconflow.cn/v1/chat/completions", 24 | "models": { 25 | "Qwen/Qwen2.5-7B-Instruct": { 26 | "temperature": 0.3, 27 | "max_tokens": 4096, 28 | "stream": false, 29 | "top_p": 0.95, 30 | "frequency_penalty": 0, 31 | "presence_penalty": 0, 32 | "stop": ["", "<|endoftext|>"], 33 | "response_format": {"type": "text"} 34 | }, 35 | "deepseek-ai/DeepSeek-V3": { 36 | "temperature": 0.3, 37 | "max_tokens": 4096, 38 | "stream": false, 39 | "top_p": 0.95, 40 | "frequency_penalty": 0, 41 | "presence_penalty": 0, 42 | "stop": ["", "<|endoftext|>"], 43 | "response_format": {"type": "text"} 44 | } 45 | } 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter import ttk, filedialog, scrolledtext, messagebox 3 | from tkinterdnd2 import * # 导入所有组件,包括 DND_FILES 4 | import threading 5 | import os 6 | from src.node_parser import NodeParser 7 | from src.translator import Translator 8 | from src.file_utils import FileUtils 9 | import sys 10 | import json 11 | import time 12 | from src.translation_config import TranslationServices 13 | from src.diff_tab import DiffTab 14 | from typing import List 15 | import logging # 添加日志模块 16 | import shutil 17 | 18 | class ComfyUITranslator: 19 | def __init__(self, root): 20 | self.root = root 21 | self.root.title("ComfyUI 节点翻译 - 作者 OldX") 22 | 23 | # 设置日志 24 | logging.basicConfig( 25 | level=logging.INFO, 26 | format='[%(levelname)s] %(message)s' 27 | ) 28 | 29 | # 设置最小窗口大小 30 | self.root.minsize(900, 800) 31 | 32 | # 获取屏幕尺寸 33 | screen_width = root.winfo_screenwidth() 34 | screen_height = root.winfo_screenheight() 35 | 36 | # 设置窗口初始大小为屏幕的 80% 37 | window_width = int(screen_width * 0.8) 38 | window_height = int(screen_height * 0.8) 39 | 40 | # 设置窗口位置为屏幕中央 41 | x = (screen_width - window_width) // 2 42 | y = (screen_height - window_height) // 2 43 | 44 | self.root.geometry(f"{window_width}x{window_height}+{x}+{y}") 45 | 46 | # 配置根窗口的网格权重,使内容可以跟随窗口调整 47 | self.root.grid_rowconfigure(0, weight=1) 48 | self.root.grid_columnconfigure(0, weight=1) 49 | 50 | # 创建主框架 51 | self.main_frame = ttk.Frame(root, padding="10") 52 | self.main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) 53 | 54 | # 配置主框架的网格权重 55 | self.main_frame.grid_rowconfigure(0, weight=1) 56 | self.main_frame.grid_columnconfigure(0, weight=1) 57 | 58 | # 加载配置 59 | self.config = self._load_config() 60 | 61 | # 创建标签页 62 | self.tab_control = ttk.Notebook(self.main_frame) 63 | self.tab_control.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), pady=5) 64 | 65 | # 创建翻译功能标签页 66 | self.translation_tab = ttk.Frame(self.tab_control) 67 | self.tab_control.add(self.translation_tab, text="翻译功能") 68 | 69 | # 创建对比功能标签页 70 | self.diff_tab = DiffTab(self.tab_control) 71 | self.tab_control.add(self.diff_tab, text="对比功能") 72 | 73 | # 创建操作说明标签页 74 | self.help_tab = ttk.Frame(self.tab_control) 75 | self.tab_control.add(self.help_tab, text="操作说明") 76 | self.setup_help_ui() 77 | 78 | # 在翻译功能标签页中添加控件 79 | self.setup_translation_ui() 80 | 81 | # 初始化其他属性 82 | self.translating = False 83 | self.detected_nodes = {} 84 | self.json_window = None 85 | 86 | # 创建工作目录 87 | self.work_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "workspace") 88 | os.makedirs(self.work_dir, exist_ok=True) 89 | 90 | # 初始化变量 91 | self.folder_path = tk.StringVar() # 添加 folder_path 变量 92 | self.plugin_folders = [] # 存储选择的文件夹列表 93 | 94 | def select_folder(self): 95 | folder = filedialog.askdirectory() 96 | if folder: 97 | self.folder_path.set(folder) 98 | self.log(f"已选择文件夹: {folder}") 99 | 100 | def log(self, message): 101 | """添加日志""" 102 | logging.info(message) # 在终端显示日志 103 | self.log_text.insert(tk.END, f"[{message}]\n") # 在 GUI 中显示日志 104 | self.log_text.see(tk.END) 105 | 106 | def detect_nodes(self): 107 | """检测文件夹中的节点""" 108 | if hasattr(self, 'plugin_folders') and self.plugin_folders: 109 | # 批量处理模式 110 | self.detect_batch_nodes() 111 | else: 112 | # 单文件夹模式 113 | if not self.folder_path.get(): 114 | tk.messagebox.showerror("错误", "请先选择插件文件夹!") 115 | return 116 | self.detect_single_folder() 117 | 118 | def detect_single_folder(self): 119 | """检测单个文件夹的节点""" 120 | self.detect_btn.config(state=tk.DISABLED) 121 | self.start_btn.config(state=tk.DISABLED) 122 | self.view_json_btn.config(state=tk.DISABLED) 123 | self.progress['value'] = 0 124 | self.detected_nodes = {} 125 | 126 | # 获取插件专属的输出目录 127 | plugin_dirs = FileUtils.get_plugin_output_dir( 128 | os.path.dirname(os.path.abspath(__file__)), 129 | self.folder_path.get() 130 | ) 131 | 132 | # 在新线程中运行检测任务 133 | threading.Thread( 134 | target=self.detection_task, 135 | args=(plugin_dirs,), # 只传递 plugin_dirs 参数 136 | daemon=True 137 | ).start() 138 | 139 | def detect_batch_nodes(self): 140 | """批量检测多个文件夹的节点""" 141 | self.detect_btn.config(state=tk.DISABLED) 142 | self.start_btn.config(state=tk.DISABLED) 143 | self.view_json_btn.config(state=tk.DISABLED) 144 | self.progress['value'] = 0 145 | self.detected_nodes = {} 146 | 147 | # 在新线程中运行批量检测任务 148 | threading.Thread( 149 | target=self.batch_detection_task, 150 | daemon=True 151 | ).start() 152 | 153 | def batch_detection_task(self): 154 | """批量检测任务""" 155 | try: 156 | total_plugins = len(self.plugin_folders) 157 | total_nodes = 0 158 | 159 | # 创建时间戳目录 160 | self.current_output_dir = os.path.join( 161 | os.path.dirname(os.path.abspath(__file__)), 162 | "output", 163 | time.strftime("%Y%m%d_%H%M%S") 164 | ) 165 | # 创建待翻译文件目录 166 | nodes_dir = os.path.join(self.current_output_dir, "nodes_to_translate") 167 | os.makedirs(nodes_dir, exist_ok=True) 168 | os.makedirs(os.path.join(self.current_output_dir, "temp"), exist_ok=True) 169 | 170 | self.log(f"[初始化] 创建输出目录: {self.current_output_dir}") 171 | 172 | for i, plugin_folder in enumerate(self.plugin_folders, 1): 173 | plugin_name = os.path.basename(plugin_folder) 174 | self.log(f"\n[检测进度] 正在检测第 {i}/{total_plugins} 个插件: {plugin_name}") 175 | 176 | # 初始化解析器 177 | node_parser = NodeParser(plugin_folder) 178 | 179 | # 扫描并解析节点 180 | nodes = node_parser.parse_folder(plugin_folder) 181 | 182 | # 优化节点信息 183 | nodes = node_parser.optimize_node_info(nodes) 184 | 185 | # 保存检测结果到时间戳目录 186 | output_file = os.path.join(nodes_dir, f'{plugin_name}_nodes.json') 187 | FileUtils.save_json(nodes, output_file) 188 | 189 | # 更新总节点数 190 | total_nodes += len(nodes) 191 | 192 | # 更新进度 193 | progress = int((i / total_plugins) * 100) 194 | self.root.after(0, lambda: self.progress.configure(value=progress)) 195 | 196 | # 将节点添加到总结果中 197 | self.detected_nodes.update(nodes) 198 | 199 | self.log(f"[检测完成] 在插件 {plugin_name} 中找到 {len(nodes)} 个待翻译节点") 200 | 201 | self.log(f"\n[检测完成] 共在 {len(self.plugin_folders)} 个插件中找到 {total_nodes} 个待翻译节点") 202 | 203 | # 启用按钮 204 | if total_nodes > 0: 205 | self.root.after(0, lambda: [ 206 | self.detect_btn.config(state=tk.NORMAL), 207 | self.start_btn.config(state=tk.NORMAL), 208 | self.view_json_btn.config(state=tk.NORMAL) 209 | ]) 210 | 211 | except Exception as e: 212 | self.log(f"[错误] 批量检测失败: {str(e)}") 213 | logging.error(f"批量检测失败: {str(e)}") 214 | # 恢复按钮状态 215 | self.root.after(0, lambda: [ 216 | self.detect_btn.config(state=tk.NORMAL), 217 | self.view_json_btn.config(state=tk.DISABLED) 218 | ]) 219 | 220 | def detection_task(self, plugin_dirs: dict): 221 | """节点检测任务""" 222 | try: 223 | # 创建时间戳目录(如果不存在) 224 | if not hasattr(self, 'current_output_dir'): 225 | self.current_output_dir = os.path.join( 226 | os.path.dirname(os.path.abspath(__file__)), 227 | "output", 228 | time.strftime("%Y%m%d_%H%M%S") 229 | ) 230 | os.makedirs(self.current_output_dir, exist_ok=True) 231 | 232 | # 创建待翻译文件目录 233 | nodes_dir = os.path.join(self.current_output_dir, "nodes_to_translate") 234 | os.makedirs(nodes_dir, exist_ok=True) 235 | 236 | # 初始化解析器 237 | node_parser = NodeParser(self.folder_path.get()) 238 | 239 | # 扫描并解析节点 240 | self.detected_nodes = node_parser.parse_folder(self.folder_path.get()) 241 | 242 | # 优化节点信息 243 | self.detected_nodes = node_parser.optimize_node_info(self.detected_nodes) 244 | 245 | # 保存检测结果到时间戳目录 246 | plugin_name = os.path.basename(self.folder_path.get()) 247 | output_file = os.path.join(nodes_dir, f"{plugin_name}_nodes.json") 248 | FileUtils.save_json(self.detected_nodes, output_file) 249 | 250 | # 生成详细日志 251 | self._generate_detail_log(self.detected_nodes, plugin_dirs) 252 | 253 | # 获取文件数量(从源文件信息中) 254 | source_files = set() 255 | for node_info in self.detected_nodes.values(): 256 | if '_source_file' in node_info: 257 | source_files.add(node_info['_source_file']) 258 | 259 | total_nodes = len(self.detected_nodes) 260 | self.log(f"检测完成!共找到 {total_nodes} 个节点在 {len(source_files)} 个文件中") 261 | # 添加保存路径信息 262 | self.log(f"检测结果已保存至: {output_file}") 263 | 264 | # 启用开始翻译按钮 265 | if total_nodes > 0: 266 | self.root.after(0, lambda: [ 267 | self.detect_btn.config(state=tk.NORMAL), 268 | self.start_btn.config(state=tk.NORMAL), 269 | self.view_json_btn.config(state=tk.NORMAL) 270 | ]) 271 | 272 | except Exception as e: 273 | self.log(f"[错误] 检测失败: {str(e)}") 274 | logging.error(f"检测失败: {str(e)}") 275 | # 恢复按钮状态 276 | self.root.after(0, lambda: [ 277 | self.detect_btn.config(state=tk.NORMAL), 278 | self.view_json_btn.config(state=tk.DISABLED) 279 | ]) 280 | 281 | def test_api(self): 282 | """测试 API 连接""" 283 | api_key = self.api_key.get().strip() 284 | model_id = self.model_id.get().strip() 285 | 286 | if not api_key: 287 | tk.messagebox.showerror("错误", "请输入 API 密钥!") 288 | return 289 | 290 | if not model_id: 291 | tk.messagebox.showerror("错误", "请输入模型 ID!") 292 | return 293 | 294 | self.test_api_btn.config(state=tk.DISABLED) 295 | self.test_api_btn.config(text="正在测试...") 296 | 297 | def test_task(): 298 | try: 299 | translator = Translator(api_key, model_id) 300 | if translator.test_connection(): 301 | def on_success(): 302 | tk.messagebox.showinfo("成功", "API 连接测试成功!") 303 | self.test_api_btn.config(state=tk.NORMAL, text="测试API") 304 | # 保存配置 305 | self._save_api_key(api_key) 306 | self.root.after(0, on_success) 307 | except Exception as e: 308 | def on_error(): 309 | tk.messagebox.showerror("错误", str(e)) 310 | self.test_api_btn.config(state=tk.NORMAL, text="测试API") 311 | self.root.after(0, on_error) 312 | 313 | threading.Thread(target=test_task, daemon=True).start() 314 | 315 | def _save_api_key(self, api_key: str): 316 | """保存 API 密钥和模型 ID""" 317 | config = { 318 | "api_keys": { 319 | "volcengine": api_key 320 | }, 321 | "model_ids": { 322 | "volcengine": self.model_id.get() # 同时保存当前的模型 ID 323 | } 324 | } 325 | try: 326 | with open('config.json', 'w', encoding='utf-8') as f: 327 | json.dump(config, f, indent=4, ensure_ascii=False) 328 | except Exception as e: 329 | tk.messagebox.showerror("错误", f"保存配置失败:{str(e)}") 330 | 331 | def view_results(self): 332 | """查看翻译结果""" 333 | try: 334 | if not hasattr(self, 'current_output_dir') or not self.current_output_dir: 335 | messagebox.showerror("错误", "没有可查看的翻译结果!") 336 | return 337 | 338 | if not os.path.exists(self.current_output_dir): 339 | messagebox.showerror("错误", "翻译结果目录不存在!") 340 | return 341 | 342 | # 打开文件夹 343 | if sys.platform == 'win32': # Windows 344 | os.startfile(self.current_output_dir) 345 | elif sys.platform == 'darwin': # macOS 346 | os.system(f'open "{self.current_output_dir}"') 347 | else: # Linux 348 | os.system(f'xdg-open "{self.current_output_dir}"') 349 | 350 | except Exception as e: 351 | error_msg = f"打开结果文件夹失败:{str(e)}" 352 | logging.error(error_msg) 353 | messagebox.showerror("错误", error_msg) 354 | 355 | def _generate_detail_log(self, nodes: dict, plugin_dirs: dict): 356 | """生成详细日志""" 357 | try: 358 | # 清空详细日志 359 | self.detail_text.delete('1.0', tk.END) 360 | 361 | # 写入日志头 362 | timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) 363 | self.detail_text.insert(tk.END, f"ComfyUI 节点检测详细日志 ({timestamp})\n") 364 | self.detail_text.insert(tk.END, "=" * 50 + "\n\n") 365 | 366 | # 写入检测配置信息 367 | self.detail_text.insert(tk.END, "检测配置:\n") 368 | self.detail_text.insert(tk.END, f"- 插件目录: {self.folder_path.get()}\n") 369 | self.detail_text.insert(tk.END, f"- 检测时间: {timestamp}\n") 370 | 371 | # 写入统计信息 372 | source_files = set() 373 | total_inputs = 0 374 | total_outputs = 0 375 | total_widgets = 0 376 | for node_info in nodes.values(): 377 | if '_source_file' in node_info: 378 | source_files.add(node_info['_source_file']) 379 | total_inputs += len(node_info.get('inputs', {})) 380 | total_outputs += len(node_info.get('outputs', {})) 381 | total_widgets += len(node_info.get('widgets', {})) 382 | 383 | self.detail_text.insert(tk.END, "\n检测结果统计:\n") 384 | self.detail_text.insert(tk.END, f"- 扫描文件数: {len(source_files)}\n") 385 | self.detail_text.insert(tk.END, f"- 检测到节点数: {len(nodes)}\n") 386 | self.detail_text.insert(tk.END, f"- 待翻译字段总数: {total_inputs + total_outputs + total_widgets}\n") 387 | self.detail_text.insert(tk.END, f" • 输入参数: {total_inputs}\n") 388 | self.detail_text.insert(tk.END, f" • 输出参数: {total_outputs}\n") 389 | self.detail_text.insert(tk.END, f" • 部件参数: {total_widgets}\n") 390 | 391 | # 写入检测结果保存路径 392 | output_file = os.path.join(plugin_dirs["temp"], 'nodes_to_translate.json') 393 | self.detail_text.insert(tk.END, f"\n检测结果保存路径: {output_file}\n") 394 | self.detail_text.insert(tk.END, "=" * 50 + "\n\n") 395 | 396 | # 写入文件列表 397 | self.detail_text.insert(tk.END, "扫描的文件列表:\n") 398 | for file_path in sorted(source_files): 399 | file_size = os.path.getsize(file_path) 400 | file_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(os.path.getmtime(file_path))) 401 | self.detail_text.insert(tk.END, f"- {file_path}\n") 402 | self.detail_text.insert(tk.END, f" • 大小: {file_size/1024:.1f} KB\n") 403 | self.detail_text.insert(tk.END, f" • 修改时间: {file_time}\n") 404 | 405 | self.detail_text.insert(tk.END, "\n" + "=" * 50 + "\n\n") 406 | 407 | # 按文件分组显示节点 408 | nodes_by_file = {} 409 | for node_name, node_info in nodes.items(): 410 | file_path = node_info.get('_source_file', 'unknown') 411 | if file_path not in nodes_by_file: 412 | nodes_by_file[file_path] = [] 413 | nodes_by_file[file_path].append((node_name, node_info)) 414 | 415 | # 写入每个文件的节点信息 416 | for file_path, file_nodes in nodes_by_file.items(): 417 | self.detail_text.insert(tk.END, f"\n文件: {file_path}\n") 418 | self.detail_text.insert(tk.END, f"节点数量: {len(file_nodes)}\n") 419 | self.detail_text.insert(tk.END, "-" * 50 + "\n") 420 | 421 | for node_name, node_info in file_nodes: 422 | # 显示原始节点名称和处理后的名称 423 | base_name = node_name.split('(')[0] 424 | self.detail_text.insert(tk.END, f"\n节点: {node_name}\n") 425 | if base_name != node_name: 426 | self.detail_text.insert(tk.END, f"翻译用名称: {base_name}\n") 427 | 428 | # 写入标题 429 | if 'title' in node_info: 430 | self.detail_text.insert(tk.END, f"标题: {node_info['title']}\n") 431 | 432 | # 统计当前节点的参数数量 433 | node_inputs = len(node_info.get('inputs', {})) 434 | node_outputs = len(node_info.get('outputs', {})) 435 | node_widgets = len(node_info.get('widgets', {})) 436 | self.detail_text.insert(tk.END, f"\n参数统计: {node_inputs + node_outputs + node_widgets} 个\n") 437 | 438 | # 写入输入信息 439 | if 'inputs' in node_info and node_info['inputs']: 440 | self.detail_text.insert(tk.END, f"\n输入 ({node_inputs}):\n") 441 | for input_name, input_info in node_info['inputs'].items(): 442 | self.detail_text.insert(tk.END, f" - {input_name}: {input_info}\n") 443 | 444 | # 写入输出信息 445 | if 'outputs' in node_info and node_info['outputs']: 446 | self.detail_text.insert(tk.END, f"\n输出 ({node_outputs}):\n") 447 | for output_name, output_info in node_info['outputs'].items(): 448 | self.detail_text.insert(tk.END, f" - {output_name}: {output_info}\n") 449 | 450 | # 写入部件信息 451 | if 'widgets' in node_info and node_info['widgets']: 452 | self.detail_text.insert(tk.END, f"\n部件 ({node_widgets}):\n") 453 | for widget_name, widget_info in node_info['widgets'].items(): 454 | self.detail_text.insert(tk.END, f" - {widget_name}: {widget_info}\n") 455 | 456 | self.detail_text.insert(tk.END, "\n" + "-" * 30 + "\n") 457 | 458 | # 滚动到顶部 459 | self.detail_text.see('1.0') 460 | 461 | except Exception as e: 462 | self.log(f"生成详细日志失败: {str(e)}") 463 | 464 | def _update_translation_log(self, message: str): 465 | """更新翻译详细日志""" 466 | # 添加时间戳 467 | timestamp = time.strftime("%H:%M:%S", time.localtime()) 468 | self.detail_text.insert(tk.END, f"[{timestamp}] {message}\n") 469 | self.detail_text.see(tk.END) # 滚动到底部 470 | 471 | def view_json(self): 472 | """查看待翻译的JSON文件""" 473 | try: 474 | if not hasattr(self, 'current_output_dir') or not self.current_output_dir: 475 | messagebox.showerror("错误", "请先执行节点检测!") 476 | return 477 | 478 | nodes_dir = os.path.join(self.current_output_dir, "nodes_to_translate") 479 | if not os.path.exists(nodes_dir): 480 | messagebox.showerror("错误", "未找到待翻译文件,请重新执行节点检测。") 481 | return 482 | 483 | if not os.listdir(nodes_dir): 484 | messagebox.showerror("错误", "未检测到任何待翻译节点,请重新检测。") 485 | return 486 | 487 | # 打开文件夹 488 | if sys.platform == 'win32': # Windows 489 | os.startfile(nodes_dir) 490 | elif sys.platform == 'darwin': # macOS 491 | os.system(f'open "{nodes_dir}"') 492 | else: # Linux 493 | os.system(f'xdg-open "{nodes_dir}"') 494 | 495 | except Exception as e: 496 | error_msg = f"打开待翻译文件夹失败:{str(e)}" 497 | logging.error(error_msg) 498 | messagebox.showerror("错误", error_msg) 499 | 500 | def _load_config(self) -> dict: 501 | """从配置文件加载配置""" 502 | try: 503 | with open('config.json', 'r', encoding='utf-8') as f: 504 | return json.load(f) 505 | except FileNotFoundError: 506 | return {} 507 | 508 | def setup_translation_ui(self): 509 | """设置翻译功能的用户界面""" 510 | # API密钥输入和服务商选择框架 511 | api_frame = ttk.Frame(self.translation_tab) 512 | api_frame.pack(fill=tk.X, padx=10, pady=5) 513 | 514 | # API密钥输入 515 | ttk.Label(api_frame, text="API密钥:", width=8).pack(side=tk.LEFT) 516 | self.api_key = tk.StringVar(value=self.config.get("api_keys", {}).get("volcengine", "")) 517 | self.api_key_entry = ttk.Entry(api_frame, textvariable=self.api_key, width=60) 518 | self.api_key_entry.pack(side=tk.LEFT, padx=5) 519 | 520 | # 添加火山引擎 model ID 输入框 521 | ttk.Label(api_frame, text="模型ID:", width=8).pack(side=tk.LEFT, padx=(10, 0)) 522 | self.model_id = tk.StringVar(value=self.config.get("model_ids", {}).get("volcengine", "")) 523 | self.model_id_entry = ttk.Entry(api_frame, textvariable=self.model_id, width=30) 524 | self.model_id_entry.pack(side=tk.LEFT, padx=5) 525 | 526 | # 按钮框架 527 | btn_frame = ttk.Frame(api_frame) 528 | btn_frame.pack(side=tk.LEFT, padx=10) 529 | 530 | self.save_config_btn = ttk.Button(btn_frame, text="保存配置", width=10, command=self._save_api_key) 531 | self.save_config_btn.pack(side=tk.LEFT, padx=2) 532 | 533 | self.test_api_btn = ttk.Button(btn_frame, text="测试API", width=10, command=self.test_api) 534 | self.test_api_btn.pack(side=tk.LEFT, padx=2) 535 | 536 | # 修改文件夹选择框架 537 | folder_frame = ttk.LabelFrame(self.translation_tab, text="插件文件夹选择", padding=5) 538 | folder_frame.pack(fill=tk.X, padx=10, pady=5) 539 | 540 | # 修改拖放提示文本 541 | drop_label = ttk.Label( 542 | folder_frame, 543 | text="请拖入插件文件夹", 544 | foreground='gray', 545 | font=('TkDefaultFont', 12) # 增大标签字体 546 | ) 547 | drop_label.pack(fill=tk.X, padx=5, pady=5) 548 | 549 | # 增大拖放区域高度和字体 550 | self.drop_area = tk.Text( 551 | folder_frame, 552 | height=6, 553 | width=50, 554 | font=('TkDefaultFont', 11) # 增大文本区域字体 555 | ) 556 | self.drop_area.pack(fill=tk.X, padx=5, pady=5) 557 | 558 | # 修改提示文本 559 | drop_text = ( 560 | "第一步:打开comfyui\\custom_nodes文件夹\n" 561 | "第二步:选择需要翻译的插件文件夹,拖入该区域\n" 562 | "·可以多选后一次性拖入" 563 | ) 564 | self.drop_area.insert('1.0', drop_text) 565 | self.drop_area.config(state='disabled') 566 | 567 | # 注册拖放目标和绑定事件 568 | self.drop_area.drop_target_register(DND_FILES) 569 | self.drop_area.dnd_bind('<>', self.on_drop) 570 | 571 | # 按钮框架 - 只保留清除按钮 572 | btn_frame = ttk.Frame(folder_frame) 573 | btn_frame.pack(fill=tk.X, padx=5, pady=5) 574 | 575 | self.clear_folders_btn = ttk.Button( 576 | btn_frame, 577 | text="清除选择", 578 | width=15, 579 | command=self.clear_selected_folders, 580 | state=tk.DISABLED 581 | ) 582 | self.clear_folders_btn.pack(side=tk.LEFT, padx=5) 583 | 584 | # 添加插件列表显示 585 | plugins_frame = ttk.LabelFrame(self.translation_tab, text="待处理插件列表", padding=5) 586 | plugins_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) 587 | 588 | self.plugins_text = scrolledtext.ScrolledText(plugins_frame, height=5, width=80) 589 | self.plugins_text.pack(fill=tk.BOTH, expand=True) 590 | 591 | # 进度条 592 | self.progress = ttk.Progressbar(self.translation_tab, length=300, mode='determinate') 593 | self.progress.pack(fill=tk.X, padx=10, pady=10) 594 | 595 | # 创建控制区域框架(包含按钮和翻译设置) 596 | control_frame = ttk.Frame(self.translation_tab) 597 | control_frame.pack(fill=tk.X, padx=10, pady=5) 598 | 599 | # 左侧:控制按钮 600 | btn_frame = ttk.LabelFrame(control_frame, text="操作按钮", padding=5) 601 | btn_frame.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 5)) 602 | 603 | button_width = 15 604 | self.detect_btn = ttk.Button(btn_frame, text="检测节点", width=button_width, command=self.detect_nodes) 605 | self.detect_btn.pack(side=tk.LEFT, padx=5) 606 | 607 | self.view_json_btn = ttk.Button( 608 | btn_frame, 609 | text="查看待翻译JSON", 610 | width=button_width, 611 | command=self.view_json, 612 | state=tk.DISABLED 613 | ) 614 | self.view_json_btn.pack(side=tk.LEFT, padx=5) 615 | 616 | self.start_btn = ttk.Button(btn_frame, text="开始翻译", width=button_width, command=self.start_translation, state=tk.DISABLED) 617 | self.start_btn.pack(side=tk.LEFT, padx=5) 618 | 619 | self.stop_btn = ttk.Button(btn_frame, text="终止翻译", width=button_width, command=self.stop_translation, state=tk.DISABLED) 620 | self.stop_btn.pack(side=tk.LEFT, padx=5) 621 | 622 | self.view_btn = ttk.Button(btn_frame, text="查看结果", width=button_width, command=self.view_results, state=tk.DISABLED) 623 | self.view_btn.pack(side=tk.LEFT, padx=5) 624 | 625 | # 右侧:翻译设置 626 | batch_frame = ttk.LabelFrame(control_frame, text="翻译设置", padding=5) 627 | batch_frame.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(5, 0)) 628 | 629 | ttk.Label(batch_frame, text="一次性翻译节点数:").pack(side=tk.LEFT, padx=5) 630 | self.batch_size = tk.StringVar(value="6") 631 | batch_entry = ttk.Entry(batch_frame, textvariable=self.batch_size, width=5) 632 | batch_entry.pack(side=tk.LEFT, padx=5) 633 | ttk.Label( 634 | batch_frame, 635 | text="(建议: 5-8, 数值越大速度越快,但可能超过模型上下文限制)" 636 | ).pack(side=tk.LEFT, padx=5) 637 | 638 | # 创建日志框架(左右布局) 639 | log_frame = ttk.Frame(self.translation_tab) 640 | log_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) 641 | 642 | # 配置日志框架的网格 643 | log_frame.grid_columnconfigure(0, weight=1) 644 | log_frame.grid_columnconfigure(1, weight=1) 645 | log_frame.grid_rowconfigure(0, weight=1) 646 | 647 | # 左侧:翻译日志 648 | left_frame = ttk.LabelFrame(log_frame, text="翻译日志", padding=5) 649 | left_frame.grid(row=0, column=0, sticky='nsew', padx=(0, 5)) 650 | 651 | self.log_text = scrolledtext.ScrolledText(left_frame, height=20, width=60) 652 | self.log_text.pack(fill=tk.BOTH, expand=True) 653 | 654 | # 右侧:详细日志(隐藏标题) 655 | right_frame = ttk.LabelFrame(log_frame, text="", padding=5) # 移除标题 656 | right_frame.grid(row=0, column=1, sticky='nsew', padx=(5, 0)) 657 | 658 | self.detail_text = scrolledtext.ScrolledText(right_frame, height=20, width=60) 659 | self.detail_text.pack(fill=tk.BOTH, expand=True) 660 | self.detail_text.pack_forget() # 隐藏详细日志区域 661 | 662 | def select_batch_folder(self): 663 | """选择多个插件文件夹""" 664 | try: 665 | # 使用 askopenfilenames 并设置为只能选择文件夹 666 | folders = filedialog.askopenfilenames( 667 | title="选择插件文件夹中的任意文件(可多选)", 668 | initialdir=os.path.dirname(self.folder_path.get()) if self.folder_path.get() else None 669 | ) 670 | 671 | if folders: 672 | # 获取选择的文件夹路径列表(去重) 673 | folder_paths = [] 674 | for file_path in folders: 675 | folder_path = os.path.dirname(file_path) 676 | if folder_path not in folder_paths: 677 | folder_paths.append(folder_path) 678 | 679 | # 初始化文件夹列表(如果不存在) 680 | if not hasattr(self, 'plugin_folders'): 681 | self.plugin_folders = [] 682 | 683 | # 添加新选择的文件夹(去重) 684 | for folder in folder_paths: 685 | if folder not in self.plugin_folders: 686 | self.plugin_folders.append(folder) 687 | 688 | # 显示插件列表 689 | self.display_plugin_list() 690 | 691 | # 在文件夹输入框中显示选择的数量 692 | self.folder_path.set(f"已选择 {len(self.plugin_folders)} 个插件文件夹") 693 | 694 | # 启用检测按钮 695 | self.detect_btn.config(state=tk.NORMAL) 696 | 697 | # 启用清除按钮 698 | self.clear_folders_btn.config(state=tk.NORMAL) 699 | 700 | except Exception as e: 701 | messagebox.showerror("错误", f"选择文件夹失败: {str(e)}") 702 | 703 | def display_plugin_list(self): 704 | """显示待处理的插件列表""" 705 | self.plugins_text.delete('1.0', tk.END) 706 | if hasattr(self, 'plugin_folders') and self.plugin_folders: 707 | self.plugins_text.insert(tk.END, "待处理插件列表:\n\n") 708 | for i, folder in enumerate(self.plugin_folders, 1): 709 | folder_name = os.path.basename(folder) 710 | folder_path = folder 711 | self.plugins_text.insert(tk.END, f"{i}. {folder_name}\n 路径: {folder_path}\n\n") 712 | else: 713 | self.plugins_text.insert(tk.END, "未选择任何插件文件夹") 714 | 715 | def start_translation(self): 716 | """开始翻译""" 717 | if hasattr(self, 'plugin_folders') and self.plugin_folders: 718 | # 批量处理模式 719 | self.batch_translation() 720 | else: 721 | # 单文件处理模式(原有逻辑) 722 | self.single_translation() 723 | 724 | def stop_translation(self): 725 | """停止翻译任务""" 726 | self.translating = False 727 | self.detect_btn.config(state=tk.NORMAL) 728 | self.start_btn.config(state=tk.NORMAL) 729 | self.stop_btn.config(state=tk.DISABLED) 730 | # 不在这里修改查看结果按钮的状态 731 | self.log("翻译已终止") 732 | 733 | def batch_translation(self): 734 | """批量处理多个插件""" 735 | # 验证 API 密钥和模型 ID 736 | api_key = self.api_key.get().strip() 737 | model_id = self.model_id.get().strip() 738 | 739 | if not api_key or not model_id: 740 | tk.messagebox.showerror("错误", "请输入 API 密钥和模型 ID!") 741 | return 742 | 743 | # 验证批次大小 744 | try: 745 | batch_size = int(self.batch_size.get()) 746 | if batch_size < 1: 747 | raise ValueError("批次大小必须大于 0") 748 | except ValueError as e: 749 | tk.messagebox.showerror("错误", f"批次大小设置无效: {str(e)}") 750 | return 751 | 752 | # 禁用按钮 753 | self.start_btn.config(state=tk.DISABLED) 754 | self.detect_btn.config(state=tk.DISABLED) 755 | self.clear_folders_btn.config(state=tk.DISABLED) # 只禁用清除按钮 756 | 757 | # 在新线程中运行批量处理任务 758 | threading.Thread( 759 | target=self.batch_translation_task, 760 | args=(api_key, batch_size, model_id), 761 | daemon=True 762 | ).start() 763 | 764 | def batch_translation_task(self, api_key: str, batch_size: int, model_id: str): 765 | """批量翻译任务""" 766 | try: 767 | # 1. 创建时间戳目录 768 | self.current_output_dir = os.path.join( # 保存当前输出目录路径 769 | os.path.dirname(os.path.abspath(__file__)), 770 | "output", 771 | time.strftime("%Y%m%d_%H%M%S") 772 | ) 773 | os.makedirs(self.current_output_dir, exist_ok=True) 774 | os.makedirs(os.path.join(self.current_output_dir, "temp"), exist_ok=True) 775 | 776 | self.log(f"[初始化] 创建翻译输出目录: {self.current_output_dir}") 777 | 778 | # 2. 开始翻译 779 | total_plugins = len(self.plugin_folders) 780 | self.translating = True 781 | self.stop_btn.config(state=tk.NORMAL) 782 | 783 | successful_translations = [] # 记录成功的翻译 784 | 785 | for i, plugin_folder in enumerate(self.plugin_folders, 1): 786 | if not self.translating: 787 | raise Exception("翻译已被用户终止") 788 | 789 | plugin_name = os.path.basename(plugin_folder) 790 | self.log(f"\n[翻译进度] 正在翻译第 {i}/{total_plugins} 个插件: {plugin_name}") 791 | 792 | try: 793 | # 2.1 翻译节点 794 | translated_nodes = self._translate_single_plugin( 795 | plugin_folder, 796 | plugin_name, 797 | api_key, 798 | model_id, 799 | batch_size, 800 | self.current_output_dir 801 | ) 802 | 803 | # 2.2 保存翻译结果 804 | result_file = os.path.join(self.current_output_dir, f"{plugin_name}.json") 805 | FileUtils.save_json(translated_nodes, result_file) 806 | 807 | # 2.3 验证保存结果 808 | if not os.path.exists(result_file): 809 | raise Exception(f"保存失败: {result_file}") 810 | 811 | successful_translations.append({ 812 | 'plugin_name': plugin_name, 813 | 'result_file': result_file 814 | }) 815 | 816 | self.log(f"[完成] 插件 {plugin_name} 翻译完成") 817 | 818 | except Exception as e: 819 | self.log(f"[错误] 插件 {plugin_name} 翻译失败: {str(e)}") 820 | continue 821 | 822 | # 3. 清理临时文件 823 | temp_dir = os.path.join(self.current_output_dir, "temp") 824 | if os.path.exists(temp_dir): 825 | shutil.rmtree(temp_dir) 826 | 827 | # 4. 完成处理 828 | if successful_translations: 829 | self.log(f"\n[完成] 翻译任务结束,成功翻译 {len(successful_translations)} 个插件") 830 | self.log(f"[输出] 翻译结果已保存到: {self.current_output_dir}") 831 | 832 | # 清理临时文件和待翻译文件 833 | nodes_dir = os.path.join(self.current_output_dir, "nodes_to_translate") 834 | temp_dir = os.path.join(self.current_output_dir, "temp") 835 | 836 | for dir_to_clean in [nodes_dir, temp_dir]: 837 | if os.path.exists(dir_to_clean): 838 | try: 839 | shutil.rmtree(dir_to_clean) 840 | self.log(f"[清理] 已清理临时文件: {os.path.basename(dir_to_clean)}") 841 | except Exception as e: 842 | self.log(f"[警告] 清理临时文件失败: {str(e)}") 843 | 844 | # 启用查看结果按钮 845 | self.root.after(0, lambda: self.view_btn.config(state=tk.NORMAL)) 846 | else: 847 | self.log("\n[警告] 所有插件翻译均失败") 848 | 849 | except Exception as e: 850 | if str(e) != "翻译已被用户终止": 851 | self.log(f"[错误] 批量处理出错: {str(e)}") 852 | logging.error(f"批量处理出错: {str(e)}") 853 | finally: 854 | # 恢复按钮状态 855 | self.root.after(0, lambda: [ 856 | self.start_btn.config(state=tk.NORMAL), 857 | self.detect_btn.config(state=tk.NORMAL), 858 | self.clear_folders_btn.config(state=tk.NORMAL) 859 | ]) 860 | self.stop_translation() 861 | 862 | def clear_selected_folders(self): 863 | """清除已选择的文件夹列表""" 864 | if hasattr(self, 'plugin_folders'): 865 | self.plugin_folders = [] 866 | self.folder_path.set("") 867 | self.display_plugin_list() 868 | self.clear_folders_btn.config(state=tk.DISABLED) 869 | 870 | def on_drop(self, event): 871 | """处理文件夹拖放事件""" 872 | try: 873 | # 获取拖放的路径 874 | data = event.data 875 | paths = data.split() # Windows 下路径以空格分隔 876 | 877 | # 初始化文件夹列表(如果不存在) 878 | if not hasattr(self, 'plugin_folders'): 879 | self.plugin_folders = [] 880 | 881 | # 处理每个路径 882 | for path in paths: 883 | # 移除可能的引号和空格 884 | path = path.strip('" ') 885 | 886 | if os.path.isdir(path): 887 | # 如果是文件夹且不在列表中,添加它 888 | if path not in self.plugin_folders: 889 | self.plugin_folders.append(path) 890 | logging.info(f"添加文件夹: {path}") 891 | elif os.path.isfile(path): 892 | # 如果是文件,获取其所在文件夹 893 | folder_path = os.path.dirname(path) 894 | if folder_path not in self.plugin_folders: 895 | self.plugin_folders.append(folder_path) 896 | logging.info(f"添加文件夹: {folder_path}") 897 | 898 | # 更新显示 899 | self.display_plugin_list() 900 | 901 | # 更新文件夹输入框 902 | self.folder_path.set(f"已选择 {len(self.plugin_folders)} 个插件文件夹") 903 | 904 | # 启用相关按钮 905 | self.detect_btn.config(state=tk.NORMAL) 906 | self.clear_folders_btn.config(state=tk.NORMAL) 907 | 908 | # 更新拖放区域文本 909 | self.drop_area.configure(state='normal') 910 | self.drop_area.delete('1.0', tk.END) 911 | self.drop_area.insert('1.0', 912 | "第一步:打开comfyui\\custom_nodes文件夹\n" 913 | "第二步:选择需要翻译的插件文件夹,拖入该区域\n" 914 | "·可以多选后一次性拖入" 915 | ) 916 | self.drop_area.configure(state='disabled') 917 | 918 | except Exception as e: 919 | error_msg = f"处理拖放文件夹失败: {str(e)}" 920 | logging.error(error_msg) # 在终端显示错误 921 | messagebox.showerror("错误", error_msg) # 同时显示错误对话框 922 | 923 | def _translate_single_plugin(self, plugin_folder: str, plugin_name: str, 924 | api_key: str, model_id: str, batch_size: int, 925 | timestamp_dir: str) -> dict: 926 | """翻译单个插件""" 927 | # 1. 解析节点 928 | node_parser = NodeParser(plugin_folder) 929 | nodes = node_parser.parse_folder(plugin_folder) 930 | 931 | if not nodes: 932 | raise Exception("未检测到节点") 933 | 934 | # 2. 优化节点信息 935 | nodes = node_parser.optimize_node_info(nodes) 936 | 937 | # 3. 翻译节点 938 | translator = Translator(api_key=api_key, model_id=model_id) 939 | 940 | def update_progress(progress: int, message: str = None): 941 | if not self.translating: 942 | raise Exception("翻译已被用户终止") 943 | if message: 944 | # 修改进度消息格式 945 | if "[翻译]" in message: 946 | message = message.replace("[翻译]", "[翻译进度] 正在翻译") 947 | elif "[验证]" in message: 948 | message = message.replace("[验证]", "[验证] 正在校验") 949 | self.log(message) 950 | 951 | # 创建临时目录 952 | temp_dir = os.path.join(timestamp_dir, "temp", plugin_name) 953 | os.makedirs(temp_dir, exist_ok=True) 954 | 955 | translated_nodes = translator.translate_nodes( 956 | nodes, 957 | folder_path=plugin_folder, 958 | batch_size=batch_size, 959 | update_progress=update_progress, 960 | temp_dir=temp_dir # 为每个插件创建独立的临时目录 961 | ) 962 | 963 | return translated_nodes 964 | 965 | def setup_help_ui(self): 966 | """设置操作说明界面""" 967 | # 创建滚动文本框 968 | help_text = scrolledtext.ScrolledText( 969 | self.help_tab, 970 | wrap=tk.WORD, 971 | width=80, 972 | height=30, 973 | font=('TkDefaultFont', 10) 974 | ) 975 | help_text.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) 976 | 977 | # 添加操作说明内容 978 | help_content = """ 979 | # 操作说明 980 | 981 | ## 使用步骤 982 | 983 | ### 第一步:打开插件文件夹 984 | - 打开 `comfyui\\custom_nodes` 文件夹,确保其中包含您要翻译的插件文件夹。 985 | 986 | ### 第二步:拖入插件文件夹 987 | - 选择需要翻译的插件文件夹,拖入程序的拖放区域。 988 | - 支持多选后一次性拖入。 989 | 990 | ### 第三步:配置 API 991 | - 在程序中输入火山引擎的 API 密钥和模型 ID。 992 | - 点击"测试 API"按钮,确保连接正常。 993 | 994 | ### 第四步:检测节点 995 | - 点击"检测节点"按钮,程序将扫描插件中的节点信息。 996 | - 检测完成后,您可以点击"查看待翻译 JSON"按钮,查看检测到的节点。 997 | 998 | ### 第五步:开始翻译 999 | - 设置一次性翻译的节点数(建议:5-8)。 1000 | - 点击"开始翻译"按钮,程序将开始翻译过程。 1001 | 1002 | ### 第六步:查看翻译结果 1003 | - 翻译完成后,您可以点击"查看结果"按钮,打开当前翻译的结果时间戳文件夹。 1004 | 1005 | ## 注意事项 1006 | - 确保有稳定的网络连接。 1007 | - API 密钥请妥善保管,不要泄露。 1008 | - 建议先用小批量测试翻译效果。 1009 | - 如遇到问题,查看日志获取详细信息。 1010 | 1011 | ## 相关链接 1012 | - [火山引擎 API 配置说明](https://www.bilibili.com/video/BV1LCN2eZEAX/?spm_id_from=333.1387.homepage.video_card.click&vd_source=ae85ec1de21e4084d40c5d4eec667b8f) 1013 | - [程序配套的视频讲解](https://www.bilibili.com/video/BV1hmAneGEbZ/?share_source=copy_web&vd_source=af996d4f530575a9634db534351104a6) 1014 | """ 1015 | 1016 | help_text.insert('1.0', help_content) 1017 | help_text.config(state='disabled') # 设置为只读 1018 | 1019 | def setup_styles(): 1020 | """设置自定义样式""" 1021 | style = ttk.Style() 1022 | 1023 | # 配置开关按钮样式 1024 | style.configure( 1025 | 'Switch.TCheckbutton', 1026 | background='white', 1027 | foreground='black', 1028 | padding=5 1029 | ) 1030 | 1031 | # 如果系统支持,可以使用更现代的开关样式 1032 | try: 1033 | style.element_create('Switch.slider', 'from', 'clam') 1034 | style.layout('Switch.TCheckbutton', 1035 | [('Checkbutton.padding', 1036 | {'children': [ 1037 | ('Switch.slider', {'side': 'left', 'sticky': ''}), 1038 | ('Checkbutton.label', {'side': 'right', 'sticky': ''}) 1039 | ]}) 1040 | ]) 1041 | except tk.TclError: 1042 | pass # 如果不支持,就使用默认复选框样式 1043 | 1044 | def main(): 1045 | root = TkinterDnD.Tk() # 使用 TkinterDnD 的 Tk 1046 | setup_styles() # 设置样式 1047 | app = ComfyUITranslator(root) 1048 | root.mainloop() 1049 | 1050 | if __name__ == "__main__": 1051 | main() -------------------------------------------------------------------------------- /project_structure.txt: -------------------------------------------------------------------------------- 1 | comfyui_node_translator/ 2 | ├── README.md 3 | ├── requirements.txt 4 | ├── src/ 5 | │ ├── __init__.py 6 | │ ├── node_parser.py # 节点解析模块 7 | │ ├── translator.py # 翻译服务模块 8 | │ ├── file_utils.py # 文件处理工具 9 | │ └── web/ 10 | │ ├── static/ 11 | │ │ ├── css/ 12 | │ │ │ └── style.css 13 | │ │ └── js/ 14 | │ │ └── main.js 15 | │ └── templates/ 16 | │ └── index.html 17 | └── main.py # 主程序入口 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask==3.0.2 2 | openai==1.12.0 3 | requests==2.31.0 4 | tkinterdnd2==0.3.0 -------------------------------------------------------------------------------- /run.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | chcp 65001 > nul 3 | title ComfyUI Node Translator - Startup Script 4 | 5 | :: Check if Python is installed 6 | python --version > nul 2>&1 7 | if errorlevel 1 ( 8 | echo [ERROR] Python is not found. Please install Python 3.7 or higher. 9 | pause 10 | exit /b 11 | ) 12 | 13 | :: Check if virtual environment exists 14 | if not exist "venv" ( 15 | echo [INFO] Creating virtual environment... 16 | python -m venv venv 17 | if errorlevel 1 ( 18 | echo [ERROR] Failed to create virtual environment. 19 | pause 20 | exit /b 21 | ) 22 | ) 23 | 24 | :: Activate virtual environment 25 | echo [INFO] Activating virtual environment... 26 | call venv\Scripts\activate.bat 27 | if errorlevel 1 ( 28 | echo [ERROR] Failed to activate virtual environment. 29 | pause 30 | exit /b 31 | ) 32 | 33 | :: Check for required dependencies 34 | echo [INFO] Checking dependencies... 35 | pip list > installed_packages.txt 2>&1 36 | 37 | setlocal enabledelayedexpansion 38 | set "missing_packages=" 39 | 40 | :: Read requirements.txt and check each package 41 | for /f "tokens=1,2 delims==" %%a in (requirements.txt) do ( 42 | findstr /i /c:"%%a" installed_packages.txt > nul 43 | if errorlevel 1 ( 44 | echo [INFO] Installing %%a... 45 | pip install %%a 46 | if errorlevel 1 ( 47 | echo [ERROR] Failed to install %%a. 48 | set "missing_packages=1" 49 | ) 50 | ) 51 | ) 52 | 53 | :: Clean up temporary files 54 | del installed_packages.txt 55 | 56 | :: If there were missing packages, exit 57 | if defined missing_packages ( 58 | echo [ERROR] Some packages could not be installed. Please check the error messages above. 59 | deactivate 60 | pause 61 | exit /b 62 | ) 63 | 64 | :: Start the main program 65 | echo [INFO] Starting the program... 66 | python main.py 67 | 68 | :: If the program crashes, pause to show the error message 69 | if errorlevel 1 ( 70 | echo. 71 | echo [ERROR] The program has crashed. 72 | pause 73 | ) 74 | 75 | :: Deactivate the virtual environment before exiting 76 | deactivate -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | # 空文件,用于标记目录为 Python 包 -------------------------------------------------------------------------------- /src/diff_tab.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter import ttk, filedialog, messagebox 3 | import json 4 | import os 5 | import sys 6 | from .node_diff import NodeDiffer 7 | 8 | class DiffTab(ttk.Frame): 9 | """节点对比标签页""" 10 | 11 | def __init__(self, parent): 12 | super().__init__(parent) 13 | self.setup_ui() 14 | self.output_file = None # 保存输出文件路径 15 | 16 | def setup_ui(self): 17 | """设置用户界面""" 18 | # 旧文件选择框 19 | old_frame = ttk.LabelFrame(self, text="旧版本节点文件", padding=5) 20 | old_frame.pack(fill=tk.X, padx=5, pady=5) 21 | 22 | self.old_path = tk.StringVar() 23 | ttk.Entry(old_frame, textvariable=self.old_path, width=50).pack(side=tk.LEFT, padx=5) 24 | ttk.Button(old_frame, text="选择文件", command=self.select_old_file).pack(side=tk.LEFT, padx=5) 25 | 26 | # 新文件选择框 27 | new_frame = ttk.LabelFrame(self, text="新版本节点文件", padding=5) 28 | new_frame.pack(fill=tk.X, padx=5, pady=5) 29 | 30 | self.new_path = tk.StringVar() 31 | ttk.Entry(new_frame, textvariable=self.new_path, width=50).pack(side=tk.LEFT, padx=5) 32 | ttk.Button(new_frame, text="选择文件", command=self.select_new_file).pack(side=tk.LEFT, padx=5) 33 | 34 | # 添加按钮框架 35 | button_frame = ttk.Frame(self) 36 | button_frame.pack(pady=5) 37 | 38 | # 比较按钮 39 | self.compare_btn = ttk.Button(button_frame, text="比较节点", command=self.compare_nodes) 40 | self.compare_btn.pack(side=tk.LEFT, padx=5) 41 | 42 | # 添加打开文件按钮(初始状态禁用) 43 | self.open_file_btn = ttk.Button( 44 | button_frame, 45 | text="打开结果文件", 46 | command=self.open_result_file, 47 | state=tk.DISABLED 48 | ) 49 | self.open_file_btn.pack(side=tk.LEFT, padx=5) 50 | 51 | # 结果显示区域 52 | result_frame = ttk.LabelFrame(self, text="比较结果", padding=5) 53 | result_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) 54 | 55 | self.result_text = tk.Text(result_frame, height=15, width=60) 56 | self.result_text.pack(fill=tk.BOTH, expand=True) 57 | 58 | def select_old_file(self): 59 | """选择旧版本文件""" 60 | filename = filedialog.askopenfilename( 61 | title="选择旧版本节点文件", 62 | filetypes=[("JSON files", "*.json")] 63 | ) 64 | if filename: 65 | self.old_path.set(filename) 66 | 67 | def select_new_file(self): 68 | """选择新版本文件""" 69 | filename = filedialog.askopenfilename( 70 | title="选择新版本节点文件", 71 | filetypes=[("JSON files", "*.json")] 72 | ) 73 | if filename: 74 | self.new_path.set(filename) 75 | 76 | def compare_nodes(self): 77 | """比较节点""" 78 | if not self.old_path.get() or not self.new_path.get(): 79 | messagebox.showerror("错误", "请选择要比较的文件!") 80 | return 81 | 82 | try: 83 | # 加载文件 84 | with open(self.old_path.get(), 'r', encoding='utf-8') as f: 85 | old_json = json.load(f) 86 | with open(self.new_path.get(), 'r', encoding='utf-8') as f: 87 | new_json = json.load(f) 88 | 89 | # 比较节点 90 | added_nodes, added_node_names = NodeDiffer.compare_nodes(old_json, new_json) 91 | 92 | # 显示结果 93 | self.result_text.delete('1.0', tk.END) 94 | if not added_nodes: 95 | self.result_text.insert(tk.END, "未发现新增节点。\n") 96 | self.open_file_btn.config(state=tk.DISABLED) # 禁用打开按钮 97 | self.output_file = None 98 | return 99 | 100 | self.result_text.insert(tk.END, f"发现 {len(added_nodes)} 个新增节点:\n\n") 101 | for node_name in added_node_names: 102 | self.result_text.insert(tk.END, f"- {node_name}\n") 103 | 104 | # 保存新增节点 105 | output_dir = os.path.dirname(self.new_path.get()) 106 | self.output_file = NodeDiffer.save_added_nodes(added_nodes, output_dir) 107 | 108 | if self.output_file: 109 | self.result_text.insert(tk.END, f"\n新增节点已保存到:\n{self.output_file}") 110 | self.open_file_btn.config(state=tk.NORMAL) # 启用打开按钮 111 | else: 112 | self.open_file_btn.config(state=tk.DISABLED) # 禁用打开按钮 113 | 114 | except Exception as e: 115 | messagebox.showerror("错误", f"比较失败:{str(e)}") 116 | self.open_file_btn.config(state=tk.DISABLED) # 禁用打开按钮 117 | self.output_file = None 118 | 119 | def open_result_file(self): 120 | """打开结果文件""" 121 | if not self.output_file or not os.path.exists(self.output_file): 122 | messagebox.showerror("错误", "结果文件不存在!") 123 | return 124 | 125 | try: 126 | # 使用系统默认程序打开文件 127 | if sys.platform == 'win32': # Windows 128 | os.startfile(self.output_file) 129 | elif sys.platform == 'darwin': # macOS 130 | os.system(f'open "{self.output_file}"') 131 | else: # Linux 132 | os.system(f'xdg-open "{self.output_file}"') 133 | except Exception as e: 134 | messagebox.showerror("错误", f"打开文件失败:{str(e)}") 135 | -------------------------------------------------------------------------------- /src/file_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | from typing import List, Dict 4 | import logging 5 | 6 | class FileUtils: 7 | """文件工具类 8 | 9 | 处理文件扫描、读写等操作 10 | """ 11 | 12 | @staticmethod 13 | def scan_python_files(folder_path: str) -> List[str]: 14 | """扫描目录下的所有 Python 文件 15 | 16 | Args: 17 | folder_path: 要扫描的文件夹路径 18 | 19 | Returns: 20 | List[str]: Python 文件路径列表 21 | """ 22 | if not os.path.exists(folder_path): 23 | raise FileNotFoundError(f"文件夹不存在: {folder_path}") 24 | 25 | if not os.path.isdir(folder_path): 26 | raise NotADirectoryError(f"路径不是文件夹: {folder_path}") 27 | 28 | python_files = [] 29 | 30 | # 遍历文件夹 31 | for root, _, files in os.walk(folder_path): 32 | for file in files: 33 | if file.endswith('.py'): 34 | # 忽略 __init__.py 和测试文件 35 | if file != '__init__.py' and not file.startswith('test_'): 36 | full_path = os.path.join(root, file) 37 | python_files.append(full_path) 38 | 39 | return python_files 40 | 41 | @staticmethod 42 | def save_json(data: dict, file_path: str): 43 | """保存 JSON 文件 44 | 45 | Args: 46 | data: 要保存的数据 47 | file_path: 文件路径 48 | """ 49 | try: 50 | # 确保目标目录存在 51 | os.makedirs(os.path.dirname(os.path.abspath(file_path)), exist_ok=True) 52 | 53 | # 保存文件 54 | with open(file_path, 'w', encoding='utf-8') as f: 55 | json.dump(data, f, indent=4, ensure_ascii=False) 56 | 57 | except Exception as e: 58 | raise Exception(f"保存 JSON 文件失败: {str(e)}") 59 | 60 | @staticmethod 61 | def load_json(file_path: str) -> Dict: 62 | """加载 JSON 文件 63 | 64 | Args: 65 | file_path: JSON 文件路径 66 | 67 | Returns: 68 | Dict: 加载的数据 69 | """ 70 | if not os.path.exists(file_path): 71 | raise FileNotFoundError(f"文件不存在: {file_path}") 72 | 73 | try: 74 | with open(file_path, 'r', encoding='utf-8') as f: 75 | return json.load(f) 76 | 77 | except json.JSONDecodeError as e: 78 | raise ValueError(f"JSON 格式错误: {str(e)}") 79 | except Exception as e: 80 | raise IOError(f"读取 JSON 文件失败: {str(e)}") 81 | 82 | @staticmethod 83 | def merge_json_files(file_paths: List[str], output_file: str) -> None: 84 | """合并多个 JSON 文件 85 | 86 | Args: 87 | file_paths: JSON 文件路径列表 88 | output_file: 输出文件路径 89 | """ 90 | merged_data = {} 91 | 92 | for file_path in file_paths: 93 | try: 94 | data = FileUtils.load_json(file_path) 95 | merged_data.update(data) 96 | except Exception as e: 97 | logging.warning(f"合并文件 {file_path} 时出错: {str(e)}") 98 | continue 99 | 100 | FileUtils.save_json(merged_data, output_file) 101 | 102 | @staticmethod 103 | def create_backup(file_path: str) -> str: 104 | """创建文件备份 105 | 106 | Args: 107 | file_path: 要备份的文件路径 108 | 109 | Returns: 110 | str: 备份文件路径 111 | """ 112 | if not os.path.exists(file_path): 113 | raise FileNotFoundError(f"文件不存在: {file_path}") 114 | 115 | backup_path = file_path + '.bak' 116 | counter = 1 117 | 118 | # 如果备份文件已存在,添加数字后缀 119 | while os.path.exists(backup_path): 120 | backup_path = f"{file_path}.bak{counter}" 121 | counter += 1 122 | 123 | try: 124 | import shutil 125 | shutil.copy2(file_path, backup_path) 126 | return backup_path 127 | except Exception as e: 128 | raise IOError(f"创建备份失败: {str(e)}") 129 | 130 | @staticmethod 131 | def is_file_empty(file_path: str) -> bool: 132 | """检查文件是否为空 133 | 134 | Args: 135 | file_path: 文件路径 136 | 137 | Returns: 138 | bool: 文件是否为空 139 | """ 140 | return os.path.exists(file_path) and os.path.getsize(file_path) == 0 141 | 142 | @staticmethod 143 | def get_file_info(file_path: str) -> Dict: 144 | """获取文件信息 145 | 146 | Args: 147 | file_path: 文件路径 148 | 149 | Returns: 150 | Dict: 文件信息字典 151 | """ 152 | if not os.path.exists(file_path): 153 | raise FileNotFoundError(f"文件不存在: {file_path}") 154 | 155 | stat = os.stat(file_path) 156 | return { 157 | 'size': stat.st_size, 158 | 'created': stat.st_ctime, 159 | 'modified': stat.st_mtime, 160 | 'accessed': stat.st_atime 161 | } 162 | 163 | @staticmethod 164 | def ensure_dir(dir_path: str) -> None: 165 | """确保目录存在,不存在则创建 166 | 167 | Args: 168 | dir_path: 目录路径 169 | """ 170 | if not os.path.exists(dir_path): 171 | os.makedirs(dir_path) 172 | 173 | @staticmethod 174 | def init_output_dirs(base_path: str) -> dict: 175 | """初始化输出目录结构 176 | 177 | Args: 178 | base_path: 程序根目录 179 | 180 | Returns: 181 | dict: 包含各个输出目录路径的字典 182 | """ 183 | # 创建主输出目录 184 | output_dir = os.path.join(base_path, "output") 185 | 186 | # 创建子目录 187 | dirs = { 188 | "main": output_dir, 189 | "temp": os.path.join(output_dir, "temp"), # 临时文件 190 | "translations": os.path.join(output_dir, "translations"), # 翻译结果 191 | "logs": os.path.join(output_dir, "logs"), # 日志文件 192 | "debug": os.path.join(output_dir, "debug"), # 调试信息 193 | "backups": os.path.join(output_dir, "backups"), # 备份文件 194 | } 195 | 196 | # 创建所有目录 197 | for dir_path in dirs.values(): 198 | os.makedirs(dir_path, exist_ok=True) 199 | 200 | return dirs 201 | 202 | @staticmethod 203 | def get_plugin_output_dir(base_path: str, plugin_path: str) -> dict: 204 | """获取插件对应的输出目录结构 205 | 206 | Args: 207 | base_path: 软件根目录 208 | plugin_path: 插件目录路径 209 | 210 | Returns: 211 | dict: 包含各个输出目录路径的字典 212 | """ 213 | # 获取插件文件夹名称 214 | plugin_name = os.path.basename(plugin_path.rstrip(os.path.sep)) 215 | 216 | # 创建主输出目录 217 | plugin_output_dir = os.path.join(base_path, "output", plugin_name) 218 | 219 | # 创建子目录 220 | dirs = { 221 | "main": plugin_output_dir, 222 | "temp": os.path.join(plugin_output_dir, "temp"), # 临时文件 223 | "translations": os.path.join(plugin_output_dir, "translations"), # 翻译结果 224 | "logs": os.path.join(plugin_output_dir, "logs"), # 日志文件 225 | "debug": os.path.join(plugin_output_dir, "debug"), # 调试信息 226 | } 227 | 228 | # 创建所有目录 229 | for dir_path in dirs.values(): 230 | os.makedirs(dir_path, exist_ok=True) 231 | 232 | return dirs -------------------------------------------------------------------------------- /src/node_diff.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from typing import Dict, Tuple, List 4 | 5 | class NodeDiffer: 6 | """节点差异分析器""" 7 | 8 | @staticmethod 9 | def _normalize_node_name(node_name: str) -> str: 10 | """规范化节点名称 11 | 12 | 例如: 13 | "LayerUtility: Llama Vision" -> "LayerUtility: LlamaVision" 14 | "LayerMask: Mask Edge Ultra Detail V2" -> "LayerMask: MaskEdgeUltraDetailV2" 15 | 16 | Args: 17 | node_name: 原始节点名称 18 | 19 | Returns: 20 | str: 规范化后的名称 21 | """ 22 | # 分割前缀和后缀 23 | if ":" in node_name: 24 | prefix, suffix = node_name.split(":", 1) 25 | prefix = prefix.strip() 26 | suffix = suffix.strip() 27 | 28 | # 特殊处理规则 29 | special_cases = { 30 | "Llama Vision": "LlamaVision", 31 | "BiRefNet Ultra": "BiRefNetUltra", 32 | "Ben Ultra": "BenUltra", 33 | "Florence2 Ultra": "Florence2Ultra", 34 | "SAM2 Ultra": "SAM2Ultra", 35 | "SAM2 Video Ultra": "SAM2VideoUltra", 36 | "EVF-SAM Ultra": "EVFSAMUltra", 37 | "Transparent Background Ultra": "TransparentBackgroundUltra", 38 | "Human Parts Ultra": "HumanPartsUltra", 39 | "Mask Edge Ultra Detail": "MaskEdgeUltraDetail" 40 | } 41 | 42 | # 处理后缀部分 43 | words = suffix.split() 44 | processed_words = [] 45 | i = 0 46 | while i < len(words): 47 | # 检查是否是特殊情况 48 | found = False 49 | for case, replacement in special_cases.items(): 50 | case_words = case.split() 51 | if i + len(case_words) <= len(words): 52 | if " ".join(words[i:i+len(case_words)]) == case: 53 | processed_words.append(replacement) 54 | i += len(case_words) 55 | found = True 56 | break 57 | 58 | if not found: 59 | # 处理版本号 60 | if words[i].startswith("V") and words[i][1:].isdigit(): 61 | processed_words.append(words[i]) # 保持版本号格式 62 | else: 63 | # 其他情况,移除空格并连接单词 64 | processed_words.append(words[i]) 65 | i += 1 66 | 67 | # 连接处理后的单词 68 | normalized_suffix = "".join(processed_words) 69 | 70 | # 重新组合,保持冒号后的空格 71 | return f"{prefix}: {normalized_suffix}" 72 | 73 | return node_name 74 | 75 | @staticmethod 76 | def _get_base_name(node_name: str) -> str: 77 | """获取节点的基础名称 78 | 79 | 例如: 80 | "LayerFilter: ChannelShake" -> "LayerFilter ChannelShake" 81 | 82 | Args: 83 | node_name: 完整节点名称 84 | 85 | Returns: 86 | str: 基础名称 87 | """ 88 | # 首先规范化节点名称 89 | normalized_name = NodeDiffer._normalize_node_name(node_name) 90 | # 移除所有标点符号和多余空格 91 | name = normalized_name.replace(":", " ").replace("-", " ").replace("_", " ") 92 | # 分割成单词并重新组合 93 | words = [word.strip() for word in name.split() if word.strip()] 94 | return " ".join(words) 95 | 96 | @staticmethod 97 | def compare_nodes(old_json: Dict, new_json: Dict) -> Tuple[Dict, List[str]]: 98 | """比较两个节点文件,找出新增的节点""" 99 | added_nodes = {} 100 | added_node_names = [] 101 | 102 | # 创建旧节点的基础名称集合 103 | old_base_names = {NodeDiffer._get_base_name(name) for name in old_json.keys()} 104 | 105 | # 遍历新文件中的所有节点 106 | for new_name, new_data in new_json.items(): 107 | # 规范化节点名称 108 | normalized_name = NodeDiffer._normalize_node_name(new_name) 109 | # 获取新节点的基础名称 110 | new_base_name = NodeDiffer._get_base_name(normalized_name) 111 | 112 | # 如果基础名称不在旧节点中,认为是新增的 113 | if new_base_name not in old_base_names: 114 | # 使用规范化后的名称保存节点 115 | added_nodes[normalized_name] = new_data 116 | added_node_names.append(normalized_name) 117 | 118 | return added_nodes, added_node_names 119 | 120 | @staticmethod 121 | def normalize_json_content(json_data: Dict) -> Dict: 122 | """规范化 JSON 内容中的节点名称 123 | 124 | Args: 125 | json_data: 原始 JSON 数据 126 | 127 | Returns: 128 | Dict: 规范化后的 JSON 数据 129 | """ 130 | normalized_data = {} 131 | 132 | # 遍历所有节点 133 | for node_name, node_data in json_data.items(): 134 | # 规范化节点名称 135 | normalized_name = NodeDiffer._normalize_node_name(node_name) 136 | normalized_data[normalized_name] = node_data 137 | 138 | return normalized_data 139 | 140 | @staticmethod 141 | def save_added_nodes(added_nodes: Dict, output_path: str) -> str: 142 | """保存新增节点到文件""" 143 | if not added_nodes: 144 | return "" 145 | 146 | # 规范化节点名称 147 | normalized_nodes = NodeDiffer.normalize_json_content(added_nodes) 148 | 149 | output_file = os.path.join(output_path, "added_nodes.json") 150 | with open(output_file, 'w', encoding='utf-8') as f: 151 | json.dump(normalized_nodes, f, indent=4, ensure_ascii=False) 152 | 153 | return output_file -------------------------------------------------------------------------------- /src/node_parser.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import os 3 | import logging 4 | import json 5 | from typing import Dict, List, Optional 6 | from src.file_utils import FileUtils 7 | 8 | class NodeParser: 9 | """ComfyUI 节点解析器类 10 | 11 | 用于解析 Python 文件中的 ComfyUI 节点定义,提取需要翻译的文本信息 12 | """ 13 | 14 | def __init__(self, folder_path: str): 15 | """初始化节点解析器 16 | 17 | Args: 18 | folder_path: 要解析的文件夹路径 19 | """ 20 | self.folder_path = folder_path 21 | self.base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 22 | self.dirs = FileUtils.init_output_dirs(self.base_path) 23 | 24 | def parse_file(self, file_path: str) -> Dict: 25 | """解析单个 Python 文件 26 | 27 | Args: 28 | file_path: Python 文件路径 29 | 30 | Returns: 31 | Dict: 解析出的节点信息字典 32 | """ 33 | logging.info(f"开始解析文件: {file_path}") 34 | 35 | with open(file_path, 'r', encoding='utf-8') as f: 36 | content = f.read() 37 | 38 | # 解析 Python 代码为 AST 39 | tree = ast.parse(content) 40 | nodes_info = {} 41 | 42 | # 首先获取映射信息 43 | node_mappings = {} # 类名到节点名的映射 44 | display_names = {} # 节点名到显示名的映射 45 | 46 | # 获取 NODE_CLASS_MAPPINGS 和 NODE_DISPLAY_NAME_MAPPINGS 47 | for node in ast.walk(tree): 48 | if isinstance(node, ast.Assign): 49 | targets = [t.id for t in node.targets if isinstance(t, ast.Name)] 50 | if 'NODE_CLASS_MAPPINGS' in targets and isinstance(node.value, ast.Dict): 51 | # 修改这里的映射获取逻辑 52 | for key, value in zip(node.value.keys, node.value.values): 53 | if isinstance(key, ast.Str) and isinstance(value, ast.Name): 54 | # 反转映射关系:使用类名作为键,映射名作为值 55 | class_name = value.id 56 | mapped_name = key.s 57 | node_mappings[class_name] = mapped_name 58 | logging.debug(f"找到节点映射: {class_name} -> {mapped_name}") 59 | elif 'NODE_DISPLAY_NAME_MAPPINGS' in targets and isinstance(node.value, ast.Dict): 60 | for key, value in zip(node.value.keys, node.value.values): 61 | if isinstance(key, ast.Str) and isinstance(value, ast.Str): 62 | display_names[key.s] = value.s 63 | logging.debug(f"找到显示名映射: {key.s} -> {value.s}") 64 | 65 | # 解析节点类 66 | for node in ast.walk(tree): 67 | if isinstance(node, ast.ClassDef): 68 | logging.debug(f"检查类: {node.name}") 69 | if self._is_comfy_node(node): 70 | node_info = self._parse_node_class(node) 71 | if node_info: 72 | # 获取正确的节点名称 73 | class_name = node.name 74 | if class_name in node_mappings: 75 | # 使用映射中定义的实际节点名 76 | node_key = node_mappings[class_name] 77 | # 获取显示名称 78 | display_name = display_names.get(node_key, node_key) 79 | logging.info(f"使用映射节点名: {node_key} (显示名称: {display_name})") 80 | else: 81 | # 如果没有映射,使用类名 82 | node_key = class_name 83 | display_name = class_name 84 | logging.info(f"使用类名作为节点名: {node_key}") 85 | 86 | nodes_info[node_key] = { 87 | "title": display_name, 88 | "inputs": node_info.get("inputs", {}), 89 | "widgets": node_info.get("widgets", {}), 90 | "outputs": node_info.get("outputs", {}) 91 | } 92 | 93 | logging.info(f"成功解析节点: {node_key} (显示名称: {display_name})") 94 | 95 | logging.info(f"文件 {file_path} 解析完成,找到 {len(nodes_info)} 个节点") 96 | return nodes_info 97 | 98 | def _parse_node_class(self, class_node: ast.ClassDef) -> Optional[Dict]: 99 | """解析节点类定义 100 | 101 | Args: 102 | class_node: 类定义的 AST 节点 103 | 104 | Returns: 105 | Optional[Dict]: 节点信息字典,如果不是 ComfyUI 节点则返回 None 106 | """ 107 | if not self._is_comfy_node(class_node): 108 | return None 109 | 110 | node_info = { 111 | 'title': self._get_node_title(class_node), 112 | 'inputs': {}, 113 | 'outputs': {}, 114 | 'widgets': {} 115 | } 116 | 117 | # 解析类中的方法和属性 118 | for item in class_node.body: 119 | # 检查 INPUT_TYPES 方法 120 | if isinstance(item, ast.FunctionDef) and item.name == 'INPUT_TYPES': 121 | # 检查是否是类方法 122 | if any(isinstance(decorator, ast.Name) and decorator.id == 'classmethod' 123 | for decorator in item.decorator_list): 124 | parsed_types = self._parse_input_types_method(item) 125 | if parsed_types: 126 | # 更新输入 127 | if 'inputs' in parsed_types: 128 | node_info['inputs'].update(parsed_types['inputs']) 129 | # 更新部件 130 | if 'widgets' in parsed_types: 131 | node_info['widgets'].update(parsed_types['widgets']) 132 | 133 | # 解析类属性 134 | elif isinstance(item, ast.Assign): 135 | targets = [t.id for t in item.targets if isinstance(t, ast.Name)] 136 | 137 | # 解析 RETURN_TYPES 138 | if 'RETURN_TYPES' in targets: 139 | return_types = self._parse_return_types(item.value) 140 | if return_types: 141 | # 为每个返回类型创建默认输出名称 142 | for i, return_type in enumerate(return_types): 143 | node_info['outputs'][f'output_{i}'] = return_type 144 | 145 | # 解析 RETURN_NAMES 146 | elif 'RETURN_NAMES' in targets: 147 | return_names = self._parse_return_names(item.value) 148 | if return_names: 149 | # 使用自定义名称替换默认输出名称 150 | outputs = {} 151 | for i, (name, type_) in enumerate(zip(return_names, node_info['outputs'].values())): 152 | outputs[name] = type_ 153 | node_info['outputs'] = outputs 154 | 155 | # 解析其他属性 156 | elif 'CATEGORY' in targets: 157 | if isinstance(item.value, ast.Constant): 158 | node_info['category'] = item.value.value 159 | elif 'FUNCTION' in targets: 160 | if isinstance(item.value, ast.Constant): 161 | node_info['function'] = item.value.value 162 | elif 'OUTPUT_NODE' in targets: 163 | if isinstance(item.value, ast.Constant): 164 | node_info['is_output'] = item.value.value 165 | 166 | return node_info 167 | 168 | def _is_comfy_node(self, class_node: ast.ClassDef) -> bool: 169 | """检查类是否是 ComfyUI 节点 170 | 171 | Args: 172 | class_node: 类定义的 AST 节点 173 | 174 | Returns: 175 | bool: 是否是 ComfyUI 节点 176 | """ 177 | has_input_types = False 178 | has_return_types = False 179 | has_category = False 180 | has_function = False 181 | 182 | # 首先检查类方法 183 | for item in class_node.body: 184 | # 检查 INPUT_TYPES 方法 185 | if isinstance(item, ast.FunctionDef) and item.name == 'INPUT_TYPES': 186 | # 检查是否是类方法或普通方法 187 | if (any(isinstance(decorator, ast.Name) and decorator.id == 'classmethod' 188 | for decorator in item.decorator_list) or 189 | item.name == 'INPUT_TYPES'): 190 | has_input_types = True 191 | 192 | # 检查类属性 193 | elif isinstance(item, ast.Assign): 194 | for target in item.targets: 195 | if isinstance(target, ast.Name): 196 | if target.id == 'RETURN_TYPES': 197 | has_return_types = True 198 | elif target.id == 'CATEGORY': 199 | has_category = True 200 | elif target.id == 'FUNCTION': 201 | has_function = True 202 | 203 | # 记录详细信息 204 | logging.debug(f"节点类 {class_node.name} 检查结果:") 205 | logging.debug(f"- INPUT_TYPES: {has_input_types}") 206 | logging.debug(f"- RETURN_TYPES: {has_return_types}") 207 | logging.debug(f"- CATEGORY: {has_category}") 208 | logging.debug(f"- FUNCTION: {has_function}") 209 | 210 | # 只要满足 INPUT_TYPES 和 RETURN_TYPES 中的一个就认为是节点 211 | is_node = has_input_types or has_return_types 212 | 213 | if is_node: 214 | logging.info(f"找到 ComfyUI 节点类: {class_node.name}") 215 | 216 | return is_node 217 | 218 | def _parse_input_types_method(self, method_node: ast.FunctionDef) -> Dict: 219 | """解析 INPUT_TYPES 方法,同时提取输入和部件信息 220 | 221 | Args: 222 | method_node: 方法的 AST 节点 223 | 224 | Returns: 225 | Dict: 包含 inputs 和 widgets 的字典 226 | """ 227 | result = { 228 | 'inputs': {}, 229 | 'widgets': {} 230 | } 231 | 232 | # 查找 return 语句 233 | for node in ast.walk(method_node): 234 | if isinstance(node, ast.Return) and isinstance(node.value, ast.Dict): 235 | # 解析返回的字典 236 | for key, value in zip(node.value.keys, node.value.values): 237 | if isinstance(key, ast.Constant): 238 | section_name = key.value # required, optional, hidden 239 | if isinstance(value, ast.Dict): 240 | # 解析每个输入/部件定义 241 | for item_key, item_value in zip(value.keys, value.values): 242 | if isinstance(item_key, ast.Constant): 243 | item_name = item_key.value 244 | # 解析类型元组 245 | if isinstance(item_value, ast.Tuple): 246 | type_info = self._parse_type_tuple(item_value) 247 | # 根据类型判断是输入还是部件 248 | if self._is_widget_type(type_info['type']): 249 | result['widgets'][item_name] = type_info['type'] 250 | else: 251 | # 对于输入,保留原始名称 252 | if section_name in ['required', 'optional', 'hidden']: 253 | result['inputs'][item_name] = item_name 254 | 255 | return result 256 | 257 | def _parse_type_tuple(self, tuple_node: ast.Tuple) -> Dict: 258 | """解析类型元组,提取类型和参数信息 259 | 260 | Args: 261 | tuple_node: 元组的 AST 节点 262 | 263 | Returns: 264 | Dict: 包含类型和参数的字典 265 | """ 266 | type_info = { 267 | 'type': 'UNKNOWN', 268 | 'params': {} 269 | } 270 | 271 | if len(tuple_node.elts) > 0: 272 | # 解析类型 273 | first_element = tuple_node.elts[0] 274 | if isinstance(first_element, ast.Constant): 275 | type_info['type'] = first_element.value 276 | elif isinstance(first_element, ast.Name): 277 | type_info['type'] = first_element.id 278 | 279 | # 解析参数(如果有) 280 | if len(tuple_node.elts) > 1: 281 | second_element = tuple_node.elts[1] 282 | if isinstance(second_element, ast.Dict): 283 | for key, value in zip(second_element.keys, second_element.values): 284 | if isinstance(key, ast.Constant): 285 | param_name = key.value 286 | if isinstance(value, ast.Constant): 287 | type_info['params'][param_name] = value.value 288 | 289 | return type_info 290 | 291 | def _is_widget_type(self, type_name: str) -> bool: 292 | """判断类型是否是部件类型 293 | 294 | Args: 295 | type_name: 类型名称 296 | 297 | Returns: 298 | bool: 是否是部件类型 299 | """ 300 | widget_types = { 301 | 'INT', 'FLOAT', 'STRING', 'BOOLEAN', 302 | 'COMBO', 'DROPDOWN', 'TEXT', 'TEXTAREA', 303 | 'SLIDER', 'CHECKBOX', 'COLOR', 'RADIO', 304 | 'SELECT', 'NUMBER' 305 | } 306 | 307 | # 添加一些常见的输入类型 308 | input_types = { 309 | 'IMAGE', 'LATENT', 'MODEL', 'VAE', 'CLIP', 310 | 'CONDITIONING', 'MASK', 'STYLE_MODEL', 311 | 'CONTROL_NET', 'BBOX', 'SEGS' 312 | } 313 | 314 | return (type_name.upper() in widget_types and 315 | type_name.upper() not in input_types) 316 | 317 | def _parse_return_types(self, value_node: ast.AST) -> List[str]: 318 | """解析 RETURN_TYPES 定义 319 | 320 | Args: 321 | value_node: 值的 AST 节点 322 | 323 | Returns: 324 | List[str]: 返回类型列表 325 | """ 326 | return_types = [] 327 | 328 | if isinstance(value_node, (ast.Tuple, ast.List)): 329 | for element in value_node.elts: 330 | if isinstance(element, ast.Constant): 331 | return_types.append(element.value) 332 | elif isinstance(element, ast.Name): 333 | return_types.append(element.id) 334 | 335 | return return_types 336 | 337 | def _parse_return_names(self, value_node: ast.AST) -> List[str]: 338 | """解析 RETURN_NAMES 定义 339 | 340 | Args: 341 | value_node: 值的 AST 节点 342 | 343 | Returns: 344 | List[str]: 返回名称列表 345 | """ 346 | return_names = [] 347 | 348 | if isinstance(value_node, (ast.Tuple, ast.List)): 349 | for element in value_node.elts: 350 | if isinstance(element, ast.Constant): 351 | return_names.append(element.value) 352 | 353 | return return_names 354 | 355 | def _get_node_title(self, class_node: ast.ClassDef) -> str: 356 | """获取节点的显示标题 357 | 358 | Args: 359 | class_node: 类定义的 AST 节点 360 | 361 | Returns: 362 | str: 节点标题 363 | """ 364 | # 首先查找 NODE_NAME 属性 365 | for item in class_node.body: 366 | if isinstance(item, ast.Assign): 367 | targets = [t.id for t in item.targets if isinstance(t, ast.Name)] 368 | if 'NODE_NAME' in targets and isinstance(item.value, ast.Str): 369 | return item.value.s 370 | 371 | # 如果没有 NODE_NAME 属性,使用类名 372 | return class_node.name 373 | 374 | def _parse_widgets(self, node_class) -> Dict: 375 | """解析节点的部件信息 376 | 377 | Args: 378 | node_class: 节点类 379 | 380 | Returns: 381 | Dict: 部件信息字典 382 | """ 383 | widgets = {} 384 | 385 | # 检查类是否有 REQUIRED 属性 386 | if hasattr(node_class, 'REQUIRED'): 387 | required = node_class.REQUIRED 388 | # 遍历所有必需的部件 389 | for widget_name, widget_type in required.items(): 390 | # 使用原始的部件名称作为值,而不是类型 391 | widgets[widget_name] = widget_name 392 | 393 | # 检查类是否有 OPTIONAL 属性 394 | if hasattr(node_class, 'OPTIONAL'): 395 | optional = node_class.OPTIONAL 396 | # 遍历所有可选的部件 397 | for widget_name, widget_type in optional.items(): 398 | # 使用原始的部件名称作为值,而不是类型 399 | widgets[widget_name] = widget_name 400 | 401 | return widgets 402 | 403 | def _parse_inputs(self, node_class) -> Dict: 404 | """解析节点的输入信息 405 | 406 | Args: 407 | node_class: 节点类 408 | 409 | Returns: 410 | Dict: 输入信息字典 411 | """ 412 | inputs = {} 413 | 414 | # 检查类是否有 INPUT_TYPES 属性 415 | if hasattr(node_class, 'INPUT_TYPES'): 416 | input_types = node_class.INPUT_TYPES 417 | # 如果 INPUT_TYPES 是一个字典 418 | if isinstance(input_types, dict) and 'required' in input_types: 419 | required = input_types['required'] 420 | # 遍历所有必需的输入 421 | for input_name, input_type in required.items(): 422 | # 使用原始的输入名称作为值 423 | inputs[input_name] = input_name 424 | 425 | return inputs 426 | 427 | def _parse_outputs(self, node_class) -> Dict: 428 | """解析节点的输出信息 429 | 430 | Args: 431 | node_class: 节点类 432 | 433 | Returns: 434 | Dict: 输出信息字典 435 | """ 436 | outputs = {} 437 | 438 | # 检查类是否有 RETURN_TYPES 属性 439 | if hasattr(node_class, 'RETURN_TYPES'): 440 | return_types = node_class.RETURN_TYPES 441 | # 遍历所有输出类型 442 | for i, output_type in enumerate(return_types): 443 | output_name = f"output_{i}" 444 | # 使用原始的输出名称作为值 445 | outputs[output_name] = output_name 446 | 447 | return outputs 448 | 449 | def optimize_node_info(self, nodes_info: Dict) -> Dict: 450 | """优化节点信息,处理特殊的键值情况并规范化格式 451 | 452 | Args: 453 | nodes_info: 原始节点信息字典 454 | 455 | Returns: 456 | Dict: 优化后的节点信息字典 457 | """ 458 | optimized = {} 459 | 460 | # 定义需要替换的类型 461 | type_replacements = { 462 | 'INT': True, 463 | 'FLOAT': True, 464 | 'BOOL': True, 465 | 'STRING': True, 466 | 'NUMBER': True, 467 | 'BOOLEAN': True 468 | } 469 | 470 | # 定义字段顺序 471 | field_order = ['title', 'inputs', 'widgets', 'outputs'] 472 | 473 | for node_name, node_info in nodes_info.items(): 474 | # 创建一个有序字典来保持字段顺序 475 | optimized_node = {} 476 | 477 | # 按照指定顺序添加字段 478 | for field in field_order: 479 | if field == 'title': 480 | optimized_node['title'] = node_info.get('title', '') 481 | elif field == 'inputs': 482 | optimized_node['inputs'] = {} 483 | for input_name, input_value in node_info.get('inputs', {}).items(): 484 | if input_value in type_replacements: 485 | optimized_node['inputs'][input_name] = input_name 486 | else: 487 | optimized_node['inputs'][input_name] = input_value 488 | elif field == 'widgets': 489 | optimized_node['widgets'] = {} 490 | for widget_name, widget_value in node_info.get('widgets', {}).items(): 491 | if widget_value in type_replacements: 492 | optimized_node['widgets'][widget_name] = widget_name 493 | else: 494 | optimized_node['widgets'][widget_name] = widget_value 495 | elif field == 'outputs': 496 | optimized_node['outputs'] = {} 497 | for output_name, output_value in node_info.get('outputs', {}).items(): 498 | if output_value in type_replacements: 499 | optimized_node['outputs'][output_name] = output_name 500 | else: 501 | optimized_node['outputs'][output_name] = output_value 502 | 503 | optimized[node_name] = optimized_node 504 | 505 | return optimized 506 | 507 | def parse_folder(self, folder_path: str) -> Dict: 508 | """解析文件夹中的所有 Python 文件""" 509 | all_nodes = {} 510 | 511 | # 获取插件专属的输出目录 512 | self.plugin_dirs = FileUtils.get_plugin_output_dir(self.base_path, folder_path) 513 | 514 | debug_info = { 515 | "total_files": 0, 516 | "processed_files": 0, 517 | "found_nodes": 0, 518 | "file_details": [] 519 | } 520 | 521 | # 扫描 Python 文件 522 | try: 523 | py_files = FileUtils.scan_python_files(folder_path) 524 | debug_info["total_files"] = len(py_files) 525 | logging.info(f"找到 {len(py_files)} 个 Python 文件") 526 | except Exception as e: 527 | logging.error(f"扫描文件夹失败: {str(e)}") 528 | return {} 529 | 530 | # 解析每个文件 531 | for file_path in py_files: 532 | try: 533 | logging.info(f"正在解析文件: {file_path}") 534 | 535 | # 读取文件内容 536 | with open(file_path, 'r', encoding='utf-8') as f: 537 | content = f.read() 538 | 539 | # 解析文件 540 | nodes = self.parse_file(file_path) 541 | 542 | # 记录文件信息 543 | file_info = { 544 | "file": file_path, 545 | "nodes_found": len(nodes) if nodes else 0, 546 | "node_names": list(nodes.keys()) if nodes else [] 547 | } 548 | debug_info["file_details"].append(file_info) 549 | 550 | if nodes: 551 | debug_info["found_nodes"] += len(nodes) 552 | all_nodes.update(nodes) 553 | logging.info(f"从文件 {file_path} 中解析出 {len(nodes)} 个节点: {list(nodes.keys())}") 554 | else: 555 | logging.info(f"文件 {file_path} 中未找到节点") 556 | 557 | debug_info["processed_files"] += 1 558 | 559 | except Exception as e: 560 | logging.error(f"解析文件失败 {file_path}: {str(e)}") 561 | debug_info["file_details"].append({ 562 | "file": file_path, 563 | "error": str(e) 564 | }) 565 | continue 566 | 567 | # 保存调试信息 568 | debug_file = os.path.join(self.plugin_dirs["debug"], "node_detection_debug.json") 569 | try: 570 | FileUtils.save_json(debug_info, debug_file) 571 | logging.info(f"调试信息已保存到: {debug_file}") 572 | except Exception as e: 573 | logging.error(f"保存调试信息失败: {str(e)}") 574 | 575 | # 优化节点信息 576 | try: 577 | optimized_nodes = self.optimize_node_info(all_nodes) 578 | logging.info(f"成功优化 {len(optimized_nodes)} 个节点的信息") 579 | return optimized_nodes 580 | except Exception as e: 581 | logging.error(f"优化节点信息失败: {str(e)}") 582 | return all_nodes -------------------------------------------------------------------------------- /src/prompts.py: -------------------------------------------------------------------------------- 1 | """提示词模板管理模块""" 2 | 3 | # 通用的翻译助手提示词 4 | TRANSLATOR_PROMPT = """你是一个专业的 ComfyUI 节点翻译助手。请遵循以下规则: 5 | 6 | 严格遵循规则: 保持 JSON 格式不变,只翻译右侧值为中文 7 | 8 | 2. 节点标题翻译规则: 9 | - 保留功能类型标识,如 "While循环-起始"、"While循环-结束" 10 | - 对于版本标识,保持原样,如 "V2"、"SDXL"、 "Ultra"等 11 | - 对于功能组合词,采用"动词+名词"结构,如 "IPAdapterApply" -> "应用IPAdapter" 12 | 13 | 3. 参数翻译规则: 14 | - 保持专业术语的准确性和一致性 15 | - 常见参数的标准翻译: 16 | * image/IMAGE -> 图像 17 | * mask/MASK -> 遮罩 18 | * text/STRING -> 文本/字符串 19 | * value -> 值 20 | * strength -> 强度 21 | * weight -> 权重/比重 22 | * scale -> 缩放 23 | * size -> 大小 24 | * mode -> 模式 25 | * type -> 类型 26 | * range -> 范围 27 | * step -> 步进 28 | * flow -> 流 29 | * boolean -> 布尔 30 | * optional -> 可选 31 | * pipe -> 节点束 32 | * embed/embeds -> 嵌入组 33 | * params -> 参数组 34 | * preset -> 预设 35 | * provider -> 设备 36 | * start_at/end_at -> 开始位置/结束位置 37 | * boost -> 增强 38 | * combine -> 合并 39 | * batch -> 批次 40 | 41 | 4. 特殊处理规则: 42 | - AI/ML 专业术语保持原样: 43 | * IPAdapter、LoRA、VAE、CLIP、Bbox、Tensor、BBOX、sigma、sigmas等 44 | * FaceID、InsightFace、SDXL 等 45 | - 复合专业术语的处理: 46 | * clip_vision -> CLIP视觉 47 | * attn_mask -> 关注层遮罩 48 | * embeds_scaling -> 嵌入组缩放 49 | - 正负面词汇统一: 50 | * positive -> 正面 51 | * negative -> 负面 52 | - 数字和层级: 53 | * 数字编号使用中文,如 "weights_1" -> "权重_1" 54 | * 保持层级关系,如 "initial_value0" -> "初始值0" 55 | * 多个相似项使用编号,如 "image1/image2" -> "图像_1/图像_2" 56 | - 值的翻译规则: 57 | * 只翻译值部分为中文 58 | * 遵循前面定义的翻译规则 59 | * 保持格式统一性 60 | 61 | 8. 翻译案例结构参考: 62 | ```json 63 | "IPAdapterMS": { 64 | "title": "应用IPAdapter Mad Scientist", 65 | "inputs": { 66 | "model": "模型", 67 | "ipadapter": "IPAdapter", 68 | "image": "图像", 69 | "image_negative": "负面图像", 70 | "attn_mask": "关注层遮罩", 71 | "clip_vision": "CLIP视觉" 72 | }, 73 | "widgets": { 74 | "weight": "权重", 75 | "weight_type": "权重类型", 76 | "combine_embeds": "合并嵌入组", 77 | "start_at": "开始应用位置", 78 | "end_at": "结束应用位置", 79 | "embeds_scaling": "嵌入组缩放" 80 | }, 81 | "outputs": { 82 | "MODEL": "模型" 83 | } 84 | } 85 | ``` 86 | 87 | 翻译要点说明: 88 | - title: 采用"动词+名词"结构,保留专有名词 89 | - inputs/outputs: 保持专业术语一致性,如 MODEL -> 模型 90 | - widgets: 参数命名规范,使用标准翻译对照 91 | - 整体结构完整,格式统一,术语翻译一致""" 92 | 93 | # 不同模型的测试提示词 94 | MODEL_TEST_PROMPTS = { 95 | "qwen-omni-turbo": "你是一个 AI 助手。", 96 | "deepseek-v3": "你是一个 AI 助手。", 97 | "default": "你是一个 AI 助手。" 98 | } 99 | 100 | # 火山引擎的特殊提示词 101 | VOLCENGINE_PROMPT = "你是豆包,是由字节跳动开发的 AI 人工智能助手" 102 | 103 | class PromptTemplate: 104 | """提示词模板类""" 105 | 106 | @staticmethod 107 | def get_translator_prompt() -> str: 108 | """获取翻译器的系统提示词""" 109 | return """你是一个专业的 ComfyUI 节点翻译专家。请将提供的节点信息从英文翻译成中文,遵循以下规则: 110 | 111 | 严格遵循规则: 保持 JSON 格式不变,只翻译右侧值为中文 112 | 113 | 2. 节点标题翻译规则: 114 | - 保留功能类型标识,如 "While循环-起始"、"While循环-结束" 115 | - 对于版本标识,保持原样,如 "V2"、"SDXL"、 "Ultra"等 116 | - 对于功能组合词,采用"动词+名词"结构,如 "IPAdapterApply" -> "应用IPAdapter" 117 | 118 | 3. 参数翻译规则: 119 | - 保持专业术语的准确性和一致性 120 | - 常见参数的标准翻译: 121 | * image/IMAGE -> 图像 122 | * mask/MASK -> 遮罩 123 | * text/STRING -> 文本/字符串 124 | * value -> 值 125 | * strength -> 强度 126 | * weight -> 权重/比重 127 | * scale -> 缩放 128 | * size -> 大小 129 | * mode -> 模式 130 | * type -> 类型 131 | * range -> 范围 132 | * step -> 步进 133 | * flow -> 流 134 | * boolean -> 布尔 135 | * optional -> 可选 136 | * pipe -> 节点束 137 | * embed/embeds -> 嵌入组 138 | * params -> 参数组 139 | * preset -> 预设 140 | * provider -> 设备 141 | * start_at/end_at -> 开始位置/结束位置 142 | * boost -> 增强 143 | * combine -> 合并 144 | * batch -> 批次 145 | 146 | 4. 特殊处理规则: 147 | - AI/ML 专业术语保持原样(无论大小写): 148 | * IPAdapter、LoRA、VAE、CLIP、Bbox、Tensor、BBOX、sigma、sigmas等 149 | * FaceID、InsightFace、SDXL 等 150 | - 复合专业术语的处理: 151 | * clip_vision -> CLIP视觉 152 | * attn_mask -> 关注层遮罩 153 | * embeds_scaling -> 嵌入组缩放 154 | - 正负面词汇统一: 155 | * positive -> 正面 156 | * negative -> 负面 157 | - 数字和层级: 158 | * 数字编号使用中文,如 "weights_1" -> "权重_1" 159 | * 保持层级关系,如 "initial_value0" -> "初始值0" 160 | * 多个相似项使用编号,如 "image1/image2" -> "图像_1/图像_2" 161 | - 值的翻译规则: 162 | * 只翻译值部分为中文 163 | * 遵循前面定义的翻译规则 164 | * 保持格式统一性 165 | 166 | 8. 翻译案例结构参考: 167 | ```json 168 | "IPAdapterMS": { 169 | "title": "应用IPAdapter Mad Scientist", 170 | "inputs": { 171 | "model": "模型", 172 | "ipadapter": "IPAdapter", 173 | "image": "图像", 174 | "image_negative": "负面图像", 175 | "attn_mask": "关注层遮罩", 176 | "clip_vision": "CLIP视觉" 177 | }, 178 | "widgets": { 179 | "weight": "权重", 180 | "weight_type": "权重类型", 181 | "combine_embeds": "合并嵌入组", 182 | "start_at": "开始应用位置", 183 | "end_at": "结束应用位置", 184 | "embeds_scaling": "嵌入组缩放" 185 | }, 186 | "outputs": { 187 | "MODEL": "模型" 188 | } 189 | } 190 | ``` 191 | 192 | 翻译要点说明: 193 | - title: 采用"动词+名词"结构,保留专有名词 194 | - inputs/outputs: 保持专业术语一致性,如 MODEL -> 模型 195 | - widgets: 参数命名规范,使用标准翻译对照 196 | - 整体结构完整,格式统一,术语翻译一致""" 197 | 198 | @staticmethod 199 | def get_test_prompt() -> str: 200 | """获取测试提示词""" 201 | return "你是一个专业的翻译助手。请用简短的一句话回应。" 202 | 203 | @staticmethod 204 | def get_volcengine_prompt() -> str: 205 | """获取火山引擎提示词""" 206 | return "你是一个专业的翻译助手。" -------------------------------------------------------------------------------- /src/translation_config.py: -------------------------------------------------------------------------------- 1 | """翻译服务配置""" 2 | 3 | class TranslationServiceConfig: 4 | """翻译服务基础配置类""" 5 | def __init__(self): 6 | self.name = "" 7 | self.models = [] 8 | self.enabled = True 9 | self.requires_model_id = False 10 | self.default_model = "" 11 | 12 | class AliyunConfig(TranslationServiceConfig): 13 | """阿里云翻译服务配置""" 14 | def __init__(self): 15 | super().__init__() 16 | self.name = "aliyun" 17 | self.models = [ 18 | "qwen-turbo", 19 | "qwen-plus", 20 | "qwen-max", 21 | "qwen-max-longcontext" 22 | ] 23 | self.enabled = True 24 | self.default_model = "qwen-plus" 25 | 26 | class VolcengineConfig(TranslationServiceConfig): 27 | """火山引擎翻译服务配置""" 28 | def __init__(self): 29 | super().__init__() 30 | self.name = "volcengine" 31 | self.models = [] # 不提供预设模型列表 32 | self.enabled = True # 启用服务 33 | self.requires_model_id = True # 需要手动输入模型ID 34 | self.default_model = "" # 无默认模型 35 | self.model_selection_enabled = False # 禁用模型选择 36 | 37 | class TranslationServices: 38 | """翻译服务管理器""" 39 | def __init__(self): 40 | self.services = { 41 | "aliyun": AliyunConfig(), 42 | "volcengine": VolcengineConfig() 43 | } 44 | 45 | def get_service(self, name: str) -> TranslationServiceConfig: 46 | """获取指定服务的配置""" 47 | return self.services.get(name) 48 | 49 | def get_enabled_services(self) -> list: 50 | """获取所有启用的服务""" 51 | return [s for s in self.services.values() if s.enabled] 52 | 53 | class TranslationConfig: 54 | """翻译配置类""" 55 | 56 | # 需要保持原样的大写类型值 57 | PRESERVED_TYPES = { 58 | 'IMAGE', 'MASK', 'MODEL', 'CROP_DATA', 'ZIP', 'PDF', 'CSV', 59 | 'INT', 'FLOAT', 'BOOLEAN', 'STRING', 'BBOX_LIST' 60 | } 61 | 62 | # 需要保持原样的技术参数键名 63 | PRESERVED_KEYS = { 64 | 'width', 'height', 'width_old', 'height_old', 'job_id', 'user_id', 65 | 'base64_string', 'image_quality', 'image_format' 66 | } 67 | 68 | # 通用参数翻译映射 69 | COMMON_TRANSLATIONS = { 70 | 'image': '图像', 71 | 'mask': '遮罩', 72 | 'model': '模型', 73 | 'processor': '处理器', 74 | 'device': '设备', 75 | 'bbox': '边界框', 76 | 'samples': '样本', 77 | 'operation': '操作', 78 | 'guide': '引导图', 79 | 'source': '源', 80 | 'destination': '目标', 81 | 'threshold': '阈值', 82 | 'radius': '半径', 83 | 'epsilon': 'epsilon', 84 | 'contrast': '对比度', 85 | 'brightness': '亮度', 86 | 'saturation': '饱和度', 87 | 'hue': '色调', 88 | 'gamma': '伽马值', 89 | 'index': '索引', 90 | 'position': '位置', 91 | 'size': '大小', 92 | 'scale': '缩放', 93 | 'dilation': '膨胀', 94 | 'count': '计数', 95 | 'result': '结果' 96 | } 97 | 98 | # 人体部位翻译映射 99 | BODY_PART_TRANSLATIONS = { 100 | 'background': '背景', 101 | 'skin': '皮肤', 102 | 'nose': '鼻子', 103 | 'eye': '眼睛', 104 | 'eye_g': '眼镜', 105 | 'brow': '眉毛', 106 | 'ear': '耳朵', 107 | 'mouth': '嘴巴', 108 | 'lip': '嘴唇', 109 | 'hair': '头发', 110 | 'hat': '帽子', 111 | 'neck': '脖子', 112 | 'cloth': '衣服' 113 | } 114 | 115 | # 方向和位置翻译映射 116 | DIRECTION_TRANSLATIONS = { 117 | # 前缀 118 | 'l_': '左', 119 | 'r_': '右', 120 | 'u_': '上', 121 | 'b_': '下', 122 | # 后缀 123 | '_l': '左', 124 | '_r': '右', 125 | '_t': '上', 126 | '_b': '下' 127 | } 128 | 129 | @classmethod 130 | def get_translation(cls, text: str) -> str: 131 | """获取文本的翻译 132 | 133 | Args: 134 | text: 要翻译的文本 135 | 136 | Returns: 137 | str: 翻译后的文本,如果没有找到翻译则返回原文 138 | """ 139 | # 1. 检查是否是保留类型 140 | if text.upper() in cls.PRESERVED_TYPES: 141 | return text 142 | 143 | # 2. 检查是否在通用翻译中 144 | if text.lower() in cls.COMMON_TRANSLATIONS: 145 | return cls.COMMON_TRANSLATIONS[text.lower()] 146 | 147 | # 3. 检查是否是人体部位 148 | if text in cls.BODY_PART_TRANSLATIONS: 149 | return cls.BODY_PART_TRANSLATIONS[text] 150 | 151 | # 4. 检查是否包含方向前缀/后缀 152 | for prefix, direction in cls.DIRECTION_TRANSLATIONS.items(): 153 | if text.startswith(prefix) or text.endswith(prefix): 154 | base = text.replace(prefix, '') 155 | if base in cls.BODY_PART_TRANSLATIONS: 156 | return f"{direction}{cls.BODY_PART_TRANSLATIONS[base]}" 157 | 158 | return text 159 | 160 | @classmethod 161 | def should_preserve_key(cls, key: str) -> bool: 162 | """检查是否应该保持键名不变 163 | 164 | Args: 165 | key: 键名 166 | 167 | Returns: 168 | bool: 是否应该保持不变 169 | """ 170 | return key.lower() in cls.PRESERVED_KEYS -------------------------------------------------------------------------------- /src/translator.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Dict, List 3 | from openai import OpenAI 4 | import requests 5 | import json 6 | import time 7 | from .prompts import PromptTemplate # 导入提示词模板 8 | from .translation_config import TranslationConfig 9 | import glob 10 | from .file_utils import FileUtils 11 | 12 | class Translator: 13 | """节点翻译器类 14 | 15 | 负责调用火山引擎 API 将节点信息翻译成中文 16 | """ 17 | 18 | def __init__(self, api_key: str, model_id: str): 19 | """初始化翻译器 20 | 21 | Args: 22 | api_key: API 密钥 23 | model_id: 火山引擎模型 ID 24 | """ 25 | self.model_id = model_id 26 | 27 | # 获取程序根目录 28 | self.base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 29 | 30 | # 初始化输出目录 31 | self.dirs = FileUtils.init_output_dirs(self.base_path) 32 | 33 | # 从提示词模板获取系统提示词 34 | self.system_prompt = PromptTemplate.get_translator_prompt() 35 | 36 | # 初始化火山引擎客户端 37 | self.client = OpenAI( 38 | api_key=api_key, 39 | base_url="https://ark.cn-beijing.volces.com/api/v3" 40 | ) 41 | 42 | # 创建工作目录 43 | self.work_dir = os.path.join(self.dirs["temp"], "workspace") 44 | os.makedirs(self.work_dir, exist_ok=True) 45 | 46 | self.total_prompt_tokens = 0 # 输入 tokens 47 | self.total_completion_tokens = 0 # 输出 tokens 48 | self.total_tokens = 0 # 总 tokens 49 | 50 | def test_connection(self) -> bool: 51 | """测试 API 连接""" 52 | try: 53 | # 从模板获取火山引擎提示词 54 | system_prompt = PromptTemplate.get_test_prompt() 55 | messages = [ 56 | {"role": "system", "content": system_prompt}, 57 | {"role": "user", "content": "你好,请回复一句话。"} 58 | ] 59 | 60 | completion = self.client.chat.completions.create( 61 | model=self.model_id, 62 | messages=messages, 63 | temperature=0.1, 64 | top_p=0.95, 65 | stream=False, 66 | max_tokens=100, 67 | presence_penalty=0, 68 | frequency_penalty=0 69 | ) 70 | 71 | # 检查响应 72 | if completion.choices and completion.choices[0].message: 73 | return True 74 | else: 75 | raise Exception("API 响应格式不正确") 76 | 77 | except Exception as e: 78 | # 解析错误信息 79 | error_msg = str(e) 80 | if "AccountOverdueError" in error_msg: 81 | raise Exception("账户余额不足,请充值后重试") 82 | elif "InvalidApiKeyError" in error_msg: 83 | raise Exception("API 密钥无效") 84 | elif "ModelNotFoundError" in error_msg: 85 | raise Exception("模型 ID 无效") 86 | else: 87 | raise Exception(f"API 连接测试失败: {error_msg}") 88 | 89 | def translate_batch(self, batch_nodes: Dict) -> Dict: 90 | """翻译一批节点""" 91 | try: 92 | # 构建提示词 93 | messages = [ 94 | {"role": "system", "content": self.system_prompt}, 95 | {"role": "user", "content": json.dumps(batch_nodes, ensure_ascii=False, indent=2)} 96 | ] 97 | 98 | completion = self.client.chat.completions.create( 99 | model=self.model_id, 100 | messages=messages, 101 | temperature=0.1, # 降低随机性,保持翻译一致性 102 | top_p=0.95, 103 | stream=False, 104 | max_tokens=4096, 105 | presence_penalty=0, 106 | frequency_penalty=0, 107 | stop=None, 108 | user="comfyui-translator" 109 | ) 110 | 111 | # 解析返回结果 112 | response_text = completion.choices[0].message.content 113 | 114 | try: 115 | translated_nodes = json.loads(response_text) 116 | return translated_nodes 117 | except json.JSONDecodeError: 118 | raise Exception(f"翻译结果不是有效的 JSON 格式: {response_text}") 119 | 120 | except Exception as e: 121 | raise Exception(f"翻译失败: {str(e)}") 122 | 123 | def translate_nodes(self, nodes_info: Dict, folder_path: str, batch_size: int = 6, 124 | update_progress=None, temp_dir: str = None) -> Dict: 125 | """翻译节点信息 126 | 127 | Args: 128 | nodes_info: 节点信息字典 129 | folder_path: 插件文件夹路径 130 | batch_size: 批处理大小 131 | update_progress: 进度更新回调函数 132 | temp_dir: 临时文件目录路径 133 | """ 134 | temp_files = [] # 记录所有临时文件 135 | self.total_prompt_tokens = 0 136 | self.total_completion_tokens = 0 137 | self.total_tokens = 0 138 | 139 | try: 140 | # 使用传入的临时目录或默认目录 141 | work_dir = temp_dir if temp_dir else os.path.join(self.dirs["temp"], "workspace") 142 | os.makedirs(work_dir, exist_ok=True) 143 | 144 | # 保存原始待翻译文件 145 | original_file = os.path.join(work_dir, "nodes_to_translate.json") 146 | FileUtils.save_json(nodes_info, original_file) 147 | temp_files.append(original_file) 148 | 149 | if update_progress: 150 | update_progress(0, f"[准备] 保存原始节点信息到: {original_file}") 151 | 152 | all_translated_nodes = {} 153 | 154 | # 分批处理节点 155 | node_items = list(nodes_info.items()) 156 | total_batches = (len(node_items) + batch_size - 1) // batch_size 157 | 158 | for batch_idx in range(total_batches): 159 | start_idx = batch_idx * batch_size 160 | end_idx = min((batch_idx + 1) * batch_size, len(node_items)) 161 | current_batch = dict(node_items[start_idx:end_idx]) 162 | 163 | # 更新进度 164 | if update_progress: 165 | progress = int((batch_idx / total_batches) * 100) 166 | node_names = list(current_batch.keys()) 167 | update_progress(progress, f"[翻译] 第 {batch_idx + 1}/{total_batches} 批: {', '.join(node_names)}") 168 | 169 | try: 170 | # 1. 翻译当前批次 171 | batch_translated = self._translate_batch(current_batch, update_progress, progress) 172 | 173 | # 2. 验证和修正翻译结果 174 | if update_progress: 175 | update_progress(progress, "[验证] 正在验证翻译结果...") 176 | 177 | batch_corrected = self._validate_and_correct_batch( 178 | current_batch, 179 | batch_translated, 180 | update_progress, 181 | progress 182 | ) 183 | 184 | # 3. 保存已修正的批次 185 | batch_file = os.path.join( 186 | work_dir, 187 | f"batch_{batch_idx + 1}_translated.json" 188 | ) 189 | FileUtils.save_json(batch_corrected, batch_file) 190 | temp_files.append(batch_file) # 记录临时文件 191 | 192 | # 4. 更新总结果 193 | all_translated_nodes.update(batch_corrected) 194 | 195 | if update_progress: 196 | update_progress(progress, f"[完成] 批次 {batch_idx + 1} 的处理已完成") 197 | 198 | except Exception as e: 199 | if update_progress: 200 | update_progress(progress, f"[错误] 批次 {batch_idx + 1} 处理失败: {str(e)}") 201 | raise 202 | 203 | # 保存最终结果 204 | plugin_name = os.path.basename(folder_path.rstrip(os.path.sep)) 205 | final_file = os.path.join(work_dir, f"{plugin_name}.json") 206 | 207 | # 最终验证 208 | if update_progress: 209 | update_progress(95, "[验证] 进行最终验证...") 210 | final_corrected = self._final_validation( 211 | nodes_info, 212 | all_translated_nodes, 213 | update_progress 214 | ) 215 | 216 | # 保存最终结果 217 | FileUtils.save_json(final_corrected, final_file) 218 | 219 | # 清理临时文件 220 | self._cleanup_temp_files(temp_files, update_progress) 221 | 222 | # 在完成时显示总计信息 223 | if update_progress: 224 | # 计算费用(火山引擎费率:输入 0.0008元/千tokens,输出 0.0020元/千tokens) 225 | prompt_cost = (self.total_prompt_tokens / 1000) * 0.0008 226 | completion_cost = (self.total_completion_tokens / 1000) * 0.0020 227 | total_cost = prompt_cost + completion_cost 228 | 229 | update_progress(100, f"[完成] 翻译和验证完成!") 230 | update_progress(100, f"[统计] 总计使用 {self.total_tokens} tokens:") 231 | update_progress(100, f" - 输入: {self.total_prompt_tokens} tokens (¥{prompt_cost:.4f})") 232 | update_progress(100, f" - 输出: {self.total_completion_tokens} tokens (¥{completion_cost:.4f})") 233 | update_progress(100, f"[费用] 预估总费用(请以实际为准): ¥{total_cost:.4f}") 234 | 235 | return final_corrected 236 | 237 | except Exception as e: 238 | # 发生错误时清理临时文件 239 | self._cleanup_temp_files(temp_files, update_progress) 240 | 241 | # 解析错误信息 242 | error_msg = str(e) 243 | if "AccountOverdueError" in error_msg: 244 | if update_progress: 245 | update_progress(-1, "[错误] 账户余额不足,请充值后重试") 246 | elif "InvalidApiKeyError" in error_msg: 247 | if update_progress: 248 | update_progress(-1, "[错误] API 密钥无效") 249 | elif "ModelNotFoundError" in error_msg: 250 | if update_progress: 251 | update_progress(-1, "[错误] 模型 ID 无效") 252 | else: 253 | if update_progress: 254 | update_progress(-1, f"[错误] 翻译过程出错: {error_msg}") 255 | raise 256 | 257 | def _validate_and_correct_batch(self, original_batch: Dict, translated_batch: Dict, 258 | update_progress=None, progress: int = 0) -> Dict: 259 | """验证和修正单个批次的翻译结果""" 260 | corrected_batch = {} 261 | 262 | for node_name, node_info in original_batch.items(): 263 | if node_name not in translated_batch: 264 | if update_progress: 265 | update_progress(progress, f"[修正] 节点 {node_name} 未翻译,使用原始数据") 266 | corrected_batch[node_name] = node_info 267 | continue 268 | 269 | translated_info = translated_batch[node_name] 270 | corrected_node = { 271 | "title": translated_info.get("title", node_info.get("title", "")), 272 | "inputs": {}, 273 | "widgets": {}, 274 | "outputs": {} 275 | } 276 | 277 | # 验证和修正每个部分 278 | for section in ["inputs", "widgets", "outputs"]: 279 | orig_section = node_info.get(section, {}) 280 | trans_section = translated_info.get(section, {}) 281 | 282 | # 确保所有原始键都存在 283 | for key in orig_section: 284 | if key in trans_section: 285 | corrected_node[section][key] = trans_section[key] 286 | else: 287 | if update_progress: 288 | update_progress(progress, f"[修正] 节点 {node_name} 的 {section} 中缺少键 {key}") 289 | corrected_node[section][key] = key 290 | 291 | corrected_batch[node_name] = corrected_node 292 | 293 | return corrected_batch 294 | 295 | def _final_validation(self, original_nodes: Dict, translated_nodes: Dict, 296 | update_progress=None) -> Dict: 297 | """最终验证,确保所有节点都被正确翻译""" 298 | final_nodes = {} 299 | 300 | # 检查所有原始节点是否都被翻译 301 | for node_name, node_info in original_nodes.items(): 302 | if node_name not in translated_nodes: 303 | if update_progress: 304 | update_progress(95, f"[修正] 节点 {node_name} 未翻译,使用原始数据") 305 | final_nodes[node_name] = node_info 306 | continue 307 | 308 | translated_info = translated_nodes[node_name] 309 | 310 | # 验证必要字段 311 | if not all(field in translated_info for field in ["title", "inputs", "widgets", "outputs"]): 312 | if update_progress: 313 | update_progress(96, f"[修正] 节点 {node_name} 缺少必要字段,使用原始数据") 314 | final_nodes[node_name] = node_info 315 | continue 316 | 317 | # 验证字段内容 318 | final_nodes[node_name] = translated_info 319 | 320 | return final_nodes 321 | 322 | def _translate_batch(self, current_batch: Dict, update_progress=None, progress=0) -> Dict: 323 | """翻译单个批次 324 | 325 | Args: 326 | current_batch: 当前批次的数据 327 | update_progress: 进度更新回调函数 328 | progress: 当前进度 329 | 330 | Returns: 331 | Dict: 翻译后的节点信息 332 | """ 333 | translated_text = "" 334 | 335 | messages = [ 336 | {"role": "system", "content": self.system_prompt}, 337 | {"role": "user", "content": f"请翻译以下节点信息:\n{json.dumps(current_batch, indent=2, ensure_ascii=False)}"} 338 | ] 339 | 340 | completion = self.client.chat.completions.create( 341 | model=self.model_id, 342 | messages=messages, 343 | temperature=0.3, 344 | max_tokens=2048, 345 | response_format={"type": "text"}, 346 | top_p=0.95, 347 | presence_penalty=0, 348 | timeout=30 # deepseek 可能需要更长的响应时间 349 | ) 350 | 351 | # deepseek 模型可能会返回 reasoning_content 352 | if hasattr(completion.choices[0].message, 'reasoning_content'): 353 | if update_progress: 354 | update_progress(progress, f"DeepSeek 思考过程: {completion.choices[0].message.reasoning_content}") 355 | 356 | # 累计 tokens 使用量 357 | if hasattr(completion, 'usage'): 358 | prompt_tokens = completion.usage.prompt_tokens 359 | completion_tokens = completion.usage.completion_tokens 360 | batch_tokens = completion.usage.total_tokens 361 | 362 | self.total_prompt_tokens += prompt_tokens 363 | self.total_completion_tokens += completion_tokens 364 | self.total_tokens += batch_tokens 365 | 366 | if update_progress: 367 | update_progress(progress, 368 | f"[统计] 当前批次使用 {batch_tokens} tokens " 369 | f"(输入: {prompt_tokens}, 输出: {completion_tokens}), " 370 | f"累计: {self.total_tokens} tokens" 371 | ) 372 | 373 | translated_text = completion.choices[0].message.content 374 | 375 | # 检查响应内容 376 | if not translated_text or len(translated_text) < 10: 377 | raise Exception("API 响应内容异常") 378 | 379 | # 提取 JSON 内容 380 | json_start = translated_text.find('{') 381 | json_end = translated_text.rfind('}') + 1 382 | if json_start >= 0 and json_end > json_start: 383 | json_content = translated_text[json_start:json_end] 384 | batch_translated = json.loads(json_content) 385 | 386 | return batch_translated 387 | 388 | raise Exception("API 响应格式不正确") 389 | 390 | def _validate_batch_files(self, source_file: str, translated_file: str) -> tuple[bool, Dict, str]: 391 | """通过三步校对修正流程处理翻译结果,并生成详细日志""" 392 | try: 393 | # 读取文件 394 | with open(source_file, 'r', encoding='utf-8') as f: 395 | original_batch = json.load(f) 396 | with open(translated_file, 'r', encoding='utf-8') as f: 397 | translated_batch = json.load(f) 398 | 399 | batch_num = os.path.basename(translated_file).split('_')[1] 400 | log_entries = [] 401 | log_entries.append(f"批次 {batch_num} 校验修正日志") 402 | log_entries.append("=" * 50) 403 | 404 | # 第一步:建立标准映射关系 405 | from .translation_config import TranslationConfig 406 | standard_translations = { 407 | **TranslationConfig.COMMON_TRANSLATIONS, 408 | **TranslationConfig.BODY_PART_TRANSLATIONS 409 | } 410 | 411 | # 第二步:修正所有节点 412 | corrected_batch = {} 413 | log_entries.append("\n节点修正") 414 | log_entries.append("-" * 30) 415 | 416 | for node_name, node_data in original_batch.items(): 417 | log_entries.append(f"\n节点: {node_name}") 418 | corrected_node = { 419 | "title": translated_batch[node_name]["title"] if node_name in translated_batch else node_data["title"], 420 | "inputs": {}, 421 | "outputs": {}, 422 | "widgets": {} 423 | } 424 | 425 | # 处理每个部分 426 | for section in ["inputs", "outputs", "widgets"]: 427 | if section in node_data: 428 | log_entries.append(f"\n{section}:") 429 | 430 | # 获取原始数据和翻译后的数据 431 | orig_section = node_data[section] 432 | trans_section = translated_batch.get(node_name, {}).get(section, {}) 433 | 434 | # 使用原始文件中的键 435 | for orig_key in orig_section.keys(): 436 | # 1. 如果是特殊类型值,保持不变 437 | if orig_key.upper() in TranslationConfig.PRESERVED_TYPES: 438 | corrected_node[section][orig_key] = orig_key.upper() 439 | log_entries.append(f"保留特殊类型: {orig_key}") 440 | continue 441 | 442 | # 2. 如果在标准映射中存在对应的翻译 443 | if orig_key.lower() in standard_translations: 444 | corrected_node[section][orig_key] = standard_translations[orig_key.lower()] 445 | log_entries.append(f"使用标准映射: {orig_key} -> {standard_translations[orig_key.lower()]}") 446 | continue 447 | 448 | # 3. 如果在翻译后的数据中有对应的中文值 449 | if orig_key in trans_section: 450 | trans_value = trans_section[orig_key] 451 | # 如果值是中文,使用它 452 | if any('\u4e00' <= char <= '\u9fff' for char in str(trans_value)): 453 | corrected_node[section][orig_key] = trans_value 454 | log_entries.append(f"使用翻译值: {orig_key} -> {trans_value}") 455 | continue 456 | 457 | # 4. 如果都没有找到合适的翻译,保持原值 458 | corrected_node[section][orig_key] = orig_key 459 | log_entries.append(f"保持原值: {orig_key}") 460 | 461 | corrected_batch[node_name] = corrected_node 462 | 463 | # 保存修正后的结果 464 | corrected_file = os.path.join(self.dirs["temp"], f"batch_{batch_num}_corrected.json") 465 | FileUtils.save_json(corrected_batch, corrected_file) 466 | 467 | # 保存详细日志 468 | log_file = os.path.join(self.dirs["logs"], 469 | f"batch_{batch_num}_correction_log_{time.strftime('%Y%m%d_%H%M%S')}.txt") 470 | with open(log_file, 'w', encoding='utf-8') as f: 471 | f.write('\n'.join(log_entries)) 472 | 473 | return True, corrected_batch, '\n'.join(log_entries) 474 | 475 | except Exception as e: 476 | return False, translated_batch, f"修正过程出错: {str(e)}" 477 | 478 | def _is_valid_translation(self, english_key: str, chinese_text: str) -> bool: 479 | """检查中文文本是否是英文键的合理翻译 480 | 481 | Args: 482 | english_key: 英文键名 483 | chinese_text: 中文文本 484 | 485 | Returns: 486 | bool: 是否是合理的翻译 487 | """ 488 | # 定义常见翻译对照 489 | translations = { 490 | 'image': {'图像', '图片'}, 491 | 'mask': {'遮罩', '掩码', '蒙版'}, 492 | 'model': {'模型'}, 493 | 'processor': {'处理器'}, 494 | 'device': {'设备'}, 495 | 'bbox': {'边界框', '边框'}, 496 | 'samples': {'样本'}, 497 | 'operation': {'操作'}, 498 | 'guide': {'引导图'}, 499 | 'radius': {'半径'}, 500 | 'epsilon': {'epsilon', 'eps'}, 501 | 'threshold': {'阈值'}, 502 | 'contrast': {'对比度'}, 503 | 'brightness': {'亮度'}, 504 | 'saturation': {'饱和度'}, 505 | 'hue': {'色调'}, 506 | 'gamma': {'伽马'} 507 | } 508 | 509 | return chinese_text in translations.get(english_key, set()) 510 | 511 | def _final_validate_and_correct(self, original_file: str, final_translated_file: str) -> tuple[bool, Dict, str]: 512 | """最终校验和修正,通过建立中英文映射关系来修正键名""" 513 | try: 514 | # 读取文件 515 | with open(original_file, 'r', encoding='utf-8') as f: 516 | original_data = json.load(f) 517 | with open(final_translated_file, 'r', encoding='utf-8') as f: 518 | translated_data = json.load(f) 519 | 520 | log_entries = [] 521 | log_entries.append("\n=== 最终校验修正日志 ===") 522 | log_entries.append(f"时间: {time.strftime('%Y-%m-%d %H:%M:%S')}") 523 | log_entries.append("=" * 50) 524 | 525 | # 第一步:建立中英文双向映射 526 | en_to_cn = {} # 英文到中文的映射 527 | cn_to_en = {} # 中文到英文的映射 528 | log_entries.append("\n第一步:建立中英文映射关系") 529 | 530 | # 1.1 首先从原始文件中获取所有英文键 531 | for node_name, node_data in original_data.items(): 532 | for section in ["inputs", "outputs", "widgets"]: 533 | if section in node_data: 534 | for key, value in node_data[section].items(): 535 | # 如果原始文件中键和值相同,这个键就是标准英文键 536 | if key == value: 537 | # 在翻译后的文件中查找对应的中文值 538 | if (node_name in translated_data and 539 | section in translated_data[node_name]): 540 | trans_section = translated_data[node_name][section] 541 | # 如果找到了这个键对应的中文值 542 | if key in trans_section: 543 | trans_value = trans_section[key] 544 | if any('\u4e00' <= char <= '\u9fff' for char in str(trans_value)): 545 | en_to_cn[key] = trans_value 546 | cn_to_en[trans_value] = key 547 | log_entries.append(f"从原始文件映射: {key} <-> {trans_value}") 548 | 549 | # 1.2 从翻译后的文件中收集其他映射关系 550 | for node_name, node_data in translated_data.items(): 551 | for section in ["inputs", "outputs", "widgets"]: 552 | if section in node_data: 553 | for key, value in node_data[section].items(): 554 | # 如果值是中文且键是英文 555 | if (any('\u4e00' <= char <= '\u9fff' for char in str(value)) and 556 | not any('\u4e00' <= char <= '\u9fff' for char in str(key))): 557 | en_to_cn[key] = value 558 | cn_to_en[value] = key 559 | log_entries.append(f"从翻译文件映射: {key} <-> {value}") 560 | # 如果键是中文且值是英文 561 | elif (any('\u4e00' <= char <= '\u9fff' for char in str(key)) and 562 | not any('\u4e00' <= char <= '\u9fff' for char in str(value))): 563 | cn_to_en[key] = value 564 | en_to_cn[value] = key 565 | log_entries.append(f"从翻译文件映射: {value} <-> {key}") 566 | 567 | # 第二步:修正所有节点 568 | final_corrected = {} 569 | corrections_made = False 570 | log_entries.append("\n第二步:应用修正") 571 | 572 | for node_name, node_data in translated_data.items(): 573 | corrected_node = { 574 | "title": node_data["title"], 575 | "inputs": {}, 576 | "outputs": {}, 577 | "widgets": {} 578 | } 579 | 580 | # 获取原始节点数据 581 | orig_node = original_data.get(node_name, {}) 582 | 583 | # 处理每个部分 584 | for section in ["inputs", "outputs", "widgets"]: 585 | if section in node_data: 586 | # 获取原始部分的键 587 | orig_keys = orig_node.get(section, {}).keys() 588 | 589 | for key, value in node_data[section].items(): 590 | # 如果键是中文,需要修正 591 | if any('\u4e00' <= char <= '\u9fff' for char in str(key)): 592 | if key in cn_to_en: 593 | eng_key = cn_to_en[key] 594 | # 验证这个英文键是否在原始数据中 595 | if eng_key in orig_keys: 596 | corrected_node[section][eng_key] = value 597 | corrections_made = True 598 | log_entries.append(f"修正键: {key} -> {eng_key}") 599 | else: 600 | # 如果不在原始数据中,尝试查找原始键 601 | for orig_key in orig_keys: 602 | if orig_key.lower() == eng_key.lower(): 603 | corrected_node[section][orig_key] = value 604 | corrections_made = True 605 | log_entries.append(f"修正键(使用原始大小写): {key} -> {orig_key}") 606 | break 607 | else: 608 | # 在原始数据中查找对应的英文键 609 | found = False 610 | for orig_key in orig_keys: 611 | if orig_key in en_to_cn and en_to_cn[orig_key] == key: 612 | corrected_node[section][orig_key] = value 613 | corrections_made = True 614 | log_entries.append(f"修正键(从原始数据): {key} -> {orig_key}") 615 | found = True 616 | break 617 | if not found: 618 | log_entries.append(f"警告: 未找到键 '{key}' 的英文映射") 619 | # 保持原样 620 | corrected_node[section][key] = value 621 | else: 622 | corrected_node[section][key] = value 623 | 624 | final_corrected[node_name] = corrected_node 625 | 626 | # 保存映射关系 627 | mapping_file = os.path.join(self.dirs["temp"], "translation_mapping.json") 628 | with open(mapping_file, 'w', encoding='utf-8') as f: 629 | json.dump({ 630 | "en_to_cn": en_to_cn, 631 | "cn_to_en": cn_to_en 632 | }, f, indent=4, ensure_ascii=False) 633 | 634 | # 保存详细日志 635 | log_file = os.path.join(self.dirs["logs"], 636 | f"final_correction_log_{time.strftime('%Y%m%d_%H%M%S')}.txt") 637 | log_content = '\n'.join(log_entries) 638 | with open(log_file, 'w', encoding='utf-8') as f: 639 | f.write(log_content) 640 | 641 | # 如果进行了修正,保存结果 642 | if corrections_made: 643 | output_file = os.path.join(self.dirs["temp"], "final_corrected.json") 644 | with open(output_file, 'w', encoding='utf-8') as f: 645 | json.dump(final_corrected, f, indent=4, ensure_ascii=False) 646 | 647 | return not corrections_made, final_corrected, log_content 648 | 649 | except Exception as e: 650 | error_msg = f"最终校验修正失败: {str(e)}" 651 | return False, translated_data, error_msg 652 | 653 | def _cleanup_temp_files(self, temp_files: List[str], update_progress=None): 654 | """清理临时文件 655 | 656 | Args: 657 | temp_files: 临时文件路径列表 658 | update_progress: 进度更新回调函数 659 | """ 660 | if not temp_files: 661 | return 662 | 663 | if update_progress: 664 | update_progress(97, "[清理] 开始清理临时文件...") 665 | 666 | for file_path in temp_files: 667 | try: 668 | if os.path.exists(file_path): 669 | os.remove(file_path) 670 | if update_progress: 671 | update_progress(98, f"[清理] 已删除: {os.path.basename(file_path)}") 672 | except Exception as e: 673 | if update_progress: 674 | update_progress(98, f"[警告] 清理文件失败: {os.path.basename(file_path)} ({str(e)})") 675 | 676 | if update_progress: 677 | update_progress(99, "[清理] 临时文件清理完成") -------------------------------------------------------------------------------- /src/web/static/css/style.css: -------------------------------------------------------------------------------- 1 | .container { 2 | max-width: 800px; 3 | margin: 0 auto; 4 | padding: 20px; 5 | } 6 | 7 | .settings-panel, 8 | .control-panel, 9 | .progress-panel, 10 | .log-panel { 11 | margin-bottom: 20px; 12 | padding: 15px; 13 | border: 1px solid #ddd; 14 | border-radius: 4px; 15 | } 16 | 17 | .input-group { 18 | margin-bottom: 10px; 19 | } 20 | 21 | .input-group label { 22 | display: block; 23 | margin-bottom: 5px; 24 | } 25 | 26 | .input-group input { 27 | width: 100%; 28 | padding: 8px; 29 | border: 1px solid #ddd; 30 | border-radius: 4px; 31 | } 32 | 33 | .progress-bar { 34 | height: 20px; 35 | background-color: #f0f0f0; 36 | border-radius: 10px; 37 | overflow: hidden; 38 | } 39 | 40 | .progress-fill { 41 | height: 100%; 42 | background-color: #4CAF50; 43 | width: 0%; 44 | transition: width 0.3s ease; 45 | } 46 | 47 | #log-content { 48 | height: 200px; 49 | overflow-y: auto; 50 | padding: 10px; 51 | background-color: #f5f5f5; 52 | font-family: monospace; 53 | } 54 | 55 | button { 56 | padding: 8px 16px; 57 | border: none; 58 | border-radius: 4px; 59 | cursor: pointer; 60 | } 61 | 62 | button.primary { 63 | background-color: #4CAF50; 64 | color: white; 65 | } 66 | 67 | button:disabled { 68 | background-color: #cccccc; 69 | cursor: not-allowed; 70 | } 71 | 72 | /* 添加按钮样式 */ 73 | button.secondary { 74 | background-color: #2196F3; 75 | color: white; 76 | margin-left: 10px; 77 | } 78 | 79 | /* JSON 查看器样式 */ 80 | .json-viewer { 81 | position: fixed; 82 | top: 0; 83 | left: 0; 84 | width: 100%; 85 | height: 100%; 86 | background: rgba(0, 0, 0, 0.7); 87 | display: none; 88 | z-index: 1000; 89 | } 90 | 91 | .json-content { 92 | position: relative; 93 | width: 80%; 94 | height: 80%; 95 | margin: 5% auto; 96 | background: white; 97 | padding: 20px; 98 | border-radius: 4px; 99 | overflow: auto; 100 | } 101 | 102 | .json-content pre { 103 | margin: 0; 104 | white-space: pre-wrap; 105 | font-family: monospace; 106 | } 107 | 108 | .close-viewer { 109 | position: absolute; 110 | right: 10px; 111 | top: 10px; 112 | font-size: 24px; 113 | cursor: pointer; 114 | color: #666; 115 | } -------------------------------------------------------------------------------- /src/web/static/js/main.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', function() { 2 | const apiKeyInput = document.getElementById('api-key'); 3 | const folderPathInput = document.getElementById('folder-path'); 4 | const selectFolderBtn = document.getElementById('select-folder'); 5 | const startTranslationBtn = document.getElementById('start-translation'); 6 | const stopTranslationBtn = document.getElementById('stop-translation'); 7 | const progressFill = document.querySelector('.progress-fill'); 8 | const progressText = document.querySelector('.progress-text'); 9 | const logContent = document.getElementById('log-content'); 10 | const viewJsonBtn = document.getElementById('view-json'); 11 | 12 | let translationInProgress = false; 13 | 14 | // 选择文件夹 15 | selectFolderBtn.addEventListener('click', async () => { 16 | const response = await fetch('/select-folder'); 17 | const data = await response.json(); 18 | if (data.path) { 19 | folderPathInput.value = data.path; 20 | addLog(`已选择文件夹: ${data.path}`); 21 | } 22 | }); 23 | 24 | // 开始翻译 25 | startTranslationBtn.addEventListener('click', async () => { 26 | if (!folderPathInput.value) { 27 | alert('请先选择插件文件夹!'); 28 | return; 29 | } 30 | 31 | translationInProgress = true; 32 | startTranslationBtn.disabled = true; 33 | stopTranslationBtn.disabled = false; 34 | 35 | const response = await fetch('/start-translation', { 36 | method: 'POST', 37 | headers: { 38 | 'Content-Type': 'application/json' 39 | }, 40 | body: JSON.stringify({ 41 | apiKey: apiKeyInput.value, 42 | folderPath: folderPathInput.value 43 | }) 44 | }); 45 | 46 | // 开始轮询进度 47 | pollProgress(); 48 | }); 49 | 50 | // 终止翻译 51 | stopTranslationBtn.addEventListener('click', async () => { 52 | const response = await fetch('/stop-translation'); 53 | translationInProgress = false; 54 | startTranslationBtn.disabled = false; 55 | stopTranslationBtn.disabled = true; 56 | addLog('翻译已终止'); 57 | }); 58 | 59 | // 轮询翻译进度 60 | async function pollProgress() { 61 | if (!translationInProgress) return; 62 | 63 | const response = await fetch('/translation-progress'); 64 | const data = await response.json(); 65 | 66 | updateProgress(data.progress); 67 | addLog(data.message); 68 | 69 | if (data.progress < 100 && translationInProgress) { 70 | setTimeout(pollProgress, 1000); 71 | } else { 72 | translationInProgress = false; 73 | startTranslationBtn.disabled = false; 74 | stopTranslationBtn.disabled = true; 75 | } 76 | } 77 | 78 | // 更新进度条 79 | function updateProgress(percent) { 80 | progressFill.style.width = `${percent}%`; 81 | progressText.textContent = `${percent}%`; 82 | } 83 | 84 | // 添加日志 85 | function addLog(message) { 86 | const logEntry = document.createElement('div'); 87 | logEntry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`; 88 | logContent.appendChild(logEntry); 89 | logContent.scrollTop = logContent.scrollHeight; 90 | } 91 | 92 | // 创建 JSON 查看器 93 | const jsonViewer = document.createElement('div'); 94 | jsonViewer.className = 'json-viewer'; 95 | jsonViewer.innerHTML = ` 96 |
97 | × 98 |

 99 |         
100 | `; 101 | document.body.appendChild(jsonViewer); 102 | 103 | const jsonContent = jsonViewer.querySelector('pre'); 104 | const closeViewer = jsonViewer.querySelector('.close-viewer'); 105 | 106 | // 查看 JSON 按钮点击事件 107 | viewJsonBtn.addEventListener('click', async () => { 108 | try { 109 | const response = await fetch('/get-nodes-json'); 110 | const data = await response.json(); 111 | 112 | if (data.error) { 113 | alert(data.error); 114 | return; 115 | } 116 | 117 | // 格式化显示 JSON 118 | jsonContent.textContent = JSON.stringify(data, null, 2); 119 | jsonViewer.style.display = 'block'; 120 | } catch (error) { 121 | alert('获取 JSON 文件失败: ' + error.message); 122 | } 123 | }); 124 | 125 | // 关闭查看器 126 | closeViewer.addEventListener('click', () => { 127 | jsonViewer.style.display = 'none'; 128 | }); 129 | 130 | // 点击背景关闭 131 | jsonViewer.addEventListener('click', (e) => { 132 | if (e.target === jsonViewer) { 133 | jsonViewer.style.display = 'none'; 134 | } 135 | }); 136 | }); -------------------------------------------------------------------------------- /src/web/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ComfyUI 节点翻译器 6 | 7 | 8 | 9 |
10 |

ComfyUI 节点翻译器

11 | 12 |
13 |
14 | 15 | 16 |
17 | 18 |
19 | 20 | 21 | 22 | 23 |
24 |
25 | 26 |
27 | 28 | 29 |
30 | 31 |
32 |
33 |
34 |
35 |
0%
36 |
37 | 38 |
39 |

翻译日志

40 |
41 |
42 |
43 | 44 | 45 | -------------------------------------------------------------------------------- /translation_mapping.json: -------------------------------------------------------------------------------- 1 | { 2 | "en_to_cn": { 3 | "model_name": "模型名称", 4 | "bbox_detector": "BBox检测器", 5 | "image": "图像", 6 | "BBOX_LIST": "BBox列表", 7 | "count": "数量", 8 | "threshold": "阈值", 9 | "dilation": "膨胀", 10 | "dilation_ratio": "膨胀比例", 11 | "by_ratio": "按比例", 12 | "bbox_list": "边界框列表", 13 | "index": "索引", 14 | "width_old": "原宽度", 15 | "height_old": "原高度", 16 | "width": "宽度", 17 | "height": "高度", 18 | "l": "左边界", 19 | "t": "上边界", 20 | "r": "右边界", 21 | "b": "下边界", 22 | "samples": "样本", 23 | "bbox": "边界框", 24 | "samples_src": "源样本", 25 | "image_src": "源图像", 26 | "images": "图像列表", 27 | "min": "最小值", 28 | "scale": "缩放比例", 29 | "scale_r": "反向缩放比例", 30 | "target_size": "目标尺寸", 31 | "force_8x": "强制为8的倍数", 32 | "force_64x": "强制为64的倍数", 33 | "device": "设备", 34 | "model": "模型", 35 | "processor": "处理器", 36 | "result": "结果", 37 | "background": "背景", 38 | "skin": "皮肤", 39 | "nose": "鼻子", 40 | "eye_g": "眼镜", 41 | "r_eye": "右眼", 42 | "l_eye": "左眼", 43 | "r_brow": "右眉毛", 44 | "l_brow": "左眉毛", 45 | "r_ear": "右耳", 46 | "l_ear": "左耳", 47 | "mouth": "嘴巴", 48 | "u_lip": "上嘴唇", 49 | "l_lip": "下嘴唇", 50 | "hair": "头发", 51 | "hat": "帽子", 52 | "ear_r": "耳饰", 53 | "neck_l": "项链", 54 | "neck": "脖子", 55 | "cloth": "衣服", 56 | "mask": "遮罩", 57 | "size": "大小", 58 | "kernel_size": "内核大小", 59 | "sigma": "标准差", 60 | "direction": "方向", 61 | "position": "位置", 62 | "destination": "目标遮罩", 63 | "source": "源遮罩", 64 | "operation": "操作", 65 | "pad": "填充", 66 | "guide": "引导图像", 67 | "radius": "半径", 68 | "eps": "误差", 69 | "contrast": "对比度", 70 | "brightness": "亮度", 71 | "saturation": "饱和度", 72 | "hue": "色相", 73 | "gamma": "伽马值", 74 | "output_0": "图像", 75 | "output_1": "面部解析结果" 76 | }, 77 | "cn_to_en": { 78 | "模型名称": "model_name", 79 | "BBox检测器": "bbox_detector", 80 | "图像": "output_0", 81 | "BBox列表": "bbox_list", 82 | "数量": "count", 83 | "阈值": "threshold", 84 | "膨胀": "dilation", 85 | "膨胀比例": "dilation_ratio", 86 | "按比例": "by_ratio", 87 | "索引": "index", 88 | "旧宽度": "width_old", 89 | "旧高度": "height_old", 90 | "宽度": "width", 91 | "高度": "height", 92 | "左边界": "l", 93 | "上边界": "t", 94 | "右边界": "r", 95 | "下边界": "b", 96 | "样本": "samples", 97 | "边界框": "bbox", 98 | "源样本": "samples_src", 99 | "边界框列表": "output_0", 100 | "源图像": "image_src", 101 | "图像列表": "images", 102 | "原宽度": "width_old", 103 | "原高度": "height_old", 104 | "最小值": "min", 105 | "缩放比例": "scale", 106 | "反向缩放比例": "scale_r", 107 | "目标尺寸": "target_size", 108 | "强制为8的倍数": "force_8x", 109 | "强制为64的倍数": "force_64x", 110 | "设备": "device", 111 | "模型": "model", 112 | "处理器": "processor", 113 | "结果": "result", 114 | "背景": "background", 115 | "皮肤": "skin", 116 | "鼻子": "nose", 117 | "眼镜": "eye_g", 118 | "右眼": "r_eye", 119 | "左眼": "l_eye", 120 | "右眉毛": "r_brow", 121 | "左眉毛": "l_brow", 122 | "右耳": "r_ear", 123 | "左耳": "l_ear", 124 | "嘴巴": "mouth", 125 | "上嘴唇": "u_lip", 126 | "下嘴唇": "l_lip", 127 | "头发": "hair", 128 | "帽子": "hat", 129 | "耳饰": "ear_r", 130 | "项链": "neck_l", 131 | "脖子": "neck", 132 | "衣服": "cloth", 133 | "遮罩": "output_0", 134 | "大小": "size", 135 | "内核大小": "kernel_size", 136 | "标准差": "sigma", 137 | "方向": "direction", 138 | "位置": "position", 139 | "目标遮罩": "destination", 140 | "源遮罩": "source", 141 | "操作": "operation", 142 | "填充": "pad", 143 | "引导图像": "guide", 144 | "半径": "radius", 145 | "误差": "eps", 146 | "对比度": "contrast", 147 | "亮度": "brightness", 148 | "饱和度": "saturation", 149 | "色相": "hue", 150 | "伽马值": "gamma", 151 | "潜在图像": "output_0", 152 | "潜在空间数据": "output_0", 153 | "人脸解析模型": "output_0", 154 | "人脸解析处理器": "output_0", 155 | "面部解析结果": "output_1" 156 | } 157 | } -------------------------------------------------------------------------------- /version_notes.md: -------------------------------------------------------------------------------- 1 | # ComfyUI 节点翻译工具 - 版本记录 2 | 3 | ## V1.0.0 (当前版本) 4 | 5 | ### 核心功能 6 | 1. 节点检测 7 | - 自动扫描插件目录 8 | - 识别 ComfyUI 节点结构 9 | - 生成待翻译 JSON 文件 10 | 11 | 2. 翻译功能 12 | - 使用火山引擎 API 13 | - 批量翻译支持 14 | - 进度显示和日志记录 15 | - tokens 使用统计 16 | - 费用预估 17 | 18 | 3. 对比功能 19 | - 新旧版本节点对比 20 | - 差异节点识别 21 | - 结果文件生成 22 | 23 | ### 技术特性 24 | 1. 文件管理 25 | - 输出目录结构化 26 | - 临时文件自动清理 27 | - 虚拟环境支持 28 | 29 | 2. 错误处理 30 | - API 错误识别 31 | - 翻译失败重试 32 | - 详细错误日志 33 | 34 | 3. 配置管理 35 | - API 密钥保存 36 | - 模型 ID 配置 37 | - 国内源支持 38 | 39 | ### 文件结构 40 | ``` 41 | ComfyUI-Node-Translator/ 42 | ├── README.md 43 | ├── requirements.txt 44 | ├── run.bat 45 | ├── run(国内源).bat 46 | ├── src/ 47 | │ ├── __init__.py 48 | │ ├── node_parser.py 49 | │ ├── translator.py 50 | │ ├── file_utils.py 51 | │ ├── prompts.py 52 | │ ├── translation_config.py 53 | │ ├── node_diff.py 54 | │ └── diff_tab.py 55 | └── main.py 56 | ``` 57 | 58 | ### 依赖版本 59 | - flask==3.0.2 60 | - openai==1.12.0 61 | - requests==2.31.0 62 | 63 | ### 已知问题 64 | 1. 需要配合 AIGODLIKE-ComfyUI-Translation 使用 65 | 2. 翻译结果需要手动复制到目标目录 66 | 67 | ### 发布日期 68 | 2024-02-20 --------------------------------------------------------------------------------