Files
2025-07-23 21:14:31 +09:00

566 lines
24 KiB
Python

"""
ComfyUI Prompt Translator Node
Multi-provider translation node with token counting functionality.
"""
import json
import os
import requests
import hashlib
import time
import threading
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, Any, Tuple, Optional
from pathlib import Path
try:
from transformers import CLIPTokenizer
HAS_TRANSFORMERS = True
except ImportError:
HAS_TRANSFORMERS = False
try:
from transformers import MarianMTModel, MarianTokenizer
HAS_MARIANMT = True
except ImportError:
HAS_MARIANMT = False
class PromptTranslator:
"""
ComfyUI Prompt Translator Node
Provides multi-provider translation with token counting functionality.
Supports DeepL, Google Translate, Yandex, Baidu, and MarianMT providers.
"""
def __init__(self):
self.providers = ["DeepL", "Google", "Yandex", "Baidu", "MarianMT"]
self.cache_file = Path.home() / ".comfyui" / "prompt_translator_cache.json"
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
self.api_cache = self.load_cache()
self.tokenizer = None
if HAS_TRANSFORMERS:
self.init_clip_tokenizer()
else:
print("Warning: transformers not available, using fallback token counting")
self.marianmt_models = {}
self.executor = ThreadPoolExecutor(max_workers=4)
self.translation_cache = {}
self.last_translation_time = 0
# Language code mappings for different providers
self.language_codes = {
"Auto": {"google": "auto", "deepl": None, "yandex": None, "baidu": "auto"},
"Japanese": {"google": "ja", "deepl": "JA", "yandex": "ja", "baidu": "jp"},
"English": {"google": "en", "deepl": "EN-US", "yandex": "en", "baidu": "en"},
"Chinese": {"google": "zh", "deepl": "ZH", "yandex": "zh", "baidu": "zh"},
"Korean": {"google": "ko", "deepl": "KO", "yandex": "ko", "baidu": "kor"},
"Spanish": {"google": "es", "deepl": "ES", "yandex": "es", "baidu": "spa"},
"French": {"google": "fr", "deepl": "FR", "yandex": "fr", "baidu": "fra"},
"German": {"google": "de", "deepl": "DE", "yandex": "de", "baidu": "de"},
"Italian": {"google": "it", "deepl": "IT", "yandex": "it", "baidu": "it"},
"Portuguese": {"google": "pt", "deepl": "PT-PT", "yandex": "pt", "baidu": "pt"},
"Russian": {"google": "ru", "deepl": "RU", "yandex": "ru", "baidu": "ru"},
"Arabic": {"google": "ar", "deepl": "AR", "yandex": "ar", "baidu": "ara"},
"Hindi": {"google": "hi", "deepl": "HI", "yandex": "hi", "baidu": "hi"},
"Thai": {"google": "th", "deepl": "TH", "yandex": "th", "baidu": "th"},
"Vietnamese": {"google": "vi", "deepl": "VI", "yandex": "vi", "baidu": "vie"},
"Indonesian": {"google": "id", "deepl": "ID", "yandex": "id", "baidu": "id"},
"Dutch": {"google": "nl", "deepl": "NL", "yandex": "nl", "baidu": "nl"},
"Swedish": {"google": "sv", "deepl": "SV", "yandex": "sv", "baidu": "swe"},
"Polish": {"google": "pl", "deepl": "PL", "yandex": "pl", "baidu": "pl"},
"Turkish": {"google": "tr", "deepl": "TR", "yandex": "tr", "baidu": "tr"},
"Czech": {"google": "cs", "deepl": "CS", "yandex": "cs", "baidu": "cs"},
"Hungarian": {"google": "hu", "deepl": "HU", "yandex": "hu", "baidu": "hu"}
}
def get_language_code(self, language_name: str, provider: str) -> str:
"""Get language code for specific provider."""
provider_key = provider.lower()
if provider_key == "deepl":
provider_key = "deepl"
elif provider_key in ["yandex"]:
provider_key = "yandex"
elif provider_key in ["baidu"]:
provider_key = "baidu"
else:
provider_key = "google" # Default to Google format
if language_name in self.language_codes:
code = self.language_codes[language_name].get(provider_key)
if code is None:
# Fallback to Google format if provider doesn't support
code = self.language_codes[language_name].get("google", "en")
return code
return "en" # Default fallback
@classmethod
def INPUT_TYPES(cls):
# ComfyUI focused: English ↔ Other Languages
translation_modes = [
"English → Other Language",
"Other Language → English"
]
# Major languages for ComfyUI prompts
languages = [
"Japanese", "Chinese", "Korean", "Spanish", "French",
"German", "Italian", "Portuguese", "Russian", "Arabic", "Hindi",
"Thai", "Vietnamese", "Indonesian", "Dutch", "Swedish", "Polish",
"Turkish", "Czech", "Hungarian"
]
return {
"required": {
"provider": (["Google", "DeepL", "Yandex", "Baidu", "MarianMT"], {"default": "Google"}),
"input_text": ("STRING", {"multiline": True, "default": "", "placeholder": "Enter text to translate..."}),
"translation_mode": (translation_modes, {"default": "Other Language → English"}),
"other_language": (languages, {"default": "Japanese"}),
"api_key": ("STRING", {"default": "", "placeholder": "API Key (Google/MarianMT不要)"}),
"execute_translation": ("BOOLEAN", {"default": False, "label_on": "Translating...", "label_off": "Translation"}),
"translated_text": ("STRING", {"multiline": True, "default": "", "placeholder": "Translation will appear here..."}),
},
"optional": {
"input_text_connection": ("STRING", {"forceInput": True}),
}
}
RETURN_TYPES = ("STRING", "STRING", "INT")
RETURN_NAMES = ("original_text", "translated_text", "token_count")
FUNCTION = "translate"
CATEGORY = "text/translation"
def load_cache(self) -> Dict[str, str]:
"""Load cached API keys from file."""
if self.cache_file.exists():
try:
with open(self.cache_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"Warning: Could not load cache file: {e}")
return {}
def save_cache(self):
"""Save API keys to cache file."""
try:
with open(self.cache_file, 'w', encoding='utf-8') as f:
json.dump(self.api_cache, f, indent=2)
except Exception as e:
print(f"Warning: Could not save cache file: {e}")
def count_tokens(self, text: str) -> int:
"""Count tokens using CLIP tokenizer with fallback methods."""
if not text or not text.strip():
return 0
# Try CLIP tokenizer first
if self.tokenizer:
try:
tokens = self.tokenizer.encode(text)
return len(tokens)
except Exception as e:
print(f"Warning: CLIP tokenizer failed: {e}")
# Fallback to simple tokenization
return self.simple_token_count(text)
def simple_token_count(self, text: str) -> int:
"""Simple token counting fallback method."""
if not text:
return 0
# Remove extra whitespace and split
words = text.strip().split()
# Rough estimation: 1 token per word, plus punctuation
token_count = 0
for word in words:
# Count word tokens
token_count += 1
# Add extra tokens for punctuation
punctuation_count = sum(1 for char in word if char in '.,!?;:"()[]{}/')
token_count += punctuation_count
return max(1, token_count) # Ensure at least 1 token for non-empty text
def init_clip_tokenizer(self):
"""Initialize CLIP tokenizer with multiple fallback options."""
tokenizer_models = [
"openai/clip-vit-base-patch32",
"openai/clip-vit-base-patch16",
"openai/clip-vit-large-patch14"
]
for model_name in tokenizer_models:
try:
self.tokenizer = CLIPTokenizer.from_pretrained(model_name)
print(f"Successfully loaded CLIP tokenizer: {model_name}")
return
except Exception as e:
print(f"Failed to load {model_name}: {e}")
continue
print("Warning: Could not load any CLIP tokenizer, using fallback method")
self.tokenizer = None
def translate_deepl(self, text: str, api_key: str, target_lang: str) -> str:
"""Translate using DeepL API with enhanced parameters."""
# Determine URL based on API key type
if api_key.endswith(":fx"):
url = "https://api-free.deepl.com/v2/translate"
else:
url = "https://api.deepl.com/v2/translate"
headers = {
"Authorization": f"DeepL-Auth-Key {api_key}",
"Content-Type": "application/json"
}
# Detect source language
source_lang = "JA" if target_lang == "EN-US" else "EN"
data = {
"text": [text],
"source_lang": source_lang,
"target_lang": target_lang,
"formality": "default", # Use default formality
"preserve_formatting": True, # Preserve text formatting
"split_sentences": "1" # Split sentences for better translation
}
print(f"DeepL API Request: {url}")
print(f"DeepL Data: {data}")
try:
response = requests.post(url, headers=headers, json=data, timeout=15)
print(f"DeepL Response Status: {response.status_code}")
if response.status_code == 403:
raise Exception("DeepL API key invalid or quota exceeded")
elif response.status_code == 456:
raise Exception("DeepL API quota exceeded")
response.raise_for_status()
result = response.json()
print(f"DeepL Response: {result}")
if "translations" not in result or not result["translations"]:
raise Exception("No translation result from DeepL")
translated_text = result["translations"][0]["text"]
detected_source = result["translations"][0].get("detected_source_language", "Unknown")
print(f"DeepL Translation: '{text}' -> '{translated_text}' (detected: {detected_source})")
return translated_text
except requests.exceptions.RequestException as e:
raise Exception(f"DeepL API request failed: {str(e)}")
except KeyError as e:
raise Exception(f"DeepL API response format error: {str(e)}")
except Exception as e:
raise Exception(f"DeepL translation failed: {str(e)}")
def translate_google(self, text: str, api_key: str, target_lang: str) -> str:
"""Translate using Google Translate API."""
if not api_key.strip():
# Use free Google Translate service (unofficial)
return self.translate_google_free(text, target_lang)
url = "https://translation.googleapis.com/language/translate/v2"
params = {
"key": api_key,
"q": text,
"target": target_lang,
"format": "text"
}
try:
response = requests.post(url, params=params, timeout=10)
response.raise_for_status()
result = response.json()
return result["data"]["translations"][0]["translatedText"]
except Exception as e:
# Fallback to free service if API fails
return self.translate_google_free(text, target_lang)
def translate_google_free(self, text: str, target_lang: str) -> str:
"""Translate using free Google Translate service."""
try:
import urllib.parse
import re
# Detect source language
source_lang = "ja" if target_lang == "en" else "en"
# Construct URL
encoded_text = urllib.parse.quote(text)
url = f"https://translate.googleapis.com/translate_a/single?client=gtx&sl={source_lang}&tl={target_lang}&dt=t&q={encoded_text}"
response = requests.get(url, timeout=10)
response.raise_for_status()
result = response.json()
if result and result[0] and result[0][0]:
return result[0][0][0]
else:
raise Exception("No translation result")
except Exception as e:
raise Exception(f"Free Google translation failed: {str(e)}")
def translate_yandex(self, text: str, api_key: str, lang_pair: str) -> str:
"""Translate using Yandex Translate API."""
url = "https://translate.yandex.net/api/v1.5/tr.json/translate"
params = {
"key": api_key,
"text": text,
"lang": lang_pair,
"format": "plain"
}
try:
response = requests.post(url, params=params, timeout=10)
response.raise_for_status()
result = response.json()
if result["code"] != 200:
raise Exception(f"Yandex API error: {result.get('message', 'Unknown error')}")
return result["text"][0]
except Exception as e:
raise Exception(f"Yandex translation failed: {str(e)}")
def translate_baidu(self, text: str, api_key: str, from_lang: str, to_lang: str) -> str:
"""Translate using Baidu Translate API."""
app_id, secret_key = api_key.split(":")
salt = str(int(time.time()))
sign_str = app_id + text + salt + secret_key
sign = hashlib.md5(sign_str.encode()).hexdigest()
url = "https://fanyi-api.baidu.com/api/trans/vip/translate"
params = {
"q": text,
"from": from_lang,
"to": to_lang,
"appid": app_id,
"salt": salt,
"sign": sign
}
try:
response = requests.post(url, params=params, timeout=10)
response.raise_for_status()
result = response.json()
if "error_code" in result:
raise Exception(f"Baidu API error: {result['error_msg']}")
return result["trans_result"][0]["dst"]
except Exception as e:
raise Exception(f"Baidu translation failed: {str(e)}")
def translate_marianmt(self, text: str, direction: str) -> str:
"""Translate using MarianMT local models."""
if not HAS_MARIANMT:
raise Exception("MarianMT not available. Install with: pip install transformers torch")
model_name = "Helsinki-NLP/opus-mt-jap-en" if direction == "日→英" else "Helsinki-NLP/opus-mt-en-jap"
try:
if model_name not in self.marianmt_models:
self.marianmt_models[model_name] = {
"model": MarianMTModel.from_pretrained(model_name),
"tokenizer": MarianTokenizer.from_pretrained(model_name)
}
model = self.marianmt_models[model_name]["model"]
tokenizer = self.marianmt_models[model_name]["tokenizer"]
inputs = tokenizer.encode(text, return_tensors="pt", max_length=512, truncation=True)
translated = model.generate(inputs, max_length=512, num_beams=4, early_stopping=True)
result = tokenizer.decode(translated[0], skip_special_tokens=True)
return result
except Exception as e:
raise Exception(f"MarianMT translation failed: {str(e)}")
def get_cache_key(self, text: str, provider: str, direction: str) -> str:
"""Generate cache key for translation."""
return hashlib.md5(f"{text}_{provider}_{direction}".encode()).hexdigest()
def translate_async(self, provider: str, text: str, api_key: str, direction: str) -> str:
"""Perform translation in background thread."""
cache_key = self.get_cache_key(text, provider, direction)
if cache_key in self.translation_cache:
return self.translation_cache[cache_key]
try:
if provider == "DeepL":
# DeepL uses specific language codes
target_lang = "EN-US" if direction == "日→英" else "JA"
translated = self.translate_deepl(text, api_key, target_lang)
elif provider == "Google":
target_lang = "en" if direction == "日→英" else "ja"
translated = self.translate_google(text, api_key, target_lang)
elif provider == "Yandex":
lang_pair = "ja-en" if direction == "日→英" else "en-ja"
translated = self.translate_yandex(text, api_key, lang_pair)
elif provider == "Baidu":
from_lang = "jp" if direction == "日→英" else "en"
to_lang = "en" if direction == "日→英" else "jp"
translated = self.translate_baidu(text, api_key, from_lang, to_lang)
elif provider == "MarianMT":
translated = self.translate_marianmt(text, direction)
else:
translated = f"Error: Unknown provider {provider}"
self.translation_cache[cache_key] = translated
return translated
except Exception as e:
error_msg = f"Translation failed: {str(e)}"
self.translation_cache[cache_key] = error_msg
return error_msg
def translate(self, provider: str, input_text: str, translation_mode: str, other_language: str, api_key: str, execute_translation: bool, translated_text: str, input_text_connection: Optional[str] = None) -> Tuple[str, str, int]:
"""
ComfyUI-focused translation: English ↔ Other Languages
Args:
provider: Translation provider
input_text: Text to translate
translation_mode: "English → Other Language" or "Other Language → English"
other_language: The non-English language (Japanese, Chinese, etc.)
api_key: API key for the provider
execute_translation: Execute translation flag
translated_text: Previous translated text (for UI display)
input_text_connection: Optional connected input text
Returns:
Tuple of (original_text, translated_text, token_count)
"""
text_to_translate = input_text_connection if input_text_connection else input_text
if not text_to_translate.strip():
return "", "", 0
if not execute_translation:
# No translation requested, return original text and any existing translation
token_count = self.count_tokens(text_to_translate)
if translated_text and translated_text.strip():
translated_tokens = self.count_tokens(translated_text)
token_count += translated_tokens
return text_to_translate, translated_text, token_count
return text_to_translate, "", token_count
# Determine source and target languages from mode
if translation_mode == "English → Other Language":
source_language = "English"
target_language = other_language
else: # "Other Language → English"
source_language = other_language
target_language = "English"
# For Google, we can use free service, others need API key
if provider not in ["MarianMT", "Google"] and not api_key.strip():
cached_key = self.api_cache.get(provider, "")
if not cached_key:
help_text = {
"DeepL": "Get API key from https://www.deepl.com/pro-api",
"Yandex": "Get API key from https://translate.yandex.com/developers",
"Baidu": "Get App ID and Secret from https://fanyi-api.baidu.com/ (format: app_id:secret_key)"
}
return text_to_translate, f"Error: {provider} API key required. {help_text.get(provider, '')}", 0
api_key = cached_key
elif provider == "Google" and not api_key.strip():
# Use free Google Translate
api_key = ""
# Validate API key format for specific providers
if provider == "DeepL" and api_key and not api_key.endswith(":fx") and not api_key.startswith("DeepL-Auth-Key"):
if ":" in api_key:
return text_to_translate, "Error: DeepL API key format incorrect. Use DeepL key only (not Baidu format)", 0
elif provider == "Yandex" and api_key and ":" in api_key:
return text_to_translate, "Error: Yandex requires standalone API key, not Baidu format", 0
elif provider == "Baidu" and api_key and ":" not in api_key:
return text_to_translate, "Error: Baidu requires format 'app_id:secret_key'", 0
if api_key.strip() and provider != "MarianMT":
self.api_cache[provider] = api_key
self.save_cache()
# Create translation direction for caching
translation_direction = f"{source_language}→{target_language}"
cache_key = self.get_cache_key(text_to_translate, provider, translation_direction)
if cache_key in self.translation_cache:
translated = self.translation_cache[cache_key]
else:
current_time = time.time()
if current_time - self.last_translation_time < 0.5:
time.sleep(0.1)
self.last_translation_time = current_time
try:
# Get language codes for this provider
source_code = self.get_language_code(source_language, provider)
target_code = self.get_language_code(target_language, provider)
# Call provider-specific translation
if provider == "DeepL":
translated = self.translate_deepl(text_to_translate, api_key, target_code)
elif provider == "Google":
translated = self.translate_google(text_to_translate, api_key, target_code)
elif provider == "Yandex":
lang_pair = f"{source_code}-{target_code}"
translated = self.translate_yandex(text_to_translate, api_key, lang_pair)
elif provider == "Baidu":
translated = self.translate_baidu(text_to_translate, api_key, source_code, target_code)
elif provider == "MarianMT":
# MarianMT still uses old direction format for now
old_direction = "日→英" if source_language == "Japanese" else "英→日"
translated = self.translate_marianmt(text_to_translate, old_direction)
else:
translated = f"Error: Unknown provider {provider}"
self.translation_cache[cache_key] = translated
except Exception as e:
translated = f"Translation failed: {str(e)}"
# Count tokens for original and translated text separately, then combine
original_tokens = self.count_tokens(text_to_translate)
translated_tokens = self.count_tokens(translated)
total_tokens = original_tokens + translated_tokens
print(f"Token count: Original={original_tokens}, Translated={translated_tokens}, Total={total_tokens}")
# Update the translated_text field directly
self.last_translated_text = translated
return text_to_translate, translated, total_tokens
NODE_CLASS_MAPPINGS = {
"PromptTranslator": PromptTranslator
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PromptTranslator": "Prompt Translator"
}