Translation Service
- Added translation service with 3 services. - MyMemory (Free no api key needed but 1000 words a day and Skow) - Deepl (Free with API key, 500000 words a month and fast) - Libre Translate (Paid unless you host your own server, open source) - Added caching for Quick Access and the translate service to speed up the UI. Can be fast depending on the service you use/ PC specs and etc).
This commit is contained in:
@@ -512,7 +512,7 @@ standard_bones = {
|
|||||||
|
|
||||||
# Eyes
|
# Eyes
|
||||||
'left_eye': 'Eye_L',
|
'left_eye': 'Eye_L',
|
||||||
'right_eye': 'Eye_R'
|
'right_eye': 'Eye_R',
|
||||||
|
|
||||||
# Breast bones
|
# Breast bones
|
||||||
'breast_1_l': 'Breast1_L',
|
'breast_1_l': 'Breast1_L',
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
# GPL License
|
||||||
|
|
||||||
|
from typing import Dict, List, Optional, Set, Tuple
|
||||||
|
from .dictionaries import bone_names, reverse_bone_lookup, simplify_bonename
|
||||||
|
from .logging_setup import logger
|
||||||
|
|
||||||
|
# Enhanced dictionaries for comprehensive translation support
|
||||||
|
|
||||||
|
# Shapekey/Morph name translations (Japanese to English)
|
||||||
|
shapekey_names: Dict[str, List[str]] = {
|
||||||
|
# Basic facial expressions
|
||||||
|
"neutral": ["ニュートラル", "中立", "通常", "普通", "デフォルト", "basis"],
|
||||||
|
"smile": ["笑顔", "スマイル", "えがお", "笑い", "にこり", "ほほえみ", "smile", "happy"],
|
||||||
|
"angry": ["怒り", "怒る", "アングリー", "いかり", "おこり", "むかつき", "angry", "mad"],
|
||||||
|
"sad": ["悲しい", "かなしい", "悲哀", "サッド", "sad", "sorrow"],
|
||||||
|
"surprised": ["驚き", "びっくり", "おどろき", "サプライズ", "surprised", "shock"],
|
||||||
|
"disgusted": ["嫌悪", "いやがり", "きもち悪い", "disgusted"],
|
||||||
|
"fearful": ["恐怖", "怖い", "こわい", "恐れ", "fearful", "scared"],
|
||||||
|
"blink": ["瞬き", "まばたき", "ブリンク", "目閉じ", "blink", "eyeclose"],
|
||||||
|
"wink_left": ["ウィンク左", "左目ウィンク", "ひだりめうぃんく", "winkleft", "wink_l"],
|
||||||
|
"wink_right": ["ウィンク右", "右目ウィンク", "みぎめうぃんく", "winkright", "wink_r"],
|
||||||
|
"eye_close": ["目閉じ", "目を閉じる", "めとじ", "eyeclose", "closedeyes"],
|
||||||
|
"eye_wide": ["目見開き", "目を見開く", "びっくり目", "eyewide", "wideeyes"],
|
||||||
|
"eye_narrow": ["細目", "目細め", "ほそめ", "eyenarrow", "narroweyes"],
|
||||||
|
"mouth_open": ["口開け", "口を開ける", "くちあけ", "mouthopen", "openmouth"],
|
||||||
|
"mouth_smile": ["口角上げ", "口笑顔", "くちえがお", "mouthsmile"],
|
||||||
|
"mouth_frown": ["口角下げ", "への字口", "くちしかめ", "mouthfrown"],
|
||||||
|
"mouth_pout": ["すぼめ口", "とがらせ口", "mouthpout"],
|
||||||
|
"eyebrow_up": ["眉上げ", "眉毛上げ", "まゆあげ", "eyebrowup", "raiseeyebrow"],
|
||||||
|
"eyebrow_down": ["眉下げ", "眉寄せ", "まゆさげ", "eyebrowdown", "lowereyebrow"],
|
||||||
|
"eyebrow_angry": ["怒り眉", "眉怒り", "まゆいかり", "angrybrow"],
|
||||||
|
"cheek_puff": ["頬膨らまし", "ほほふくらまし", "cheekpuff"],
|
||||||
|
"cheek_suck": ["頬すぼめ", "ほほすぼめ", "cheeksuck"],
|
||||||
|
"joy": ["喜び", "よろこび", "ジョイ", "joy", "happiness"],
|
||||||
|
"contempt": ["軽蔑", "けいべつ", "contempt"],
|
||||||
|
"confusion": ["困惑", "こんわく", "confusion", "confused"],
|
||||||
|
"concentration": ["集中", "しゅうちゅう", "concentration", "focused"],
|
||||||
|
|
||||||
|
# VRC Visemes
|
||||||
|
"viseme_sil": ["無音", "むおん", "サイレンス", "silence", "sil"],
|
||||||
|
"viseme_aa": ["あ", "aa", "mouth_a"],
|
||||||
|
"viseme_ih": ["い", "ih", "mouth_i"],
|
||||||
|
"viseme_ou": ["う", "ou", "mouth_u"],
|
||||||
|
"viseme_e": ["え", "e", "mouth_e"],
|
||||||
|
"viseme_oh": ["お", "oh", "mouth_o"],
|
||||||
|
"viseme_ch": ["ち", "ch"],
|
||||||
|
"viseme_dd": ["だ", "dd"],
|
||||||
|
"viseme_ff": ["ふ", "ff"],
|
||||||
|
"viseme_kk": ["か", "kk"],
|
||||||
|
"viseme_nn": ["ん", "nn"],
|
||||||
|
"viseme_pp": ["ぱ", "pp"],
|
||||||
|
"viseme_rr": ["ら", "rr"],
|
||||||
|
"viseme_ss": ["さ", "ss"],
|
||||||
|
"viseme_th": ["た", "th"],
|
||||||
|
|
||||||
|
"basis": ["基本", "きほん", "ベース", "base", "basis", "default"],
|
||||||
|
"reset": ["リセット", "初期化", "しょきか", "reset", "clear"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Material name translations (Japanese to English)
|
||||||
|
material_names: Dict[str, List[str]] = {
|
||||||
|
# Basic materials
|
||||||
|
"skin": ["肌", "はだ", "皮膚", "ひふ", "スキン", "skin", "flesh"],
|
||||||
|
"hair": ["髪", "かみ", "毛髪", "もうはつ", "ヘア", "hair"],
|
||||||
|
"eyes": ["目", "め", "眼", "がん", "アイ", "eye", "iris"],
|
||||||
|
"eyebrow": ["眉", "まゆ", "眉毛", "まゆげ", "eyebrow", "brow"],
|
||||||
|
"eyelash": ["まつ毛", "まつげ", "睫毛", "eyelash", "lash"],
|
||||||
|
"teeth": ["歯", "は", "歯列", "しれつ", "tooth", "teeth"],
|
||||||
|
"tongue": ["舌", "した", "tongue"],
|
||||||
|
"nails": ["爪", "つめ", "nail", "nails"],
|
||||||
|
"shirt": ["シャツ", "上着", "うわぎ", "shirt", "top"],
|
||||||
|
"pants": ["パンツ", "ズボン", "下着", "したぎ", "pants", "trousers"],
|
||||||
|
"skirt": ["スカート", "skirt"],
|
||||||
|
"dress": ["ドレス", "ワンピース", "dress"],
|
||||||
|
"shoes": ["靴", "くつ", "シューズ", "shoe", "shoes"],
|
||||||
|
"socks": ["靴下", "くつした", "ソックス", "sock", "socks"],
|
||||||
|
"gloves": ["手袋", "てぶくろ", "グローブ", "glove", "gloves"],
|
||||||
|
"hat": ["帽子", "ぼうし", "ハット", "hat", "cap"],
|
||||||
|
"jacket": ["ジャケット", "上着", "うわぎ", "jacket", "coat"],
|
||||||
|
"underwear": ["下着", "したぎ", "パンティー", "underwear", "panties"],
|
||||||
|
"bra": ["ブラ", "ブラジャー", "胸当て", "bra", "brassiere"],
|
||||||
|
"glasses": ["眼鏡", "めがね", "メガネ", "glasses", "spectacles"],
|
||||||
|
"earring": ["イヤリング", "耳飾り", "みみかざり", "earring"],
|
||||||
|
"necklace": ["ネックレス", "首飾り", "くびかざり", "necklace"],
|
||||||
|
"bracelet": ["ブレスレット", "腕輪", "うでわ", "bracelet"],
|
||||||
|
"ring": ["指輪", "ゆびわ", "リング", "ring"],
|
||||||
|
"watch": ["時計", "とけい", "ウォッチ", "watch"],
|
||||||
|
"bag": ["鞄", "かばん", "バッグ", "bag", "purse"],
|
||||||
|
"belt": ["ベルト", "帯", "おび", "belt"],
|
||||||
|
"transparent": ["透明", "とうめい", "クリア", "transparent", "clear"],
|
||||||
|
"metal": ["金属", "きんぞく", "メタル", "metal"],
|
||||||
|
"fabric": ["布", "ぬの", "生地", "きじ", "fabric", "cloth"],
|
||||||
|
"leather": ["革", "かわ", "皮", "ひ", "レザー", "leather"],
|
||||||
|
"plastic": ["プラスチック", "プラ", "plastic"],
|
||||||
|
"glass": ["ガラス", "硝子", "glass"],
|
||||||
|
"rubber": ["ゴム", "ラバー", "rubber"],
|
||||||
|
"wood": ["木", "き", "木材", "もくざい", "wood", "wooden"],
|
||||||
|
"diffuse": ["ディフューズ", "基本色", "きほんしょく", "diffuse", "albedo"],
|
||||||
|
"normal": ["ノーマル", "法線", "ほうせん", "normal", "bump"],
|
||||||
|
"specular": ["スペキュラー", "反射", "はんしゃ", "specular", "reflection"],
|
||||||
|
"emission": ["発光", "はっこう", "エミッション", "emission", "glow"],
|
||||||
|
"roughness": ["粗さ", "あらさ", "ラフネス", "roughness"],
|
||||||
|
"metallic": ["メタリック", "金属性", "きんぞくせい", "metallic"],
|
||||||
|
"subsurface": ["表面下散乱", "サブサーフェス", "subsurface", "sss"],
|
||||||
|
|
||||||
|
# Common naming patterns
|
||||||
|
"main": ["メイン", "主要", "しゅよう", "main", "primary"],
|
||||||
|
"sub": ["サブ", "副", "ふく", "sub", "secondary"],
|
||||||
|
"detail": ["詳細", "しょうさい", "ディテール", "detail"],
|
||||||
|
"shadow": ["影", "かげ", "シャドウ", "shadow"],
|
||||||
|
"highlight": ["ハイライト", "強調", "きょうちょう", "highlight"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Object name translations (Japanese to English)
|
||||||
|
object_names: Dict[str, List[str]] = {
|
||||||
|
|
||||||
|
"body": ["体", "からだ", "身体", "しんたい", "ボディ", "body", "torso"],
|
||||||
|
"head": ["頭", "あたま", "ヘッド", "head"],
|
||||||
|
"face": ["顔", "かお", "フェイス", "face"],
|
||||||
|
"neck": ["首", "くび", "ネック", "neck"],
|
||||||
|
"chest": ["胸", "むね", "チェスト", "chest", "breast"],
|
||||||
|
"back": ["背中", "せなか", "バック", "back"],
|
||||||
|
"waist": ["腰", "こし", "ウエスト", "waist"],
|
||||||
|
"hip": ["腰", "こし", "ヒップ", "hip"],
|
||||||
|
"arm": ["腕", "うで", "アーム", "arm"],
|
||||||
|
"hand": ["手", "て", "ハンド", "hand"],
|
||||||
|
"finger": ["指", "ゆび", "フィンガー", "finger"],
|
||||||
|
"leg": ["足", "あし", "脚", "レッグ", "leg"],
|
||||||
|
"foot": ["足", "あし", "フット", "foot"],
|
||||||
|
"toe": ["つま先", "つまさき", "トゥ", "toe"],
|
||||||
|
"clothing": ["服", "ふく", "衣服", "いふく", "クロージング", "clothing", "clothes"],
|
||||||
|
"outfit": ["服装", "ふくそう", "アウトフィット", "outfit"],
|
||||||
|
"accessory": ["アクセサリー", "装身具", "そうしんぐ", "accessory"],
|
||||||
|
"decoration": ["装飾", "そうしょく", "デコレーション", "decoration"],
|
||||||
|
"hair_front": ["前髪", "まえがみ", "フロント髪", "hairfront"],
|
||||||
|
"hair_back": ["後ろ髪", "うしろがみ", "バック髪", "hairback"],
|
||||||
|
"hair_side": ["横髪", "よこがみ", "サイド髪", "hairside"],
|
||||||
|
"ponytail": ["ポニーテール", "一つ結び", "ひとつむすび", "ponytail"],
|
||||||
|
"twintail": ["ツインテール", "二つ結び", "ふたつむすび", "twintail"],
|
||||||
|
"ahoge": ["あほ毛", "アホ毛", "はね毛", "ahoge", "antenna"],
|
||||||
|
"eyeball": ["眼球", "がんきゅう", "目玉", "めだま", "eyeball"],
|
||||||
|
"pupil": ["瞳", "ひとみ", "瞳孔", "どうこう", "pupil"],
|
||||||
|
"iris": ["虹彩", "こうさい", "アイリス", "iris"],
|
||||||
|
"eyelid": ["まぶた", "眼瞼", "がんけん", "eyelid"],
|
||||||
|
"nose": ["鼻", "はな", "ノーズ", "nose"],
|
||||||
|
"mouth": ["口", "くち", "マウス", "mouth"],
|
||||||
|
"lip": ["唇", "くちびる", "リップ", "lip"],
|
||||||
|
"ear": ["耳", "みみ", "イヤー", "ear"],
|
||||||
|
|
||||||
|
# Common object suffixes
|
||||||
|
"left": ["左", "ひだり", "レフト", "left", "l"],
|
||||||
|
"right": ["右", "みぎ", "ライト", "right", "r"],
|
||||||
|
"upper": ["上", "うえ", "アッパー", "upper", "top"],
|
||||||
|
"lower": ["下", "した", "ロワー", "lower", "bottom"],
|
||||||
|
"inner": ["内", "うち", "インナー", "inner", "inside"],
|
||||||
|
"outer": ["外", "そと", "アウター", "outer", "outside"],
|
||||||
|
"front": ["前", "まえ", "フロント", "front"],
|
||||||
|
"back": ["後ろ", "うしろ", "バック", "back", "rear"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Physics object names (for MMD rigid bodies and joints)
|
||||||
|
physics_names: Dict[str, List[str]] = {
|
||||||
|
# Rigid body types
|
||||||
|
"rigidbody": ["剛体", "ごうたい", "リジッドボディ", "rigidbody", "rigid"],
|
||||||
|
"joint": ["ジョイント", "関節", "かんせつ", "joint", "constraint"],
|
||||||
|
"collision": ["当たり判定", "あたりはんてい", "コリジョン", "collision"],
|
||||||
|
"hair_physics": ["髪物理", "かみぶつり", "ヘアフィジックス", "hairphys"],
|
||||||
|
"hair_root": ["髪根元", "かみねもと", "ヘアルート", "hairroot"],
|
||||||
|
"hair_tip": ["髪先", "かみさき", "ヘアティップ", "hairtip"],
|
||||||
|
"cloth_physics": ["布物理", "ぬのぶつり", "クロスフィジックス", "clothphys"],
|
||||||
|
"skirt_physics": ["スカート物理", "スカートフィジックス", "skirtphys"],
|
||||||
|
"breast_physics": ["胸物理", "むねぶつり", "ブレストフィジックス", "breastphys"],
|
||||||
|
"breast_root": ["胸根元", "むねねもと", "ブレストルート", "breastroot"],
|
||||||
|
"breast_tip": ["胸先", "むねさき", "ブレストティップ", "breasttip"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create reverse lookup dictionaries
|
||||||
|
reverse_shapekey_lookup: Dict[str, str] = {}
|
||||||
|
reverse_material_lookup: Dict[str, str] = {}
|
||||||
|
reverse_object_lookup: Dict[str, str] = {}
|
||||||
|
reverse_physics_lookup: Dict[str, str] = {}
|
||||||
|
|
||||||
|
def _build_reverse_lookups():
|
||||||
|
"""Build reverse lookup dictionaries for fast translation"""
|
||||||
|
global reverse_shapekey_lookup, reverse_material_lookup, reverse_object_lookup, reverse_physics_lookup
|
||||||
|
|
||||||
|
for standard_name, variations in shapekey_names.items():
|
||||||
|
for variation in variations:
|
||||||
|
simplified = simplify_bonename(variation)
|
||||||
|
reverse_shapekey_lookup[simplified] = standard_name
|
||||||
|
|
||||||
|
for standard_name, variations in material_names.items():
|
||||||
|
for variation in variations:
|
||||||
|
simplified = simplify_bonename(variation)
|
||||||
|
reverse_material_lookup[simplified] = standard_name
|
||||||
|
|
||||||
|
for standard_name, variations in object_names.items():
|
||||||
|
for variation in variations:
|
||||||
|
simplified = simplify_bonename(variation)
|
||||||
|
reverse_object_lookup[simplified] = standard_name
|
||||||
|
|
||||||
|
for standard_name, variations in physics_names.items():
|
||||||
|
for variation in variations:
|
||||||
|
simplified = simplify_bonename(variation)
|
||||||
|
reverse_physics_lookup[simplified] = standard_name
|
||||||
|
|
||||||
|
_build_reverse_lookups()
|
||||||
|
|
||||||
|
|
||||||
|
class EnhancedDictionaryTranslator:
|
||||||
|
"""Enhanced dictionary translator with support for bones, shapekeys, materials, and objects"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.translation_stats = {
|
||||||
|
'bones': 0,
|
||||||
|
'shapekeys': 0,
|
||||||
|
'materials': 0,
|
||||||
|
'objects': 0,
|
||||||
|
'physics': 0,
|
||||||
|
'total': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
def translate_bone_name(self, name: str) -> Optional[str]:
|
||||||
|
"""Translate bone name using existing bone dictionary"""
|
||||||
|
simplified = simplify_bonename(name)
|
||||||
|
if simplified in reverse_bone_lookup:
|
||||||
|
self.translation_stats['bones'] += 1
|
||||||
|
self.translation_stats['total'] += 1
|
||||||
|
return reverse_bone_lookup[simplified]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def translate_shapekey_name(self, name: str) -> Optional[str]:
|
||||||
|
"""Translate shapekey/morph name using shapekey dictionary"""
|
||||||
|
simplified = simplify_bonename(name)
|
||||||
|
if simplified in reverse_shapekey_lookup:
|
||||||
|
self.translation_stats['shapekeys'] += 1
|
||||||
|
self.translation_stats['total'] += 1
|
||||||
|
return reverse_shapekey_lookup[simplified]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def translate_material_name(self, name: str) -> Optional[str]:
|
||||||
|
"""Translate material name using material dictionary"""
|
||||||
|
simplified = simplify_bonename(name)
|
||||||
|
if simplified in reverse_material_lookup:
|
||||||
|
self.translation_stats['materials'] += 1
|
||||||
|
self.translation_stats['total'] += 1
|
||||||
|
return reverse_material_lookup[simplified]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def translate_object_name(self, name: str) -> Optional[str]:
|
||||||
|
"""Translate object name using object dictionary"""
|
||||||
|
simplified = simplify_bonename(name)
|
||||||
|
if simplified in reverse_object_lookup:
|
||||||
|
self.translation_stats['objects'] += 1
|
||||||
|
self.translation_stats['total'] += 1
|
||||||
|
return reverse_object_lookup[simplified]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def translate_physics_name(self, name: str) -> Optional[str]:
|
||||||
|
"""Translate physics object name using physics dictionary"""
|
||||||
|
simplified = simplify_bonename(name)
|
||||||
|
if simplified in reverse_physics_lookup:
|
||||||
|
self.translation_stats['physics'] += 1
|
||||||
|
self.translation_stats['total'] += 1
|
||||||
|
return reverse_physics_lookup[simplified]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def translate_name(self, name: str, category: str = "auto") -> Tuple[Optional[str], str]:
|
||||||
|
"""
|
||||||
|
Translate name with automatic category detection or specified category
|
||||||
|
Returns (translated_name, detected_category)
|
||||||
|
"""
|
||||||
|
if not name or not name.strip():
|
||||||
|
return None, "none"
|
||||||
|
|
||||||
|
if category == "bones":
|
||||||
|
result = self.translate_bone_name(name)
|
||||||
|
return (result, "bones") if result else (None, "unknown")
|
||||||
|
elif category == "shapekeys":
|
||||||
|
result = self.translate_shapekey_name(name)
|
||||||
|
return (result, "shapekeys") if result else (None, "unknown")
|
||||||
|
elif category == "materials":
|
||||||
|
result = self.translate_material_name(name)
|
||||||
|
return (result, "materials") if result else (None, "unknown")
|
||||||
|
elif category == "objects":
|
||||||
|
result = self.translate_object_name(name)
|
||||||
|
return (result, "objects") if result else (None, "unknown")
|
||||||
|
elif category == "physics":
|
||||||
|
result = self.translate_physics_name(name)
|
||||||
|
return (result, "physics") if result else (None, "unknown")
|
||||||
|
elif category == "auto":
|
||||||
|
# Try all categories in order of likelihood
|
||||||
|
for cat_name, translate_func in [
|
||||||
|
("bones", self.translate_bone_name),
|
||||||
|
("shapekeys", self.translate_shapekey_name),
|
||||||
|
("materials", self.translate_material_name),
|
||||||
|
("objects", self.translate_object_name),
|
||||||
|
("physics", self.translate_physics_name)
|
||||||
|
]:
|
||||||
|
result = translate_func(name)
|
||||||
|
if result:
|
||||||
|
return result, cat_name
|
||||||
|
return None, "unknown"
|
||||||
|
else:
|
||||||
|
return None, "invalid_category"
|
||||||
|
|
||||||
|
def get_statistics(self) -> Dict[str, int]:
|
||||||
|
"""Get translation statistics"""
|
||||||
|
return self.translation_stats.copy()
|
||||||
|
|
||||||
|
def reset_statistics(self) -> None:
|
||||||
|
"""Reset translation statistics"""
|
||||||
|
for key in self.translation_stats:
|
||||||
|
self.translation_stats[key] = 0
|
||||||
|
|
||||||
|
|
||||||
|
# Global enhanced dictionary translator instance
|
||||||
|
_enhanced_translator: Optional[EnhancedDictionaryTranslator] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_enhanced_translator() -> EnhancedDictionaryTranslator:
|
||||||
|
"""Get the global enhanced dictionary translator"""
|
||||||
|
global _enhanced_translator
|
||||||
|
if _enhanced_translator is None:
|
||||||
|
_enhanced_translator = EnhancedDictionaryTranslator()
|
||||||
|
return _enhanced_translator
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_dictionary_names() -> Dict[str, Dict[str, List[str]]]:
|
||||||
|
"""Get all dictionary names for reference"""
|
||||||
|
return {
|
||||||
|
"bones": bone_names,
|
||||||
|
"shapekeys": shapekey_names,
|
||||||
|
"materials": material_names,
|
||||||
|
"objects": object_names,
|
||||||
|
"physics": physics_names
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def add_custom_translation(category: str, standard_name: str, variations: List[str]) -> bool:
|
||||||
|
"""Add custom translation to the dictionaries"""
|
||||||
|
try:
|
||||||
|
if category == "bones":
|
||||||
|
if standard_name not in bone_names:
|
||||||
|
bone_names[standard_name] = []
|
||||||
|
bone_names[standard_name].extend(variations)
|
||||||
|
elif category == "shapekeys":
|
||||||
|
if standard_name not in shapekey_names:
|
||||||
|
shapekey_names[standard_name] = []
|
||||||
|
shapekey_names[standard_name].extend(variations)
|
||||||
|
elif category == "materials":
|
||||||
|
if standard_name not in material_names:
|
||||||
|
material_names[standard_name] = []
|
||||||
|
material_names[standard_name].extend(variations)
|
||||||
|
elif category == "objects":
|
||||||
|
if standard_name not in object_names:
|
||||||
|
object_names[standard_name] = []
|
||||||
|
object_names[standard_name].extend(variations)
|
||||||
|
elif category == "physics":
|
||||||
|
if standard_name not in physics_names:
|
||||||
|
physics_names[standard_name] = []
|
||||||
|
physics_names[standard_name].extend(variations)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
_build_reverse_lookups()
|
||||||
|
logger.info(f"Added custom translation for {category}: {standard_name}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to add custom translation: {e}")
|
||||||
|
return False
|
||||||
+137
-4
@@ -197,6 +197,7 @@ class AvatarToolkitSceneProperties(PropertyGroup):
|
|||||||
items=get_armature_list,
|
items=get_armature_list,
|
||||||
name=t("QuickAccess.select_armature"),
|
name=t("QuickAccess.select_armature"),
|
||||||
description=t("QuickAccess.select_armature"),
|
description=t("QuickAccess.select_armature"),
|
||||||
|
update=lambda self, context: update_active_armature(self, context)
|
||||||
)
|
)
|
||||||
|
|
||||||
language: EnumProperty(
|
language: EnumProperty(
|
||||||
@@ -610,17 +611,149 @@ class AvatarToolkitSceneProperties(PropertyGroup):
|
|||||||
|
|
||||||
# VRM Conversion Properties
|
# VRM Conversion Properties
|
||||||
vrm_remove_colliders: BoolProperty(
|
vrm_remove_colliders: BoolProperty(
|
||||||
name="Remove Colliders",
|
name=t("VRM.remove_colliders"),
|
||||||
description="Remove VRM collider bones during conversion",
|
description=t("VRM.remove_colliders_desc"),
|
||||||
default=True
|
default=True
|
||||||
)
|
)
|
||||||
|
|
||||||
vrm_remove_root: BoolProperty(
|
vrm_remove_root: BoolProperty(
|
||||||
name="Remove Root Bone",
|
name=t("VRM.remove_root"),
|
||||||
description="Remove unnecessary VRM root bone and make Hips the root bone",
|
description=t("VRM.remove_root_desc"),
|
||||||
default=True
|
default=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Translation System Properties
|
||||||
|
translation_service: EnumProperty(
|
||||||
|
name=t("Translation.service"),
|
||||||
|
description=t("Translation.service_desc"),
|
||||||
|
items=[
|
||||||
|
('mymemory', t("Translation.service.mymemory"), t("Translation.service.mymemory_desc")),
|
||||||
|
('libretranslate', t("Translation.service.libretranslate"), t("Translation.service.libretranslate_desc")),
|
||||||
|
('deepl', t("Translation.service.deepl"), t("Translation.service.deepl_desc"))
|
||||||
|
],
|
||||||
|
default=get_preference("translation_service", "mymemory"),
|
||||||
|
update=lambda self, context: update_translation_service(self, context)
|
||||||
|
)
|
||||||
|
|
||||||
|
translation_mode: EnumProperty(
|
||||||
|
name=t("Translation.mode"),
|
||||||
|
description=t("Translation.mode_desc"),
|
||||||
|
items=[
|
||||||
|
('hybrid', t("Translation.mode.hybrid"), t("Translation.mode.hybrid_desc")),
|
||||||
|
('dictionary_only', t("Translation.mode.dictionary_only"), t("Translation.mode.dictionary_only_desc")),
|
||||||
|
('api_only', t("Translation.mode.api_only"), t("Translation.mode.api_only_desc"))
|
||||||
|
],
|
||||||
|
default=get_preference("translation_mode", "hybrid"),
|
||||||
|
update=lambda self, context: update_translation_mode(self, context)
|
||||||
|
)
|
||||||
|
|
||||||
|
translation_expand: BoolProperty(
|
||||||
|
name="Translation Settings Expanded",
|
||||||
|
default=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
translation_target_language: EnumProperty(
|
||||||
|
name=t("Translation.target_language"),
|
||||||
|
description=t("Translation.target_language_desc"),
|
||||||
|
items=[
|
||||||
|
('en', 'English', 'Translate to English'),
|
||||||
|
('ja', 'Japanese', 'Translate to Japanese'),
|
||||||
|
('ko', 'Korean', 'Translate to Korean'),
|
||||||
|
('zh', 'Chinese', 'Translate to Chinese'),
|
||||||
|
('es', 'Spanish', 'Translate to Spanish'),
|
||||||
|
('fr', 'French', 'Translate to French'),
|
||||||
|
('de', 'German', 'Translate to German')
|
||||||
|
],
|
||||||
|
default='en'
|
||||||
|
)
|
||||||
|
|
||||||
|
translation_source_language: EnumProperty(
|
||||||
|
name=t("Translation.source_language"),
|
||||||
|
description=t("Translation.source_language_desc"),
|
||||||
|
items=[
|
||||||
|
('auto', 'Auto-detect', 'Automatically detect source language'),
|
||||||
|
('ja', 'Japanese', 'Source is Japanese'),
|
||||||
|
('en', 'English', 'Source is English'),
|
||||||
|
('ko', 'Korean', 'Source is Korean'),
|
||||||
|
('zh', 'Chinese', 'Source is Chinese')
|
||||||
|
],
|
||||||
|
default='ja'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_translation_service(self: PropertyGroup, context: Context) -> None:
|
||||||
|
"""Update translation service preference"""
|
||||||
|
logger.info(f"Updating translation service to: {self.translation_service}")
|
||||||
|
save_preference("translation_service", self.translation_service)
|
||||||
|
|
||||||
|
# Clear module-level translation caches when service changes
|
||||||
|
try:
|
||||||
|
from ..ui.translation_panel import _ui_cache
|
||||||
|
_ui_cache['deepl_config'].clear()
|
||||||
|
_ui_cache['libretranslate_config'].clear()
|
||||||
|
_ui_cache['translation_status'].clear()
|
||||||
|
if 'batch_info' in _ui_cache:
|
||||||
|
del _ui_cache['batch_info'] # Clear batch info cache when service changes
|
||||||
|
except ImportError:
|
||||||
|
pass # UI module might not be loaded yet
|
||||||
|
|
||||||
|
# Set the primary service
|
||||||
|
try:
|
||||||
|
from .translation_manager import get_avatar_translation_manager
|
||||||
|
manager = get_avatar_translation_manager()
|
||||||
|
manager.service_manager.set_primary_service(self.translation_service)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update translation service: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def update_translation_mode(self: PropertyGroup, context: Context) -> None:
|
||||||
|
"""Update translation mode preference"""
|
||||||
|
logger.info(f"Updating translation mode to: {self.translation_mode}")
|
||||||
|
save_preference("translation_mode", self.translation_mode)
|
||||||
|
|
||||||
|
# Clear module-level translation status cache when mode changes
|
||||||
|
try:
|
||||||
|
from ..ui.translation_panel import _ui_cache
|
||||||
|
_ui_cache['translation_status'].clear()
|
||||||
|
if 'batch_info' in _ui_cache:
|
||||||
|
del _ui_cache['batch_info'] # Clear batch info cache when mode changes
|
||||||
|
except ImportError:
|
||||||
|
pass # UI module might not be loaded yet
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .translation_manager import get_avatar_translation_manager, TranslationMode
|
||||||
|
manager = get_avatar_translation_manager()
|
||||||
|
manager.set_translation_mode(TranslationMode(self.translation_mode))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update translation mode: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def update_active_armature(self: PropertyGroup, context: Context) -> None:
|
||||||
|
"""Update the active armature when selection changes"""
|
||||||
|
if self.active_armature:
|
||||||
|
logger.info(f"Active armature set to: {self.active_armature}")
|
||||||
|
# Deselect all objects first
|
||||||
|
bpy.ops.object.select_all(action='DESELECT')
|
||||||
|
# Select and make active the chosen armature
|
||||||
|
self.active_armature.select_set(True)
|
||||||
|
context.view_layer.objects.active = self.active_armature
|
||||||
|
logger.info(f"Selected and activated armature: {self.active_armature.name}")
|
||||||
|
|
||||||
|
# Clear armature caches when armature changes to ensure fresh validation
|
||||||
|
try:
|
||||||
|
from ..ui.quick_access_panel import clear_armature_caches
|
||||||
|
clear_armature_caches()
|
||||||
|
except ImportError:
|
||||||
|
pass # UI module might not be loaded yet
|
||||||
|
else:
|
||||||
|
logger.info("No armature selected")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def register() -> None:
|
def register() -> None:
|
||||||
"""Register the Avatar Toolkit property group"""
|
"""Register the Avatar Toolkit property group"""
|
||||||
logger.info("Registering Avatar Toolkit properties")
|
logger.info("Registering Avatar Toolkit properties")
|
||||||
|
|||||||
@@ -0,0 +1,600 @@
|
|||||||
|
# GPL License
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from typing import Dict, List, Optional, Tuple, Set, Any, Callable
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from bpy.types import Object, Material, ShapeKey
|
||||||
|
|
||||||
|
from .translation_service import get_translation_manager, TranslationServiceManager
|
||||||
|
from .enhanced_dictionaries import get_enhanced_translator, EnhancedDictionaryTranslator
|
||||||
|
from .logging_setup import logger
|
||||||
|
from .addon_preferences import get_preference, save_preference
|
||||||
|
from .translations import t
|
||||||
|
|
||||||
|
|
||||||
|
class TranslationMode(Enum):
|
||||||
|
"""Translation modes for different approaches"""
|
||||||
|
DICTIONARY_ONLY = "dictionary_only"
|
||||||
|
API_ONLY = "api_only"
|
||||||
|
HYBRID = "hybrid" # Default: Dictionary first, then API fallback
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TranslationJob:
|
||||||
|
"""Represents a translation job for batch processing"""
|
||||||
|
name: str
|
||||||
|
category: str
|
||||||
|
source_lang: str = "ja"
|
||||||
|
target_lang: str = "en"
|
||||||
|
object_ref: Optional[Any] = None
|
||||||
|
property_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TranslationResult:
|
||||||
|
"""Result of a translation operation"""
|
||||||
|
original: str
|
||||||
|
translated: str
|
||||||
|
method: str # "dictionary", "api", "failed"
|
||||||
|
service: Optional[str] = None
|
||||||
|
category: str = "unknown"
|
||||||
|
confidence: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
class TranslationCache:
|
||||||
|
"""Persistent translation cache with file storage"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._cache: Dict[str, Dict[str, str]] = {}
|
||||||
|
self._cache_file = self._get_cache_file_path()
|
||||||
|
self._cache_lock = threading.Lock()
|
||||||
|
self._load_cache()
|
||||||
|
|
||||||
|
def _get_cache_file_path(self) -> str:
|
||||||
|
"""Get the cache file path in user preferences directory"""
|
||||||
|
user_path = bpy.utils.resource_path('USER')
|
||||||
|
cache_dir = os.path.join(user_path, "config", "avatar_toolkit_prefs")
|
||||||
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
|
return os.path.join(cache_dir, "translation_cache.json")
|
||||||
|
|
||||||
|
def _load_cache(self) -> None:
|
||||||
|
"""Load cache from file"""
|
||||||
|
try:
|
||||||
|
if os.path.exists(self._cache_file):
|
||||||
|
with open(self._cache_file, 'r', encoding='utf-8') as f:
|
||||||
|
self._cache = json.load(f)
|
||||||
|
logger.debug(f"Loaded translation cache with {len(self._cache)} entries")
|
||||||
|
else:
|
||||||
|
self._cache = {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load translation cache: {e}")
|
||||||
|
self._cache = {}
|
||||||
|
|
||||||
|
def _save_cache(self) -> None:
|
||||||
|
"""Save cache to file"""
|
||||||
|
try:
|
||||||
|
with open(self._cache_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(self._cache, f, indent=2, ensure_ascii=False)
|
||||||
|
logger.debug(f"Saved translation cache with {len(self._cache)} entries")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save translation cache: {e}")
|
||||||
|
|
||||||
|
def get(self, text: str, source_lang: str, target_lang: str) -> Optional[str]:
|
||||||
|
"""Get cached translation"""
|
||||||
|
cache_key = f"{source_lang}_{target_lang}"
|
||||||
|
with self._cache_lock:
|
||||||
|
if cache_key in self._cache and text in self._cache[cache_key]:
|
||||||
|
return self._cache[cache_key][text]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def put(self, text: str, translation: str, source_lang: str, target_lang: str) -> None:
|
||||||
|
"""Store translation in cache"""
|
||||||
|
cache_key = f"{source_lang}_{target_lang}"
|
||||||
|
with self._cache_lock:
|
||||||
|
if cache_key not in self._cache:
|
||||||
|
self._cache[cache_key] = {}
|
||||||
|
self._cache[cache_key][text] = translation
|
||||||
|
|
||||||
|
# Save cache periodically (every 10 new entries)
|
||||||
|
if len(self._cache.get(cache_key, {})) % 10 == 0:
|
||||||
|
self._save_cache()
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear all cached translations"""
|
||||||
|
with self._cache_lock:
|
||||||
|
self._cache.clear()
|
||||||
|
self._save_cache()
|
||||||
|
logger.info("Translation cache cleared")
|
||||||
|
|
||||||
|
def get_stats(self) -> Dict[str, int]:
|
||||||
|
"""Get cache statistics"""
|
||||||
|
with self._cache_lock:
|
||||||
|
total_entries = sum(len(lang_cache) for lang_cache in self._cache.values())
|
||||||
|
return {
|
||||||
|
"language_pairs": len(self._cache),
|
||||||
|
"total_entries": total_entries
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarToolkitTranslationManager:
|
||||||
|
"""Main translation manager for Avatar Toolkit"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.service_manager: TranslationServiceManager = get_translation_manager()
|
||||||
|
self.dictionary_translator: EnhancedDictionaryTranslator = get_enhanced_translator()
|
||||||
|
self.cache: TranslationCache = TranslationCache()
|
||||||
|
self.translation_mode: TranslationMode = TranslationMode(
|
||||||
|
get_preference("translation_mode", "hybrid")
|
||||||
|
)
|
||||||
|
self._progress_callback: Optional[Callable[[int, int, str], None]] = None
|
||||||
|
|
||||||
|
def set_translation_mode(self, mode: TranslationMode) -> None:
|
||||||
|
"""Set the translation mode"""
|
||||||
|
self.translation_mode = mode
|
||||||
|
save_preference("translation_mode", mode.value)
|
||||||
|
logger.info(f"Translation mode set to: {mode.value}")
|
||||||
|
|
||||||
|
def set_progress_callback(self, callback: Optional[Callable[[int, int, str], None]]) -> None:
|
||||||
|
"""Set progress callback for batch operations"""
|
||||||
|
self._progress_callback = callback
|
||||||
|
|
||||||
|
def translate_single(self, name: str, category: str = "auto",
|
||||||
|
source_lang: str = "ja", target_lang: str = "en") -> TranslationResult:
|
||||||
|
"""Translate a single name with comprehensive fallback logic"""
|
||||||
|
if not name or not name.strip():
|
||||||
|
return TranslationResult(name, name, "skipped")
|
||||||
|
|
||||||
|
original_name = name.strip()
|
||||||
|
|
||||||
|
# Check cache first
|
||||||
|
cached_result = self.cache.get(original_name, source_lang, target_lang)
|
||||||
|
if cached_result:
|
||||||
|
return TranslationResult(original_name, cached_result, "cache", category=category)
|
||||||
|
|
||||||
|
# Dictionary translation (always try first in hybrid mode)
|
||||||
|
if self.translation_mode in [TranslationMode.DICTIONARY_ONLY, TranslationMode.HYBRID]:
|
||||||
|
dict_result, detected_category = self.dictionary_translator.translate_name(original_name, category)
|
||||||
|
if dict_result:
|
||||||
|
self.cache.put(original_name, dict_result, source_lang, target_lang)
|
||||||
|
return TranslationResult(original_name, dict_result, "dictionary",
|
||||||
|
category=detected_category, confidence=1.0)
|
||||||
|
|
||||||
|
if self.translation_mode in [TranslationMode.API_ONLY, TranslationMode.HYBRID]:
|
||||||
|
try:
|
||||||
|
api_result, service_name = self.service_manager.translate_with_fallback(
|
||||||
|
original_name, source_lang, target_lang
|
||||||
|
)
|
||||||
|
if api_result != original_name: # Translation succeeded
|
||||||
|
self.cache.put(original_name, api_result, source_lang, target_lang)
|
||||||
|
return TranslationResult(original_name, api_result, "api",
|
||||||
|
service=service_name, category=category, confidence=0.8)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"API translation failed for '{original_name}': {e}")
|
||||||
|
|
||||||
|
# No translation available
|
||||||
|
return TranslationResult(original_name, original_name, "failed", category=category)
|
||||||
|
|
||||||
|
def translate_batch(self, jobs: List[TranslationJob],
|
||||||
|
apply_results: bool = True) -> List[TranslationResult]:
|
||||||
|
"""Translate multiple items in batch with progress reporting and interruption handling"""
|
||||||
|
results = []
|
||||||
|
total_jobs = len(jobs)
|
||||||
|
|
||||||
|
logger.info(f"Starting batch translation of {total_jobs} items")
|
||||||
|
|
||||||
|
# Group jobs by category for more efficient processing
|
||||||
|
jobs_by_category: Dict[str, List[TranslationJob]] = {}
|
||||||
|
for job in jobs:
|
||||||
|
if job.category not in jobs_by_category:
|
||||||
|
jobs_by_category[job.category] = []
|
||||||
|
jobs_by_category[job.category].append(job)
|
||||||
|
|
||||||
|
completed = 0
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
for category, category_jobs in jobs_by_category.items():
|
||||||
|
logger.debug(f"Processing {len(category_jobs)} {category} translations")
|
||||||
|
|
||||||
|
# Check if we can use optimized batch translation for API calls
|
||||||
|
can_use_api_batch = (self.translation_mode in [TranslationMode.API_ONLY, TranslationMode.HYBRID] and
|
||||||
|
len(category_jobs) > 3)
|
||||||
|
|
||||||
|
if can_use_api_batch:
|
||||||
|
# Try optimized batch translation with API
|
||||||
|
batch_results = self._process_category_batch_optimized(category_jobs, completed, total_jobs, start_time)
|
||||||
|
if batch_results:
|
||||||
|
# Apply results to Blender objects if requested
|
||||||
|
for i, (job, result) in enumerate(zip(category_jobs, batch_results)):
|
||||||
|
if apply_results and result.method != "failed" and job.object_ref:
|
||||||
|
try:
|
||||||
|
self._apply_translation_to_object(job, result)
|
||||||
|
logger.debug(f"Successfully applied translation: {job.name} -> {result.translated}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to apply translation to object {job.name}: {e}")
|
||||||
|
result.method = "apply_failed"
|
||||||
|
result.translated = job.name
|
||||||
|
|
||||||
|
results.extend(batch_results)
|
||||||
|
completed += len(category_jobs)
|
||||||
|
|
||||||
|
progress_percent = (completed / total_jobs) * 100
|
||||||
|
logger.info(f"Batch translation progress: {completed}/{total_jobs} ({progress_percent:.1f}%) - completed {category} batch")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Fallback to individual processing
|
||||||
|
for job in category_jobs:
|
||||||
|
# Check if we should continue (for potential cancellation support)
|
||||||
|
current_time = time.time()
|
||||||
|
elapsed_time = current_time - start_time
|
||||||
|
|
||||||
|
# Progress callback with detailed status
|
||||||
|
if self._progress_callback:
|
||||||
|
avg_time_per_item = elapsed_time / max(completed, 1)
|
||||||
|
remaining_items = total_jobs - completed
|
||||||
|
estimated_remaining = avg_time_per_item * remaining_items
|
||||||
|
|
||||||
|
status_msg = f"Translating {job.name}"
|
||||||
|
if completed > 0:
|
||||||
|
status_msg += f" (ETA: {estimated_remaining:.1f}s)"
|
||||||
|
|
||||||
|
self._progress_callback(completed, total_jobs, status_msg)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.debug(f"Translating job {completed + 1}/{total_jobs}: {job.name} ({job.category})")
|
||||||
|
|
||||||
|
result = self.translate_single(job.name, job.category,
|
||||||
|
job.source_lang, job.target_lang)
|
||||||
|
|
||||||
|
if apply_results and result.method != "failed" and job.object_ref:
|
||||||
|
try:
|
||||||
|
self._apply_translation_to_object(job, result)
|
||||||
|
logger.debug(f"Successfully applied translation: {job.name} -> {result.translated}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to apply translation to object {job.name}: {e}")
|
||||||
|
result.method = "apply_failed"
|
||||||
|
result.translated = job.name
|
||||||
|
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Translation failed for job {job.name}: {e}")
|
||||||
|
# Create a failed result
|
||||||
|
failed_result = TranslationResult(
|
||||||
|
original=job.name,
|
||||||
|
translated=job.name,
|
||||||
|
method="failed",
|
||||||
|
category=job.category
|
||||||
|
)
|
||||||
|
results.append(failed_result)
|
||||||
|
|
||||||
|
completed += 1
|
||||||
|
|
||||||
|
# Log progress periodically
|
||||||
|
if completed % 10 == 0 or completed == total_jobs:
|
||||||
|
progress_percent = (completed / total_jobs) * 100
|
||||||
|
logger.info(f"Batch translation progress: {completed}/{total_jobs} ({progress_percent:.1f}%)")
|
||||||
|
|
||||||
|
if self._progress_callback:
|
||||||
|
total_time = time.time() - start_time
|
||||||
|
self._progress_callback(total_jobs, total_jobs, f"Translation complete ({total_time:.1f}s)")
|
||||||
|
|
||||||
|
successful = sum(1 for r in results if r.method not in ["failed", "skipped", "apply_failed"])
|
||||||
|
failed = sum(1 for r in results if r.method in ["failed", "apply_failed"])
|
||||||
|
skipped = sum(1 for r in results if r.method == "skipped")
|
||||||
|
|
||||||
|
dictionary_count = sum(1 for r in results if r.method == "dictionary")
|
||||||
|
api_count = sum(1 for r in results if r.method == "api")
|
||||||
|
cache_count = sum(1 for r in results if r.method == "cache")
|
||||||
|
|
||||||
|
logger.info(f"Batch translation complete: {successful}/{total_jobs} successful, {failed} failed, {skipped} skipped")
|
||||||
|
logger.info(f"Translation methods used: Dictionary: {dictionary_count}, API: {api_count}, Cache: {cache_count}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _process_category_batch_optimized(self, category_jobs: List[TranslationJob],
|
||||||
|
completed: int, total_jobs: int, start_time: float) -> Optional[List[TranslationResult]]:
|
||||||
|
"""Process a batch of jobs from the same category using optimized API batch translation"""
|
||||||
|
if not category_jobs:
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info(f"Starting optimized batch translation for {len(category_jobs)} {category_jobs[0].category} items")
|
||||||
|
|
||||||
|
api_batch_jobs = []
|
||||||
|
api_batch_texts = []
|
||||||
|
results = [None] * len(category_jobs)
|
||||||
|
|
||||||
|
# First pass: try dictionary translations and collect API candidates
|
||||||
|
for i, job in enumerate(category_jobs):
|
||||||
|
if not job.name or not job.name.strip():
|
||||||
|
results[i] = TranslationResult(job.name, job.name, "skipped", category=job.category)
|
||||||
|
continue
|
||||||
|
|
||||||
|
original_name = job.name.strip()
|
||||||
|
|
||||||
|
# Check cache first
|
||||||
|
cached_result = self.cache.get(original_name, job.source_lang, job.target_lang)
|
||||||
|
if cached_result:
|
||||||
|
results[i] = TranslationResult(original_name, cached_result, "cache", category=job.category)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try dictionary translation first (if in hybrid mode)
|
||||||
|
if self.translation_mode == TranslationMode.HYBRID:
|
||||||
|
dict_result, detected_category = self.dictionary_translator.translate_name(original_name, job.category)
|
||||||
|
if dict_result:
|
||||||
|
self.cache.put(original_name, dict_result, job.source_lang, job.target_lang)
|
||||||
|
results[i] = TranslationResult(original_name, dict_result, "dictionary",
|
||||||
|
category=detected_category, confidence=1.0)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Add to API batch candidates
|
||||||
|
api_batch_jobs.append((i, job))
|
||||||
|
api_batch_texts.append(original_name)
|
||||||
|
|
||||||
|
# Process API batch if we have candidates
|
||||||
|
if api_batch_texts:
|
||||||
|
logger.info(f"Sending {len(api_batch_texts)} items to API batch translation")
|
||||||
|
|
||||||
|
if self._progress_callback:
|
||||||
|
elapsed_time = time.time() - start_time
|
||||||
|
avg_time_per_item = elapsed_time / max(completed, 1) if completed > 0 else 1.0
|
||||||
|
remaining_items = total_jobs - completed
|
||||||
|
estimated_remaining = avg_time_per_item * remaining_items
|
||||||
|
|
||||||
|
status_msg = f"Batch translating {len(api_batch_texts)} {category_jobs[0].category} items"
|
||||||
|
if completed > 0:
|
||||||
|
status_msg += f" (ETA: {estimated_remaining:.1f}s)"
|
||||||
|
|
||||||
|
self._progress_callback(completed, total_jobs, status_msg)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use the service manager's optimized batch translation
|
||||||
|
if len(set(job.source_lang for _, job in api_batch_jobs)) == 1 and len(set(job.target_lang for _, job in api_batch_jobs)) == 1:
|
||||||
|
source_lang = api_batch_jobs[0][1].source_lang
|
||||||
|
target_lang = api_batch_jobs[0][1].target_lang
|
||||||
|
|
||||||
|
batch_results = self.service_manager.batch_translate_with_fallback(
|
||||||
|
api_batch_texts, source_lang, target_lang
|
||||||
|
)
|
||||||
|
|
||||||
|
for j, (result_idx, job) in enumerate(api_batch_jobs):
|
||||||
|
if j < len(batch_results):
|
||||||
|
translated_text, service_name = batch_results[j]
|
||||||
|
|
||||||
|
# Cache successful translations
|
||||||
|
if translated_text != job.name:
|
||||||
|
self.cache.put(job.name.strip(), translated_text, job.source_lang, job.target_lang)
|
||||||
|
|
||||||
|
results[result_idx] = TranslationResult(
|
||||||
|
original=job.name.strip(),
|
||||||
|
translated=translated_text,
|
||||||
|
method="api" if translated_text != job.name else "failed",
|
||||||
|
service=service_name,
|
||||||
|
category=job.category,
|
||||||
|
confidence=0.8
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Fallback for missing results
|
||||||
|
results[result_idx] = TranslationResult(job.name, job.name, "failed", category=job.category)
|
||||||
|
else:
|
||||||
|
# Mixed language pairs - fallback to individual translations
|
||||||
|
logger.info("Mixed language pairs detected, falling back to individual API translations")
|
||||||
|
for result_idx, job in api_batch_jobs:
|
||||||
|
try:
|
||||||
|
result = self.translate_single(job.name, job.category, job.source_lang, job.target_lang)
|
||||||
|
results[result_idx] = result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Individual API translation failed for {job.name}: {e}")
|
||||||
|
results[result_idx] = TranslationResult(job.name, job.name, "failed", category=job.category)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Batch API translation failed: {e}")
|
||||||
|
# Fallback to individual translations
|
||||||
|
for result_idx, job in api_batch_jobs:
|
||||||
|
try:
|
||||||
|
result = self.translate_single(job.name, job.category, job.source_lang, job.target_lang)
|
||||||
|
results[result_idx] = result
|
||||||
|
except Exception as individual_e:
|
||||||
|
logger.error(f"Individual fallback translation failed for {job.name}: {individual_e}")
|
||||||
|
results[result_idx] = TranslationResult(job.name, job.name, "failed", category=job.category)
|
||||||
|
|
||||||
|
for i, result in enumerate(results):
|
||||||
|
if result is None:
|
||||||
|
results[i] = TranslationResult(category_jobs[i].name, category_jobs[i].name, "failed", category=category_jobs[i].category)
|
||||||
|
|
||||||
|
successful_batch = sum(1 for r in results if r.method not in ["failed", "skipped"])
|
||||||
|
logger.info(f"Optimized batch complete: {successful_batch}/{len(category_jobs)} successful")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _apply_translation_to_object(self, job: TranslationJob, result: TranslationResult) -> None:
|
||||||
|
"""Apply translation result to a Blender object"""
|
||||||
|
if not job.object_ref or not job.property_name:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
setattr(job.object_ref, job.property_name, result.translated)
|
||||||
|
logger.debug(f"Applied translation: {job.object_ref.name}.{job.property_name} = '{result.translated}'")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to set property {job.property_name}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def translate_armature_bones(self, armature: Object, apply_results: bool = True) -> List[TranslationResult]:
|
||||||
|
"""Translate all bone names in an armature"""
|
||||||
|
if not armature or armature.type != 'ARMATURE':
|
||||||
|
return []
|
||||||
|
|
||||||
|
jobs = []
|
||||||
|
for bone in armature.data.bones:
|
||||||
|
jobs.append(TranslationJob(
|
||||||
|
name=bone.name,
|
||||||
|
category="bones",
|
||||||
|
object_ref=bone,
|
||||||
|
property_name="name"
|
||||||
|
))
|
||||||
|
|
||||||
|
return self.translate_batch(jobs, apply_results)
|
||||||
|
|
||||||
|
def translate_object_shapekeys(self, mesh_obj: Object, apply_results: bool = True) -> List[TranslationResult]:
|
||||||
|
"""Translate all shape key names in a mesh object"""
|
||||||
|
if not mesh_obj or mesh_obj.type != 'MESH' or not mesh_obj.data.shape_keys:
|
||||||
|
return []
|
||||||
|
|
||||||
|
jobs = []
|
||||||
|
for shape_key in mesh_obj.data.shape_keys.key_blocks:
|
||||||
|
jobs.append(TranslationJob(
|
||||||
|
name=shape_key.name,
|
||||||
|
category="shapekeys",
|
||||||
|
object_ref=shape_key,
|
||||||
|
property_name="name"
|
||||||
|
))
|
||||||
|
|
||||||
|
return self.translate_batch(jobs, apply_results)
|
||||||
|
|
||||||
|
def translate_scene_materials(self, apply_results: bool = True) -> List[TranslationResult]:
|
||||||
|
"""Translate all material names in the scene"""
|
||||||
|
jobs = []
|
||||||
|
processed_materials: Set[str] = set()
|
||||||
|
|
||||||
|
for obj in bpy.data.objects:
|
||||||
|
if obj.type == 'MESH' and obj.data.materials:
|
||||||
|
for material in obj.data.materials:
|
||||||
|
if material and material.name not in processed_materials:
|
||||||
|
jobs.append(TranslationJob(
|
||||||
|
name=material.name,
|
||||||
|
category="materials",
|
||||||
|
object_ref=material,
|
||||||
|
property_name="name"
|
||||||
|
))
|
||||||
|
processed_materials.add(material.name)
|
||||||
|
|
||||||
|
return self.translate_batch(jobs, apply_results)
|
||||||
|
|
||||||
|
def translate_scene_objects(self, object_types: Optional[Set[str]] = None,
|
||||||
|
apply_results: bool = True) -> List[TranslationResult]:
|
||||||
|
"""Translate all object names in the scene"""
|
||||||
|
if object_types is None:
|
||||||
|
object_types = {'MESH', 'ARMATURE', 'EMPTY'}
|
||||||
|
|
||||||
|
jobs = []
|
||||||
|
for obj in bpy.data.objects:
|
||||||
|
if obj.type in object_types:
|
||||||
|
jobs.append(TranslationJob(
|
||||||
|
name=obj.name,
|
||||||
|
category="objects",
|
||||||
|
object_ref=obj,
|
||||||
|
property_name="name"
|
||||||
|
))
|
||||||
|
|
||||||
|
return self.translate_batch(jobs, apply_results)
|
||||||
|
|
||||||
|
def get_translation_stats(self) -> Dict[str, Any]:
|
||||||
|
"""Get comprehensive translation statistics"""
|
||||||
|
dict_stats = self.dictionary_translator.get_statistics()
|
||||||
|
cache_stats = self.cache.get_stats()
|
||||||
|
available_services = self.service_manager.get_available_services()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"dictionary_translations": dict_stats,
|
||||||
|
"cache_stats": cache_stats,
|
||||||
|
"available_services": available_services,
|
||||||
|
"current_mode": self.translation_mode.value,
|
||||||
|
"primary_service": get_preference("translation_service", "microsoft")
|
||||||
|
}
|
||||||
|
|
||||||
|
def clear_all_caches(self) -> None:
|
||||||
|
"""Clear all translation caches"""
|
||||||
|
self.cache.clear()
|
||||||
|
for service_id, service in self.service_manager._services.items():
|
||||||
|
service.clear_cache()
|
||||||
|
logger.info("All translation caches cleared")
|
||||||
|
|
||||||
|
|
||||||
|
_translation_manager: Optional[AvatarToolkitTranslationManager] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_avatar_translation_manager() -> AvatarToolkitTranslationManager:
|
||||||
|
"""Get the global Avatar Toolkit translation manager"""
|
||||||
|
global _translation_manager
|
||||||
|
if _translation_manager is None:
|
||||||
|
_translation_manager = AvatarToolkitTranslationManager()
|
||||||
|
return _translation_manager
|
||||||
|
|
||||||
|
|
||||||
|
def translate_name_simple(name: str, category: str = "auto") -> str:
|
||||||
|
"""Simple translation function for quick use"""
|
||||||
|
manager = get_avatar_translation_manager()
|
||||||
|
result = manager.translate_single(name, category)
|
||||||
|
return result.translated
|
||||||
|
|
||||||
|
|
||||||
|
def is_translation_service_available(service_name: str) -> bool:
|
||||||
|
"""Check if a specific translation service is available"""
|
||||||
|
manager = get_avatar_translation_manager()
|
||||||
|
available_services = manager.service_manager.get_available_services()
|
||||||
|
return any(service_id == service_name for service_id, _ in available_services)
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_translation_services() -> List[Tuple[str, str]]:
|
||||||
|
"""Get list of available translation services"""
|
||||||
|
manager = get_avatar_translation_manager()
|
||||||
|
return manager.service_manager.get_available_services()
|
||||||
|
|
||||||
|
|
||||||
|
def get_batch_translation_info() -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""Get information about batch translation capabilities of available services"""
|
||||||
|
manager = get_avatar_translation_manager()
|
||||||
|
batch_info = {}
|
||||||
|
|
||||||
|
for service_id, service_name in manager.service_manager.get_available_services():
|
||||||
|
service = manager.service_manager.get_service(service_id)
|
||||||
|
if service:
|
||||||
|
batch_info[service_id] = {
|
||||||
|
'name': service_name,
|
||||||
|
'supports_batch': service.supports_batch_translation(),
|
||||||
|
'batch_type': 'native' if service_id == 'deepl' else 'concurrent' if service_id in ['libretranslate', 'mymemory'] else 'individual'
|
||||||
|
}
|
||||||
|
|
||||||
|
return batch_info
|
||||||
|
|
||||||
|
|
||||||
|
def configure_translation_service(service_id: str, **config) -> bool:
|
||||||
|
"""Configure a translation service with the provided settings (now with batch support)"""
|
||||||
|
try:
|
||||||
|
success = False
|
||||||
|
if service_id == "deepl":
|
||||||
|
from .translation_service import configure_deepl_translator
|
||||||
|
success = configure_deepl_translator(
|
||||||
|
config.get("api_key", ""),
|
||||||
|
config.get("use_free_api", True)
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
logger.info("DeepL configured with native batch translation support (up to 50 texts per request)")
|
||||||
|
elif service_id == "libretranslate":
|
||||||
|
from .translation_service import configure_libretranslate_server
|
||||||
|
success = configure_libretranslate_server(
|
||||||
|
config.get("server_url", "https://libretranslate.com"),
|
||||||
|
config.get("api_key", None)
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
logger.info("LibreTranslate configured with concurrent batch processing (3x faster)")
|
||||||
|
elif service_id == "microsoft":
|
||||||
|
from .translation_service import configure_microsoft_translator
|
||||||
|
success = configure_microsoft_translator(
|
||||||
|
config.get("api_key", ""),
|
||||||
|
config.get("region", "global")
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.error(f"Unknown translation service: {service_id}")
|
||||||
|
success = False
|
||||||
|
|
||||||
|
return success
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to configure translation service {service_id}: {e}")
|
||||||
|
return False
|
||||||
@@ -0,0 +1,942 @@
|
|||||||
|
# GPL License
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
import threading
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Dict, List, Optional, Tuple, Any, Set
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from .logging_setup import logger
|
||||||
|
from .addon_preferences import save_preference, get_preference
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TranslationRequest:
|
||||||
|
"""Represents a translation request"""
|
||||||
|
text: str
|
||||||
|
source_lang: str = "ja"
|
||||||
|
target_lang: str = "en"
|
||||||
|
category: str = "general"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TranslationResult:
|
||||||
|
"""Represents a translation result"""
|
||||||
|
original: str
|
||||||
|
translated: str
|
||||||
|
service: str
|
||||||
|
confidence: float = 1.0
|
||||||
|
cached: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class TranslationError(Exception):
|
||||||
|
"""Custom exception for translation errors"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TranslationService(ABC):
|
||||||
|
"""Abstract base class for translation services"""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
self.name = name
|
||||||
|
self._cache: Dict[str, str] = {}
|
||||||
|
self._rate_limit_lock = threading.Lock()
|
||||||
|
self._last_request_time = 0.0
|
||||||
|
self._request_count = 0
|
||||||
|
self._rate_limit_per_second = 10 # Default rate limit
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def translate_text(self, text: str, source_lang: str = "ja", target_lang: str = "en") -> str:
|
||||||
|
"""Translate a single text string"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""Check if the service is available"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_supported_languages(self) -> List[Tuple[str, str]]:
|
||||||
|
"""Get list of supported language pairs (code, name)"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def batch_translate(self, texts: List[str], source_lang: str = "ja", target_lang: str = "en") -> List[str]:
|
||||||
|
"""Translate multiple texts with rate limiting - base implementation for services without native batch support"""
|
||||||
|
results = []
|
||||||
|
for text in texts:
|
||||||
|
# Check cache first
|
||||||
|
cache_key = f"{source_lang}_{target_lang}_{text}"
|
||||||
|
if cache_key in self._cache:
|
||||||
|
results.append(self._cache[cache_key])
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
with self._rate_limit_lock:
|
||||||
|
current_time = time.time()
|
||||||
|
if current_time - self._last_request_time < (1.0 / self._rate_limit_per_second):
|
||||||
|
time.sleep((1.0 / self._rate_limit_per_second) - (current_time - self._last_request_time))
|
||||||
|
|
||||||
|
try:
|
||||||
|
translated = self.translate_text(text, source_lang, target_lang)
|
||||||
|
self._cache[cache_key] = translated
|
||||||
|
results.append(translated)
|
||||||
|
self._last_request_time = time.time()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Translation failed for '{text}': {e}")
|
||||||
|
results.append(text)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def supports_batch_translation(self) -> bool:
|
||||||
|
"""Check if service supports native batch translation"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
def clear_cache(self) -> None:
|
||||||
|
"""Clear the translation cache"""
|
||||||
|
self._cache.clear()
|
||||||
|
logger.info(f"Cleared cache for {self.name}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class DeepLService(TranslationService):
|
||||||
|
"""DeepL translation service - requires API key"""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str = "", use_free_api: bool = True):
|
||||||
|
super().__init__("DeepL" + (" (Free)" if use_free_api else " (Pro)"))
|
||||||
|
self.api_key = api_key
|
||||||
|
self.use_free_api = use_free_api
|
||||||
|
self._rate_limit_per_second = 5 # DeepL allows more requests
|
||||||
|
self._base_url = "https://api-free.deepl.com" if use_free_api else "https://api.deepl.com"
|
||||||
|
|
||||||
|
def translate_text(self, text: str, source_lang: str = "ja", target_lang: str = "en") -> str:
|
||||||
|
"""Translate text using DeepL API"""
|
||||||
|
logger.info(f"DeepL: Starting translation of '{text}' from {source_lang} to {target_lang}")
|
||||||
|
|
||||||
|
if not text or not text.strip():
|
||||||
|
logger.debug("Empty text provided, returning as-is")
|
||||||
|
return text
|
||||||
|
|
||||||
|
if not self.api_key:
|
||||||
|
raise TranslationError("DeepL API key is required")
|
||||||
|
|
||||||
|
# DeepL language codes mapping
|
||||||
|
lang_map = {
|
||||||
|
"ja": "JA", "en": "EN", "ko": "KO", "zh": "ZH",
|
||||||
|
"es": "ES", "fr": "FR", "de": "DE", "it": "IT",
|
||||||
|
"pt": "PT", "ru": "RU", "nl": "NL", "pl": "PL"
|
||||||
|
}
|
||||||
|
source_lang = lang_map.get(source_lang, source_lang.upper())
|
||||||
|
target_lang = lang_map.get(target_lang, target_lang.upper())
|
||||||
|
|
||||||
|
endpoint = f"{self._base_url}/v2/translate"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"DeepL-Auth-Key {self.api_key}",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded"
|
||||||
|
}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"text": text,
|
||||||
|
"source_lang": source_lang,
|
||||||
|
"target_lang": target_lang
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.debug(f"Making request to DeepL API: {endpoint}")
|
||||||
|
response = requests.post(endpoint, headers=headers, data=data, timeout=15)
|
||||||
|
logger.debug(f"DeepL response status: {response.status_code}")
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
logger.debug(f"DeepL response: {result}")
|
||||||
|
|
||||||
|
if "translations" in result and len(result["translations"]) > 0:
|
||||||
|
translated_text = result["translations"][0]["text"]
|
||||||
|
logger.info(f"DeepL SUCCESS: '{text}' -> '{translated_text}'")
|
||||||
|
return translated_text
|
||||||
|
else:
|
||||||
|
raise TranslationError("DeepL API returned no translations")
|
||||||
|
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
if e.response.status_code == 401:
|
||||||
|
raise TranslationError("DeepL API key is invalid")
|
||||||
|
elif e.response.status_code == 403:
|
||||||
|
raise TranslationError("DeepL API key access denied or quota exceeded")
|
||||||
|
elif e.response.status_code == 456:
|
||||||
|
raise TranslationError("DeepL quota exceeded")
|
||||||
|
else:
|
||||||
|
logger.error(f"DeepL HTTP error: {e}")
|
||||||
|
raise TranslationError(f"DeepL API error: {e}")
|
||||||
|
except requests.Timeout:
|
||||||
|
logger.error("DeepL request timed out")
|
||||||
|
raise TranslationError("DeepL request timed out after 15 seconds")
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(f"DeepL API request failed: {e}")
|
||||||
|
raise TranslationError(f"DeepL API request failed: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error in DeepL: {e}")
|
||||||
|
raise TranslationError(f"Unexpected error: {e}")
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""Check if DeepL service is available"""
|
||||||
|
if not self.api_key:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
headers = {"Authorization": f"DeepL-Auth-Key {self.api_key}"}
|
||||||
|
response = requests.get(f"{self._base_url}/v2/usage", headers=headers, timeout=5)
|
||||||
|
return response.status_code == 200
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_supported_languages(self) -> List[Tuple[str, str]]:
|
||||||
|
"""Get supported languages for DeepL"""
|
||||||
|
return [
|
||||||
|
("ja", "Japanese"),
|
||||||
|
("en", "English"),
|
||||||
|
("ko", "Korean"),
|
||||||
|
("zh", "Chinese"),
|
||||||
|
("es", "Spanish"),
|
||||||
|
("fr", "French"),
|
||||||
|
("de", "German"),
|
||||||
|
("it", "Italian"),
|
||||||
|
("pt", "Portuguese"),
|
||||||
|
("ru", "Russian"),
|
||||||
|
("nl", "Dutch"),
|
||||||
|
("pl", "Polish")
|
||||||
|
]
|
||||||
|
|
||||||
|
def supports_batch_translation(self) -> bool:
|
||||||
|
"""DeepL supports native batch translation"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
def batch_translate(self, texts: List[str], source_lang: str = "ja", target_lang: str = "en") -> List[str]:
|
||||||
|
"""Translate multiple texts using DeepL batch API"""
|
||||||
|
if not texts:
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info(f"DeepL: Starting batch translation of {len(texts)} texts from {source_lang} to {target_lang}")
|
||||||
|
|
||||||
|
results = [None] * len(texts)
|
||||||
|
uncached_indices = []
|
||||||
|
uncached_texts = []
|
||||||
|
|
||||||
|
for i, text in enumerate(texts):
|
||||||
|
if not text or not text.strip():
|
||||||
|
results[i] = text
|
||||||
|
continue
|
||||||
|
|
||||||
|
cache_key = f"{source_lang}_{target_lang}_{text}"
|
||||||
|
if cache_key in self._cache:
|
||||||
|
results[i] = self._cache[cache_key]
|
||||||
|
continue
|
||||||
|
|
||||||
|
uncached_indices.append(i)
|
||||||
|
uncached_texts.append(text)
|
||||||
|
|
||||||
|
if not uncached_texts:
|
||||||
|
logger.info(f"DeepL: All {len(texts)} texts found in cache")
|
||||||
|
return results
|
||||||
|
|
||||||
|
logger.info(f"DeepL: Translating {len(uncached_texts)} uncached texts")
|
||||||
|
|
||||||
|
if not self.api_key:
|
||||||
|
logger.error("DeepL API key is required for batch translation")
|
||||||
|
for i, idx in enumerate(uncached_indices):
|
||||||
|
results[idx] = texts[idx]
|
||||||
|
return results
|
||||||
|
|
||||||
|
# DeepL language codes mapping
|
||||||
|
lang_map = {
|
||||||
|
"ja": "JA", "en": "EN", "ko": "KO", "zh": "ZH",
|
||||||
|
"es": "ES", "fr": "FR", "de": "DE", "it": "IT",
|
||||||
|
"pt": "PT", "ru": "RU", "nl": "NL", "pl": "PL"
|
||||||
|
}
|
||||||
|
source_lang_code = lang_map.get(source_lang, source_lang.upper())
|
||||||
|
target_lang_code = lang_map.get(target_lang, target_lang.upper())
|
||||||
|
|
||||||
|
# Batch size limit for DeepL
|
||||||
|
batch_size = 50
|
||||||
|
|
||||||
|
for batch_start in range(0, len(uncached_texts), batch_size):
|
||||||
|
batch_end = min(batch_start + batch_size, len(uncached_texts))
|
||||||
|
batch_texts = uncached_texts[batch_start:batch_end]
|
||||||
|
batch_indices = uncached_indices[batch_start:batch_end]
|
||||||
|
|
||||||
|
logger.debug(f"DeepL batch {batch_start//batch_size + 1}: Processing {len(batch_texts)} texts")
|
||||||
|
|
||||||
|
endpoint = f"{self._base_url}/v2/translate"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"DeepL-Auth-Key {self.api_key}",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build form data with multiple text parameters (DeepL supports multiple 'text' params)
|
||||||
|
form_data = [
|
||||||
|
('source_lang', source_lang_code),
|
||||||
|
('target_lang', target_lang_code)
|
||||||
|
]
|
||||||
|
for text in batch_texts:
|
||||||
|
form_data.append(('text', text))
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.debug(f"Making batch request to DeepL API: {endpoint}")
|
||||||
|
|
||||||
|
import requests
|
||||||
|
response = requests.post(endpoint, headers=headers, data=form_data, timeout=30)
|
||||||
|
logger.debug(f"DeepL batch response status: {response.status_code}")
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
logger.debug(f"DeepL batch response: {result}")
|
||||||
|
|
||||||
|
if "translations" in result and len(result["translations"]) == len(batch_texts):
|
||||||
|
for i, translation_data in enumerate(result["translations"]):
|
||||||
|
original_text = batch_texts[i]
|
||||||
|
translated_text = translation_data["text"]
|
||||||
|
original_idx = batch_indices[i]
|
||||||
|
|
||||||
|
cache_key = f"{source_lang}_{target_lang}_{original_text}"
|
||||||
|
self._cache[cache_key] = translated_text
|
||||||
|
|
||||||
|
results[original_idx] = translated_text
|
||||||
|
logger.debug(f"DeepL batch SUCCESS: '{original_text}' -> '{translated_text}'")
|
||||||
|
else:
|
||||||
|
logger.error(f"DeepL batch API returned unexpected response: {result}")
|
||||||
|
for i, idx in enumerate(batch_indices):
|
||||||
|
results[idx] = batch_texts[i]
|
||||||
|
|
||||||
|
# Rate limiting between batches
|
||||||
|
if batch_end < len(uncached_texts):
|
||||||
|
time.sleep(1.0 / self._rate_limit_per_second)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DeepL batch translation failed: {e}")
|
||||||
|
for i, idx in enumerate(batch_indices):
|
||||||
|
results[idx] = batch_texts[i]
|
||||||
|
|
||||||
|
# Ensure all results are filled
|
||||||
|
for i, result in enumerate(results):
|
||||||
|
if result is None:
|
||||||
|
results[i] = texts[i]
|
||||||
|
|
||||||
|
successful_translations = sum(1 for i, result in enumerate(results) if result != texts[i])
|
||||||
|
logger.info(f"DeepL batch translation complete: {successful_translations}/{len(texts)} successfully translated")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
class MyMemoryService(TranslationService):
|
||||||
|
"""MyMemory free translation service - no API key required"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("MyMemory (Free)")
|
||||||
|
self._rate_limit_per_second = 1 # Conservative rate limiting for free service
|
||||||
|
self._base_url = "https://api.mymemory.translated.net"
|
||||||
|
|
||||||
|
def translate_text(self, text: str, source_lang: str = "ja", target_lang: str = "en") -> str:
|
||||||
|
"""Translate text using MyMemory free API"""
|
||||||
|
logger.info(f"MyMemory: Starting translation of '{text}' from {source_lang} to {target_lang}")
|
||||||
|
|
||||||
|
if not text or not text.strip():
|
||||||
|
logger.debug("Empty text provided, returning as-is")
|
||||||
|
return text
|
||||||
|
|
||||||
|
# MyMemory uses different language codes
|
||||||
|
lang_map = {"ja": "ja", "en": "en", "ko": "ko", "zh": "zh", "es": "es", "fr": "fr", "de": "de"}
|
||||||
|
source_lang = lang_map.get(source_lang, source_lang)
|
||||||
|
target_lang = lang_map.get(target_lang, target_lang)
|
||||||
|
|
||||||
|
endpoint = f"{self._base_url}/get"
|
||||||
|
params = {
|
||||||
|
'q': text,
|
||||||
|
'langpair': f"{source_lang}|{target_lang}",
|
||||||
|
'de': 'neoneko@avatartoolkit.com' # Optional email for higher quotas
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.debug(f"Making request to MyMemory API: {endpoint} with params: {params}")
|
||||||
|
response = requests.get(endpoint, params=params, timeout=15) # Increased timeout
|
||||||
|
logger.debug(f"MyMemory response status: {response.status_code}")
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
logger.debug(f"MyMemory response: {result}")
|
||||||
|
|
||||||
|
if result.get('responseStatus') == 200 and 'responseData' in result:
|
||||||
|
translated_text = result['responseData']['translatedText']
|
||||||
|
matches = result.get('matches', [])
|
||||||
|
if matches and len(matches) > 0:
|
||||||
|
match_quality = matches[0].get('quality', '0')
|
||||||
|
logger.debug(f"MyMemory translation quality: {match_quality}")
|
||||||
|
|
||||||
|
logger.info(f"MyMemory SUCCESS: '{text}' -> '{translated_text}'")
|
||||||
|
return translated_text
|
||||||
|
else:
|
||||||
|
error_msg = result.get('responseDetails', 'Unknown error')
|
||||||
|
logger.error(f"MyMemory API error: {error_msg}")
|
||||||
|
|
||||||
|
if 'QUOTA_EXCEEDED' in error_msg:
|
||||||
|
raise TranslationError(f"MyMemory daily quota (1000 requests) exceeded. Try again tomorrow or switch to another service.")
|
||||||
|
else:
|
||||||
|
raise TranslationError(f"MyMemory API error: {error_msg}")
|
||||||
|
|
||||||
|
except requests.Timeout as e:
|
||||||
|
logger.error(f"MyMemory request timed out: {e}")
|
||||||
|
raise TranslationError(f"MyMemory request timed out after 15 seconds")
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(f"MyMemory API request failed: {e}")
|
||||||
|
raise TranslationError(f"MyMemory API request failed: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error in MyMemory: {e}")
|
||||||
|
raise TranslationError(f"Unexpected error: {e}")
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""Check if MyMemory service is available"""
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{self._base_url}/get",
|
||||||
|
params={'q': 'test', 'langpair': 'en|en'},
|
||||||
|
timeout=5)
|
||||||
|
return response.status_code == 200
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_supported_languages(self) -> List[Tuple[str, str]]:
|
||||||
|
"""Get supported languages for MyMemory"""
|
||||||
|
return [
|
||||||
|
("ja", "Japanese"),
|
||||||
|
("en", "English"),
|
||||||
|
("ko", "Korean"),
|
||||||
|
("zh", "Chinese"),
|
||||||
|
("es", "Spanish"),
|
||||||
|
("fr", "French"),
|
||||||
|
("de", "German"),
|
||||||
|
("it", "Italian"),
|
||||||
|
("pt", "Portuguese"),
|
||||||
|
("ru", "Russian")
|
||||||
|
]
|
||||||
|
|
||||||
|
def supports_batch_translation(self) -> bool:
|
||||||
|
"""MyMemory optimized batch processing"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
def batch_translate(self, texts: List[str], source_lang: str = "ja", target_lang: str = "en") -> List[str]:
|
||||||
|
"""Translate multiple texts using MyMemory with optimized batching and caching"""
|
||||||
|
if not texts:
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info(f"MyMemory: Starting batch translation of {len(texts)} texts from {source_lang} to {target_lang}")
|
||||||
|
|
||||||
|
results = [None] * len(texts)
|
||||||
|
uncached_indices = []
|
||||||
|
uncached_texts = []
|
||||||
|
|
||||||
|
for i, text in enumerate(texts):
|
||||||
|
if not text or not text.strip():
|
||||||
|
results[i] = text
|
||||||
|
continue
|
||||||
|
|
||||||
|
cache_key = f"{source_lang}_{target_lang}_{text}"
|
||||||
|
if cache_key in self._cache:
|
||||||
|
results[i] = self._cache[cache_key]
|
||||||
|
continue
|
||||||
|
|
||||||
|
uncached_indices.append(i)
|
||||||
|
uncached_texts.append(text)
|
||||||
|
|
||||||
|
if not uncached_texts:
|
||||||
|
logger.info(f"MyMemory: All {len(texts)} texts found in cache")
|
||||||
|
return results
|
||||||
|
|
||||||
|
logger.info(f"MyMemory: Translating {len(uncached_texts)} uncached texts using concurrent processing")
|
||||||
|
|
||||||
|
# Use concurrent processing for MyMemory to speed up translations
|
||||||
|
import concurrent.futures
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
import threading
|
||||||
|
|
||||||
|
def translate_single_text(text_info):
|
||||||
|
idx, text = text_info
|
||||||
|
try:
|
||||||
|
with self._rate_limit_lock:
|
||||||
|
current_time = time.time()
|
||||||
|
if current_time - self._last_request_time < (1.0 / self._rate_limit_per_second):
|
||||||
|
sleep_time = (1.0 / self._rate_limit_per_second) - (current_time - self._last_request_time)
|
||||||
|
time.sleep(sleep_time)
|
||||||
|
self._last_request_time = time.time()
|
||||||
|
|
||||||
|
translated = self.translate_text(text, source_lang, target_lang)
|
||||||
|
|
||||||
|
cache_key = f"{source_lang}_{target_lang}_{text}"
|
||||||
|
self._cache[cache_key] = translated
|
||||||
|
|
||||||
|
return idx, translated, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"MyMemory concurrent translation failed for '{text}': {e}")
|
||||||
|
return idx, text, e
|
||||||
|
|
||||||
|
# Use conservative concurrent processing (2 workers max for free service)
|
||||||
|
max_workers = min(len(uncached_texts), 2)
|
||||||
|
batch_size = 8
|
||||||
|
|
||||||
|
for batch_start in range(0, len(uncached_texts), batch_size):
|
||||||
|
batch_end = min(batch_start + batch_size, len(uncached_texts))
|
||||||
|
batch_texts = uncached_texts[batch_start:batch_end]
|
||||||
|
batch_indices = uncached_indices[batch_start:batch_end]
|
||||||
|
|
||||||
|
text_info_batch = [(batch_indices[i], text) for i, text in enumerate(batch_texts)]
|
||||||
|
|
||||||
|
logger.debug(f"MyMemory concurrent batch {batch_start//batch_size + 1}: Processing {len(batch_texts)} texts with {max_workers} workers")
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
|
future_to_text = {executor.submit(translate_single_text, text_info): text_info for text_info in text_info_batch}
|
||||||
|
|
||||||
|
for future in concurrent.futures.as_completed(future_to_text):
|
||||||
|
try:
|
||||||
|
original_idx, translated_text, error = future.result(timeout=25)
|
||||||
|
results[original_idx] = translated_text
|
||||||
|
|
||||||
|
if error is None:
|
||||||
|
logger.debug(f"MyMemory concurrent SUCCESS: -> '{translated_text}'")
|
||||||
|
else:
|
||||||
|
logger.debug(f"MyMemory concurrent FAILED: {error}")
|
||||||
|
|
||||||
|
except concurrent.futures.TimeoutError:
|
||||||
|
text_info = future_to_text[future]
|
||||||
|
original_idx, original_text = text_info
|
||||||
|
results[original_idx] = original_text
|
||||||
|
logger.warning(f"MyMemory concurrent timeout for text: '{original_text}'")
|
||||||
|
except Exception as e:
|
||||||
|
text_info = future_to_text[future]
|
||||||
|
original_idx, original_text = text_info
|
||||||
|
results[original_idx] = original_text
|
||||||
|
logger.error(f"MyMemory concurrent thread error for '{original_text}': {e}")
|
||||||
|
|
||||||
|
# Shorter pause between batches since we're not hammering the API
|
||||||
|
if batch_end < len(uncached_texts):
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
for i, result in enumerate(results):
|
||||||
|
if result is None:
|
||||||
|
results[i] = texts[i]
|
||||||
|
|
||||||
|
successful_translations = sum(1 for i, result in enumerate(results) if result != texts[i])
|
||||||
|
logger.info(f"MyMemory concurrent batch translation complete: {successful_translations}/{len(texts)} successfully translated")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
class LibreTranslateService(TranslationService):
|
||||||
|
"""LibreTranslate translation service with configurable server"""
|
||||||
|
|
||||||
|
def __init__(self, api_url: str = "https://libretranslate.com", api_key: str = None):
|
||||||
|
super().__init__("LibreTranslate")
|
||||||
|
# Ensure URL has trailing slash like official implementation
|
||||||
|
self.api_url = api_url.rstrip('/') + '/'
|
||||||
|
self.api_key = api_key
|
||||||
|
self._rate_limit_per_second = 2 # Conservative rate limiting
|
||||||
|
self._is_paid_service = "libretranslate.com" in api_url.lower()
|
||||||
|
|
||||||
|
def translate_text(self, text: str, source_lang: str = "ja", target_lang: str = "en") -> str:
|
||||||
|
"""Translate text using LibreTranslate API"""
|
||||||
|
logger.info(f"LibreTranslate: Starting translation of '{text}' from {source_lang} to {target_lang}")
|
||||||
|
|
||||||
|
if not text or not text.strip():
|
||||||
|
logger.debug("Empty text provided, returning as-is")
|
||||||
|
return text
|
||||||
|
|
||||||
|
lang_map = {"ja": "ja", "en": "en", "ko": "ko", "zh": "zh", "es": "es", "fr": "fr", "de": "de", "it": "it", "pt": "pt", "ru": "ru"}
|
||||||
|
source_lang = lang_map.get(source_lang, source_lang)
|
||||||
|
target_lang = lang_map.get(target_lang, target_lang)
|
||||||
|
|
||||||
|
endpoint = f"{self.api_url}translate"
|
||||||
|
data = {
|
||||||
|
"q": text,
|
||||||
|
"source": source_lang,
|
||||||
|
"target": target_lang
|
||||||
|
}
|
||||||
|
# Add API key if available (required for libretranslate.com, optional for self-hosted)
|
||||||
|
if self.api_key:
|
||||||
|
data["api_key"] = self.api_key
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.debug(f"Making request to LibreTranslate API: {endpoint}")
|
||||||
|
# Use JSON format like official API documentation
|
||||||
|
response = requests.post(endpoint, json=data, headers=headers, timeout=15)
|
||||||
|
logger.debug(f"LibreTranslate response status: {response.status_code}")
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
logger.debug(f"LibreTranslate response: {result}")
|
||||||
|
|
||||||
|
if "translatedText" in result:
|
||||||
|
translated_text = result["translatedText"]
|
||||||
|
logger.info(f"LibreTranslate SUCCESS: '{text}' -> '{translated_text}'")
|
||||||
|
return translated_text
|
||||||
|
else:
|
||||||
|
raise TranslationError("LibreTranslate API returned no translation")
|
||||||
|
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
if e.response.status_code == 429:
|
||||||
|
raise TranslationError("LibreTranslate rate limit exceeded")
|
||||||
|
elif e.response.status_code == 400:
|
||||||
|
raise TranslationError("LibreTranslate: Invalid language pair or text")
|
||||||
|
else:
|
||||||
|
logger.error(f"LibreTranslate HTTP error: {e}")
|
||||||
|
raise TranslationError(f"LibreTranslate API error: {e}")
|
||||||
|
except requests.Timeout:
|
||||||
|
logger.error("LibreTranslate request timed out")
|
||||||
|
raise TranslationError("LibreTranslate request timed out after 15 seconds")
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(f"LibreTranslate API request failed: {e}")
|
||||||
|
raise TranslationError(f"LibreTranslate API request failed: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error in LibreTranslate: {e}")
|
||||||
|
raise TranslationError(f"Unexpected error: {e}")
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""Check if LibreTranslate service is available"""
|
||||||
|
try:
|
||||||
|
endpoint = f"{self.api_url}languages"
|
||||||
|
|
||||||
|
params = {}
|
||||||
|
if self.api_key:
|
||||||
|
params["api_key"] = self.api_key
|
||||||
|
|
||||||
|
response = requests.get(endpoint, params=params if params else None, timeout=5)
|
||||||
|
return response.status_code == 200
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_supported_languages(self) -> List[Tuple[str, str]]:
|
||||||
|
"""Get supported languages for LibreTranslate"""
|
||||||
|
try:
|
||||||
|
endpoint = f"{self.api_url}languages"
|
||||||
|
|
||||||
|
params = {}
|
||||||
|
if self.api_key:
|
||||||
|
params["api_key"] = self.api_key
|
||||||
|
|
||||||
|
response = requests.get(endpoint, params=params if params else None, timeout=5)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
languages = response.json()
|
||||||
|
return [(lang["code"], lang["name"]) for lang in languages]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback to common languages
|
||||||
|
return [
|
||||||
|
("ja", "Japanese"),
|
||||||
|
("en", "English"),
|
||||||
|
("ko", "Korean"),
|
||||||
|
("zh", "Chinese"),
|
||||||
|
("es", "Spanish"),
|
||||||
|
("fr", "French"),
|
||||||
|
("de", "German"),
|
||||||
|
("it", "Italian"),
|
||||||
|
("pt", "Portuguese"),
|
||||||
|
("ru", "Russian")
|
||||||
|
]
|
||||||
|
|
||||||
|
def supports_batch_translation(self) -> bool:
|
||||||
|
"""LibreTranslate optimized batch processing (concurrent requests)"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
def batch_translate(self, texts: List[str], source_lang: str = "ja", target_lang: str = "en") -> List[str]:
|
||||||
|
"""Translate multiple texts using LibreTranslate with optimized concurrent requests"""
|
||||||
|
if not texts:
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info(f"LibreTranslate: Starting batch translation of {len(texts)} texts from {source_lang} to {target_lang}")
|
||||||
|
|
||||||
|
# Check cache and separate cached vs uncached texts
|
||||||
|
results = [None] * len(texts)
|
||||||
|
uncached_indices = []
|
||||||
|
uncached_texts = []
|
||||||
|
|
||||||
|
for i, text in enumerate(texts):
|
||||||
|
if not text or not text.strip():
|
||||||
|
results[i] = text
|
||||||
|
continue
|
||||||
|
|
||||||
|
cache_key = f"{source_lang}_{target_lang}_{text}"
|
||||||
|
if cache_key in self._cache:
|
||||||
|
results[i] = self._cache[cache_key]
|
||||||
|
continue
|
||||||
|
|
||||||
|
uncached_indices.append(i)
|
||||||
|
uncached_texts.append(text)
|
||||||
|
|
||||||
|
if not uncached_texts:
|
||||||
|
logger.info(f"LibreTranslate: All {len(texts)} texts found in cache")
|
||||||
|
return results
|
||||||
|
|
||||||
|
logger.info(f"LibreTranslate: Translating {len(uncached_texts)} uncached texts")
|
||||||
|
|
||||||
|
# LibreTranslate language mapping
|
||||||
|
lang_map = {"ja": "ja", "en": "en", "ko": "ko", "zh": "zh", "es": "es", "fr": "fr", "de": "de", "it": "it", "pt": "pt", "ru": "ru"}
|
||||||
|
source_lang_code = lang_map.get(source_lang, source_lang)
|
||||||
|
target_lang_code = lang_map.get(target_lang, target_lang)
|
||||||
|
|
||||||
|
# Batch process in groups to avoid overwhelming the server
|
||||||
|
import concurrent.futures
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
def translate_single_text(text_info):
|
||||||
|
idx, text = text_info
|
||||||
|
try:
|
||||||
|
translated = self.translate_text(text, source_lang, target_lang)
|
||||||
|
cache_key = f"{source_lang}_{target_lang}_{text}"
|
||||||
|
self._cache[cache_key] = translated
|
||||||
|
return idx, translated, None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"LibreTranslate translation failed for '{text}': {e}")
|
||||||
|
return idx, text, e
|
||||||
|
|
||||||
|
# Use thread pool for concurrent requests (limited to avoid server overload)
|
||||||
|
max_workers = min(len(uncached_texts), 3)
|
||||||
|
batch_size = 10 # Process in smaller batches
|
||||||
|
|
||||||
|
for batch_start in range(0, len(uncached_texts), batch_size):
|
||||||
|
batch_end = min(batch_start + batch_size, len(uncached_texts))
|
||||||
|
batch_texts = uncached_texts[batch_start:batch_end]
|
||||||
|
batch_indices = uncached_indices[batch_start:batch_end]
|
||||||
|
|
||||||
|
text_info_batch = [(batch_indices[i], text) for i, text in enumerate(batch_texts)]
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
|
future_to_text = {executor.submit(translate_single_text, text_info): text_info for text_info in text_info_batch}
|
||||||
|
|
||||||
|
for future in concurrent.futures.as_completed(future_to_text):
|
||||||
|
try:
|
||||||
|
original_idx, translated_text, error = future.result(timeout=30)
|
||||||
|
results[original_idx] = translated_text
|
||||||
|
|
||||||
|
if error is None:
|
||||||
|
logger.debug(f"LibreTranslate SUCCESS: -> '{translated_text}'")
|
||||||
|
else:
|
||||||
|
logger.debug(f"LibreTranslate FAILED: {error}")
|
||||||
|
|
||||||
|
except concurrent.futures.TimeoutError:
|
||||||
|
text_info = future_to_text[future]
|
||||||
|
original_idx, original_text = text_info
|
||||||
|
results[original_idx] = original_text
|
||||||
|
logger.warning(f"LibreTranslate timeout for text: '{original_text}'")
|
||||||
|
except Exception as e:
|
||||||
|
text_info = future_to_text[future]
|
||||||
|
original_idx, original_text = text_info
|
||||||
|
results[original_idx] = original_text
|
||||||
|
logger.error(f"LibreTranslate thread error for '{original_text}': {e}")
|
||||||
|
|
||||||
|
if batch_end < len(uncached_texts):
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
for i, result in enumerate(results):
|
||||||
|
if result is None:
|
||||||
|
results[i] = texts[i]
|
||||||
|
|
||||||
|
successful_translations = sum(1 for i, result in enumerate(results) if result != texts[i])
|
||||||
|
logger.info(f"LibreTranslate batch translation complete: {successful_translations}/{len(texts)} successfully translated")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
class TranslationServiceManager:
|
||||||
|
"""Manages multiple translation services with fallback logic"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._services: Dict[str, TranslationService] = {}
|
||||||
|
self._primary_service: Optional[str] = None
|
||||||
|
self._initialize_services()
|
||||||
|
|
||||||
|
def _initialize_services(self):
|
||||||
|
"""Initialize available translation services"""
|
||||||
|
mymemory = MyMemoryService()
|
||||||
|
self._services["mymemory"] = mymemory
|
||||||
|
|
||||||
|
libretranslate_url = get_preference("libretranslate_url", "https://libretranslate.com")
|
||||||
|
libretranslate_api_key = get_preference("libretranslate_api_key", "")
|
||||||
|
libretranslate = LibreTranslateService(api_url=libretranslate_url, api_key=libretranslate_api_key if libretranslate_api_key else None)
|
||||||
|
self._services["libretranslate"] = libretranslate
|
||||||
|
|
||||||
|
deepl_api_key = get_preference("deepl_api_key", "")
|
||||||
|
if deepl_api_key:
|
||||||
|
deepl = DeepLService(api_key=deepl_api_key, use_free_api=True)
|
||||||
|
self._services["deepl"] = deepl
|
||||||
|
|
||||||
|
# Set primary service from preferences (default to free service)
|
||||||
|
self._primary_service = get_preference("translation_service", "mymemory")
|
||||||
|
|
||||||
|
logger.info(f"Initialized translation services: {list(self._services.keys())}")
|
||||||
|
logger.info(f"Primary service: {self._primary_service}")
|
||||||
|
|
||||||
|
def get_available_services(self) -> List[Tuple[str, str]]:
|
||||||
|
"""Get list of available translation services"""
|
||||||
|
available = []
|
||||||
|
for service_id, service in self._services.items():
|
||||||
|
if service.is_available():
|
||||||
|
available.append((service_id, service.name))
|
||||||
|
else:
|
||||||
|
logger.debug(f"Service {service.name} is not available")
|
||||||
|
return available
|
||||||
|
|
||||||
|
def set_primary_service(self, service_id: str) -> bool:
|
||||||
|
"""Set the primary translation service"""
|
||||||
|
if service_id in self._services:
|
||||||
|
self._primary_service = service_id
|
||||||
|
save_preference("translation_service", service_id)
|
||||||
|
logger.info(f"Set primary translation service to: {service_id}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_service(self, service_id: Optional[str] = None) -> Optional[TranslationService]:
|
||||||
|
"""Get a translation service by ID"""
|
||||||
|
if service_id is None:
|
||||||
|
service_id = self._primary_service
|
||||||
|
|
||||||
|
if service_id and service_id in self._services:
|
||||||
|
service = self._services[service_id]
|
||||||
|
if service.is_available():
|
||||||
|
return service
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def translate_with_fallback(self, text: str, source_lang: str = "ja", target_lang: str = "en") -> Tuple[str, str]:
|
||||||
|
"""Translate text with automatic fallback to other services"""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return text, "none"
|
||||||
|
|
||||||
|
# Try primary service first
|
||||||
|
primary_service = self.get_service()
|
||||||
|
if primary_service:
|
||||||
|
try:
|
||||||
|
result = primary_service.translate_text(text, source_lang, target_lang)
|
||||||
|
return result, primary_service.name
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Primary service {primary_service.name} failed: {e}")
|
||||||
|
|
||||||
|
for service_id, service in self._services.items():
|
||||||
|
if service_id == self._primary_service:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if service.is_available():
|
||||||
|
try:
|
||||||
|
result = service.translate_text(text, source_lang, target_lang)
|
||||||
|
logger.info(f"Fallback to {service.name} successful")
|
||||||
|
return result, service.name
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Fallback service {service.name} failed: {e}")
|
||||||
|
|
||||||
|
logger.error(f"All translation services failed for: {text}")
|
||||||
|
return text, "failed"
|
||||||
|
|
||||||
|
def batch_translate_with_fallback(self, texts: List[str], source_lang: str = "ja", target_lang: str = "en") -> List[Tuple[str, str]]:
|
||||||
|
"""Batch translate with fallback - uses optimized batch processing when available"""
|
||||||
|
if not texts:
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info(f"Starting batch translation of {len(texts)} texts using service manager")
|
||||||
|
|
||||||
|
primary_service = self.get_service()
|
||||||
|
if primary_service:
|
||||||
|
try:
|
||||||
|
if primary_service.supports_batch_translation():
|
||||||
|
logger.info(f"Using native batch translation with {primary_service.name}")
|
||||||
|
translations = primary_service.batch_translate(texts, source_lang, target_lang)
|
||||||
|
return [(translation, primary_service.name) for translation in translations]
|
||||||
|
else:
|
||||||
|
logger.info(f"Service {primary_service.name} does not support batch translation, using individual requests")
|
||||||
|
# Use the base implementation for services without batch support
|
||||||
|
translations = []
|
||||||
|
for text in texts:
|
||||||
|
translated = primary_service.translate_text(text, source_lang, target_lang)
|
||||||
|
translations.append(translated)
|
||||||
|
return [(translation, primary_service.name) for translation in translations]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Batch translation failed with {primary_service.name}: {e}")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for text in texts:
|
||||||
|
translation, service_name = self.translate_with_fallback(text, source_lang, target_lang)
|
||||||
|
results.append((translation, service_name))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# Global translation service manager instance
|
||||||
|
_translation_manager: Optional[TranslationServiceManager] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_translation_manager() -> TranslationServiceManager:
|
||||||
|
"""Get the global translation service manager"""
|
||||||
|
global _translation_manager
|
||||||
|
if _translation_manager is None:
|
||||||
|
_translation_manager = TranslationServiceManager()
|
||||||
|
return _translation_manager
|
||||||
|
|
||||||
|
|
||||||
|
def configure_deepl_translator(api_key: str, use_free_api: bool = True) -> bool:
|
||||||
|
"""Configure DeepL translation service"""
|
||||||
|
try:
|
||||||
|
save_preference("deepl_api_key", api_key)
|
||||||
|
save_preference("deepl_use_free_api", use_free_api)
|
||||||
|
|
||||||
|
# Test the API key
|
||||||
|
deepl = DeepLService(api_key=api_key, use_free_api=use_free_api)
|
||||||
|
if deepl.is_available():
|
||||||
|
# Re-initialize the global manager to pick up new service
|
||||||
|
global _translation_manager
|
||||||
|
_translation_manager = None
|
||||||
|
logger.info("DeepL translator configured successfully")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error("DeepL API key test failed")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to configure DeepL translator: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def configure_libretranslate_server(server_url: str, api_key: str = None) -> bool:
|
||||||
|
"""Configure LibreTranslate server URL and optional API key"""
|
||||||
|
try:
|
||||||
|
if not server_url.strip():
|
||||||
|
server_url = "https://libretranslate.com"
|
||||||
|
|
||||||
|
# Ensure proper URL format
|
||||||
|
if not server_url.startswith(('http://', 'https://')):
|
||||||
|
server_url = 'https://' + server_url
|
||||||
|
|
||||||
|
save_preference("libretranslate_url", server_url)
|
||||||
|
save_preference("libretranslate_api_key", api_key if api_key else "")
|
||||||
|
|
||||||
|
# Test the server
|
||||||
|
libretranslate = LibreTranslateService(api_url=server_url, api_key=api_key)
|
||||||
|
if libretranslate.is_available():
|
||||||
|
# Re-initialize the global manager to pick up new service
|
||||||
|
global _translation_manager
|
||||||
|
_translation_manager = None
|
||||||
|
logger.info(f"LibreTranslate server configured successfully: {server_url}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"LibreTranslate server test failed: {server_url}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to configure LibreTranslate server: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -554,6 +554,74 @@
|
|||||||
"Language.ko_KR": "Korean",
|
"Language.ko_KR": "Korean",
|
||||||
"Language.changed.title": "Language Changed",
|
"Language.changed.title": "Language Changed",
|
||||||
"Language.changed.success": "Language changed successfully!",
|
"Language.changed.success": "Language changed successfully!",
|
||||||
"Language.changed.restart": "Some UI elements may require restarting Blender"
|
"Language.changed.restart": "Some UI elements may require restarting Blender",
|
||||||
|
|
||||||
|
"Translation.label": "Translation",
|
||||||
|
"Translation.service": "Translation Service",
|
||||||
|
"Translation.service_desc": "Choose the translation service to use",
|
||||||
|
"Translation.mode": "Translation Mode",
|
||||||
|
"Translation.mode_desc": "Select how translation should work",
|
||||||
|
"Translation.mode.hybrid": "Hybrid (Dictionary + API)",
|
||||||
|
"Translation.mode.hybrid_desc": "Try dictionary first, then use API service as fallback",
|
||||||
|
"Translation.mode.dictionary_only": "Dictionary Only",
|
||||||
|
"Translation.mode.dictionary_only_desc": "Only use built-in dictionaries for translation",
|
||||||
|
"Translation.mode.api_only": "API Only",
|
||||||
|
"Translation.mode.api_only_desc": "Only use online translation services",
|
||||||
|
"Translation.service_settings": "Translation Service",
|
||||||
|
"Translation.language_settings": "Language Settings",
|
||||||
|
"Translation.quick_actions": "Quick Actions",
|
||||||
|
"Translation.utilities": "Utilities",
|
||||||
|
"Translation.advanced_settings": "Advanced Settings",
|
||||||
|
"Translation.source_language": "Source Language",
|
||||||
|
"Translation.source_language_desc": "Language to translate from",
|
||||||
|
"Translation.target_language": "Target Language",
|
||||||
|
"Translation.target_language_desc": "Language to translate to",
|
||||||
|
"Translation.translate_names": "Translate Names",
|
||||||
|
"Translation.translate_names_desc": "Translate names using the selected service and settings",
|
||||||
|
"Translation.test_service": "Test Service",
|
||||||
|
"Translation.test_service_desc": "Test the currently selected translation service",
|
||||||
|
"Translation.clear_cache": "Clear Cache",
|
||||||
|
"Translation.clear_cache_desc": "Clear all cached translations",
|
||||||
|
"Translation.show_stats": "Show Statistics",
|
||||||
|
"Translation.show_stats_desc": "Show translation statistics and information",
|
||||||
|
"Translation.no_armature": "No armature selected",
|
||||||
|
"Translation.test_failed": "Translation service test failed - check configuration",
|
||||||
|
"Translation.cache_cleared": "Translation cache cleared successfully",
|
||||||
|
"Translation.mymemory_info": "MyMemory is completely free with no API key required. Provides 1000 translations per day.",
|
||||||
|
"Translation.service.mymemory": "MyMemory (Free)",
|
||||||
|
"Translation.service.mymemory_desc": "Completely free service - no API key needed!",
|
||||||
|
"Translation.service.libretranslate": "LibreTranslate",
|
||||||
|
"Translation.service.libretranslate_desc": "Configurable server - can be self-hosted",
|
||||||
|
"Translation.service.deepl": "DeepL",
|
||||||
|
"Translation.service.deepl_desc": "High-quality translations - API key required",
|
||||||
|
"Translation.type.bones": "Bones",
|
||||||
|
"Translation.type.bones_desc": "Translate bone names",
|
||||||
|
"Translation.type.shapekeys": "Shape Keys",
|
||||||
|
"Translation.type.shapekeys_desc": "Translate shape key names",
|
||||||
|
"Translation.type.materials": "Materials",
|
||||||
|
"Translation.type.materials_desc": "Translate material names",
|
||||||
|
"Translation.type.objects": "Objects",
|
||||||
|
"Translation.type.objects_desc": "Translate object names",
|
||||||
|
"Translation.type.all": "All",
|
||||||
|
"Translation.type.all_desc": "Translate all supported types",
|
||||||
|
"Translation.configure_deepl": "Configure DeepL API",
|
||||||
|
"Translation.configure_deepl_desc": "Configure DeepL translation service API key",
|
||||||
|
"Translation.deepl_api_key": "DeepL API Key",
|
||||||
|
"Translation.deepl_api_key_desc": "Your DeepL API key (get free key at deepl.com/pro)",
|
||||||
|
"Translation.configure_libretranslate": "Configure LibreTranslate Server",
|
||||||
|
"Translation.configure_libretranslate_desc": "Configure LibreTranslate translation service server URL",
|
||||||
|
"Translation.server_url": "Server URL",
|
||||||
|
"Translation.server_url_desc": "LibreTranslate server URL (e.g., https://your-server.com)",
|
||||||
|
"Translation.api_key": "API Key",
|
||||||
|
"Translation.api_key_desc": "API key for LibreTranslate server (optional for some servers)",
|
||||||
|
|
||||||
|
"VRM.remove_colliders": "Remove Colliders",
|
||||||
|
"VRM.remove_colliders_desc": "Remove VRM collider bones during conversion",
|
||||||
|
"VRM.remove_root": "Remove Root Bone",
|
||||||
|
"VRM.remove_root_desc": "Remove unnecessary VRM root bone and make Hips the root bone",
|
||||||
|
|
||||||
|
"Visemes.no_meshes": "No meshes found",
|
||||||
|
"TextureAtlas.search_materials": "Search Materials",
|
||||||
|
"TextureAtlas.search_materials_desc": "Filter materials by name"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -537,6 +537,74 @@
|
|||||||
"Language.ko_KR": "韓国語",
|
"Language.ko_KR": "韓国語",
|
||||||
"Language.changed.title": "言語が変更されました",
|
"Language.changed.title": "言語が変更されました",
|
||||||
"Language.changed.success": "言語が正常に変更されました!",
|
"Language.changed.success": "言語が正常に変更されました!",
|
||||||
"Language.changed.restart": "一部のUI要素はBlenderの再起動が必要な場合があります"
|
"Language.changed.restart": "一部のUI要素はBlenderの再起動が必要な場合があります",
|
||||||
|
|
||||||
|
"Translation.label": "翻訳",
|
||||||
|
"Translation.service": "翻訳サービス",
|
||||||
|
"Translation.service_desc": "使用する翻訳サービスを選択",
|
||||||
|
"Translation.mode": "翻訳モード",
|
||||||
|
"Translation.mode_desc": "翻訳の動作方法を選択",
|
||||||
|
"Translation.mode.hybrid": "ハイブリッド(辞書 + API)",
|
||||||
|
"Translation.mode.hybrid_desc": "まず辞書を試し、その後APIサービスをフォールバックとして使用",
|
||||||
|
"Translation.mode.dictionary_only": "辞書のみ",
|
||||||
|
"Translation.mode.dictionary_only_desc": "翻訳には組み込み辞書のみを使用",
|
||||||
|
"Translation.mode.api_only": "APIのみ",
|
||||||
|
"Translation.mode.api_only_desc": "オンライン翻訳サービスのみを使用",
|
||||||
|
"Translation.service_settings": "翻訳サービス",
|
||||||
|
"Translation.language_settings": "言語設定",
|
||||||
|
"Translation.quick_actions": "クイックアクション",
|
||||||
|
"Translation.utilities": "ユーティリティ",
|
||||||
|
"Translation.advanced_settings": "詳細設定",
|
||||||
|
"Translation.source_language": "ソース言語",
|
||||||
|
"Translation.source_language_desc": "翻訳元の言語",
|
||||||
|
"Translation.target_language": "ターゲット言語",
|
||||||
|
"Translation.target_language_desc": "翻訳先の言語",
|
||||||
|
"Translation.translate_names": "名前を翻訳",
|
||||||
|
"Translation.translate_names_desc": "選択したサービスと設定を使用して名前を翻訳",
|
||||||
|
"Translation.test_service": "サービスをテスト",
|
||||||
|
"Translation.test_service_desc": "現在選択されている翻訳サービスをテスト",
|
||||||
|
"Translation.clear_cache": "キャッシュをクリア",
|
||||||
|
"Translation.clear_cache_desc": "すべてのキャッシュされた翻訳をクリア",
|
||||||
|
"Translation.show_stats": "統計を表示",
|
||||||
|
"Translation.show_stats_desc": "翻訳統計と情報を表示",
|
||||||
|
"Translation.no_armature": "アーマチュアが選択されていません",
|
||||||
|
"Translation.test_failed": "翻訳サービステストが失敗しました - 設定を確認してください",
|
||||||
|
"Translation.cache_cleared": "翻訳キャッシュが正常にクリアされました",
|
||||||
|
"Translation.mymemory_info": "MyMemoryは完全に無料でAPIキー不要です。1日1000回の翻訳を提供します。",
|
||||||
|
"Translation.service.mymemory": "MyMemory(無料)",
|
||||||
|
"Translation.service.mymemory_desc": "完全に無料のサービス - APIキー不要!",
|
||||||
|
"Translation.service.libretranslate": "LibreTranslate",
|
||||||
|
"Translation.service.libretranslate_desc": "設定可能なサーバー - セルフホスト可能",
|
||||||
|
"Translation.service.deepl": "DeepL",
|
||||||
|
"Translation.service.deepl_desc": "高品質な翻訳 - APIキーが必要",
|
||||||
|
"Translation.type.bones": "ボーン",
|
||||||
|
"Translation.type.bones_desc": "ボーン名を翻訳",
|
||||||
|
"Translation.type.shapekeys": "シェイプキー",
|
||||||
|
"Translation.type.shapekeys_desc": "シェイプキー名を翻訳",
|
||||||
|
"Translation.type.materials": "マテリアル",
|
||||||
|
"Translation.type.materials_desc": "マテリアル名を翻訳",
|
||||||
|
"Translation.type.objects": "オブジェクト",
|
||||||
|
"Translation.type.objects_desc": "オブジェクト名を翻訳",
|
||||||
|
"Translation.type.all": "すべて",
|
||||||
|
"Translation.type.all_desc": "サポートされているすべてのタイプを翻訳",
|
||||||
|
"Translation.configure_deepl": "DeepL APIを設定",
|
||||||
|
"Translation.configure_deepl_desc": "DeepL翻訳サービスAPIキーを設定",
|
||||||
|
"Translation.deepl_api_key": "DeepL APIキー",
|
||||||
|
"Translation.deepl_api_key_desc": "あなたのDeepL APIキー(deepl.com/proで無料キーを取得)",
|
||||||
|
"Translation.configure_libretranslate": "LibreTranslateサーバーを設定",
|
||||||
|
"Translation.configure_libretranslate_desc": "LibreTranslate翻訳サービスサーバーURLを設定",
|
||||||
|
"Translation.server_url": "サーバーURL",
|
||||||
|
"Translation.server_url_desc": "LibreTranslateサーバーURL(例:https://your-server.com)",
|
||||||
|
"Translation.api_key": "APIキー",
|
||||||
|
"Translation.api_key_desc": "LibreTranslateサーバー用のAPIキー(一部のサーバーでは任意)",
|
||||||
|
|
||||||
|
"VRM.remove_colliders": "コライダーを削除",
|
||||||
|
"VRM.remove_colliders_desc": "変換中にVRMコライダーボーンを削除",
|
||||||
|
"VRM.remove_root": "ルートボーンを削除",
|
||||||
|
"VRM.remove_root_desc": "不要なVRMルートボーンを削除し、ヒップをルートボーンにする",
|
||||||
|
|
||||||
|
"Visemes.no_meshes": "メッシュが見つかりません",
|
||||||
|
"TextureAtlas.search_materials": "マテリアルを検索",
|
||||||
|
"TextureAtlas.search_materials_desc": "名前でマテリアルをフィルタリング"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -537,6 +537,74 @@
|
|||||||
"Language.ko_KR": "한국어",
|
"Language.ko_KR": "한국어",
|
||||||
"Language.changed.title": "언어 변경됨",
|
"Language.changed.title": "언어 변경됨",
|
||||||
"Language.changed.success": "언어가 성공적으로 변경되었습니다!",
|
"Language.changed.success": "언어가 성공적으로 변경되었습니다!",
|
||||||
"Language.changed.restart": "일부 UI 요소는 블렌더를 다시 시작해야 할 수 있습니다"
|
"Language.changed.restart": "일부 UI 요소는 블렌더를 다시 시작해야 할 수 있습니다",
|
||||||
|
|
||||||
|
"Translation.label": "번역",
|
||||||
|
"Translation.service": "번역 서비스",
|
||||||
|
"Translation.service_desc": "사용할 번역 서비스 선택",
|
||||||
|
"Translation.mode": "번역 모드",
|
||||||
|
"Translation.mode_desc": "번역 동작 방식 선택",
|
||||||
|
"Translation.mode.hybrid": "하이브리드 (사전 + API)",
|
||||||
|
"Translation.mode.hybrid_desc": "먼저 사전을 시도하고, 그 다음 API 서비스를 폴백으로 사용",
|
||||||
|
"Translation.mode.dictionary_only": "사전만",
|
||||||
|
"Translation.mode.dictionary_only_desc": "번역에 내장 사전만 사용",
|
||||||
|
"Translation.mode.api_only": "API만",
|
||||||
|
"Translation.mode.api_only_desc": "온라인 번역 서비스만 사용",
|
||||||
|
"Translation.service_settings": "번역 서비스",
|
||||||
|
"Translation.language_settings": "언어 설정",
|
||||||
|
"Translation.quick_actions": "빠른 작업",
|
||||||
|
"Translation.utilities": "유틸리티",
|
||||||
|
"Translation.advanced_settings": "고급 설정",
|
||||||
|
"Translation.source_language": "소스 언어",
|
||||||
|
"Translation.source_language_desc": "번역할 원본 언어",
|
||||||
|
"Translation.target_language": "대상 언어",
|
||||||
|
"Translation.target_language_desc": "번역할 대상 언어",
|
||||||
|
"Translation.translate_names": "이름 번역",
|
||||||
|
"Translation.translate_names_desc": "선택한 서비스와 설정을 사용하여 이름 번역",
|
||||||
|
"Translation.test_service": "서비스 테스트",
|
||||||
|
"Translation.test_service_desc": "현재 선택된 번역 서비스 테스트",
|
||||||
|
"Translation.clear_cache": "캐시 지우기",
|
||||||
|
"Translation.clear_cache_desc": "모든 캐시된 번역 지우기",
|
||||||
|
"Translation.show_stats": "통계 표시",
|
||||||
|
"Translation.show_stats_desc": "번역 통계 및 정보 표시",
|
||||||
|
"Translation.no_armature": "선택된 아마추어 없음",
|
||||||
|
"Translation.test_failed": "번역 서비스 테스트 실패 - 구성을 확인하세요",
|
||||||
|
"Translation.cache_cleared": "번역 캐시가 성공적으로 지워졌습니다",
|
||||||
|
"Translation.mymemory_info": "MyMemory는 API 키 없이 완전히 무료입니다. 하루 1000회 번역을 제공합니다.",
|
||||||
|
"Translation.service.mymemory": "MyMemory (무료)",
|
||||||
|
"Translation.service.mymemory_desc": "완전히 무료 서비스 - API 키 불필요!",
|
||||||
|
"Translation.service.libretranslate": "LibreTranslate",
|
||||||
|
"Translation.service.libretranslate_desc": "구성 가능한 서버 - 셀프 호스팅 가능",
|
||||||
|
"Translation.service.deepl": "DeepL",
|
||||||
|
"Translation.service.deepl_desc": "고품질 번역 - API 키 필요",
|
||||||
|
"Translation.type.bones": "본",
|
||||||
|
"Translation.type.bones_desc": "본 이름 번역",
|
||||||
|
"Translation.type.shapekeys": "쉐이프 키",
|
||||||
|
"Translation.type.shapekeys_desc": "쉐이프 키 이름 번역",
|
||||||
|
"Translation.type.materials": "재질",
|
||||||
|
"Translation.type.materials_desc": "재질 이름 번역",
|
||||||
|
"Translation.type.objects": "객체",
|
||||||
|
"Translation.type.objects_desc": "객체 이름 번역",
|
||||||
|
"Translation.type.all": "모두",
|
||||||
|
"Translation.type.all_desc": "지원되는 모든 유형 번역",
|
||||||
|
"Translation.configure_deepl": "DeepL API 구성",
|
||||||
|
"Translation.configure_deepl_desc": "DeepL 번역 서비스 API 키 구성",
|
||||||
|
"Translation.deepl_api_key": "DeepL API 키",
|
||||||
|
"Translation.deepl_api_key_desc": "당신의 DeepL API 키 (deepl.com/pro에서 무료 키 획득)",
|
||||||
|
"Translation.configure_libretranslate": "LibreTranslate 서버 구성",
|
||||||
|
"Translation.configure_libretranslate_desc": "LibreTranslate 번역 서비스 서버 URL 구성",
|
||||||
|
"Translation.server_url": "서버 URL",
|
||||||
|
"Translation.server_url_desc": "LibreTranslate 서버 URL (예: https://your-server.com)",
|
||||||
|
"Translation.api_key": "API 키",
|
||||||
|
"Translation.api_key_desc": "LibreTranslate 서버용 API 키 (일부 서버는 선택사항)",
|
||||||
|
|
||||||
|
"VRM.remove_colliders": "콜라이더 제거",
|
||||||
|
"VRM.remove_colliders_desc": "변환 중 VRM 콜라이더 본 제거",
|
||||||
|
"VRM.remove_root": "루트 본 제거",
|
||||||
|
"VRM.remove_root_desc": "불필요한 VRM 루트 본을 제거하고 힙을 루트 본으로 설정",
|
||||||
|
|
||||||
|
"Visemes.no_meshes": "메시를 찾을 수 없음",
|
||||||
|
"TextureAtlas.search_materials": "재질 검색",
|
||||||
|
"TextureAtlas.search_materials_desc": "이름으로 재질 필터링"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,17 @@ from ..core.common import (
|
|||||||
get_armature_list,
|
get_armature_list,
|
||||||
get_armature_stats
|
get_armature_stats
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Module-level cache for UI performance (avoids Blender scene property write restrictions)
|
||||||
|
_validation_cache = {}
|
||||||
|
_stats_cache = {}
|
||||||
|
|
||||||
|
def clear_armature_caches():
|
||||||
|
"""Clear all armature-related caches - called when armature changes"""
|
||||||
|
global _validation_cache, _stats_cache
|
||||||
|
_validation_cache.clear()
|
||||||
|
_stats_cache.clear()
|
||||||
|
|
||||||
from ..functions.pose_mode import (
|
from ..functions.pose_mode import (
|
||||||
AvatarToolkit_OT_StartPoseMode,
|
AvatarToolkit_OT_StartPoseMode,
|
||||||
AvatarToolkit_OT_StopPoseMode,
|
AvatarToolkit_OT_StopPoseMode,
|
||||||
@@ -84,10 +95,16 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel):
|
|||||||
# Armature Selection
|
# Armature Selection
|
||||||
col.prop(context.scene.avatar_toolkit, "active_armature", text="")
|
col.prop(context.scene.avatar_toolkit, "active_armature", text="")
|
||||||
|
|
||||||
# Armature Validation
|
# Armature Validation (cached to improve performance)
|
||||||
active_armature: Optional[Object] = get_active_armature(context)
|
active_armature: Optional[Object] = get_active_armature(context)
|
||||||
if active_armature:
|
if active_armature:
|
||||||
is_valid, messages, is_acceptable, hierarchy_messages, scale_messages, non_standard_messages = validate_armature(active_armature, detailed_messages=True)
|
# Cache validation results to avoid expensive recalculations on every draw
|
||||||
|
cache_key = f"validation_{active_armature.name}_{active_armature.data.name}_{len(active_armature.data.bones)}"
|
||||||
|
|
||||||
|
if cache_key not in _validation_cache:
|
||||||
|
_validation_cache[cache_key] = validate_armature(active_armature, detailed_messages=True)
|
||||||
|
|
||||||
|
is_valid, messages, is_acceptable, hierarchy_messages, scale_messages, non_standard_messages = _validation_cache[cache_key]
|
||||||
|
|
||||||
# Check if this is a PMX model
|
# Check if this is a PMX model
|
||||||
is_pmx_model = False
|
is_pmx_model = False
|
||||||
@@ -235,7 +252,14 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel):
|
|||||||
row = info_box.row()
|
row = info_box.row()
|
||||||
split = row.split(factor=0.6)
|
split = row.split(factor=0.6)
|
||||||
split.label(text=t("QuickAccess.valid_armature"), icon='CHECKMARK')
|
split.label(text=t("QuickAccess.valid_armature"), icon='CHECKMARK')
|
||||||
stats = get_armature_stats(active_armature)
|
|
||||||
|
# Cache armature stats to avoid expensive recalculations
|
||||||
|
stats_cache_key = f"stats_{active_armature.name}_{active_armature.data.name}_{len(active_armature.data.bones)}"
|
||||||
|
|
||||||
|
if stats_cache_key not in _stats_cache:
|
||||||
|
_stats_cache[stats_cache_key] = get_armature_stats(active_armature)
|
||||||
|
|
||||||
|
stats = _stats_cache[stats_cache_key]
|
||||||
split.label(text=t("QuickAccess.bones_count", count=stats['bone_count']))
|
split.label(text=t("QuickAccess.bones_count", count=stats['bone_count']))
|
||||||
|
|
||||||
if stats['has_pose']:
|
if stats['has_pose']:
|
||||||
|
|||||||
@@ -0,0 +1,731 @@
|
|||||||
|
# GPL License
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from typing import Set, Dict, List, Optional, Any
|
||||||
|
from bpy.types import (
|
||||||
|
Operator,
|
||||||
|
Panel,
|
||||||
|
Context,
|
||||||
|
UILayout,
|
||||||
|
WindowManager,
|
||||||
|
Event,
|
||||||
|
Object
|
||||||
|
)
|
||||||
|
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
|
||||||
|
from ..core.translations import t
|
||||||
|
from ..core.logging_setup import logger
|
||||||
|
from ..core.common import get_active_armature, ProgressTracker
|
||||||
|
|
||||||
|
# Module-level cache for UI performance (avoids Blender scene property write restrictions)
|
||||||
|
_ui_cache = {
|
||||||
|
'translation_status': {},
|
||||||
|
'deepl_config': {},
|
||||||
|
'libretranslate_config': {},
|
||||||
|
'last_refresh_frame': 0,
|
||||||
|
'cache_refresh_interval': 30
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarToolkit_OT_TranslateNames(Operator):
|
||||||
|
"""Translate names using the translation system"""
|
||||||
|
bl_idname: str = "avatar_toolkit.translate_names"
|
||||||
|
bl_label: str = t("Translation.translate_names")
|
||||||
|
bl_description: str = t("Translation.translate_names_desc")
|
||||||
|
|
||||||
|
translation_type: bpy.props.EnumProperty(
|
||||||
|
items=[
|
||||||
|
('bones', t("Translation.type.bones"), t("Translation.type.bones_desc")),
|
||||||
|
('shapekeys', t("Translation.type.shapekeys"), t("Translation.type.shapekeys_desc")),
|
||||||
|
('materials', t("Translation.type.materials"), t("Translation.type.materials_desc")),
|
||||||
|
('objects', t("Translation.type.objects"), t("Translation.type.objects_desc")),
|
||||||
|
('all', t("Translation.type.all"), t("Translation.type.all_desc"))
|
||||||
|
],
|
||||||
|
default='bones'
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute(self, context: Context) -> Set[str]:
|
||||||
|
logger.info(f"Starting translation operation: {self.translation_type}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ..core.translation_manager import get_avatar_translation_manager
|
||||||
|
manager = get_avatar_translation_manager()
|
||||||
|
|
||||||
|
# Set up progress callback for detailed feedback
|
||||||
|
def progress_callback(current: int, total: int, message: str):
|
||||||
|
progress_percent = (current / max(total, 1)) * 100
|
||||||
|
logger.info(f"Translation progress: {current}/{total} ({progress_percent:.1f}%) - {message}")
|
||||||
|
context.area.header_text_set(f"Translating: {current}/{total} - {message}")
|
||||||
|
|
||||||
|
manager.set_progress_callback(progress_callback)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
armature = get_active_armature(context)
|
||||||
|
|
||||||
|
total_steps = 0
|
||||||
|
if self.translation_type == 'bones' or self.translation_type == 'all':
|
||||||
|
if armature:
|
||||||
|
total_steps += len(armature.data.bones)
|
||||||
|
if self.translation_type == 'shapekeys' or self.translation_type == 'all':
|
||||||
|
meshes = [obj for obj in context.scene.objects if obj.type == 'MESH']
|
||||||
|
for mesh in meshes:
|
||||||
|
if mesh.data.shape_keys:
|
||||||
|
total_steps += len(mesh.data.shape_keys.key_blocks)
|
||||||
|
if self.translation_type == 'materials' or self.translation_type == 'all':
|
||||||
|
materials = set()
|
||||||
|
for obj in context.scene.objects:
|
||||||
|
if obj.type == 'MESH' and obj.data.materials:
|
||||||
|
for mat in obj.data.materials:
|
||||||
|
if mat:
|
||||||
|
materials.add(mat)
|
||||||
|
total_steps += len(materials)
|
||||||
|
if self.translation_type == 'objects' or self.translation_type == 'all':
|
||||||
|
objects = [obj for obj in context.scene.objects if obj.type in {'MESH', 'ARMATURE', 'EMPTY'}]
|
||||||
|
total_steps += len(objects)
|
||||||
|
|
||||||
|
logger.info(f"Translation operation will process approximately {total_steps} items")
|
||||||
|
|
||||||
|
with ProgressTracker(context, total_steps, "Translation") as progress:
|
||||||
|
if self.translation_type == 'bones' or self.translation_type == 'all':
|
||||||
|
if armature:
|
||||||
|
logger.info(f"Starting bone translation for armature: {armature.name}")
|
||||||
|
self.report({'INFO'}, f"Translating {len(armature.data.bones)} bones...")
|
||||||
|
|
||||||
|
bone_results = manager.translate_armature_bones(armature, apply_results=True)
|
||||||
|
results.extend(bone_results)
|
||||||
|
|
||||||
|
successful_bones = sum(1 for r in bone_results if r.method not in ['failed', 'skipped'])
|
||||||
|
progress.step(f"Bones: {successful_bones}/{len(bone_results)} translated")
|
||||||
|
logger.info(f"Bone translation complete: {successful_bones}/{len(bone_results)} successful")
|
||||||
|
else:
|
||||||
|
self.report({'WARNING'}, t("Translation.no_armature"))
|
||||||
|
logger.warning("No armature selected for bone translation")
|
||||||
|
|
||||||
|
if self.translation_type == 'shapekeys' or self.translation_type == 'all':
|
||||||
|
meshes = [obj for obj in context.scene.objects if obj.type == 'MESH']
|
||||||
|
logger.info(f"Starting shape key translation for {len(meshes)} mesh objects")
|
||||||
|
|
||||||
|
total_shapekeys = 0
|
||||||
|
for mesh in meshes:
|
||||||
|
if mesh.data.shape_keys:
|
||||||
|
shapekey_count = len(mesh.data.shape_keys.key_blocks)
|
||||||
|
self.report({'INFO'}, f"Translating {shapekey_count} shape keys in {mesh.name}...")
|
||||||
|
|
||||||
|
shapekey_results = manager.translate_object_shapekeys(mesh, apply_results=True)
|
||||||
|
results.extend(shapekey_results)
|
||||||
|
total_shapekeys += len(shapekey_results)
|
||||||
|
|
||||||
|
successful_shapekeys = sum(1 for r in results[-total_shapekeys:] if r.method not in ['failed', 'skipped'])
|
||||||
|
progress.step(f"Shape keys: {successful_shapekeys}/{total_shapekeys} translated")
|
||||||
|
logger.info(f"Shape key translation complete: {successful_shapekeys}/{total_shapekeys} successful")
|
||||||
|
|
||||||
|
if self.translation_type == 'materials' or self.translation_type == 'all':
|
||||||
|
logger.info("Starting material translation")
|
||||||
|
self.report({'INFO'}, "Translating materials...")
|
||||||
|
|
||||||
|
material_results = manager.translate_scene_materials(apply_results=True)
|
||||||
|
results.extend(material_results)
|
||||||
|
|
||||||
|
successful_materials = sum(1 for r in material_results if r.method not in ['failed', 'skipped'])
|
||||||
|
progress.step(f"Materials: {successful_materials}/{len(material_results)} translated")
|
||||||
|
logger.info(f"Material translation complete: {successful_materials}/{len(material_results)} successful")
|
||||||
|
|
||||||
|
if self.translation_type == 'objects' or self.translation_type == 'all':
|
||||||
|
logger.info("Starting object translation")
|
||||||
|
self.report({'INFO'}, "Translating objects...")
|
||||||
|
|
||||||
|
object_results = manager.translate_scene_objects(apply_results=True)
|
||||||
|
results.extend(object_results)
|
||||||
|
|
||||||
|
successful_objects = sum(1 for r in object_results if r.method not in ['failed', 'skipped'])
|
||||||
|
progress.step(f"Objects: {successful_objects}/{len(object_results)} translated")
|
||||||
|
logger.info(f"Object translation complete: {successful_objects}/{len(object_results)} successful")
|
||||||
|
|
||||||
|
manager.set_progress_callback(None)
|
||||||
|
context.area.header_text_set(None)
|
||||||
|
|
||||||
|
# Final results summary
|
||||||
|
successful = sum(1 for r in results if r.method not in ['failed', 'skipped'])
|
||||||
|
total = len(results)
|
||||||
|
|
||||||
|
dictionary_count = sum(1 for r in results if r.method == 'dictionary')
|
||||||
|
api_count = sum(1 for r in results if r.method == 'api')
|
||||||
|
cache_count = sum(1 for r in results if r.method == 'cache')
|
||||||
|
failed_count = sum(1 for r in results if r.method == 'failed')
|
||||||
|
|
||||||
|
logger.info(f"Translation summary: {successful}/{total} successful (Dictionary: {dictionary_count}, API: {api_count}, Cache: {cache_count}, Failed: {failed_count})")
|
||||||
|
|
||||||
|
if successful > 0:
|
||||||
|
success_msg = f"Successfully translated {successful}/{total} items"
|
||||||
|
if dictionary_count > 0:
|
||||||
|
success_msg += f" (Dictionary: {dictionary_count}"
|
||||||
|
if api_count > 0:
|
||||||
|
success_msg += f", API: {api_count}"
|
||||||
|
if cache_count > 0:
|
||||||
|
success_msg += f", Cache: {cache_count}"
|
||||||
|
if dictionary_count > 0 or api_count > 0 or cache_count > 0:
|
||||||
|
success_msg += ")"
|
||||||
|
|
||||||
|
self.report({'INFO'}, success_msg)
|
||||||
|
else:
|
||||||
|
if total > 0:
|
||||||
|
self.report({'WARNING'}, f"No translations were applied ({total} items checked)")
|
||||||
|
else:
|
||||||
|
self.report({'WARNING'}, "No items found to translate")
|
||||||
|
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
manager.set_progress_callback(None)
|
||||||
|
context.area.header_text_set(None)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.error(f"Translation operation failed: {e}", exc_info=True)
|
||||||
|
self.report({'ERROR'}, f"Translation failed: {str(e)}")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarToolkit_OT_TestTranslationService(Operator):
|
||||||
|
"""Test the currently selected translation service"""
|
||||||
|
bl_idname: str = "avatar_toolkit.test_translation_service"
|
||||||
|
bl_label: str = t("Translation.test_service")
|
||||||
|
bl_description: str = t("Translation.test_service_desc")
|
||||||
|
|
||||||
|
def execute(self, context: Context) -> Set[str]:
|
||||||
|
logger.info("Starting translation service test")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ..core.translation_manager import get_avatar_translation_manager
|
||||||
|
manager = get_avatar_translation_manager()
|
||||||
|
|
||||||
|
self.report({'INFO'}, "Testing translation service...")
|
||||||
|
context.area.header_text_set("Testing translation service...")
|
||||||
|
|
||||||
|
# Test translation with a simple word
|
||||||
|
test_word = "テスト" # "Test" in Japanese
|
||||||
|
logger.info(f"Testing translation of '{test_word}'")
|
||||||
|
|
||||||
|
result = manager.translate_single(test_word, "auto")
|
||||||
|
|
||||||
|
# Clear status
|
||||||
|
context.area.header_text_set(None)
|
||||||
|
|
||||||
|
if result.method == "failed":
|
||||||
|
logger.error(f"Translation test failed: {result}")
|
||||||
|
self.report({'ERROR'}, t("Translation.test_failed"))
|
||||||
|
else:
|
||||||
|
service_info = f" ({result.service})" if result.service else ""
|
||||||
|
success_msg = f"Translation test successful: '{test_word}' → '{result.translated}' via {result.method}{service_info}"
|
||||||
|
logger.info(f"Translation test successful: {result}")
|
||||||
|
self.report({'INFO'}, success_msg)
|
||||||
|
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
context.area.header_text_set(None)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.error(f"Translation service test failed: {e}", exc_info=True)
|
||||||
|
self.report({'ERROR'}, f"Service test failed: {str(e)}")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarToolkit_OT_ClearTranslationCache(Operator):
|
||||||
|
"""Clear all translation caches"""
|
||||||
|
bl_idname: str = "avatar_toolkit.clear_translation_cache"
|
||||||
|
bl_label: str = t("Translation.clear_cache")
|
||||||
|
bl_description: str = t("Translation.clear_cache_desc")
|
||||||
|
|
||||||
|
def execute(self, context: Context) -> Set[str]:
|
||||||
|
try:
|
||||||
|
from ..core.translation_manager import get_avatar_translation_manager
|
||||||
|
manager = get_avatar_translation_manager()
|
||||||
|
manager.clear_all_caches()
|
||||||
|
|
||||||
|
self.report({'INFO'}, t("Translation.cache_cleared"))
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to clear translation cache: {e}")
|
||||||
|
self.report({'ERROR'}, f"Failed to clear cache: {str(e)}")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarToolkit_OT_ConfigureDeepL(Operator):
|
||||||
|
"""Configure DeepL API settings"""
|
||||||
|
bl_idname: str = "avatar_toolkit.configure_deepl"
|
||||||
|
bl_label: str = t("Translation.configure_deepl")
|
||||||
|
bl_description: str = t("Translation.configure_deepl_desc")
|
||||||
|
|
||||||
|
api_key: bpy.props.StringProperty(
|
||||||
|
name=t("Translation.deepl_api_key"),
|
||||||
|
description=t("Translation.deepl_api_key_desc"),
|
||||||
|
default="",
|
||||||
|
subtype='PASSWORD'
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute(self, context: Context) -> Set[str]:
|
||||||
|
try:
|
||||||
|
if not self.api_key.strip():
|
||||||
|
self.report({'ERROR'}, "API key cannot be empty")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
from ..core.translation_manager import configure_translation_service
|
||||||
|
success = configure_translation_service("deepl", api_key=self.api_key.strip())
|
||||||
|
|
||||||
|
if success:
|
||||||
|
_ui_cache['deepl_config'].clear()
|
||||||
|
_ui_cache['translation_status'].clear()
|
||||||
|
if 'batch_info' in _ui_cache:
|
||||||
|
del _ui_cache['batch_info']
|
||||||
|
self.report({'INFO'}, "DeepL API configured successfully")
|
||||||
|
return {'FINISHED'}
|
||||||
|
else:
|
||||||
|
self.report({'ERROR'}, "Failed to configure DeepL API - check your API key")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DeepL configuration failed: {e}")
|
||||||
|
self.report({'ERROR'}, f"Configuration failed: {str(e)}")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
def invoke(self, context: Context, event: Event) -> Set[str]:
|
||||||
|
# Load existing API key if available
|
||||||
|
try:
|
||||||
|
from ..core.addon_preferences import get_preference
|
||||||
|
existing_key = get_preference("deepl_api_key", "")
|
||||||
|
if existing_key:
|
||||||
|
# Show only first/last few characters for security
|
||||||
|
if len(existing_key) > 8:
|
||||||
|
display_key = existing_key[:4] + "..." + existing_key[-4:]
|
||||||
|
self.api_key = existing_key # Keep full key for editing
|
||||||
|
else:
|
||||||
|
self.api_key = existing_key
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
wm: WindowManager = context.window_manager
|
||||||
|
return wm.invoke_props_dialog(self, width=400)
|
||||||
|
|
||||||
|
def draw(self, context: Context) -> None:
|
||||||
|
layout: UILayout = self.layout
|
||||||
|
|
||||||
|
info_box = layout.box()
|
||||||
|
info_col = info_box.column()
|
||||||
|
info_col.label(text="DeepL API Configuration", icon='SETTINGS')
|
||||||
|
info_col.separator()
|
||||||
|
info_col.label(text="1. Visit deepl.com/pro to get your free API key")
|
||||||
|
info_col.label(text="2. Free tier: 500,000 characters/month")
|
||||||
|
info_col.label(text="3. Higher quality than other services")
|
||||||
|
info_col.label(text="4. The Fastest Option due to native batching support")
|
||||||
|
|
||||||
|
layout.separator()
|
||||||
|
layout.prop(self, "api_key")
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarToolkit_OT_ConfigureLibreTranslate(Operator):
|
||||||
|
"""Configure LibreTranslate server settings"""
|
||||||
|
bl_idname: str = "avatar_toolkit.configure_libretranslate"
|
||||||
|
bl_label: str = t("Translation.configure_libretranslate")
|
||||||
|
bl_description: str = t("Translation.configure_libretranslate_desc")
|
||||||
|
|
||||||
|
server_url: bpy.props.StringProperty(
|
||||||
|
name=t("Translation.server_url"),
|
||||||
|
description=t("Translation.server_url_desc"),
|
||||||
|
default="https://libretranslate.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
api_key: bpy.props.StringProperty(
|
||||||
|
name=t("Translation.api_key"),
|
||||||
|
description=t("Translation.api_key_desc"),
|
||||||
|
default="",
|
||||||
|
subtype='PASSWORD'
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute(self, context: Context) -> Set[str]:
|
||||||
|
try:
|
||||||
|
if not self.server_url.strip():
|
||||||
|
self.report({'ERROR'}, "Server URL cannot be empty")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
from ..core.translation_manager import configure_translation_service
|
||||||
|
success = configure_translation_service("libretranslate",
|
||||||
|
server_url=self.server_url.strip(),
|
||||||
|
api_key=self.api_key.strip() if self.api_key.strip() else None)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
_ui_cache['libretranslate_config'].clear()
|
||||||
|
_ui_cache['translation_status'].clear()
|
||||||
|
if 'batch_info' in _ui_cache:
|
||||||
|
del _ui_cache['batch_info']
|
||||||
|
self.report({'INFO'}, f"LibreTranslate server configured: {self.server_url}")
|
||||||
|
return {'FINISHED'}
|
||||||
|
else:
|
||||||
|
self.report({'ERROR'}, "Failed to connect to LibreTranslate server")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LibreTranslate configuration failed: {e}")
|
||||||
|
self.report({'ERROR'}, f"Configuration failed: {str(e)}")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
def invoke(self, context: Context, event: Event) -> Set[str]:
|
||||||
|
# Load existing server URL and API key if available
|
||||||
|
try:
|
||||||
|
from ..core.addon_preferences import get_preference
|
||||||
|
existing_url = get_preference("libretranslate_url", "https://libretranslate.com")
|
||||||
|
existing_api_key = get_preference("libretranslate_api_key", "")
|
||||||
|
self.server_url = existing_url
|
||||||
|
self.api_key = existing_api_key
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
wm: WindowManager = context.window_manager
|
||||||
|
return wm.invoke_props_dialog(self, width=500)
|
||||||
|
|
||||||
|
def draw(self, context: Context) -> None:
|
||||||
|
layout: UILayout = self.layout
|
||||||
|
|
||||||
|
info_box = layout.box()
|
||||||
|
info_col = info_box.column()
|
||||||
|
info_col.label(text="LibreTranslate Server Configuration", icon='SETTINGS')
|
||||||
|
info_col.separator()
|
||||||
|
info_col.label(text="⚠ libretranslate.com requires payment for API access")
|
||||||
|
info_col.label(text="✓ You can run your own LibreTranslate server")
|
||||||
|
info_col.label(text="✓ Or find community-hosted instances")
|
||||||
|
info_col.separator()
|
||||||
|
info_col.label(text="Examples:")
|
||||||
|
info_col.label(text=" • Your server: https://translate.yoursite.com")
|
||||||
|
info_col.label(text=" • Docker local: http://localhost:5000")
|
||||||
|
|
||||||
|
layout.separator()
|
||||||
|
layout.prop(self, "server_url")
|
||||||
|
layout.prop(self, "api_key")
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarToolkit_OT_TranslationStats(Operator):
|
||||||
|
"""Show translation statistics"""
|
||||||
|
bl_idname: str = "avatar_toolkit.translation_stats"
|
||||||
|
bl_label: str = t("Translation.show_stats")
|
||||||
|
bl_description: str = t("Translation.show_stats_desc")
|
||||||
|
|
||||||
|
def execute(self, context: Context) -> Set[str]:
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
def invoke(self, context: Context, event: Event) -> Set[str]:
|
||||||
|
wm: WindowManager = context.window_manager
|
||||||
|
return wm.invoke_props_dialog(self, width=400)
|
||||||
|
|
||||||
|
def draw(self, context: Context) -> None:
|
||||||
|
layout: UILayout = self.layout
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ..core.translation_manager import get_avatar_translation_manager
|
||||||
|
manager = get_avatar_translation_manager()
|
||||||
|
stats = manager.get_translation_stats()
|
||||||
|
|
||||||
|
dict_box = layout.box()
|
||||||
|
dict_box.label(text="Dictionary Translations", icon='BOOKMARKS')
|
||||||
|
dict_stats = stats['dictionary_translations']
|
||||||
|
for category, count in dict_stats.items():
|
||||||
|
if count > 0:
|
||||||
|
dict_box.label(text=f"{category.title()}: {count}")
|
||||||
|
|
||||||
|
cache_box = layout.box()
|
||||||
|
cache_box.label(text="Translation Cache", icon='FILE_CACHE')
|
||||||
|
cache_stats = stats['cache_stats']
|
||||||
|
cache_box.label(text=f"Language pairs: {cache_stats['language_pairs']}")
|
||||||
|
cache_box.label(text=f"Total cached: {cache_stats['total_entries']}")
|
||||||
|
|
||||||
|
service_box = layout.box()
|
||||||
|
service_box.label(text="Translation Services", icon='WORLD')
|
||||||
|
service_box.label(text=f"Current mode: {stats['current_mode']}")
|
||||||
|
service_box.label(text=f"Primary service: {stats['primary_service']}")
|
||||||
|
|
||||||
|
available_services = stats['available_services']
|
||||||
|
if available_services:
|
||||||
|
service_box.label(text="Available services:")
|
||||||
|
for service_id, service_name in available_services:
|
||||||
|
service_box.label(text=f" • {service_name}")
|
||||||
|
else:
|
||||||
|
service_box.label(text="No services available", icon='ERROR')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
layout.label(text=f"Error loading stats: {str(e)}", icon='ERROR')
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarToolKit_PT_TranslationPanel(Panel):
|
||||||
|
"""Translation panel for Avatar Toolkit"""
|
||||||
|
bl_label: str = t("Translation.label")
|
||||||
|
bl_idname: str = "OBJECT_PT_avatar_toolkit_translation"
|
||||||
|
bl_space_type: str = 'VIEW_3D'
|
||||||
|
bl_region_type: str = 'UI'
|
||||||
|
bl_category: str = CATEGORY_NAME
|
||||||
|
bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname
|
||||||
|
bl_order: int = 9
|
||||||
|
bl_options = {'DEFAULT_CLOSED'}
|
||||||
|
|
||||||
|
def draw(self, context: Context) -> None:
|
||||||
|
"""Draw the translation panel layout"""
|
||||||
|
layout: UILayout = self.layout
|
||||||
|
props = context.scene.avatar_toolkit
|
||||||
|
|
||||||
|
# Translation Service Settings
|
||||||
|
service_box: UILayout = layout.box()
|
||||||
|
col: UILayout = service_box.column(align=True)
|
||||||
|
row: UILayout = col.row()
|
||||||
|
row.scale_y = 1.2
|
||||||
|
row.label(text=t("Translation.service_settings"), icon='WORLD')
|
||||||
|
col.separator()
|
||||||
|
|
||||||
|
col.prop(props, "translation_service", text="")
|
||||||
|
|
||||||
|
col.prop(props, "translation_mode", text="")
|
||||||
|
|
||||||
|
row = col.row(align=True)
|
||||||
|
row.prop(props, "translation_expand",
|
||||||
|
icon="TRIA_DOWN" if props.translation_expand else "TRIA_RIGHT",
|
||||||
|
icon_only=True, emboss=False)
|
||||||
|
row.label(text=t("Translation.advanced_settings"))
|
||||||
|
|
||||||
|
if props.translation_expand:
|
||||||
|
config_col = service_box.column(align=True)
|
||||||
|
|
||||||
|
# MyMemory settings (no configuration needed)
|
||||||
|
if props.translation_service == 'mymemory':
|
||||||
|
config_col.separator()
|
||||||
|
config_col.label(text="MyMemory Configuration:", icon='CHECKMARK')
|
||||||
|
success_col = config_col.column()
|
||||||
|
success_col.alert = False
|
||||||
|
success_col.label(text="✓ No API key required!", icon='CHECKMARK')
|
||||||
|
success_col.label(text="✓ Completely free service")
|
||||||
|
success_col.label(text="✓ 1000 translations per day")
|
||||||
|
success_col.label(text="✓ Slowest Option due to no native batching")
|
||||||
|
success_col.label(text="✓ Ready to use!")
|
||||||
|
|
||||||
|
elif props.translation_service == 'libretranslate':
|
||||||
|
config_col.separator()
|
||||||
|
config_col.label(text="LibreTranslate Configuration:", icon='SETTINGS')
|
||||||
|
|
||||||
|
# Check current server configuration (cached to avoid performance issues)
|
||||||
|
try:
|
||||||
|
if 'libretranslate_url' not in _ui_cache['libretranslate_config']:
|
||||||
|
from ..core.addon_preferences import get_preference
|
||||||
|
_ui_cache['libretranslate_config']['libretranslate_url'] = get_preference("libretranslate_url", "https://libretranslate.com")
|
||||||
|
|
||||||
|
server_url = _ui_cache['libretranslate_config']['libretranslate_url']
|
||||||
|
|
||||||
|
info_col = config_col.column()
|
||||||
|
info_col.alert = False
|
||||||
|
info_col.label(text=f"Server: {server_url}", icon='URL')
|
||||||
|
|
||||||
|
if "libretranslate.com" in server_url.lower():
|
||||||
|
warning_col = config_col.column()
|
||||||
|
warning_col.alert = True
|
||||||
|
warning_col.label(text="⚠ Default server requires payment", icon='ERROR')
|
||||||
|
warning_col.label(text="Configure your own LibreTranslate server")
|
||||||
|
else:
|
||||||
|
success_col = config_col.column()
|
||||||
|
success_col.alert = False
|
||||||
|
success_col.label(text="✓ Custom server configured", icon='CHECKMARK')
|
||||||
|
|
||||||
|
config_row = config_col.row()
|
||||||
|
config_row.operator("avatar_toolkit.configure_libretranslate", text="Configure Server", icon='SETTINGS')
|
||||||
|
except Exception as e:
|
||||||
|
config_col.label(text="LibreTranslate configuration error", icon='ERROR')
|
||||||
|
|
||||||
|
elif props.translation_service == 'deepl':
|
||||||
|
config_col.separator()
|
||||||
|
config_col.label(text="DeepL Configuration:", icon='SETTINGS')
|
||||||
|
|
||||||
|
# Check if API key is configured (cached to avoid performance issues)
|
||||||
|
try:
|
||||||
|
if 'deepl_api_key' not in _ui_cache['deepl_config']:
|
||||||
|
from ..core.addon_preferences import get_preference
|
||||||
|
_ui_cache['deepl_config']['deepl_api_key'] = get_preference("deepl_api_key", "")
|
||||||
|
|
||||||
|
deepl_api_key = _ui_cache['deepl_config']['deepl_api_key']
|
||||||
|
|
||||||
|
if deepl_api_key and deepl_api_key.strip():
|
||||||
|
success_col = config_col.column()
|
||||||
|
success_col.alert = False
|
||||||
|
success_col.label(text="✓ API key configured", icon='CHECKMARK')
|
||||||
|
success_col.label(text="✓ High quality translations")
|
||||||
|
success_col.label(text="✓ 500,000 chars/month free")
|
||||||
|
success_col.label(text="✓ Ready to use!")
|
||||||
|
|
||||||
|
reconfig_row = config_col.row()
|
||||||
|
reconfig_row.operator("avatar_toolkit.configure_deepl", text="Reconfigure API Key", icon='SETTINGS')
|
||||||
|
else:
|
||||||
|
warning_col = config_col.column()
|
||||||
|
warning_col.alert = True
|
||||||
|
warning_col.label(text="⚠ API key required!", icon='ERROR')
|
||||||
|
warning_col.label(text="Get free key at deepl.com/pro")
|
||||||
|
warning_col.label(text="500,000 characters/month free")
|
||||||
|
|
||||||
|
config_row = config_col.row()
|
||||||
|
config_row.operator("avatar_toolkit.configure_deepl", text="Configure API Key", icon='PLUS')
|
||||||
|
except Exception as e:
|
||||||
|
config_col.label(text="DeepL configuration error", icon='ERROR')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Language Settings
|
||||||
|
lang_box: UILayout = layout.box()
|
||||||
|
col = lang_box.column(align=True)
|
||||||
|
row = col.row()
|
||||||
|
row.scale_y = 1.2
|
||||||
|
row.label(text=t("Translation.language_settings"), icon='SYNTAX_ON')
|
||||||
|
col.separator()
|
||||||
|
col.prop(props, "translation_source_language", text="From")
|
||||||
|
col.prop(props, "translation_target_language", text="To")
|
||||||
|
|
||||||
|
# Quick Actions
|
||||||
|
action_box: UILayout = layout.box()
|
||||||
|
col = action_box.column(align=True)
|
||||||
|
row = col.row()
|
||||||
|
row.scale_y = 1.2
|
||||||
|
row.label(text=t("Translation.quick_actions"), icon='PLAY')
|
||||||
|
col.separator()
|
||||||
|
|
||||||
|
# Translate buttons
|
||||||
|
row = col.row(align=True)
|
||||||
|
op_bones = row.operator(AvatarToolkit_OT_TranslateNames.bl_idname, text="Bones", icon='BONE_DATA')
|
||||||
|
op_bones.translation_type = 'bones'
|
||||||
|
|
||||||
|
op_shapes = row.operator(AvatarToolkit_OT_TranslateNames.bl_idname, text="Shape Keys", icon='SHAPEKEY_DATA')
|
||||||
|
op_shapes.translation_type = 'shapekeys'
|
||||||
|
|
||||||
|
row = col.row(align=True)
|
||||||
|
op_mats = row.operator(AvatarToolkit_OT_TranslateNames.bl_idname, text="Materials", icon='MATERIAL_DATA')
|
||||||
|
op_mats.translation_type = 'materials'
|
||||||
|
|
||||||
|
op_objs = row.operator(AvatarToolkit_OT_TranslateNames.bl_idname, text="Objects", icon='OBJECT_DATA')
|
||||||
|
op_objs.translation_type = 'objects'
|
||||||
|
|
||||||
|
col.separator()
|
||||||
|
op_all = col.operator(AvatarToolkit_OT_TranslateNames.bl_idname, text="Translate All", icon='WORLD')
|
||||||
|
op_all.translation_type = 'all'
|
||||||
|
|
||||||
|
# Utility buttons
|
||||||
|
util_box: UILayout = layout.box()
|
||||||
|
col = util_box.column(align=True)
|
||||||
|
row = col.row()
|
||||||
|
row.scale_y = 1.2
|
||||||
|
row.label(text=t("Translation.utilities"), icon='TOOL_SETTINGS')
|
||||||
|
col.separator()
|
||||||
|
|
||||||
|
row = col.row(align=True)
|
||||||
|
row.operator(AvatarToolkit_OT_TestTranslationService.bl_idname, icon='PLAY')
|
||||||
|
row.operator(AvatarToolkit_OT_TranslationStats.bl_idname, icon='INFO')
|
||||||
|
|
||||||
|
col.operator(AvatarToolkit_OT_ClearTranslationCache.bl_idname, icon='TRASH')
|
||||||
|
|
||||||
|
status_box = layout.box()
|
||||||
|
status_col = status_box.column()
|
||||||
|
|
||||||
|
try:
|
||||||
|
status_cache_key = f"translation_status_{props.translation_service}_{props.translation_mode}"
|
||||||
|
|
||||||
|
# Refresh cache periodically
|
||||||
|
frame = context.scene.frame_current
|
||||||
|
cache_expired = (frame - _ui_cache['last_refresh_frame'] >= _ui_cache['cache_refresh_interval']) or status_cache_key not in _ui_cache['translation_status']
|
||||||
|
|
||||||
|
if cache_expired:
|
||||||
|
from ..core.translation_manager import get_available_translation_services, get_avatar_translation_manager
|
||||||
|
|
||||||
|
manager = get_avatar_translation_manager()
|
||||||
|
available_services = get_available_translation_services()
|
||||||
|
|
||||||
|
_ui_cache['translation_status'][status_cache_key] = {
|
||||||
|
'available_services': available_services,
|
||||||
|
'manager': manager,
|
||||||
|
'cache_stats': None
|
||||||
|
}
|
||||||
|
_ui_cache['last_refresh_frame'] = frame
|
||||||
|
|
||||||
|
try:
|
||||||
|
stats = manager.get_translation_stats()
|
||||||
|
_ui_cache['translation_status'][status_cache_key]['cache_stats'] = stats['cache_stats']
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Use cached data
|
||||||
|
cached_data = _ui_cache['translation_status'].get(status_cache_key, {})
|
||||||
|
available_services = cached_data.get('available_services', [])
|
||||||
|
cache_stats = cached_data.get('cache_stats')
|
||||||
|
|
||||||
|
if available_services:
|
||||||
|
status_col.label(text="Translation services ready", icon='CHECKMARK')
|
||||||
|
|
||||||
|
# Show current service status
|
||||||
|
current_service = props.translation_service
|
||||||
|
service_available = any(service_id == current_service for service_id, _ in available_services)
|
||||||
|
|
||||||
|
if service_available:
|
||||||
|
service_name = next((name for sid, name in available_services if sid == current_service), current_service)
|
||||||
|
status_col.label(text=f"Active: {service_name}", icon='WORLD')
|
||||||
|
|
||||||
|
# Show translation mode
|
||||||
|
mode_display = {
|
||||||
|
'hybrid': 'Dictionary + API',
|
||||||
|
'dictionary_only': 'Dictionary Only',
|
||||||
|
'api_only': 'API Only'
|
||||||
|
}.get(props.translation_mode, props.translation_mode)
|
||||||
|
status_col.label(text=f"Mode: {mode_display}", icon='SETTINGS')
|
||||||
|
|
||||||
|
# Show cache status
|
||||||
|
if cache_stats and cache_stats['total_entries'] > 0:
|
||||||
|
status_col.label(text=f"Cache: {cache_stats['total_entries']} translations", icon='FILE_CACHE')
|
||||||
|
|
||||||
|
# Show batch translation capability
|
||||||
|
try:
|
||||||
|
if 'batch_info' not in _ui_cache:
|
||||||
|
from ..core.translation_manager import get_batch_translation_info
|
||||||
|
_ui_cache['batch_info'] = get_batch_translation_info()
|
||||||
|
|
||||||
|
batch_info = _ui_cache['batch_info'].get(current_service, {})
|
||||||
|
if batch_info.get('supports_batch', False):
|
||||||
|
batch_type = batch_info.get('batch_type', 'individual')
|
||||||
|
if batch_type == 'native':
|
||||||
|
status_col.label(text="⚡ DeepL Native batch translation (up to 50x faster)", icon='LIGHT')
|
||||||
|
elif batch_type == 'concurrent':
|
||||||
|
if current_service == 'mymemory':
|
||||||
|
status_col.label(text="⚡ Slowest Option, no native Batching", icon='LIGHT')
|
||||||
|
else:
|
||||||
|
status_col.label(text="⚡ Slightly Faster then MyMemory processing (3x faster)", icon='LIGHT')
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
else:
|
||||||
|
warning_col = status_col.column()
|
||||||
|
warning_col.alert = True
|
||||||
|
warning_col.label(text=f"Service unavailable: {props.translation_service}", icon='ERROR')
|
||||||
|
|
||||||
|
|
||||||
|
else:
|
||||||
|
warning_col = status_col.column()
|
||||||
|
warning_col.alert = True
|
||||||
|
warning_col.label(text="No translation services available", icon='ERROR')
|
||||||
|
|
||||||
|
if props.translation_service == 'mymemory':
|
||||||
|
warning_col.label(text="Internet connection required")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_col = status_col.column()
|
||||||
|
error_col.alert = True
|
||||||
|
error_col.label(text="Translation system error", icon='ERROR')
|
||||||
|
logger.error(f"Status display error: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if hasattr(context.area, 'header_text') and context.area.header_text:
|
||||||
|
progress_col = status_col.column()
|
||||||
|
progress_col.alert = False
|
||||||
|
progress_col.label(text=context.area.header_text, icon='TIME')
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user