From f7f7f5b1b3941fcf8c0754d4933615c18cc714af Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:41:45 +0300 Subject: [PATCH] feat: add local image enhancement and lossless mod assets --- README.md | 4 + backend/app/converter/assets.py | 16 +++ backend/app/mods.py | 12 ++ backend/app/routers/mods.py | 103 +++++++++++++++- backend/tests/test_image_enhance.py | 145 ++++++++++++++++++++++ docs/IMAGE_ENHANCE.md | 159 +++++++++++++++++++++++++ frontend/src/App.tsx | 5 + frontend/src/imager/ImageEnhance.tsx | 122 +++++++++++++++++++ frontend/src/imager/ImagerSetup.tsx | 61 ++++++++++ frontend/src/imager/QueuePanel.tsx | 18 +++ frontend/src/imager/backends/a1111.ts | 33 +++++ frontend/src/imager/backends/comfy.ts | 57 +++++++++ frontend/src/imager/backends/index.ts | 11 ++ frontend/src/imager/backends/swarm.ts | 40 +++++++ frontend/src/imager/backends/types.ts | 25 ++++ frontend/src/imager/cache.ts | 53 +++++++++ frontend/src/imager/hash.ts | 46 +++++++ frontend/src/imager/imager.css | 15 +++ frontend/src/imager/images.ts | 70 +++++++++++ frontend/src/imager/live.ts | 62 ++++++++++ frontend/src/imager/presets.ts | 58 +++++++++ frontend/src/imager/queue.ts | 101 ++++++++++++++++ frontend/src/pages/GamePage.tsx | 1 + frontend/src/pages/ModEditor.tsx | 1 + frontend/src/player/engine.ts | 34 +++++- frontend/src/player/renderer.ts | 15 ++- frontend/src/player/types.ts | 2 + frontend/tests/imager-browser.test.mjs | 85 +++++++++++++ frontend/tests/imager.test.mjs | 151 +++++++++++++++++++++++ 29 files changed, 1495 insertions(+), 10 deletions(-) create mode 100644 backend/tests/test_image_enhance.py create mode 100644 docs/IMAGE_ENHANCE.md create mode 100644 frontend/src/imager/ImageEnhance.tsx create mode 100644 frontend/src/imager/ImagerSetup.tsx create mode 100644 frontend/src/imager/QueuePanel.tsx create mode 100644 frontend/src/imager/backends/a1111.ts create mode 100644 frontend/src/imager/backends/comfy.ts create mode 100644 frontend/src/imager/backends/index.ts create mode 100644 frontend/src/imager/backends/swarm.ts create mode 100644 frontend/src/imager/backends/types.ts create mode 100644 frontend/src/imager/cache.ts create mode 100644 frontend/src/imager/hash.ts create mode 100644 frontend/src/imager/imager.css create mode 100644 frontend/src/imager/images.ts create mode 100644 frontend/src/imager/live.ts create mode 100644 frontend/src/imager/presets.ts create mode 100644 frontend/src/imager/queue.ts create mode 100644 frontend/tests/imager-browser.test.mjs create mode 100644 frontend/tests/imager.test.mjs diff --git a/README.md b/README.md index e040a69..814dceb 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,10 @@ Naninovel описаны в [docs/IMPORT.md](docs/IMPORT.md). участия сервера), — страница `/translator` и раздел «Машинный перевод через локальный переводчик» в [docs/LOCALIZATION.md](docs/LOCALIZATION.md). +Локальное улучшение графики — страница `/imager`: SwarmUI, ComfyUI или A1111, +кэш в браузере для плеера и пакетная замена картинок в модах с предпросмотром и откатом. +Настройка и ограничения — в [docs/IMAGE_ENHANCE.md](docs/IMAGE_ENHANCE.md). + ## Установка на VM ```bash diff --git a/backend/app/converter/assets.py b/backend/app/converter/assets.py index eed2d48..1482342 100644 --- a/backend/app/converter/assets.py +++ b/backend/app/converter/assets.py @@ -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), diff --git a/backend/app/mods.py b/backend/app/mods.py index 2a31e48..2fec121 100644 --- a/backend/app/mods.py +++ b/backend/app/mods.py @@ -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", "")): diff --git a/backend/app/routers/mods.py b/backend/app/routers/mods.py index 6bf4aa2..6dfaf47 100644 --- a/backend/app/routers/mods.py +++ b/backend/app/routers/mods.py @@ -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"]} diff --git a/backend/tests/test_image_enhance.py b/backend/tests/test_image_enhance.py new file mode 100644 index 0000000..9bc1d0a --- /dev/null +++ b/backend/tests/test_image_enhance.py @@ -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 diff --git a/docs/IMAGE_ENHANCE.md b/docs/IMAGE_ENHANCE.md new file mode 100644 index 0000000..9cdaf7e --- /dev/null +++ b/docs/IMAGE_ENHANCE.md @@ -0,0 +1,159 @@ +# Улучшение графики локальной нейросетью (дизайн) + +Клиент у себя поднимает генератор изображений (SwarmUI; также ComfyUI и A1111), браузер ходит к нему +напрямую — как к `local-translator` ([LOCALIZATION.md](LOCALIZATION.md)). Сервер библиотеки о генераторе +не знает; он получает только готовые файлы через уже существующую замену ассетов в моде +(`PUT /api/versions/{id}/asset`, [MODDING.md](MODDING.md)). + +Два режима поверх одного движка задач: + +| Режим | Что делает | Где хранится результат | Видят другие | +|---|---|---|---| +| **На лету** | плеер подменяет картинку при показе на улучшенную | IndexedDB браузера, на сервер ничего не уходит | нет | +| **В исходниках** | редактор «Улучшение графики» массово обрабатывает файлы мод-форка | блобы мода (через замену ассета) | да, как обычный мод | + +## Схема + +``` +Браузер ──► SwarmUI / ComfyUI / A1111 (127.0.0.1, разрешён CORS для сайта) + │ + ├─ IndexedDB: кэш результатов key = версия|путь|хэш оригинала|хэш пресета + ├─ плеер: engine.fileUrl() → кэш, если есть улучшение + └─ редактор: результат → PUT /asset (мод-форк), по одному файлу +``` + +## Модули клиента (`frontend/src/imager/`) + +* `backends/` — интерфейс `ImageBackend { health(), models(), process(png, preset, signal) → Blob }` + и адаптеры `swarm.ts` (основной: `GetNewSession`, `GenerateText2Image` с `initimage`, + `refinerupscale`/`refinerupscalemethod`, `ListT2IParams`), `comfy.ts`, `a1111.ts`. +* `presets.ts` — пресет = бэкенд + режим (`upscale` | `img2img`) + параметры (модель, апскейлер, + множитель, creativity, prompt/negative, steps, cfg). Хранится в localStorage, экспорт в JSON. + `hash(preset)` входит в ключ кэша. +* `queue.ts` — очередь задач: параллелизм 1 (GPU), отмена, повтор, возобновление после + перезагрузки (журнал в IndexedDB), прогресс. +* `cache.ts` — IndexedDB: `{key, blob, scale}`; очистка по версии. +* `ImagerSetup.tsx` — страница настройки, как `TranslatorSetup` (проверка связи, CORS-подсказка). +* `ImageEnhance.tsx` — редактор массовой обработки (ниже). + +## Масштаб изображения (важно) + +Плеер рисует `` в натуральном размере (`renderer.ts`), поэтому ×2 апскейл без поправки покажется +вдвое больше. Решение: у каждой улучшенной картинки хранится `scale` (отношение новой ширины к исходной). + +* на лету — `scale` лежит в записи кэша; +* в исходниках — `game.json → assetScale: { "images/bg room.webp": 2 }` (проверяется `mods.validate`); +* рендерер: после загрузки `width = naturalWidth / scale` для `` без явного размера; + размеры из `def.size`, ATL и масок масштабом не затрагиваются. + +Это единственное изменение плеера и схемы PGVN; без него замена файла на файл другого размера ломает вёрстку. + +## Режим «на лету» + +Хук в `Engine.fileUrl()`: если для `(версия, логический путь)` в кэше есть результат — возвращается +`blob:` URL (создаётся один раз, освобождается в `destroy`). Предзагрузка ближайших картинок уже +использует `fileUrl`, поэтому работает без изменений. Фоновая обработка: при показе картинки без улучшения +в очередь ставится её задача (опция «улучшать по ходу игры»), результат подхватывается при следующем показе. +Переключатель «оригинал / улучшенное» в меню плеера для сравнения. + +## Режим «в исходниках» (редактор) + +Страница доступна для `kind=mod` с `versions.fork`: + +1. Список изображений версии (фильтр по папке/размеру/уже обработанные), миниатюры. +2. Выбор пресета, предпросмотр «до/после» на одном файле (слайдер). +3. Запуск пачки: для каждого файла — скачать оригинал → бэкенд → превью в списке → при «Применить» + загрузка в мод. Загрузка последовательная (каждая замена меняет `revision`; клиент берёт + актуальную из ответа). Для скорости позже — один пакетный эндпоинт `POST …/assets` с одной ревизией. +4. Откат — обычная история ревизий; «вернуть оригинал» = восстановление блоба из родителя. + +## Что нужно поменять на сервере + +* `converter/assets._image`: не пересжимать результат повторно в WebP q88 (потеря качества после + апскейла). Для замены из этого редактора принимать PNG/WebP без перекодирования лосслесс-веткой + (параметр `lossless=1` в `/asset`). +* лимит `WEBP_MAX = 16383`: при апскейле ×4 больших фонов результат уйдёт в PNG — предупреждать в UI. +* `assetScale` в `game.json` и его проверка в `mods.validate`. +* пакетный эндпоинт замены ассетов (вторая очередь работ). + +## Риски и открытые вопросы + +* **CORS**: проверить, какие заголовки отдают SwarmUI/ComfyUI/A1111 при запросах с чужого origin; + страница настройки должна объяснять флаг (`--enable-cors-header`, `--cors-allow-origins`, настройка Swarm). + Mixed content: сайт по https → локальный http; Chrome разрешает для `127.0.0.1`, Safari нет. +* **Качество**: img2img на персонажах с альфой — нейросеть не сохраняет прозрачность. Для спрайтов + режим «только апскейлер» (модель ESRGAN-типа) с отдельной обработкой альфа-канала (апскейл + маски обычным ресайзом, цвета — моделью). Это должно быть в первой версии, иначе спрайты получат ореол. +* **Анимации** (animated WebP) и видео — вне первой версии. +* **Права на результаты**: производные изображения остаются в моде пользователя, в публичные версии + не попадают без `versions.publish`. + +## Этапы + +1. Каркас: `imager/` (клиент SwarmUI, пресеты, очередь, кэш), `ImagerSetup`. +2. `assetScale` в схеме, рендерере и валидаторе; режим «на лету» в плеере. +3. Редактор массовой обработки + загрузка в мод + лосслесс-замена. +4. Адаптеры ComfyUI и A1111, обработка альфы, пакетный эндпоинт. + +## Реализация (25 октября 2026) + +Добавлены все четыре группы функций: + +* `/imager` — настройка локального генератора, проверка API из браузера, список моделей и + апскейлеров, пресеты с импортом/экспортом JSON. Последний сохранённый пресет используется плеером. +* `frontend/src/imager/` — адаптеры SwarmUI, ComfyUI и A1111, общая очередь, журнал и кэш + IndexedDB. Ключ включает ID версии, логический путь, хэш исходного блоба и SHA-256 параметров + пресета (включая адрес генератора и фиксированный seed). Переименование пресета кэш не меняет. +* Плеер PGVN: Настройки → Графика → «Показывать улучшенные изображения» и отдельно + «Улучшать по ходу игры». Переключатели сохраняются по версии. По умолчанию оба выключены. + Завершённые задания подхватываются при следующем показе изображения; URL освобождаются при закрытии. +* `/versions/:versionId/enhance` — выбор картинок, фильтры пути/размера файла/готовности, + выбор пресета, сравнение слайдером, обработка выбранных файлов и отдельное применение. + Открывается из редактора мода или его карточки. Применять можно только в доступный для + редактирования черновик. Ошибки ревизий не удаляют локальные результаты. +* `PUT /api/versions/{id}/asset?lossless=1&scale=2` сохраняет проверенные статичные PNG/WebP + без повторного сжатия. `scale` — полный масштаб относительно исходных игровых координат, + поэтому повторный апскейл умножает прежний `assetScale`. Файл и метаданные пишутся одной ревизией. + Обычная ручная замена без `scale` сбрасывает прежний масштаб этого файла. +* `POST /api/versions/{id}/assets`: JSON `{revision, assets: [{path, filename, data, scale}]}`, + где `data` — base64 без префикса data URL. Максимум 32 картинки / 128 МБ запроса; редактор + дополнительно ограничивает группу 80 МБ исходных блобов. Каждый пакет атомарен и создаёт одну + ревизию. При ошибке файлы версии и её ревизия не меняются. +* `POST /api/versions/{id}/asset/restore`: `{revision, path}` возвращает файл **из замороженной + базовой ревизии форка**, вместе с прежним масштабом. Для отката пачки также доступна история мода. +* Альфа-канал масштабируется отдельно в браузере и возвращается в итоговый PNG. Img2img + прозрачных изображений отклоняется с предложением выбрать апскейлер. Анимации отклоняются, + а не превращаются незаметно в первый кадр. Изменение пропорций генератором также отклоняется. + +Отмена активного задания отбрасывает его результат, но ждёт завершения запроса перед стартом +следующего: глобальный interrupt генератора мог бы остановить чужую работу. После перезагрузки +незавершённые задания видны в журнале и запускаются кнопкой «Повторить». При наличии Web Locks +очередь сериализует GPU-запросы также между вкладками этого сайта. При очистке кэша нужно закрыть +плеер, чтобы освободить уже загруженные в память blob-URL. + +Текущие адаптеры рассчитаны на локальные API без обязательной авторизации. Прямые обращения +генератора не проксируются через сервер библиотеки. Для SwarmUI настройка CORS называется +`Network.AccessControlAllowOrigin`; для ComfyUI — `--enable-cors-header ORIGIN`, +для A1111 — `--api --cors-allow-origins ORIGIN`. Страница настройки проверяет доступ к API, +а скачивание результата проверяется первым пробным заданием. Реальный SwarmUI на +`127.0.0.1:7801` при разработке не отвечал: GPU-генерация и CORS установленного сервиса пока +не подтверждены. Протоколы адаптеров покрыты тестовыми HTTP-ответами. + +Проверки: `frontend/tests/imager.test.mjs`, `frontend/tests/imager-browser.test.mjs` +(опциональный Playwright/Chromium, как существующие браузерные тесты), +`backend/tests/test_image_enhance.py`. Браузерный тест проверяет реальные пиксели альфы, +геометрию ``, сохранность IndexedDB после перезагрузки, освобождение URL и отсутствие +загрузок на сервер в локальном режиме. + +Результат проверки реализации: 9 клиентских тестов imager, 13 серверных тестов +imager/mods и отдельный тест Chromium проходят; production-сборка и Ruff проходят. +Интеграционный тест с PostgreSQL пропущен без `TEST_POSTGRES`. В полном клиентском +наборе отдельно воспроизводится сбой изменённого теста интерпретатора Ren'Py +`interpreted instances can own arguments without exposing host function properties` +(`frontend/tests/renpy-runtime.test.mjs`, неизвестное имя `list`); этот тест не импортирует imager. + +Первичная документация протоколов: +[SwarmUI API](https://github.com/mcmonkeyprojects/SwarmUI/blob/master/docs/API.md), +[параметры SwarmUI](https://github.com/mcmonkeyprojects/SwarmUI/blob/master/docs/APIRoutes/T2IAPI.md), +[ComfyUI API](https://github.com/comfyanonymous/ComfyUI/blob/master/script_examples/basic_api_example.py), +[A1111 API](https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/master/modules/api/models.py). diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 93d732b..f869606 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,8 @@ import { Roles } from "./pages/Roles"; import { Saves } from "./pages/Saves"; import { TranslationEditor } from "./pages/TranslationEditor"; import { TranslatorSetup } from "./translator/TranslatorSetup"; +import { ImagerSetup } from "./imager/ImagerSetup"; +import { ImageEnhance } from "./imager/ImageEnhance"; import { Users } from "./pages/Users"; import { useTheme } from "./theme"; import { Icon } from "./ui/Icon"; @@ -64,6 +66,7 @@ function UserMenu() { Аккаунт и пароль Сохранения + Улучшение графики {can("translations.edit") && ( Локальный переводчик )} @@ -122,6 +125,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/imager/ImageEnhance.tsx b/frontend/src/imager/ImageEnhance.tsx new file mode 100644 index 0000000..fcc49c7 --- /dev/null +++ b/frontend/src/imager/ImageEnhance.tsx @@ -0,0 +1,122 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; +import { Link, useParams } from "react-router-dom"; +import { api, uploadRaw, type Version } from "../api"; +import { ErrorNote, PageHeader, Spinner } from "../ui/Bits"; +import { imageStore, type CachedImage } from "./cache"; +import { dataUrl } from "./images"; +import { cacheKey, loadPresets, presetHash } from "./presets"; +import { imageQueue } from "./queue"; +import { QueuePanel } from "./QueuePanel"; +import "./imager.css"; + +interface Editor { + revision: string; editable: boolean; baseRevision?: string; + files: Record; + document: { game: { assetScale?: Record } }; +} +function useBlobUrl(blob?: Blob) { + const [url, setUrl] = useState(""); + useEffect(() => { if (!blob) { setUrl(""); return; } const next = URL.createObjectURL(blob); setUrl(next); return () => URL.revokeObjectURL(next); }, [blob]); + return url; +} +function Compare({ source, result }: { source: string; result: CachedImage }) { + const url = useBlobUrl(result.blob); + const [position, setPosition] = useState(50); + return

