Files
pgames-manager/backend/app/routers/mods.py
T
mrleo1nid f7f7f5b1b3
CI / frontend (push) Failing after 26s
CI / backend (push) Failing after 1m37s
feat: add local image enhancement and lossless mod assets
2026-10-25 21:41:45 +03:00

407 lines
19 KiB
Python

"""Mod editor, revision history, three-way merge and rebase."""
import base64
import binascii
import copy
import math
import tempfile
from pathlib import Path
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy import select
from starlette.concurrency import run_in_threadpool
from app import mods
from app.blobs import put_file
from app.config import get_settings
from app.converter import assets
from app.deps import DB, has_perm, require
from app.models import GameVersion, Revision, User
from app.routers.versions import can_manage, get_version, lock_version
from app.translation import file_json, put_json
router = APIRouter(prefix="/api/versions", tags=["mods"])
Player = Annotated[User, Depends(require("games.play"))]
Editor = Annotated[User, Depends(require("games.play", "versions.fork"))]
def editable(v, user):
if v.status != "ready" or not v.manifest:
raise HTTPException(409, "Версия ещё не готова")
if v.kind != "mod" or v.visibility != "draft" or not can_manage(user, v):
raise HTTPException(403, "Редактировать можно свой черновик мода")
def expected(v, revision):
if v.revision_id != revision:
raise HTTPException(409, "Версия изменилась. Загрузите её заново; локальные правки сохранены в редакторе")
def update_manifest(v, manifest):
v.manifest = manifest
v.size_bytes = sum(f["size"] for f in manifest["files"].values())
@router.get("/{version_id}/editor")
async def read_editor(version_id: int, user: Player, db: DB):
v = await get_version(db, user, version_id)
if not v.manifest:
raise HTTPException(409, "Версия ещё не готова")
base = await db.get(Revision, v.base_revision_id) if v.base_revision_id else None
old = base.manifest["files"] if base else {}
changes = [p for p in sorted(set(old) | set(v.manifest["files"]))
if old.get(p) != v.manifest["files"].get(p)] if base else []
diff = await run_in_threadpool(lambda: mods.differences(
mods.comparable(mods.prepare(mods.document(base.manifest))),
mods.comparable(mods.prepare(mods.document(v.manifest))))) if base else []
return {"revision": v.revision_id, "baseRevision": v.base_revision_id, "changes": changes,
"diff": diff,
"document": await run_in_threadpool(lambda: mods.prepare(mods.document(v.manifest))),
"files": v.manifest["files"], "editable": has_perm(user, "versions.fork") and
v.kind == "mod" and v.visibility == "draft" and can_manage(user, v)}
class EditIn(BaseModel):
revision: str
document: dict[str, Any]
@router.put("/{version_id}/editor")
async def save_editor(version_id: int, body: EditIn, user: Editor, db: DB):
await get_version(db, user, version_id)
v = await lock_version(db, version_id)
editable(v, user)
expected(v, body.revision)
try:
manifest = await run_in_threadpool(mods.store, body.document, v.manifest)
except (ValueError, KeyError, TypeError, RecursionError) as e:
raise HTTPException(422, f"Некорректный PGVN: {e}") from None
update_manifest(v, manifest)
await db.commit()
return {"revision": v.revision_id, "files": v.manifest["files"]}
@router.put("/{version_id}/asset")
async def replace_asset(version_id: int, path: str, revision: str, filename: str,
request: Request, user: Editor, db: DB, lossless: bool = False,
scale: float | None = None):
await get_version(db, user, version_id)
v = await lock_version(db, version_id)
editable(v, user)
expected(v, revision)
if scale is not None and (not math.isfinite(scale) or not 0 < scale <= 1024 or assets.kind_of(Path(path)) != "image"):
raise HTTPException(422, "Некорректный масштаб изображения")
if lossless and (assets.kind_of(Path(path)) != "image" or Path(filename).suffix.lower() not in (".png", ".webp")):
raise HTTPException(422, "Замена без потерь принимает только PNG/WebP")
if path not in v.manifest["files"] or path == "game.json" or path.startswith(("script/", "locales/")):
raise HTTPException(422, "Выберите существующий медиафайл")
suffix = Path(filename).suffix.lower()
if assets.kind_of(Path(path)) not in ("image", "audio", "video", "font") or assets.kind_of(Path(filename)) != assets.kind_of(Path(path)):
raise HTTPException(422, "Тип загруженного файла не совпадает с заменяемым")
get_settings().work_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(dir=get_settings().work_dir) as folder:
root = Path(folder)
source = root / ("upload" + suffix)
count = 0
with source.open("wb") as out:
async for chunk in request.stream():
count += len(chunk)
if count > 512 * 1024 * 1024:
raise HTTPException(413, "Максимум 512 МБ")
out.write(chunk)
if not count:
raise HTTPException(422, "Пустой файл")
def convert():
if lossless:
output, ext = assets.lossless_image(source, root / "lossless")
return put_file(output, ext, move=True)
# Do not accept unparsed webm passthrough for editor uploads.
if suffix == ".webm":
output = assets._ffmpeg(source, root / "verified.webm",
["-c:v", "libvpx-vp9", "-crf", "34", "-b:v", "0", "-deadline", "realtime", "-cpu-used", "8", "-c:a", "libopus"])
return put_file(output, ".webm", move=True)
if assets.kind_of(source) == "font":
signatures = {".ttf": b"\x00\x01\x00\x00", ".otf": b"OTTO", ".woff": b"wOFF", ".woff2": b"wOF2", ".ttc": b"ttcf"}
with source.open("rb") as stream:
if stream.read(4) != signatures.get(suffix):
raise ValueError("Некорректный шрифт")
output, ext = assets.convert(source, root)
return put_file(output, ext, move=True)
try:
blob_id, size = await run_in_threadpool(convert)
except Exception as e:
raise HTTPException(422, f"Не удалось обработать файл: {e}") from None
manifest = copy.deepcopy(v.manifest)
manifest["files"][path] = {"hash": blob_id, "size": size}
await run_in_threadpool(set_asset_scale, manifest, {path: scale})
update_manifest(v, manifest)
await db.commit()
return {"revision": v.revision_id, "files": v.manifest["files"]}
def set_asset_scale(manifest, scales):
game = copy.deepcopy(file_json(manifest, "game.json"))
values = game.setdefault("assetScale", {})
for path, scale in scales.items():
if scale is None or scale == 1:
values.pop(path, None)
else:
values[path] = scale
put_json(manifest["files"], "game.json", game)
class RestoreAssetIn(BaseModel):
revision: str
path: str
@router.post("/{version_id}/asset/restore")
async def restore_asset(version_id: int, body: RestoreAssetIn, user: Editor, db: DB):
await get_version(db, user, version_id)
v = await lock_version(db, version_id)
editable(v, user)
expected(v, body.revision)
base = await db.get(Revision, v.base_revision_id) if v.base_revision_id else None
if not base or base.game_id != v.game_id or body.path not in base.manifest["files"] or assets.kind_of(Path(body.path)) != "image":
raise HTTPException(422, "В базовой ревизии нет этого изображения")
manifest = copy.deepcopy(v.manifest)
manifest["files"][body.path] = copy.deepcopy(base.manifest["files"][body.path])
scale = file_json(base.manifest, "game.json").get("assetScale", {}).get(body.path)
await run_in_threadpool(set_asset_scale, manifest, {body.path: scale})
update_manifest(v, manifest)
await db.commit()
return {"revision": v.revision_id, "files": v.manifest["files"]}
class AssetIn(BaseModel):
path: str
filename: str
data: str # base64 PNG/WebP, without the data URL prefix
scale: float = Field(gt=0, le=1024, allow_inf_nan=False)
class AssetsIn(BaseModel):
revision: str
assets: list[AssetIn] = Field(min_length=1, max_length=32)
@router.post("/{version_id}/assets")
async def replace_assets(version_id: int, request: Request, user: Editor, db: DB):
"""One atomic image-only replacement and revision for a bounded batch."""
await get_version(db, user, version_id)
v = await lock_version(db, version_id)
editable(v, user)
raw = bytearray()
async for chunk in request.stream():
raw.extend(chunk)
if len(raw) > 128 * 1024 * 1024:
raise HTTPException(413, "Максимум 128 МБ на пакет")
try:
body = AssetsIn.model_validate_json(raw)
except ValueError:
raise HTTPException(422, "Некорректный пакет изображений") from None
expected(v, body.revision)
if len({a.path for a in body.assets}) != len(body.assets):
raise HTTPException(422, "Повторяющиеся пути в пакете")
for a in body.assets:
if a.path not in v.manifest["files"] or assets.kind_of(Path(a.path)) != "image" or Path(a.filename).suffix.lower() not in (".png", ".webp"):
raise HTTPException(422, "Выберите существующее изображение и PNG/WebP")
def build():
manifest = copy.deepcopy(v.manifest)
get_settings().work_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(dir=get_settings().work_dir) as folder:
root = Path(folder)
for i, item in enumerate(body.assets):
source = root / f"source{i}{Path(item.filename).suffix.lower()}"
source.write_bytes(base64.b64decode(item.data, validate=True))
output, ext = assets.lossless_image(source, root / f"out{i}")
blob_id, size = put_file(output, ext, move=True)
manifest["files"][item.path] = {"hash": blob_id, "size": size}
set_asset_scale(manifest, {a.path: a.scale for a in body.assets})
return manifest
try:
manifest = await run_in_threadpool(build)
except (ValueError, OSError, binascii.Error) as e:
raise HTTPException(422, f"Не удалось обработать пакет: {e}") from None
update_manifest(v, manifest)
await db.commit()
return {"revision": v.revision_id, "files": v.manifest["files"]}
async def revision_graph(db, game_id):
return {r.id: r for r in (await db.scalars(select(Revision).where(Revision.game_id == game_id))).all()}
def ancestors(graph, rid):
found, queue = {}, [(rid, 0)]
while queue:
key, depth = queue.pop(0)
if key in found or key not in graph:
continue
found[key] = depth
queue.extend((p, depth + 1) for p in graph[key].parents)
return found
@router.get("/{version_id}/revisions")
async def history(version_id: int, user: Player, db: DB):
v = await get_version(db, user, version_id)
graph = await revision_graph(db, v.game_id)
reachable = ancestors(graph, v.revision_id)
return [{"id": key, "parents": graph[key].parents, "created_at": graph[key].created_at,
"user_id": graph[key].user_id} for key in sorted(reachable, key=lambda k: graph[k].created_at, reverse=True)]
class RestoreIn(BaseModel):
revision: str
restore: str
@router.post("/{version_id}/revisions/restore")
async def restore(version_id: int, body: RestoreIn, user: Editor, db: DB):
await get_version(db, user, version_id)
v = await lock_version(db, version_id)
editable(v, user)
expected(v, body.revision)
graph = await revision_graph(db, v.game_id)
if body.restore not in ancestors(graph, v.revision_id):
raise HTTPException(404, "Ревизия не найдена")
update_manifest(v, copy.deepcopy(graph[body.restore].manifest))
await db.commit()
return {"revision": v.revision_id}
class MergeIn(BaseModel):
target_id: int
source_revision: str
target_revision: str
mode: Literal["merge", "rebase"] = "merge"
choices: dict[str, Literal["left", "right"]] = Field(default_factory=dict)
mapping: dict[str, str] = Field(default_factory=dict)
name: str = Field(default="Объединённый мод", min_length=1, max_length=255)
apply: bool = False
def merge_tree(manifest):
doc = mods.comparable(mods.prepare(mods.document(manifest)))
for info in doc["game"].get("locales", {}).values():
for key in ("progress", "lines", "strings_count"):
info.pop(key, None)
extras = {}
for p, entry in manifest["files"].items():
if p == "game.json" or p in doc["scripts"]:
continue
extras[p] = {"json": copy.deepcopy(file_json(manifest, p))} if p.startswith("locales/") and p.endswith(".json") else entry
return {"document": doc, "files": extras}
@router.post("/{version_id}/merge")
async def merge(version_id: int, body: MergeIn, user: Editor, db: DB):
source = await get_version(db, user, version_id)
target = await get_version(db, user, body.target_id)
if source.game_id != target.game_id or source.id == target.id or source.kind != "mod":
raise HTTPException(422, "Нужен мод и другая версия той же игры")
if source.status != "ready" or target.status != "ready":
raise HTTPException(409, "Версии ещё не готовы")
if body.apply:
# Stable lock ordering avoids deadlocks in simultaneous opposite merges.
locked = {key: await lock_version(db, key) for key in sorted((source.id, target.id))}
source, target = locked[source.id], locked[target.id]
expected(source, body.source_revision)
expected(target, body.target_revision)
graph = await revision_graph(db, source.game_id)
if body.mode == "rebase":
base_id = source.base_revision_id
else:
a, b = ancestors(graph, source.revision_id), ancestors(graph, target.revision_id)
common = set(a) & set(b)
base_id = min(common, key=lambda k: (max(a[k], b[k]), a[k] + b[k], k)) if common else None
if not base_id or base_id not in graph:
raise HTTPException(409, "Нет достоверной базовой ревизии. Для старого мода создайте новый форк и перенесите правки вручную")
def build():
base = merge_tree(graph[base_id].manifest)
left, right = merge_tree(source.manifest), merge_tree(target.manifest)
unmatched = []
aliases, target_tids = {}, {}
if body.mode == "rebase":
before = right["document"]
right["document"], unmatched = mods.align(base["document"], before, body.mapping)
def identities(raw, aligned):
if isinstance(raw, dict) and isinstance(aligned, dict):
if raw.get("id") and aligned.get("id"):
aliases[aligned["id"]] = raw["id"]
if raw.get("tid") and aligned.get("tid"):
target_tids[aligned["tid"]] = raw["tid"]
for key in raw.keys() & aligned.keys():
identities(raw[key], aligned[key])
elif isinstance(raw, list) and isinstance(aligned, list):
for a, b in zip(raw, aligned):
identities(a, b)
identities(before, right["document"])
from app.converter.locales import walk
tids = {}
for path, module in before["scripts"].items():
for old_label, new_label in zip(module["labels"], right["document"]["scripts"][path]["labels"]):
for old_node, new_node in zip(walk(old_label["body"]), walk(new_label["body"])):
if old_node.get("tid") and new_node.get("tid"):
tids[old_node["tid"]] = new_node["tid"]
for entry in right["files"].values():
if set(entry) == {"json"} and isinstance(entry["json"], dict):
entry["json"] = {tids.get(k, k): v for k, v in entry["json"].items()}
conflicts = []
result = mods.merge_value(base, left, right, "", conflicts, body.choices)
if body.mode == "rebase" and body.apply and not conflicts:
# Compare in old-base identity, then adopt the new original's canonical
# IDs. This makes the frozen target a valid base for the NEXT rebase.
def canonical(value):
if isinstance(value, dict):
for key, v in value.items():
if key == "id" and isinstance(v, str):
value[key] = aliases.get(v, v)
elif key == "tid" and isinstance(v, str):
value[key] = target_tids.get(v, v)
else:
canonical(v)
elif isinstance(value, list):
for v in value:
canonical(v)
canonical(result["document"])
for entry in result["files"].values():
if set(entry) == {"json"} and isinstance(entry["json"], dict):
entry["json"] = {target_tids.get(k, k): v for k, v in entry["json"].items()}
return result, conflicts, unmatched
try:
result, conflicts, unmatched = await run_in_threadpool(build)
except (ValueError, TypeError, KeyError) as e:
raise HTTPException(422, str(e)) from None
if not body.apply:
return {"baseRevision": base_id, "conflicts": conflicts, "unmatched": unmatched,
"document": result["document"]}
if conflicts:
raise HTTPException(409, "Остались нерешённые конфликты")
def write():
manifest = {"format": "pgvn/1", "files": {}}
# Keep script entries until store() replaces them and validates references.
for p, f in target.manifest["files"].items():
if p == "game.json" or p in result["document"]["scripts"]:
manifest["files"][p] = f
for p, entry in result["files"].items():
if set(entry) == {"json"}:
put_json(manifest["files"], p, entry["json"])
else:
manifest["files"][p] = entry
return mods.store(result["document"], manifest)
try:
manifest = await run_in_threadpool(write)
except (ValueError, KeyError, TypeError) as e:
raise HTTPException(422, f"Результат не прошёл проверку: {e}") from None
fork = GameVersion(game_id=source.game_id, parent_id=target.id, owner_id=user.id,
name=body.name, kind="mod", visibility="draft", status="ready", manifest=manifest,
base_revision_id=target.revision_id if body.mode == "rebase" else base_id,
size_bytes=sum(f["size"] for f in manifest["files"].values()))
fork._revision_parents = [source.revision_id, target.revision_id]
db.add(fork)
await db.commit()
return {"id": fork.id, "revision": fork.revision_id}