"""Naninovel importer: compiled Unity data -> validated PGVN package.""" import json import tempfile from pathlib import Path, PurePosixPath 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: 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 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)