Initial local translation API with persistent projects and batch jobs

This commit is contained in:
mrleo1nid committed 2026-10-23 13:31:13 +03:00
commit 13492929c6
21 files changed
+1624

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
"""Extract Naninovel DefaultUI TextAsset from Tomie into this workspace.
Only reads the game. Writes a JSON translation input and a source manifest.
Requires: python -m pip install -e ".[unity]"
"""
import argparse
import hashlib
import json
from pathlib import Path
def parse_dictionary(text):
result = []
seen = set()
for line_number, line in enumerate(text.splitlines(), 1):
if not line.strip() or line.lstrip().startswith((";", "#", "//")):
continue
key, separator, value = line.partition(":")
if not separator or not key.strip() or not value.strip():
continue
key = key.strip()
if key in seen:
raise ValueError(f"Duplicate dictionary key at line {line_number}: {key}")
seen.add(key)
result.append({"id": key, "text": value.lstrip(), "line": line_number})
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("game", type=Path)
parser.add_argument("--output", type=Path, default=Path("data/tomie/input.json"))
parser.add_argument("--limit", type=int, default=15, help="0 extracts the full DefaultUI dictionary")
args = parser.parse_args()
if args.limit < 0:
parser.error("limit must be nonnegative")
try:
import UnityPy
except ImportError:
parser.error('Install optional extractor: python -m pip install -e ".[unity]"')
asset_path = args.game.resolve() / "TomieWGM_Data" / "data.unity3d"
output = args.output.resolve()
if output.is_relative_to(args.game.resolve()):
parser.error("Choose an output outside the game directory")
environment = UnityPy.load(str(asset_path))
assets = [obj for obj in environment.objects if obj.type.name == "TextAsset" and obj.read().m_Name == "DefaultUI"]
if len(assets) != 1:
parser.error(f"Expected exactly one DefaultUI TextAsset, found {len(assets)}")
obj = assets[0]
script = obj.read().m_Script
if isinstance(script, bytes):
script = script.decode("utf-8-sig")
entries = parse_dictionary(script)
selected = entries[:args.limit] if args.limit else entries
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps([{"id": row["id"], "text": row["text"]} for row in selected], ensure_ascii=False, indent=2), encoding="utf-8")
manifest = {
"game": str(args.game.resolve()), "source_file": str(asset_path), "asset_name": "DefaultUI",
"path_id": obj.path_id, "serialized_file": obj.assets_file.name,
"script_sha256": hashlib.sha256(script.encode("utf-8")).hexdigest(),
"total_entries": len(entries), "selected_entries": selected,
"note": "Read-only extraction. No patching, font replacement or changes to game files.",
}
output.with_suffix(".manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"Extracted {len(selected)}/{len(entries)} UI strings to {output}")
if __name__ == "__main__":
main()
+5
View File
@@ -0,0 +1,5 @@
[
{"id": "intro.001", "text": "Welcome, {player_name}!"},
{"id": "intro.002", "text": "Bring me the Moonstone."},
{"id": "intro.003", "text": "<color=red>Danger!</color>\nYou have %d seconds."}
]
+27
View File
@@ -0,0 +1,27 @@
{
"name": "Tomie Wants to Get Married — test",
"source_language": "English",
"target_language": "Russian",
"context": "Tomie Wants to Get Married is an adult dating and life simulation game. Tomie is a college student on leave who has lost interest in studying and working. She wants to marry a wealthy man to satisfy her vanity. Following a friend's recommendation she joins a matchmaking agency serving wealthy clients. The player's choices determine her relationships and multiple endings. The game features morally dark situations, adult relationships and coercion. This project translates existing game content faithfully.",
"instructions": "Use natural, concise Russian suitable for a game interface and dialogue. Preserve the meaning, tone, profanity and explicitness of the original without softening or embellishment. Do not add moral commentary or invent facts. Interface labels should be short. Preserve all protected tokens exactly.",
"glossary": {
"Tomie": "Томи",
"AUTO": "АВТО",
"HIDE": "СКРЫТЬ",
"LOG": "ИСТОРИЯ",
"Q.LOAD": "БЫСТРАЯ ЗАГРУЗКА",
"Q.SAVE": "БЫСТРОЕ СОХРАНЕНИЕ",
"TITLE": "ГЛАВНОЕ МЕНЮ",
"SAVE": "СОХРАНИТЬ",
"LOAD": "ЗАГРУЗИТЬ",
"SETTINGS": "НАСТРОЙКИ",
"SKIP": "ПРОПУСК",
"CG GALLERY": "ГАЛЕРЕЯ CG",
"TIPS&HINTS": "СОВЕТЫ И ПОДСКАЗКИ",
"matchmaking agency": "брачное агентство"
},
"provider": "lmstudio",
"model": null,
"disabled_rules": [],
"protection_rules": []
}
+82
View File
@@ -0,0 +1,82 @@
"""Python standard-library client. No third-party packages required.
python examples/translate_game.py --input strings.json --output translated.json
Rerun with the same output path to resume an existing job.
"""
import argparse
import json
from pathlib import Path
import time
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
def api(base, path, payload=None, method=None):
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
request = Request(base + path, data=data, headers={"Content-Type": "application/json"}, method=method)
try:
with urlopen(request, timeout=45) as response:
return json.load(response)
except HTTPError as exc:
raise RuntimeError(f"API HTTP {exc.code}: {exc.read().decode('utf-8', errors='replace')}") from exc
def save(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
temporary.replace(path)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--base", default="http://127.0.0.1:8787")
parser.add_argument("--input", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--project", help="Existing project ID; otherwise create one")
parser.add_argument("--project-file", type=Path, help="Project settings JSON used when creating a project")
parser.add_argument("--context", default="Fantasy RPG. Characters speak naturally. Preserve names consistently.")
parser.add_argument("--target", default="Russian")
parser.add_argument("--retry", action="store_true", help="Retry failed items from the saved job")
parser.add_argument("--new-job", action="store_true", help="Ignore saved job and translate using current project settings")
args = parser.parse_args()
base = args.base.rstrip("/")
items = json.loads(args.input.read_text(encoding="utf-8"))
if not isinstance(items, list) or not all(isinstance(x, dict) and isinstance(x.get("id"), str) and isinstance(x.get("text"), str) for x in items):
parser.error("Input must be an array of {id: string, text: string}")
if not 1 <= len(items) <= 500:
parser.error("Send 1..500 items per saved job; split larger files")
state_path = args.output.with_suffix(args.output.suffix + ".job.json")
if state_path.exists() and not args.new_job:
state = json.loads(state_path.read_text(encoding="utf-8"))
if state["base"] != base or state["items"] != items or (args.project and args.project != state["project_id"]):
parser.error("Saved job belongs to a different input, project or server; use --new-job")
job = api(base, "/jobs/" + state["job_id"])
if args.retry and job["status"] in {"failed", "completed_with_errors"}:
job = api(base, "/jobs/" + job["id"] + "/retry", {})
else:
project_id = args.project
if not project_id:
project_config = json.loads(args.project_file.read_text(encoding="utf-8")) if args.project_file else {
"name": args.input.stem, "target_language": args.target, "context": args.context,
"glossary": {"Moonstone": "Лунный камень"}}
project = api(base, "/projects", project_config)
project_id = project["id"]
job = api(base, f"/projects/{project_id}/jobs", {"items": items, "batch_size": 10})
save(state_path, {"base": base, "project_id": project_id, "job_id": job["id"], "items": items})
print(f"Project: {job['project_id']} Job: {job['id']}")
while job["status"] in {"queued", "running"}:
print(f"Status: {job['status']} {job['progress']}", flush=True)
time.sleep(1)
job = api(base, "/jobs/" + job["id"])
save(args.output, job)
print(f"Saved {args.output}: {job['status']}")
if job["status"] != "completed":
raise SystemExit("Some items failed. See output; rerun with --retry after fixing the cause.")
if __name__ == "__main__":
try:
main()
except (RuntimeError, URLError, OSError, ValueError) as exc:
raise SystemExit(str(exc)) from exc