feat: add local image enhancement and lossless mod assets
CI / frontend (push) Failing after 26s
CI / backend (push) Failing after 1m37s

This commit is contained in:
mrleo1nid committed 2026-10-25 21:41:45 +03:00
1 parent cc71050362
commit f7f7f5b1b3
29 files changed
+1495 -10

No files matched your search

+102 -1
View File
@@ -1,5 +1,8 @@
"""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
@@ -81,11 +84,16 @@ async def save_editor(version_id: int, body: EditIn, user: Editor, db: DB):
@router.put("/{version_id}/asset")
async def replace_asset(version_id: int, path: str, revision: str, filename: str,
request: Request, user: Editor, db: DB):
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()
@@ -105,6 +113,9 @@ async def replace_asset(version_id: int, path: str, revision: str, filename: str
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",
@@ -123,6 +134,96 @@ async def replace_asset(version_id: int, path: str, revision: str, filename: str
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"]}