Initial local translation API with persistent projects and batch jobs
This commit is contained in:
commit
13492929c6
21 files changed
+1624
No files matched your search
@@ -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()
|
||||
@@ -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."}
|
||||
]
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user