feat: add native Tomie adapter and strict import compatibility checks

This commit is contained in:
mrleo1nid committed 2026-10-25 11:02:52 +03:00
1 parent c8d83b3fe7
commit 57a4edcbfd
45 files changed
+2985 -71

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
"""Recover the pinned Tomie build's media and ResourceManager actor bindings."""
from pathlib import Path
import hashlib
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