Add immutable revisions, PGVN editor, asset replacement, merge and rebase workflows, authenticated media delivery and reachability GC. Harden the script interpreter, pin unrpyc and remove tracked build metadata. Include current compatibility checks, Naninovel import and regression tests.
133 lines
6.2 KiB
Python
133 lines
6.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)
|
|
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)}
|
|
|
|
|
|
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):
|
|
from app.converter.pipeline import Result, anchors
|
|
|
|
progress("Чтение сценариев Naninovel и проверка совместимости")
|
|
package = read_package(root, work / "unity", media=True)
|
|
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"
|
|
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}
|
|
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)
|