"""Recover the pinned Tomie build's media and ResourceManager actor bindings.""" import hashlib from pathlib import Path from app.blobs import put_file from app.converter import assets from app.converter.compatibility import CompatibilityError, Issue def recover_object(obj, data: Path, out: Path, stem: str): """Decode a single owned temporary file; never rename or edit a source file.""" asset = obj.read() kind, size = obj.type.name, None if kind == "Texture2D": if asset.m_Width == 0 or asset.m_Height == 0: return None # empty runtime font atlas path = out / (stem + ".png") asset.image.save(path, compress_level=1) size = [asset.m_Width, asset.m_Height] elif kind == "AudioClip": samples = asset.samples if len(samples) != 1: raise ValueError("Не найден единственный аудиоклип") filename, raw = next(iter(samples.items())) extension = Path(filename).suffix.lower() or ".wav" if extension not in assets.AUDIO_EXTS: raise ValueError("Не поддерживается контейнер аудиоклипа " + extension) path = out / (stem + extension) path.write_bytes(raw) elif kind == "Font": raw = bytes(asset.m_FontData or b"") if not raw: return None path = out / (stem + (".otf" if raw[:4] == b"OTTO" else ".ttf")) path.write_bytes(raw) elif kind == "VideoClip": tree = obj.read_typetree() reference = tree["m_ExternalResources"] source_name = reference["m_Source"].replace("\\", "/").rsplit("/", 1)[-1] resource = data / source_name offset, length = int(reference["m_Offset"]), int(reference["m_Size"]) if not resource.is_file() or offset < 0 or length <= 0 or offset + length > resource.stat().st_size: raise ValueError("Некорректный диапазон исходного видео") extension = Path(tree["m_OriginalPath"]).suffix.lower() if extension not in assets.VIDEO_EXTS: raise ValueError("Не поддерживается исходный видеоконтейнер " + extension) path = out / (stem + extension) with resource.open("rb") as incoming, path.open("wb") as outgoing: incoming.seek(offset) remaining = length while remaining: chunk = incoming.read(min(1 << 20, remaining)) if not chunk: raise EOFError("Оборванный видеопоток") outgoing.write(chunk) remaining -= len(chunk) else: raise ValueError("Не поддерживается тип медиа " + kind) return {"path": path, "name": asset.m_Name.lower(), "size": size, "kind": {"Texture2D": "image", "AudioClip": "audio", "Font": "font", "VideoClip": "video"}[kind]} def export_media(package, root: Path, work: Path, progress): if package.issues: raise CompatibilityError("naninovel", package.issues) data = root / "TomieWGM_Data" out = work / "media" out.mkdir(parents=True, exist_ok=True) files, counts = {}, {"image": 0, "audio": 0, "video": 0, "font": 0} index = {"backgrounds": {}, "characters": {}, "audio": {}, "videos": {}, "textures": {}, "fonts": {}} exported = {} def store(path: Path, kind: str): converted, extension = assets.convert(path, out) digest, size = put_file(converted, extension, move=True) logical = "media/" + digest files[logical] = {"hash": digest, "size": size} counts[kind] += 1 return logical for number, source in enumerate(sorted((data / "Resources/Backgrounds/MainBackground").rglob("*"))): if not source.is_file(): continue if assets.kind_of(source) != "image": raise CompatibilityError("naninovel", [Issue("unsupported_background", "Не поддерживается формат фона Tomie", source.name)]) if number % 100 == 0: progress(f"Фоны Tomie: {number}") name = source.stem.lower() if name in index["backgrounds"]: raise CompatibilityError("naninovel", [Issue("ambiguous_background", "Неоднозначный фон Tomie", name)]) index["backgrounds"][name] = store(source, "image") objects = {} for source, obj in package.resources: identity = f"{source.relative_to(root).as_posix()}:{obj.assets_file.name}:{obj.path_id}" objects.setdefault(identity, (source, obj)) for number, (identity, (source, obj)) in enumerate(objects.items()): if number % 100 == 0: progress(f"Ресурсы Tomie: {number}/{len(objects)}") try: stem = hashlib.sha256(identity.encode()).hexdigest()[:24] recovered = recover_object(obj, data, out, stem) if recovered is None: continue path, name = recovered["path"], recovered["name"] try: logical = store(path, recovered["kind"]) finally: path.unlink(missing_ok=True) if obj.type.name == "Texture2D": exported[identity] = {"file": logical, "size": recovered["size"]} # Main resources take precedence over the UI bundle, as in the port. index["textures"].setdefault(name, logical) elif obj.type.name == "AudioClip": index["audio"].setdefault(name, logical) elif obj.type.name == "Font": index["fonts"].setdefault(name, logical) elif obj.type.name == "VideoClip": index["videos"][name] = logical except Exception as exc: raise CompatibilityError("naninovel", [Issue("tomie_media", f"Не удалось восстановить медиа: {exc}", identity)]) from exc for name, identity in package.actor_resources.items(): if identity not in exported: raise CompatibilityError("naninovel", [Issue("missing_actor", "Не найдено исходное изображение персонажа", name)]) index["characters"][name] = exported[identity] if not index["backgrounds"] or not index["characters"] or not index["videos"]: raise CompatibilityError("naninovel", [Issue("missing_media", "Не восстановлены все категории медиа Tomie")]) return files, index, counts