До / после

ОригиналУлучшенное изображение
+ +

{result.width} × {result.height}, масштаб ×{result.scale.toFixed(3)}.{Math.max(result.width, result.height) > 16383 && " Больше лимита WebP: сохранится как PNG."}

+
; +} +export function ImageEnhance() { + const { versionId = "" } = useParams(); const id = Number(versionId); + const [editor, setEditor] = useState(null); + const [version, setVersion] = useState(null); + const [error, setError] = useState(""); const [message, setMessage] = useState(""); + const [busy, setBusy] = useState(false); + const [presets] = useState(loadPresets); const [preset, setPreset] = useState(0); + const [hash, setHash] = useState(""); + const [selected, setSelected] = useState>(new Set()); + const [filter, setFilter] = useState(""); const [minSize, setMinSize] = useState(0); const [only, setOnly] = useState("all"); + const [preview, setPreview] = useState<{ path: string; result: CachedImage } | null>(null); + const [batch, setBatch] = useState(false); + const tasks = useSyncExternalStore(imageQueue.subscribe, imageQueue.snapshot); + const load = async () => { + const [e, v] = await Promise.all([api.get(`/api/versions/${id}/editor`), api.get(`/api/versions/${id}`)]); + setEditor(e); setVersion(v); + }; + useEffect(() => { setEditor(null); setSelected(new Set()); setPreview(null); void load().catch((e) => setError(e.message)); }, [id]); + useEffect(() => { + let cancelled = false; setHash(""); setPreview(null); + void presetHash(presets[preset]).then((h) => { if (!cancelled) setHash(h); }).catch((e) => setError(e.message)); + return () => { cancelled = true; }; + }, [preset, presets]); + const run = async (fn: () => Promise) => { setBusy(true); setError(""); setMessage(""); try { await fn(); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } }; + if (!editor || !version) return
{error ? {error} : }
; + const source = (path: string) => `/api/versions/${id}/files/${editor.files[path].hash}`; + const results = new Map(tasks.filter((t) => t.version === id && t.status === "done" && t.key === cacheKey(id, t.path, editor.files[t.path]?.hash ?? "", hash)).map((t) => [t.path, t])); + const resultTask = (path: string) => results.get(path); + const paths = Object.keys(editor.files).filter((path) => /\.(png|webp|jpe?g|avif|bmp)$/i.test(path)); + const visible = paths.filter((path) => path.toLowerCase().includes(filter.toLowerCase()) && editor.files[path].size >= minSize * 1024 && (only === "all" || (only === "done" ? !!resultTask(path) : !resultTask(path)))); + const ready = [...selected].filter((path) => resultTask(path)); + const apply = async () => { + let current = editor; + const applyGroup = async (group: string[]) => { + const items = []; + for (const path of group) { + const t = resultTask(path); if (!t) continue; + const image = await imageStore.get(t.key); if (!image) throw new Error(`Результат ${path} удалён из кэша. Повторите обработку`); + items.push({ path, image, scale: (current.document.game.assetScale?.[path] ?? 1) * image.scale }); + } + let reply: Pick; + if (batch) { + const assets = []; + for (const item of items) assets.push({ path: item.path, filename: "enhanced.png", scale: item.scale, data: (await dataUrl(item.image.blob)).split(",")[1] }); + reply = await api.post(`/api/versions/${id}/assets`, { revision: current.revision, assets }); + } else { + const item = items[0]; if (!item) return; + reply = await uploadRaw("PUT", `/api/versions/${id}/asset?` + new URLSearchParams({ path: item.path, revision: current.revision, filename: "enhanced.png", lossless: "1", scale: String(item.scale) }), item.image.blob, () => {}); + } + current = { ...current, ...reply }; setEditor(current); + }; + // Bound the JSON batch in both count and encoded bytes. Keep results in IndexedDB + // if a later group conflicts, so the user can reload and inspect before retrying. + let group: string[] = [], bytes = 0; + for (const path of ready) { + const t = resultTask(path)!; const image = await imageStore.get(t.key); + if (!image) throw new Error(`Нет результата ${path}`); + if (batch && image.blob.size > 80 * 1024 * 1024) throw new Error("Для файлов больше 80 МБ выключите пакетную загрузку"); + if (group.length && (!batch || group.length >= 32 || bytes + image.blob.size > 80 * 1024 * 1024)) { await applyGroup(group); group = []; bytes = 0; } + group.push(path); bytes += image.blob.size; + } + if (group.length) await applyGroup(group); + await load(); setPreview(null); setMessage(`Применено изображений: ${ready.length}. Откат доступен в истории мода.`); + }; + return
Настройка генератораРедактор и история} /> + {error && {error}}{message &&

{message}

} + {!editor.editable || version.kind !== "mod" ?

Для применения создайте свой черновик мода кнопкой «Форк» в карточке игры.

: null} +

Изображения версии

Сначала обработайте один файл и проверьте результат. «Обработать» сохраняет превью локально; «Применить» заменяет выбранные файлы в моде. Анимации пропускаются с пояснением.

+
+ + +
+
+ +
+ + + +
+ {preview && editor.files[preview.path] && } +
{visible.map((path) =>
{path} + +

{Math.round(editor.files[path].size / 1024)} КБ {resultTask(path) ? "· результат готов" : ""}

+ + {resultTask(path) && } +
)}
+ +
; +} diff --git a/frontend/src/imager/ImagerSetup.tsx b/frontend/src/imager/ImagerSetup.tsx new file mode 100644 index 0000000..dac6a27 --- /dev/null +++ b/frontend/src/imager/ImagerSetup.tsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { PageHeader, ErrorNote } from "../ui/Bits"; +import { backend } from "./backends"; +import { DEFAULT_PRESET, loadPresets, savePresets, validatePreset, type Preset } from "./presets"; +import { QueuePanel } from "./QueuePanel"; +import { imageQueue } from "./queue"; +import "./imager.css"; + +export function ImagerSetup() { + const [presets, setPresets] = useState(loadPresets); + const [p, setP] = useState(presets[0]); + const [error, setError] = useState(""); + const [message, setMessage] = useState(""); + const [busy, setBusy] = useState(false); + const [models, setModels] = useState([]); + const [upscalers, setUpscalers] = useState([]); + const [version, setVersion] = useState(""); + const change = (key: K, value: Preset[K]) => setP({ ...p, [key]: value }); + const run = async (fn: () => Promise) => { setBusy(true); setError(""); setMessage(""); try { await fn(); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } }; + const save = () => { const next = [validatePreset(p), ...presets.filter((x) => x.name !== p.name)]; savePresets(next); setPresets(next); setMessage("Пресет сохранён и выбран для плеера"); }; + return
+ {error && {error}}{message &&

{message}

} +

Подключение и пресеты

+
{ e.preventDefault(); void run(async () => save()); }}>
+ +
+ +
+ +
+ + + {([['scale', 'Множитель', 1, 8, .25], ['creativity', 'Creativity', 0, 1, .05], ['steps', 'Шаги', 1, 150, 1], ['cfg', 'CFG', 0, 30, .5], ['seed', 'Seed', 0, 2147483647, 1]] as const).map(([key, label, min, max, step]) => )} +