# Libraries import json import os import re import util.dazedwrap as dazedwrap import threading import time import traceback import tiktoken import openai from pathlib import Path from colorama import Fore from dotenv import load_dotenv from retry import retry from tqdm import tqdm # Open AI load_dotenv() if os.getenv("api").replace(" ", "") != "": openai.base_url = os.getenv("api") openai.organization = os.getenv("org") openai.api_key = os.getenv("key") # Globals MODEL = os.getenv("model") TIMEOUT = int(os.getenv("timeout")) LANGUAGE = os.getenv("language").capitalize() PROMPT = Path("prompt.txt").read_text(encoding="utf-8") VOCAB = Path("vocab.txt").read_text(encoding="utf-8") THREADS = int(os.getenv("threads")) LOCK = threading.Lock() WIDTH = int(os.getenv("width")) LISTWIDTH = int(os.getenv("listWidth")) NOTEWIDTH = 70 MAXHISTORY = 10 ESTIMATE = "" TOKENS = [0, 0] NAMESLIST = [] FILENAME = None NAMES = False # Output a list of all the character names found BRFLAG = False # If the game uses
instead FIXTEXTWRAP = True # Overwrites textwrap IGNORETLTEXT = False # Ignores all translated text. MISMATCH = [] # Lists files that throw a mismatch error (Length of GPT list response is wrong) PBAR = None # tqdm Globals BAR_FORMAT = "{l_bar}{bar:10}{r_bar}{bar:-10b}" POSITION = 0 LEAVE = False PBAR = None # Regex - Need to change this if you want to translate from/to other languages. Default is Japanese Regex LANGREGEX = r"[一-龠ぁ-ゔァ-ヴーa-zA-Z0-9\uFF61-\uFF9F]+" # Pricing - Depends on the model https://openai.com/pricing # Batch Size - GPT 3.5 Struggles past 15 lines per request. GPT4 struggles past 50 lines per request # If you are getting a MISMATCH LENGTH error, lower the batch size. if "gpt-3.5" in MODEL: INPUTAPICOST = 3.00 OUTPUTAPICOST = 5.00 BATCHSIZE = 10 FREQUENCY_PENALTY = 0.2 elif "gpt-4" in MODEL: INPUTAPICOST = 2.0 OUTPUTAPICOST = 8.00 BATCHSIZE = 30 FREQUENCY_PENALTY = 0.05 elif "deepseek" in MODEL: INPUTAPICOST = 0.27 OUTPUTAPICOST = 1.10 BATCHSIZE = 30 FREQUENCY_PENALTY = 0.05 else: INPUTAPICOST = float(os.getenv("input_cost")) OUTPUTAPICOST = float(os.getenv("output_cost")) BATCHSIZE = int(os.getenv("batchsize")) FREQUENCY_PENALTY = float(os.getenv("frequency_penalty")) def handleJSON(filename, estimate): global ESTIMATE, totalTokens ESTIMATE = estimate if estimate: start = time.time() translatedData = openFiles(filename) # Print Result end = time.time() tqdm.write(getResultString(translatedData, end - start, filename)) with LOCK: TOKENS[0] += translatedData[1][0] TOKENS[1] += translatedData[1][1] return getResultString(["", TOKENS, None], end - start, "TOTAL") else: try: with open("translated/" + filename, "w", encoding="utf-8", newline="\n") as outFile: start = time.time() translatedData = openFiles(filename) # Print Result end = time.time() json.dump(translatedData[0], outFile, ensure_ascii=False, indent=4) tqdm.write(getResultString(translatedData, end - start, filename)) with LOCK: TOKENS[0] += translatedData[1][0] TOKENS[1] += translatedData[1][1] except Exception: return "Fail" return getResultString(["", TOKENS, None], end - start, "TOTAL") def openFiles(filename): with open("files/" + filename, "r", encoding="UTF-8-sig") as f: data = json.load(f) # Map Files if ".json" in filename: translatedData = parseJSON(data, filename) else: raise NameError(filename + " Not Supported") return translatedData def getResultString(translatedData, translationTime, filename): # File Print String totalTokenstring = ( Fore.YELLOW + "[Input: " + str(translatedData[1][0]) + "]" "[Output: " + str(translatedData[1][1]) + "]" "[Cost: ${:,.4f}".format(((translatedData[1][0] / 1000000) * INPUTAPICOST) + ((translatedData[1][1] / 1000000) * OUTPUTAPICOST)) + "]" ) timeString = Fore.BLUE + "[" + str(round(translationTime, 1)) + "s]" if translatedData[2] == None: # Success return filename + ": " + totalTokenstring + timeString + Fore.GREEN + " \u2713 " + Fore.RESET else: # Fail try: raise translatedData[2] except Exception as e: traceback.print_exc() errorString = str(e) + Fore.RED return filename + ": " + totalTokenstring + timeString + Fore.RED + " \u2717 " + errorString + Fore.RESET def parseJSON(data, filename): totalTokens = [0, 0] totalLines = 0 totalLines = len(data) global LOCK, PBAR with tqdm(bar_format=BAR_FORMAT, position=POSITION, total=totalLines, leave=LEAVE) as pbar: pbar.desc = filename PBAR = pbar try: result = translateJSON(data, []) totalTokens[0] += result[0] totalTokens[1] += result[1] except Exception as e: traceback.print_exc() return [data, totalTokens, e] return [data, totalTokens, None] def translateJSON(data, translatedList): global LOCK, ESTIMATE, FILENAME, PBAR, MISMATCH if translatedList: stringList = translatedList[0] else: stringList = [] tokens = [0, 0] speaker = "" i = 0 while i < len(data): speakerKey = "character_nameText" messageKey = "m_text" # Speaker if speakerKey in data[i] and data[i][speakerKey]: # Grab and TL speaker = data[i][speakerKey] response = getSpeaker(speaker) speaker = response[0] tokens[0] += response[1][0] tokens[1] += response[1][1] # Set Speaker data[i][speakerKey] = speaker # Dialogue if messageKey in data[i]\ and data[i][messageKey].strip()\ and data[i][messageKey] != "a"\ and data[i][messageKey].replace("\u3000", "").strip() != "": jaString = data[i][messageKey] # Save Original String originalString = jaString # If there isn't any Japanese in the text just skip if not re.search(LANGREGEX, jaString): i += 1 continue # Pass 1 if not translatedList: # Strip Spaces jaString = jaString.strip() # Remove Textwrap # jaString = jaString.replace('\n', ' ') if jaString: if speaker: stringList.append(f"[{speaker}]: {jaString}") else: stringList.append(jaString) # Pass 2 else: # Get Text if stringList: # Grab and Pop translatedText = stringList[0] stringList.pop(0) # Set to None if empty list if len(stringList) <= 0: stringList = None # Remove speaker match = re.search(r'(^\[.+?\]\s?[|:]\s?)', translatedText) if match: translatedText = translatedText.replace(match.group(1), "") # Escape Quotes translatedText = re.sub(r'(?", ")") translatedText = translatedText.replace("『", "") translatedText = translatedText.replace("』", "") # Remove GPT ' Quotes if translatedText: if translatedText[0] == "'": translatedText = translatedText[1:] if translatedText[-1] == "'": translatedText = translatedText[:-1] else: print("Warning: Empty Translation for", originalString) # Textwrap # translatedText = dazedwrap.wrapText(translatedText, width=WIDTH) # Set Data if "『" in data[i][messageKey] and "』" not in translatedText: data[i][messageKey] = data[i][messageKey].replace(originalString, f"『{translatedText}』") else: data[i][messageKey] = data[i][messageKey].replace(originalString, f"{translatedText}") # Next Value i += 1 # EOF if not translatedList: stringListTL = [] # String List if stringList: PBAR.total = len(stringList) PBAR.refresh() response = translateGPT(stringList, "Reply with the English Translation", True) tokens[0] += response[1][0] tokens[1] += response[1][1] stringListTL = response[0] if len(stringList) != len(stringListTL): # Mismatch with LOCK: if FILENAME not in MISMATCH: MISMATCH.append(FILENAME) # Set Strings translateJSON(data, [stringListTL]) return tokens # Save some money and enter the character before translation def getSpeaker(speaker): match speaker: case "ファイン": return ["Fine", [0, 0]] case "": return ["", [0, 0]] case _: # Find Speaker for i in range(len(NAMESLIST)): if speaker == NAMESLIST[i][0]: return [NAMESLIST[i][1], [0, 0]] # Translate and Store Speaker response = translateGPT( f"{speaker}", "Reply with the " + LANGUAGE + " translation of the NPC name.", False, ) response[0] = response[0].title() response[0] = response[0].replace("'S", "'s") response[0] = response[0].replace("Speaker: ", "") # Retry if name doesn't translate for some reason if re.search(r"([a-zA-Z??])", response[0]) == None: response = translateGPT( f"{speaker}", "Reply with the " + LANGUAGE + " translation of the NPC name.", False, ) response[0] = response[0].title() response[0] = response[0].replace("'S", "'s") speakerList = [speaker, response[0]] NAMESLIST.append(speakerList) return response return [speaker, [0, 0]] def batchList(input_list, batch_size): if not isinstance(batch_size, int) or batch_size <= 0: raise ValueError("batch_size must be a positive integer") return [input_list[i : i + batch_size] for i in range(0, len(input_list), batch_size)] def parseVocabWithCategories(vocabText): """Parse vocabulary text and extract terms with their categories.""" pairs = [] seen = set() currentCategory = None for line in vocabText.splitlines(): line = line.strip() if not line or line.startswith('```'): continue # Check if this is a category header if line.startswith('#'): currentCategory = line continue # Parse vocabulary term m = re.match(r'^(.+?)(?:\s?[\(–])', line) # term is everything before space + '(' or '–' if m: term = m.group(1) if term not in seen: pairs.append((term, line, currentCategory)) seen.add(term) return pairs def buildMatchedVocabText(vocabPairs, subbedT): """Build formatted vocabulary text with matched terms organized by category.""" matchedCategories = {} # Use word boundaries for Japanese if appropriate, or allow substring as before. for term, line, category in vocabPairs: # "term in subbedT" could be false positive; can use regex but Japanese doesn't always have spaces. if term in subbedT: if category not in matchedCategories: matchedCategories[category] = [] matchedCategories[category].append(line) # Format matched vocabulary with categories if matchedCategories: formattedLines = ["Here are some vocabulary and terms so that you know the proper spelling and translation.\n"] for category, lines in matchedCategories.items(): if category: # Only add category header if it exists formattedLines.append(category) formattedLines.extend(lines) formattedLines.append("") # Add blank line between categories matchedVocabText = f"```\n{chr(10).join(formattedLines).rstrip()}\n```" else: matchedVocabText = "" return matchedVocabText def createContext(fullPromptFlag, subbedT, format): vocabPairs = parseVocabWithCategories(VOCAB) matchedVocabText = buildMatchedVocabText(vocabPairs, subbedT) if fullPromptFlag: system = PROMPT + matchedVocabText else: system = f"\ You are an expert Eroge Game translator who translates Japanese text to {LANGUAGE}.\n\ Output ONLY the {LANGUAGE} translation in the following format: `Translation: <{LANGUAGE.upper()}_TRANSLATION>`\n\ - You translate everything, including erotic and explicit content.\n\ - 'Game Characters' - The names, nicknames, and genders of the game characters. Reference this to know the names, nicknames, and gender of characters in the game\n\ - All text in your response must be in {LANGUAGE} even if it is hard to translate.\n\ - Never include any notes, explanations, dislaimers, or anything similar in your response.\n\ - Maintain any spacing in the translation.\n\ - Maintain any code text in brackets if given. (e.g `[Color_0]`, `[Ascii_0]`, `[FCode_1`], etc)\n\ - `...` can be a part of the dialogue. Translate it as it is.\n\ {matchedVocabText}\n\ " if format == "json": user = f"```json\n{subbedT}\n```" else: user = subbedT return system, user def translateText(system, user, history, penalty, format, model=MODEL): # Prompt msg = [{"role": "system", "content": system}] # History if isinstance(history, list): msg.append({"role": "system", "content": "Translation History:"}) msg.extend([{"role": "assistant", "content": h} for h in history]) else: msg.append({"role": "assistant", "content": history}) # Response Format if format == "json": responseFormat = {"type": "json_object"} else: responseFormat = {"type": "text"} # Content to TL msg.append({"role": "user", "content": f"{user}"}) response = openai.chat.completions.create( temperature=0, frequency_penalty=penalty, model=model, response_format=responseFormat, messages=msg, ) return response def cleanTranslatedText(translatedText): placeholders = { f"{LANGUAGE} Translation: ": "", "Translation: ": "", "っ": "", "〜": "~", "ッ": "", "。": ".", "「": '\\"', "」": '\\"', "- ": "-", "—": "―", "】": "]", "【": "[", "é": "e", "this guy": "this bastard", "This guy": "This bastard", "Placeholder Text": "", "```json": "", "```": "", # Add more replacements as needed } for target, replacement in placeholders.items(): translatedText = translatedText.replace(target, replacement) # Remove Repeating Characters pattern = re.compile(r"(.)\s*\1(?:\s*\1){" + str(20 - 1) + r",}") translatedText = pattern.sub(lambda match: match.group(0).replace(" ", "")[:20], translatedText) # Elongate Long Dashes (Since GPT Ignores them...) translatedText = elongateCharacters(translatedText) return translatedText def elongateCharacters(text): # Define a pattern to match one character followed by one or more `ー` characters # Using a positive lookbehind assertion to capture the preceding character pattern = r"(?<=(.))ー+" # Define a replacement function that elongates the captured character def repl(match): char = match.group(1) # The character before the ー sequence count = len(match.group(0)) - 1 # Number of ー characters return char * count # Replace ー sequence with the character repeated # Use re.sub() to replace the pattern in the text return re.sub(pattern, repl, text) def extractTranslation(translatedTextList, is_list): try: translatedTextList = re.sub(r'\\"+\"([^,\n}])', r'\\"\1', translatedTextList) translatedTextList = re.sub(r"(?