Files
pgames-manager/backend/app/converter/naninovel.py
T

192 lines
9.2 KiB
Python

"""Naninovel importer: compiled Unity data -> validated PGVN package."""
from pathlib import Path, PurePosixPath
import json
import re
import tempfile
from app.blobs import put_bytes, put_file
from app.converter import assets, locales
from app.converter.compatibility import CompatibilityError, Issue
from app.converter.nani_compiler import compile_package
from app.converter.nani_package import read_package
def inspect_naninovel(root: Path, *, work: Path | None = None) -> dict:
if work is None:
with tempfile.TemporaryDirectory(prefix="pgames-naninovel-") as directory:
return inspect_naninovel(root, work=Path(directory))
package = read_package(root, work, media=True)
try:
if package.profile:
from app.converter.tomie_format import prepare
native = prepare(package)
return {"engine": "naninovel", "profile": package.profile, "compatible": True,
"scripts": len(native["scripts"]), "textResources": len(package.texts),
"commands": sum(len(script["commands"]) for script in native["scripts"].values()),
"warnings": {}}
data, entry, _ = compile_package(package)
resolve_media(package, data)
return {"engine": "naninovel", "compatible": True, "scripts": len(package.scripts),
"textResources": len(package.texts), "entry": entry,
"commands": sum(len(line["commands"]) for script in package.scripts.values() for line in script["lines"]),
"warnings": dict(data.warnings)}
finally:
package.close()
def resolve_media(package, data):
"""Resolve every resource before decoding or transcoding any of them."""
wanted = {image["file"] for image in data.images.values() if image.get("file")}
for modules in data.modules.values():
for label in modules:
for node in locales.walk(label["body"]):
if node["op"] == "play":
wanted.update(value["v"] for value in node["files"])
candidates = {}
for source, obj in package.resources:
try:
name = obj.peek_name()
except (ValueError, AttributeError):
continue
if name:
candidates.setdefault(name, []).append((source, obj))
resolved, issues = {}, []
for name in sorted(wanted):
matches = candidates.get(name, candidates.get(name.rsplit("/", 1)[-1], []))
if len(matches) != 1:
issues.append(Issue("ambiguous_media" if matches else "missing_media",
"Неоднозначный ресурс" if matches else "Не найден ресурс", name))
elif matches[0][1].type.name not in {"Texture2D", "AudioClip"}:
issues.append(Issue("unsupported_media", "Тип медиа пока не поддерживается", name))
else:
resolved[name] = matches[0]
if issues:
raise CompatibilityError("naninovel", issues)
return resolved
def export_media(package, data, root: Path, work: Path, progress):
resolved = resolve_media(package, data)
files, aliases, issues = {}, {}, []
out = work / "media"
out.mkdir(parents=True, exist_ok=True)
for index, (name, (source, obj)) in enumerate(resolved.items()):
progress(f"Медиа Naninovel {index + 1}/{len(resolved)}")
try:
asset = obj.read()
stem = str(index)
if obj.type.name == "Texture2D":
path = out / (stem + ".png")
asset.image.save(path)
elif obj.type.name == "AudioClip":
samples = asset.samples
if len(samples) != 1:
raise ValueError("Аудиоресурс не содержит единственный клип")
filename, raw = next(iter(samples.items()))
path = out / (stem + (Path(filename).suffix or ".wav"))
path.write_bytes(raw)
else:
raise ValueError(f"Тип медиа {obj.type.name} пока не поддерживается")
converted, ext = assets.convert(path, out)
digest, size = put_file(converted, ext, move=True)
logical = "media/" + stem + ext
files[logical] = {"hash": digest, "size": size}
aliases[name] = logical
except Exception as exc:
issues.append(Issue("media_error", f"Не удалось извлечь медиа: {exc}", name))
if issues:
raise CompatibilityError("naninovel", issues)
for image in data.images.values():
if image.get("file"):
image["file"] = aliases[image["file"]]
for modules in data.modules.values():
for label in modules:
for node in locales.walk(label["body"]):
if node["op"] == "play":
for value in node["files"]:
value["v"] = aliases[value["v"]]
return files, aliases
def convert_naninovel(root: Path, work: Path, progress):
progress("Чтение сценариев Naninovel и проверка совместимости")
package = read_package(root, work / "unity", media=True)
try:
return _convert_package(root, work, progress, package)
finally:
package.close()
def _convert_package(root: Path, work: Path, progress, package):
from app.converter.pipeline import Result, anchors
if package.profile:
return _convert_tomie(root, work, progress, package)
data, entry, expressions = compile_package(package)
# No media is decoded or written to the blob store before the script gate.
files, aliases = export_media(package, data, root, work, progress)
locales.assign_ids(data.modules)
scripts = []
def store(path, value):
raw = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
digest, size = put_bytes(raw, ".json")
files[path] = {"hash": digest, "size": size}
for path, labels in data.modules.items():
logical = "script/" + str(PurePosixPath(path).with_suffix(".json"))
store(logical, {"file": path, "labels": labels, "anchors": anchors(labels)})
scripts.append(logical)
source = locales.detect_language(data.modules) or "und"
resources = {}
for tid, record in package.texts.items():
resources.setdefault(record["collection"], {}).setdefault(record["key"], []).append({"tid": tid, **record})
game = {"format": "pgvn/1", "engine": "naninovel", "title": root.name,
"resolution": [1920, 1080], "entry": entry, "scripts": scripts,
"characters": data.characters, "images": data.images, "transforms": {},
"transitions": {}, "audio": {}, "files": aliases, "defaults": [], "init": [],
"ui": {}, "sourceLang": source, "locales": {}, "naniExpressions": expressions,
"naniResources": resources}
store("game.json", game)
total = sum(entry["size"] for entry in files.values())
report = {"engine": "naninovel", "compatible": True, "format": "pgvn/1",
"scripts": len(package.scripts), "textResources": len(package.texts),
"media": len(aliases), "warnings": []}
return Result({"format": "pgvn/1", "files": files}, report, root.name, None, total)
def _convert_tomie(root, work, progress, package):
from app.converter.pipeline import Result, anchors
from app.converter.tomie_format import RUNTIME, modules, prepare
from app.converter.tomie_assets import export_media as export_tomie_media
native = prepare(package) # all script checks precede media decoding/blob writes
scripts = modules(native, package.texts)
files, media, counts = export_tomie_media(package, root, work, progress)
def store(path, value):
raw = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
digest, size = put_bytes(raw, ".json")
files[path] = {"hash": digest, "size": size}
resources = {}
for tid, record in package.texts.items():
resources.setdefault(record["collection"], {}).setdefault(record["key"], []).append({"tid": tid, **record})
for path, module in scripts.items():
module["anchors"] = anchors(module["labels"])
store(path, module)
game = {"format": "pgvn/1", "engine": "naninovel", "runtime": RUNTIME, "title": root.name,
"resolution": [1920, 1080], "entry": "nani:" + native["start"]["script"], "scripts": list(scripts),
"characters": {}, "images": {}, "transforms": {}, "transitions": {}, "audio": {}, "files": {},
"defaults": [], "init": [], "ui": {}, "sourceLang": locales.detect_language(
{path: module["labels"] for path, module in scripts.items()}) or "und", "locales": {},
"naniResources": resources,
"tomie": {**{key: value for key, value in native.items() if key != "scripts"}, "media": media}}
store("game.json", game)
report = {"engine": "naninovel", "profile": package.profile, "compatible": True, "format": "pgvn/1",
"runtime": RUNTIME, "scripts": len(package.scripts), "textResources": len(package.texts),
"media": counts, "warnings": []}
return Result({"format": "pgvn/1", "files": files}, report, root.name, None,
sum(entry["size"] for entry in files.values()))