Initial MVP: local visual novel library with Ren'Py → PGVN converter
- FastAPI backend: admin bootstrap with forced credential change, admin-created users with one-time generated passwords, roles as permission sets - Games and versions with content-addressed blob storage; forks copy only the manifest, so mods share assets with the original - Worker: archive/RPA extraction, unrpyc for .rpyc, own .rpy parser, Python subset translated via ast, media converted to WebP/Opus/WebM - React panel (library search, game page, upload, users, roles) and a framework-free web player with saves, history, skip/auto, transitions - docker compose: postgres, backend, worker, nginx on a single HTTP port Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
364b312064
72 files changed
+9707
No files matched your search
@@ -0,0 +1,237 @@
|
||||
"""Full conversion: uploaded archive → blobs + version manifest."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from app.blobs import put_bytes, put_file
|
||||
from app.config import get_settings
|
||||
from app.converter import assets
|
||||
from app.converter.archive import extract, find_game_dir
|
||||
from app.converter.rpa import RpaError, extract_rpa
|
||||
from app.converter.rpy_lexer import ParseError
|
||||
from app.converter.rpy_parser import RpyParser, ScriptData
|
||||
from app.converter.rpyc import decompile_missing
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
FORMAT = "pgvn/1"
|
||||
|
||||
Progress = Callable[[str], None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
manifest: dict[str, Any]
|
||||
report: dict[str, Any]
|
||||
title: str | None
|
||||
cover_source: str | None # blob id of an image suitable as a cover
|
||||
size: int
|
||||
|
||||
|
||||
def convert_archive(archive: Path, filename: str, work: Path, progress: Progress) -> Result:
|
||||
started = time.monotonic()
|
||||
if work.exists():
|
||||
shutil.rmtree(work)
|
||||
src_root = work / "src"
|
||||
out_dir = work / "out"
|
||||
out_dir.mkdir(parents=True)
|
||||
data = ScriptData()
|
||||
|
||||
progress("Распаковка архива")
|
||||
extract(archive, filename, src_root)
|
||||
game_dir = find_game_dir(src_root)
|
||||
|
||||
rpas = sorted(game_dir.rglob("*.rpa"))
|
||||
for i, rpa in enumerate(rpas, 1):
|
||||
progress(f"Распаковка {rpa.name} ({i}/{len(rpas)})")
|
||||
try:
|
||||
extract_rpa(rpa, game_dir)
|
||||
except RpaError as e:
|
||||
data.warn(str(e))
|
||||
rpa.unlink()
|
||||
|
||||
progress("Декомпиляция .rpyc")
|
||||
decompiled = decompile_missing(game_dir, data.warn)
|
||||
|
||||
progress("Разбор скриптов")
|
||||
rpy_files = sorted((p for p in game_dir.rglob("*.rpy") if not _is_engine_file(game_dir, p)),
|
||||
key=lambda p: p.relative_to(game_dir).as_posix().lower())
|
||||
for p in rpy_files:
|
||||
rel = p.relative_to(game_dir).as_posix()
|
||||
try:
|
||||
RpyParser(data, rel).parse(p.read_text(encoding="utf-8", errors="replace"))
|
||||
except ParseError as e:
|
||||
data.warn(f"Ошибка разбора файла: {e.msg}", f"{e.filename}:{e.lineno}")
|
||||
except RecursionError:
|
||||
data.warn("Слишком глубокая вложенность", rel)
|
||||
if not any(lbl["name"] == "start" for labels in data.modules.values() for lbl in labels):
|
||||
raise RuntimeError("В скриптах не найдена метка start — нечего запускать")
|
||||
|
||||
files: dict[str, dict[str, Any]] = {}
|
||||
aliases: dict[str, str] = {}
|
||||
media = [p for p in game_dir.rglob("*")
|
||||
if p.is_file() and assets.kind_of(p) and not _is_engine_file(game_dir, p)]
|
||||
counts = {"image": 0, "audio": 0, "video": 0, "font": 0}
|
||||
failed: list[str] = []
|
||||
done = 0
|
||||
|
||||
def work_one(p: Path) -> tuple[str, str, str, int]:
|
||||
out, ext = assets.convert(p, out_dir)
|
||||
blob_id, size = put_file(out, ext, move=True)
|
||||
rel = p.relative_to(game_dir).as_posix()
|
||||
logical = str(PurePosixPath(rel).with_suffix(ext))
|
||||
return rel, logical, blob_id, size
|
||||
|
||||
with ThreadPoolExecutor(max_workers=get_settings().worker_threads) as pool:
|
||||
futures = {pool.submit(work_one, p): p for p in media}
|
||||
for fut in as_completed(futures):
|
||||
p = futures[fut]
|
||||
done += 1
|
||||
if done % 20 == 0 or done == len(media):
|
||||
progress(f"Конвертация медиа {done}/{len(media)}")
|
||||
try:
|
||||
rel, logical, blob_id, size = fut.result()
|
||||
except Exception as e: # noqa: BLE001 — one broken file must not fail the game
|
||||
failed.append(p.name)
|
||||
data.warn("Не удалось сконвертировать файл", f"{p.name}: {e}")
|
||||
continue
|
||||
files[logical] = {"hash": blob_id, "size": size}
|
||||
aliases[rel.lower()] = logical
|
||||
counts[assets.kind_of(p)] += 1
|
||||
|
||||
progress("Сборка формата PGVN")
|
||||
_auto_images(data, aliases)
|
||||
missing: set[str] = set()
|
||||
images = {name: _resolve_image(img, aliases, missing) for name, img in data.images.items()}
|
||||
audio = {name: _resolve(path, aliases) or path for name, path in data.audio.items()}
|
||||
|
||||
scripts: list[str] = []
|
||||
for rel, labels in data.modules.items():
|
||||
logical = "script/" + str(PurePosixPath(rel).with_suffix(".json"))
|
||||
payload = json.dumps({"file": rel, "labels": labels}, ensure_ascii=False,
|
||||
separators=(",", ":")).encode()
|
||||
blob_id, size = put_bytes(payload, ".json")
|
||||
files[logical] = {"hash": blob_id, "size": size}
|
||||
scripts.append(logical)
|
||||
|
||||
gui = data.gui
|
||||
resolution = gui.get("resolution") or [
|
||||
data.config.get("screen_width") or 1280, data.config.get("screen_height") or 720]
|
||||
title = data.config.get("name") if isinstance(data.config.get("name"), str) else None
|
||||
game = {
|
||||
"format": FORMAT,
|
||||
"engine": "renpy",
|
||||
"title": title,
|
||||
"version": data.config.get("version"),
|
||||
"resolution": resolution,
|
||||
"entry": "start",
|
||||
"scripts": scripts,
|
||||
"characters": data.characters,
|
||||
"images": images,
|
||||
"transforms": data.transforms,
|
||||
"transitions": data.transitions,
|
||||
"audio": audio,
|
||||
"files": aliases,
|
||||
"defaults": data.defaults,
|
||||
"init": data.init_code,
|
||||
"ui": _ui(gui, aliases),
|
||||
}
|
||||
payload = json.dumps(game, ensure_ascii=False, separators=(",", ":")).encode()
|
||||
blob_id, size = put_bytes(payload, ".json")
|
||||
files["game.json"] = {"hash": blob_id, "size": size}
|
||||
|
||||
cover = None
|
||||
for candidate in ("gui/main_menu.png", "gui/main_menu.jpg", "gui/main_menu.webp",
|
||||
"gui/window_icon.png"):
|
||||
if (logical := aliases.get(candidate)) and logical in files:
|
||||
cover = files[logical]["hash"]
|
||||
break
|
||||
|
||||
total = sum(f["size"] for f in files.values())
|
||||
report = {
|
||||
"format": FORMAT,
|
||||
"durationSec": round(time.monotonic() - started, 1),
|
||||
"scripts": len(rpy_files),
|
||||
"decompiled": decompiled,
|
||||
"labels": sum(len(v) for v in data.modules.values()),
|
||||
"stats": dict(data.stats),
|
||||
"media": counts,
|
||||
"failedMedia": failed[:50],
|
||||
"missingFiles": sorted(missing)[:100],
|
||||
"warnings": [
|
||||
{"message": msg, "count": n, "example": data.warning_examples.get(msg)}
|
||||
for msg, n in data.warnings.most_common()
|
||||
],
|
||||
"sourceSize": archive.stat().st_size,
|
||||
"outputSize": total,
|
||||
}
|
||||
return Result({"format": FORMAT, "files": files}, report, title, cover, total)
|
||||
|
||||
|
||||
def _is_engine_file(game_dir: Path, p: Path) -> bool:
|
||||
parts = [x.lower() for x in p.relative_to(game_dir).parts]
|
||||
return len(parts) > 1 and parts[0] in ("cache", "saves", "tl", "renpy")
|
||||
|
||||
|
||||
def _resolve(path: str, aliases: dict[str, str]) -> str | None:
|
||||
key = path.replace("\\", "/").lower().lstrip("/")
|
||||
for prefix in ("", "images/", "audio/", "music/", "sound/", "sounds/"):
|
||||
if (hit := aliases.get(prefix + key)) is not None:
|
||||
return hit
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_image(img: dict[str, Any], aliases: dict[str, str], missing: set[str]
|
||||
) -> dict[str, Any]:
|
||||
img = dict(img)
|
||||
for key in ("file", "movie"):
|
||||
if isinstance(img.get(key), str):
|
||||
hit = _resolve(img[key], aliases)
|
||||
if hit is None:
|
||||
missing.add(img[key])
|
||||
else:
|
||||
img[key] = hit
|
||||
if "switch" in img:
|
||||
img["switch"] = [[c, _resolve_image(sub, aliases, missing)] for c, sub in img["switch"]]
|
||||
if "composite" in img:
|
||||
img["composite"] = [{**layer, "img": _resolve_image(layer["img"], aliases, missing)}
|
||||
for layer in img["composite"]]
|
||||
return img
|
||||
|
||||
|
||||
def _auto_images(data: ScriptData, aliases: dict[str, str]) -> None:
|
||||
"""Ren'Py defines an image for every file under images/ (name = lowercase basename)."""
|
||||
for rel, logical in aliases.items():
|
||||
if not rel.startswith("images/") or assets.kind_of(Path(rel)) != "image":
|
||||
continue
|
||||
name = os.path.splitext(os.path.basename(rel))[0].lower().strip()
|
||||
name = " ".join(name.split())
|
||||
if name and name not in data.images:
|
||||
data.images[name] = {"file": rel}
|
||||
|
||||
|
||||
def _ui(gui: dict[str, Any], aliases: dict[str, str]) -> dict[str, Any]:
|
||||
ui: dict[str, Any] = {}
|
||||
for key, rel in (("textbox", "gui/textbox.png"), ("namebox", "gui/namebox.png"),
|
||||
("mainMenu", "gui/main_menu.png"), ("nvl", "gui/nvl.png"),
|
||||
("choice", "gui/button/choice_idle_background.png"),
|
||||
("choiceHover", "gui/button/choice_hover_background.png")):
|
||||
if hit := aliases.get(rel):
|
||||
ui[key] = hit
|
||||
for key in ("text_font", "name_text_font"):
|
||||
if isinstance(gui.get(key), str) and (hit := _resolve(gui[key], aliases)):
|
||||
ui[key] = hit
|
||||
for key in ("text_size", "name_text_size", "text_color", "accent_color", "textbox_height",
|
||||
"textbox_yalign", "dialogue_xpos", "dialogue_ypos", "dialogue_width",
|
||||
"name_xpos", "name_ypos", "interface_text_color", "idle_color", "hover_color",
|
||||
"choice_button_text_idle_color", "choice_button_text_hover_color"):
|
||||
if isinstance(gui.get(key), (int, float, str)):
|
||||
ui[key] = gui[key]
|
||||
return ui
|
||||
Reference in new issue
Block a user