feat: add local image enhancement and lossless mod assets
This commit is contained in:
1 parent
cc71050362
commit
f7f7f5b1b3
29 files changed
+1495
-10
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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
@@ -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"]}
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user