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

+16
View File
@@ -75,6 +75,22 @@ def _image(src: Path, tmp: Path) -> tuple[Path, str]:
return out, ".webp"
def lossless_image(src: Path, tmp: Path) -> tuple[Path, str]:
"""Validate a static PNG/WebP and preserve its pixels (and original bytes when possible)."""
with Image.open(src) as img:
if img.format not in ("PNG", "WEBP") or getattr(img, "is_animated", False):
raise ValueError("Нужен статичный PNG или WebP")
img.load() # Decode fully: a file header alone is not validation.
if max(img.size) > WEBP_MAX:
out = tmp.with_suffix(".png")
img.save(out, "PNG")
return out, ".png"
ext = ".png" if img.format == "PNG" else ".webp"
out = tmp.with_suffix(ext)
shutil.copyfile(src, out)
return out, ext
def _ffmpeg(src: Path, out: Path, args: list[str]) -> Path:
proc = subprocess.run(
["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", str(src),
+12
View File
@@ -2,6 +2,8 @@
import copy
import difflib
import json
import math
from pathlib import Path
from uuid import NAMESPACE_URL, uuid4, uuid5
from app.blobs import BLOB_ID_RE
@@ -146,6 +148,16 @@ def validate(doc, manifest):
if profile := for_runtime(game.get("runtime")):
profile.validate_mod(game, scripts, manifest)
files = manifest["files"]
scales = game.get("assetScale", {})
if not isinstance(scales, dict):
raise ValueError("assetScale должен быть объектом")
from app.converter.assets import kind_of
for path, scale in scales.items():
check_path(path)
if path not in files or kind_of(Path(path)) != "image":
raise ValueError("assetScale ссылается на отсутствующее изображение")
if type(scale) not in (int, float) or not math.isfinite(scale) or not 0 < scale <= 1024:
raise ValueError("Некорректный assetScale")
for path, entry in files.items():
check_path(path)
if not BLOB_ID_RE.fullmatch(entry.get("hash", "")):
+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"]}
+145
View File
@@ -0,0 +1,145 @@
import asyncio
import base64
import copy
import io
import json
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from PIL import Image
from starlette.requests import Request
from app import mods
from app.blobs import blob_path
from app.converter.assets import WEBP_MAX, lossless_image
from app.routers import mods as routes
from app.translation import file_json
from tests.test_mods import pg_manifest # noqa: F401
def image_bytes(size=(40, 20), format="PNG"):
out = io.BytesIO()
Image.new("RGBA", size, (40, 80, 160, 90)).save(out, format)
return out.getvalue()
def test_lossless_preserves_bytes_and_alpha(tmp_path):
for format, suffix in [("PNG", ".png"), ("WEBP", ".webp")]:
data = image_bytes(format=format)
source = tmp_path / ("source" + suffix)
source.write_bytes(data)
result, ext = lossless_image(source, tmp_path / "output")
assert ext == suffix and result.read_bytes() == data
with Image.open(result) as image:
assert image.convert("RGBA").getpixel((0, 0))[3] == 90
def test_large_images_stay_png_and_animation_is_rejected(tmp_path):
source = tmp_path / "large.png"
source.write_bytes(image_bytes((WEBP_MAX + 1, 1)))
out, ext = lossless_image(source, tmp_path / "output")
assert ext == ".png"
with Image.open(out) as image:
assert image.size == (WEBP_MAX + 1, 1)
source = tmp_path / "animated.webp"
Image.new("RGBA", (4, 4), "red").save(source, "WEBP", save_all=True,
append_images=[Image.new("RGBA", (4, 4), "blue")], duration=100)
with pytest.raises(ValueError, match="PNG"):
lossless_image(source, tmp_path / "animation")
source.write_bytes(b"not an image")
with pytest.raises(OSError):
lossless_image(source, tmp_path / "broken")
def test_asset_scale_validation(pg_manifest):
doc = mods.prepare(mods.document(pg_manifest))
path = next(p for p in pg_manifest["files"] if p.endswith(".webp"))
doc["game"]["assetScale"] = {path: 2}
mods.validate(doc, pg_manifest)
for bad in [0, -1, True, "2", float("nan"), float("inf"), 1025]:
doc["game"]["assetScale"] = {path: bad}
with pytest.raises(ValueError, match="assetScale"):
mods.validate(doc, pg_manifest)
for bad in [{"missing.png": 2}, {"game.json": 2}, [2]]:
doc["game"]["assetScale"] = bad
with pytest.raises(ValueError, match="assetScale"):
mods.validate(doc, pg_manifest)
def request(body):
async def receive():
return {"type": "http.request", "body": body, "more_body": False}
return Request({"type": "http", "method": "POST", "headers": []}, receive)
@pytest.fixture
def editor(pg_manifest, monkeypatch):
v = SimpleNamespace(status="ready", manifest=copy.deepcopy(pg_manifest), kind="mod", visibility="draft", owner_id=1, revision_id="r1")
user = SimpleNamespace(id=1, permissions=["games.play", "versions.fork"])
async def version(*_):
return v
monkeypatch.setattr(routes, "get_version", version)
monkeypatch.setattr(routes, "lock_version", version)
class DB:
commits = 0
async def commit(self):
self.commits += 1
v.revision_id = f"r{self.commits + 1}"
return v, user, DB()
def test_single_lossless_upload_stores_scale_in_same_commit(editor):
v, user, db = editor
path = next(p for p in v.manifest["files"] if p.endswith(".webp"))
data = image_bytes()
reply = asyncio.run(routes.replace_asset(1, path, "r1", "result.png", request(data), user, db, True, 2))
assert reply["revision"] == "r2" and db.commits == 1
assert blob_path(reply["files"][path]["hash"]).read_bytes() == data
assert file_json(v.manifest, "game.json")["assetScale"][path] == 2
# Ordinary manual replacement resets the previous scale.
asyncio.run(routes.replace_asset(1, path, "r2", "manual.png", request(data), user, db))
assert path not in file_json(v.manifest, "game.json")["assetScale"]
def test_batch_is_atomic_and_rejects_stale_duplicate_and_non_owner(editor):
v, user, db = editor
paths = [p for p in v.manifest["files"] if p.endswith(".webp")][:2]
assert len(paths) == 2
def asset(p):
return {"path": p, "filename": "result.png", "scale": 2, "data": base64.b64encode(image_bytes()).decode()}
before = copy.deepcopy(v.manifest)
bad = [asset(paths[0]), {**asset(paths[1]), "data": "bad base64!"}]
with pytest.raises(HTTPException) as error:
asyncio.run(routes.replace_assets(1, request(json.dumps({"revision": "r1", "assets": bad}).encode()), user, db))
assert error.value.status_code == 422 and v.manifest == before and db.commits == 0
assets = [asset(p) for p in paths]
reply = asyncio.run(routes.replace_assets(1, request(json.dumps({"revision": "r1", "assets": assets}).encode()), user, db))
assert db.commits == 1 and reply["revision"] == "r2"
assert file_json(v.manifest, "game.json")["assetScale"] == dict.fromkeys(paths, 2)
for revision, items, actor, code in [("r1", assets, user, 409), ("r2", [assets[0], assets[0]], user, 422),
("r2", assets, SimpleNamespace(id=2, permissions=[]), 403)]:
with pytest.raises(HTTPException) as error:
asyncio.run(routes.replace_assets(1, request(json.dumps({"revision": revision, "assets": items}).encode()), actor, db))
assert error.value.status_code == code
assert db.commits == 1
def test_restore_asset_uses_frozen_base_with_its_scale(editor):
v, user, db = editor
v.base_revision_id = "base"
v.game_id = 17
base_manifest = copy.deepcopy(v.manifest)
path = next(p for p in v.manifest["files"] if p.endswith(".webp"))
routes.set_asset_scale(base_manifest, {path: 2})
base = SimpleNamespace(game_id=17, manifest=base_manifest)
async def get(_type, _id):
assert _id == "base"
return base
db.get = get
asyncio.run(routes.replace_asset(1, path, "r1", "result.png", request(image_bytes()), user, db, True, 4))
assert file_json(v.manifest, "game.json")["assetScale"][path] == 4
reply = asyncio.run(routes.restore_asset(1, routes.RestoreAssetIn(revision="r2", path=path), user, db))
assert reply["files"][path] == base_manifest["files"][path]
assert file_json(v.manifest, "game.json")["assetScale"][path] == 2
assert db.commits == 2