566 lines
24 KiB
Python
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"
|
|
} |