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

+4
View File
@@ -34,6 +34,10 @@ Naninovel описаны в [docs/IMPORT.md](docs/IMPORT.md).
участия сервера), — страница `/translator` и раздел «Машинный перевод через участия сервера), — страница `/translator` и раздел «Машинный перевод через
локальный переводчик» в [docs/LOCALIZATION.md](docs/LOCALIZATION.md). локальный переводчик» в [docs/LOCALIZATION.md](docs/LOCALIZATION.md).
Локальное улучшение графики — страница `/imager`: SwarmUI, ComfyUI или A1111,
кэш в браузере для плеера и пакетная замена картинок в модах с предпросмотром и откатом.
Настройка и ограничения — в [docs/IMAGE_ENHANCE.md](docs/IMAGE_ENHANCE.md).
## Установка на VM ## Установка на VM
```bash ```bash
+16
View File
@@ -75,6 +75,22 @@ def _image(src: Path, tmp: Path) -> tuple[Path, str]:
return out, ".webp" 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: def _ffmpeg(src: Path, out: Path, args: list[str]) -> Path:
proc = subprocess.run( proc = subprocess.run(
["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", str(src), ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", str(src),
+12
View File
@@ -2,6 +2,8 @@
import copy import copy
import difflib import difflib
import json import json
import math
from pathlib import Path
from uuid import NAMESPACE_URL, uuid4, uuid5 from uuid import NAMESPACE_URL, uuid4, uuid5
from app.blobs import BLOB_ID_RE from app.blobs import BLOB_ID_RE
@@ -146,6 +148,16 @@ def validate(doc, manifest):
if profile := for_runtime(game.get("runtime")): if profile := for_runtime(game.get("runtime")):
profile.validate_mod(game, scripts, manifest) profile.validate_mod(game, scripts, manifest)
files = manifest["files"] 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(): for path, entry in files.items():
check_path(path) check_path(path)
if not BLOB_ID_RE.fullmatch(entry.get("hash", "")): 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.""" """Mod editor, revision history, three-way merge and rebase."""
import base64
import binascii
import copy import copy
import math
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Annotated, Any, Literal 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") @router.put("/{version_id}/asset")
async def replace_asset(version_id: int, path: str, revision: str, filename: str, 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) await get_version(db, user, version_id)
v = await lock_version(db, version_id) v = await lock_version(db, version_id)
editable(v, user) editable(v, user)
expected(v, revision) 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/")): if path not in v.manifest["files"] or path == "game.json" or path.startswith(("script/", "locales/")):
raise HTTPException(422, "Выберите существующий медиафайл") raise HTTPException(422, "Выберите существующий медиафайл")
suffix = Path(filename).suffix.lower() 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: if not count:
raise HTTPException(422, "Пустой файл") raise HTTPException(422, "Пустой файл")
def convert(): 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. # Do not accept unparsed webm passthrough for editor uploads.
if suffix == ".webm": if suffix == ".webm":
output = assets._ffmpeg(source, root / "verified.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 raise HTTPException(422, f"Не удалось обработать файл: {e}") from None
manifest = copy.deepcopy(v.manifest) manifest = copy.deepcopy(v.manifest)
manifest["files"][path] = {"hash": blob_id, "size": size} 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) update_manifest(v, manifest)
await db.commit() await db.commit()
return {"revision": v.revision_id, "files": v.manifest["files"]} 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
+159
View File
@@ -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` — редактор массовой обработки (ниже).
## Масштаб изображения (важно)
Плеер рисует `<img>` в натуральном размере (`renderer.ts`), поэтому ×2 апскейл без поправки покажется
вдвое больше. Решение: у каждой улучшенной картинки хранится `scale` (отношение новой ширины к исходной).
* на лету — `scale` лежит в записи кэша;
* в исходниках — `game.json → assetScale: { "images/bg room.webp": 2 }` (проверяется `mods.validate`);
* рендерер: после загрузки `width = naturalWidth / scale` для `<img>` без явного размера;
размеры из `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`. Браузерный тест проверяет реальные пиксели альфы,
геометрию `<img>`, сохранность 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).
+5
View File
@@ -12,6 +12,8 @@ import { Roles } from "./pages/Roles";
import { Saves } from "./pages/Saves"; import { Saves } from "./pages/Saves";
import { TranslationEditor } from "./pages/TranslationEditor"; import { TranslationEditor } from "./pages/TranslationEditor";
import { TranslatorSetup } from "./translator/TranslatorSetup"; import { TranslatorSetup } from "./translator/TranslatorSetup";
import { ImagerSetup } from "./imager/ImagerSetup";
import { ImageEnhance } from "./imager/ImageEnhance";
import { Users } from "./pages/Users"; import { Users } from "./pages/Users";
import { useTheme } from "./theme"; import { useTheme } from "./theme";
import { Icon } from "./ui/Icon"; import { Icon } from "./ui/Icon";
@@ -64,6 +66,7 @@ function UserMenu() {
</div> </div>
<NavLink to="/account" className="menu-item" role="menuitem"><Icon name="key" size={18} />Аккаунт и пароль</NavLink> <NavLink to="/account" className="menu-item" role="menuitem"><Icon name="key" size={18} />Аккаунт и пароль</NavLink>
<NavLink to="/saves" className="menu-item" role="menuitem"><Icon name="download" size={18} />Сохранения</NavLink> <NavLink to="/saves" className="menu-item" role="menuitem"><Icon name="download" size={18} />Сохранения</NavLink>
<NavLink to="/imager" className="menu-item" role="menuitem"><Icon name="edit" size={18} />Улучшение графики</NavLink>
{can("translations.edit") && ( {can("translations.edit") && (
<NavLink to="/translator" className="menu-item" role="menuitem"><Icon name="globe" size={18} />Локальный переводчик</NavLink> <NavLink to="/translator" className="menu-item" role="menuitem"><Icon name="globe" size={18} />Локальный переводчик</NavLink>
)} )}
@@ -122,6 +125,8 @@ export function App() {
<Route path="/games/:gameId" element={<RequirePerm perm="games.play"><GamePage /></RequirePerm>} /> <Route path="/games/:gameId" element={<RequirePerm perm="games.play"><GamePage /></RequirePerm>} />
<Route path="/versions/:versionId/translate" element={<RequirePerm perm="games.play"><TranslationEditor /></RequirePerm>} /> <Route path="/versions/:versionId/translate" element={<RequirePerm perm="games.play"><TranslationEditor /></RequirePerm>} />
<Route path="/translator" element={<RequirePerm perm="translations.edit"><TranslatorSetup /></RequirePerm>} /> <Route path="/translator" element={<RequirePerm perm="translations.edit"><TranslatorSetup /></RequirePerm>} />
<Route path="/imager" element={<RequirePerm perm="games.play"><ImagerSetup /></RequirePerm>} />
<Route path="/versions/:versionId/enhance" element={<RequirePerm perm="games.play"><ImageEnhance /></RequirePerm>} />
<Route path="/versions/:versionId/edit" element={<RequirePerm perm="games.play"><ModEditor /></RequirePerm>} /> <Route path="/versions/:versionId/edit" element={<RequirePerm perm="games.play"><ModEditor /></RequirePerm>} />
<Route path="/admin/users" element={<RequirePerm perm="users.manage"><Users /></RequirePerm>} /> <Route path="/admin/users" element={<RequirePerm perm="users.manage"><Users /></RequirePerm>} />
<Route path="/admin/roles" element={<RequirePerm perm="roles.manage"><Roles /></RequirePerm>} /> <Route path="/admin/roles" element={<RequirePerm perm="roles.manage"><Roles /></RequirePerm>} />
+122
View File
@@ -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<string, { hash: string; size: number }>;
document: { game: { assetScale?: Record<string, number> } };
}
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 <section className="card"><h2>До / после</h2><div className="imager-compare"><img src={source} alt="Оригинал" /><img src={url} alt="Улучшенное изображение" style={{ clipPath: `inset(0 0 0 ${position}%)` }} /></div>
<label>Сравнить оригинал слева и результат справа<input type="range" min="0" max="100" value={position} onChange={(e) => setPosition(Number(e.target.value))} /></label>
<p>{result.width} × {result.height}, масштаб ×{result.scale.toFixed(3)}.{Math.max(result.width, result.height) > 16383 && " Больше лимита WebP: сохранится как PNG."}</p>
</section>;
}
export function ImageEnhance() {
const { versionId = "" } = useParams(); const id = Number(versionId);
const [editor, setEditor] = useState<Editor | null>(null);
const [version, setVersion] = useState<Version | null>(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<Set<string>>(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<Editor>(`/api/versions/${id}/editor`), api.get<Version>(`/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<void>) => { setBusy(true); setError(""); setMessage(""); try { await fn(); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } };
if (!editor || !version) return <div className="page">{error ? <ErrorNote>{error}</ErrorNote> : <Spinner />}</div>;
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<Editor, "revision" | "files">;
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 <div className="page imager"><PageHeader title="Улучшение графики" subtitle={`${version.name} · ревизия ${editor.revision.slice(0, 8)}`} actions={<><Link className="btn" to="/imager">Настройка генератора</Link><Link className="btn" to={`/versions/${id}/edit`}>Редактор и история</Link></>} />
{error && <ErrorNote>{error}</ErrorNote>}{message && <p role="status">{message}</p>}
{!editor.editable || version.kind !== "mod" ? <p>Для применения создайте свой черновик мода кнопкой «Форк» в карточке игры.</p> : null}
<section className="card"><h2>Изображения версии</h2><p>Сначала обработайте один файл и проверьте результат. «Обработать» сохраняет превью локально; «Применить» заменяет выбранные файлы в моде. Анимации пропускаются с пояснением.</p>
<div className="imager-fields"><label>Путь / папка<input value={filter} onChange={(e) => setFilter(e.target.value)} /></label>
<label>Размер от (КБ)<input type="number" min="0" value={minSize} onChange={(e) => setMinSize(Number(e.target.value))} /></label>
<label>Результат<select value={only} onChange={(e) => setOnly(e.target.value)}><option value="all">Все</option><option value="done">Обработанные</option><option value="pending">Без результата</option></select></label>
<label>Пресет<select value={preset} onChange={(e) => setPreset(Number(e.target.value))}>{presets.map((p, i) => <option key={i} value={i}>{p.name}</option>)}</select></label></div>
<div className="actions"><button className="btn" disabled={busy} onClick={() => setSelected(new Set(visible))}>Выбрать видимые ({visible.length})</button><button className="btn" onClick={() => setSelected(new Set())}>Снять выбор</button>
<button className="btn" disabled={busy || !selected.size} onClick={() => void run(async () => { for (const path of selected) await imageQueue.enqueue(id, path, editor.files[path].hash, source(path), presets[preset]); setMessage("Задания добавлены в общую очередь"); })}>Обработать ({selected.size})</button>
<button className="btn btn-primary" disabled={busy || !editor.editable || !ready.length} onClick={() => void run(apply)}>Применить ({ready.length})</button></div>
<button className="btn" disabled={busy || !editor.editable || !editor.baseRevision || !selected.size} onClick={() => void run(async () => {
let current = editor;
for (const path of selected) { const reply = await api.post<Pick<Editor, "revision" | "files">>(`/api/versions/${id}/asset/restore`, { revision: current.revision, path }); current = { ...current, ...reply }; setEditor(current); }
await load(); setPreview(null); setMessage("Выбранные изображения возвращены из базовой ревизии форка. Предыдущее состояние сохранено в истории.");
})}>Вернуть выбранные из базы форка</button>
<label className="imager-check"><input type="checkbox" checked={batch} onChange={(e) => setBatch(e.target.checked)} />Пакетная загрузка (до 32 картинок и 80 МБ на одну ревизию)</label>
<button className="btn btn-sm" disabled={busy} onClick={() => void run(load)}>Обновить ревизию после внешних изменений</button>
</section>
{preview && editor.files[preview.path] && <Compare source={source(preview.path)} result={preview.result} />}
<div className="imager-grid">{visible.map((path) => <article className="imager-tile" key={path}><img src={source(path)} loading="lazy" alt={path} />
<label className="imager-check"><input type="checkbox" checked={selected.has(path)} onChange={(e) => setSelected((old) => { const next = new Set(old); if (e.target.checked) next.add(path); else next.delete(path); return next; })} />{path}</label>
<p className="muted">{Math.round(editor.files[path].size / 1024)} КБ {resultTask(path) ? "· результат готов" : ""}</p>
<button className="btn btn-sm" disabled={busy} onClick={() => void run(async () => { await imageQueue.enqueue(id, path, editor.files[path].hash, source(path), presets[preset]); })}>Обработать</button>
{resultTask(path) && <button className="btn btn-sm" onClick={() => void run(async () => { const t = resultTask(path)!; const image = await imageStore.get(t.key); if (!image) throw new Error("Результат удалён из кэша"); setPreview({ path, result: image }); })}>До / после</button>}
</article>)}</div>
<QueuePanel version={id} />
</div>;
}
+61
View File
@@ -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<string[]>([]);
const [upscalers, setUpscalers] = useState<string[]>([]);
const [version, setVersion] = useState("");
const change = <K extends keyof Preset>(key: K, value: Preset[K]) => setP({ ...p, [key]: value });
const run = async (fn: () => Promise<void>) => { 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 <div className="page imager"><PageHeader title="Локальное улучшение графики" subtitle="Браузер обращается к генератору на вашем компьютере. По ходу игры результаты хранятся только в этом браузере." />
{error && <ErrorNote>{error}</ErrorNote>}{message && <p role="status">{message}</p>}
<section className="card"><h2>Подключение и пресеты</h2>
<form onSubmit={(e) => { e.preventDefault(); void run(async () => save()); }}><fieldset disabled={busy}>
<label>Сохранённые пресеты<select value="" onChange={(e) => { const chosen = presets[Number(e.target.value)]; if (chosen) { setP(chosen); setModels([]); setUpscalers([]); } }}><option value="" disabled>Выбрать…</option>{presets.map((x, i) => <option value={i} key={i}>{x.name}</option>)}</select></label>
<div className="imager-fields"><label>Название<input required value={p.name} onChange={(e) => change("name", e.target.value)} /></label>
<label>Генератор<select value={p.backend} onChange={(e) => { const kind = e.target.value as Preset["backend"]; setP({ ...p, backend: kind, url: `http://127.0.0.1:${kind === "swarm" ? 7801 : kind === "comfy" ? 8188 : 7860}`, model: "", upscaler: kind === "a1111" ? "Lanczos" : "pixel-lanczos" }); setModels([]); setUpscalers([]); }}><option value="swarm">SwarmUI</option><option value="comfy">ComfyUI</option><option value="a1111">AUTOMATIC1111</option></select></label>
<label>Адрес<input required value={p.url} onChange={(e) => change("url", e.target.value)} /></label></div>
<button className="btn" type="button" onClick={() => void run(async () => {
const client = backend(validatePreset(p)); const signal = AbortSignal.timeout(10_000);
try { await client.health(signal); const list = await client.models(signal); setModels(list.models); setUpscalers(list.upscalers); setMessage(`Связь установлена. Моделей: ${list.models.length}, апскейлеров: ${list.upscalers.length}`); }
catch (e) { throw new Error(`Не удалось прочитать API. Проверьте запуск, адрес и CORS для ${location.origin}. ${(e as Error).message}`); }
})}>{busy ? "Проверка…" : "Проверить связь и модели"}</button>
<div className="imager-fields"><label>Режим<select value={p.mode} onChange={(e) => change("mode", e.target.value as Preset["mode"])}><option value="upscale">Только апскейлер (сохраняет альфу)</option><option value="img2img">Img2img (непрозрачные изображения)</option></select></label>
<label>Модель<input list="imager-models" value={p.model} onChange={(e) => change("model", e.target.value)} /><datalist id="imager-models">{models.map((m) => <option key={m} value={m} />)}</datalist></label>
<label>Апскейлер<input list="imager-upscalers" value={p.upscaler} onChange={(e) => change("upscaler", e.target.value)} /><datalist id="imager-upscalers">{upscalers.map((m) => <option key={m} value={m} />)}</datalist></label>
{([['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]) => <label key={key}>{label}<input type="number" min={min} max={max} step={step} value={p[key]} onChange={(e) => change(key, Number(e.target.value))} /></label>)}
</div><label>Prompt<textarea value={p.prompt} onChange={(e) => change("prompt", e.target.value)} /></label><label>Negative prompt<textarea value={p.negative} onChange={(e) => change("negative", e.target.value)} /></label>
<div className="actions"><button className="btn btn-primary" type="submit">Сохранить и использовать</button>
<button className="btn" type="button" onClick={() => { setP({ ...DEFAULT_PRESET, name: "Новый пресет" }); }}>Новый пресет</button>
<button className="btn" type="button" onClick={() => void run(async () => {
const url = URL.createObjectURL(new Blob([JSON.stringify(validatePreset(p), null, 2)], { type: "application/json" }));
const a = document.createElement("a"); a.href = url; a.download = "image-preset.json"; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
})}>Экспорт JSON</button>
<label>Импорт JSON<input type="file" accept=".json,application/json" onChange={(e) => { const file = e.target.files?.[0]; e.target.value = ""; if (file) void run(async () => setP(validatePreset(JSON.parse(await file.text())))); }} /></label></div>
</fieldset></form>
</section>
<section className="card"><h2>Установка и доступ из браузера</h2>
<p>Установите <a href="https://github.com/mcmonkeyprojects/SwarmUI" target="_blank" rel="noreferrer">SwarmUI</a>, загрузите модель и апскейлер. В Server → Server Configuration → Network задайте <code>AccessControlAllowOrigin</code>: <code>{location.origin}</code>, сохраните и перезапустите. Проверка выше делает настоящий запрос из этого браузера.</p>
<p>ComfyUI: запустите с <code>--enable-cors-header {location.origin}</code>. A1111: <code>--api --cors-allow-origins {location.origin}</code>. Оставьте сервис доступным только локально.</p>
<p>Если браузер запрашивает доступ к локальной сети, разрешите его для сайта. При блокировке HTTP из HTTPS используйте совместимую настройку браузера или HTTPS для локального генератора. Успешная проверка API ещё не проверяет скачивание результата: сначала обработайте одну картинку в редакторе.</p>
<p>В плеере: Настройки → Графика. Обработка по ходу игры включается отдельно; по умолчанию выключена. Для замены исходников откройте свой черновик мода → <Link to="/">Библиотека</Link>.</p>
<p>SwarmUI использует выбранную модель даже в режиме апскейлера; для ESRGAN выберите модельный апскейлер из списка. Animated WebP, GIF и видео исключены.</p>
</section>
<QueuePanel />
<section className="card"><h2>Очистка локального кэша</h2><label>ID версии<input type="number" min="1" value={version} onChange={(e) => setVersion(e.target.value)} /></label><button className="btn" disabled={busy || !version} onClick={() => void run(async () => {
await imageQueue.clear(Number(version)); setMessage("Кэш версии удалён. Перезапустите открытый плеер, чтобы освободить изображения из памяти.");
})}>Удалить кэш версии</button></section>
</div>;
}
+18
View File
@@ -0,0 +1,18 @@
import { useEffect, useSyncExternalStore, useState } from "react";
import { imageQueue } from "./queue";
const labels = { queued: "В очереди", running: "Обработка", done: "Готово", failed: "Ошибка", cancelled: "Отменено" };
export function QueuePanel({ version }: { version?: number }) {
const tasks = useSyncExternalStore(imageQueue.subscribe, imageQueue.snapshot);
const [error, setError] = useState("");
useEffect(() => { void imageQueue.restore().catch((e) => setError(e.message)); }, []);
const visible = tasks.filter((t) => version === undefined || t.version === version);
return <section className="card"><h2>Очередь обработки</h2>
<p>{visible.filter((t) => t.status === "done").length} / {visible.length} готово. Одновременно обрабатывается одно изображение.</p>
<p className="muted">При отмене результат активного задания отбрасывается; следующее начнётся, когда генератор освободится. После перезагрузки нажмите «Повторить».</p>
{(error || imageQueue.storageError) && <p role="alert">{error || imageQueue.storageError}</p>}
<div className="imager-jobs">{visible.map((t) => <div key={t.key} className="imager-job"><span>{t.path} · {labels[t.status]} {t.error}</span>
{["queued", "running"].includes(t.status) && <button className="btn btn-sm" onClick={() => imageQueue.cancel(t.key)}>Отменить</button>}
{["failed", "cancelled"].includes(t.status) && <button className="btn btn-sm" onClick={() => void imageQueue.retry(t.key).catch((e) => setError(e.message))}>Повторить</button>}
</div>)}</div>
</section>;
}
+33
View File
@@ -0,0 +1,33 @@
import { dataUrl } from "../images";
import type { Preset } from "../presets";
import { json, output, type ImageBackend } from "./types";
export class A1111Backend implements ImageBackend {
constructor(private base: string) {}
async health(signal?: AbortSignal) { await json(this.base + "/sdapi/v1/options", undefined, signal); }
async models(signal?: AbortSignal) {
const [models, upscalers] = await Promise.all([
json(this.base + "/sdapi/v1/sd-models", undefined, signal), json(this.base + "/sdapi/v1/upscalers", undefined, signal),
]);
return { models: models.map((m: any) => m.title), upscalers: upscalers.map((m: any) => m.name) };
}
async process(png: Blob, p: Preset, signal?: AbortSignal) {
const image = await dataUrl(png);
let result: string;
if (p.mode === "upscale") {
result = (await json(this.base + "/sdapi/v1/extra-single-image", { image, upscaling_resize: p.scale,
upscaler_1: p.upscaler, resize_mode: 0, gfpgan_visibility: 0, codeformer_visibility: 0 }, signal)).image;
} else {
const bitmap = await createImageBitmap(png);
const width = Math.round(bitmap.width * p.scale), height = Math.round(bitmap.height * p.scale); bitmap.close();
if (!p.model) throw new Error("Выберите модель A1111");
result = (await json(this.base + "/sdapi/v1/img2img", { init_images: [image], width, height,
prompt: p.prompt, negative_prompt: p.negative, steps: p.steps, cfg_scale: p.cfg,
denoising_strength: p.creativity, seed: p.seed, batch_size: 1, n_iter: 1,
override_settings: { sd_model_checkpoint: p.model }, override_settings_restore_afterwards: true,
save_images: false, send_images: true,
}, signal)).images?.[0];
}
if (!result) throw new Error("A1111 не вернул изображение");
return output(result.startsWith("data:") ? result : "data:image/png;base64," + result, this.base, signal);
}
}
+57
View File
@@ -0,0 +1,57 @@
import type { Preset } from "../presets";
import { randomId } from "../hash";
import { json, output, type ImageBackend } from "./types";
export function workflow(image: string, p: Preset, width: number, height: number) {
const graph: Record<string, { class_type: string; inputs: Record<string, any> }> = {};
const node = (id: string, type: string, inputs: Record<string, any>) => { graph[id] = { class_type: type, inputs }; return [id, 0]; };
let pixels = node("1", "LoadImage", { image });
if (p.mode === "upscale" && !p.upscaler.startsWith("pixel-")) {
const model = node("2", "UpscaleModelLoader", { model_name: p.upscaler });
pixels = node("3", "ImageUpscaleWithModel", { upscale_model: model, image: pixels });
}
pixels = node("4", "ImageScale", { image: pixels, upscale_method: "lanczos", width: Math.round(width * p.scale), height: Math.round(height * p.scale), crop: "disabled" });
if (p.mode === "img2img") {
if (!p.model) throw new Error("Выберите checkpoint ComfyUI");
node("5", "CheckpointLoaderSimple", { ckpt_name: p.model });
const positive = node("6", "CLIPTextEncode", { clip: ["5", 1], text: p.prompt });
const negative = node("7", "CLIPTextEncode", { clip: ["5", 1], text: p.negative });
const latent = node("8", "VAEEncode", { pixels, vae: ["5", 2] });
const samples = node("9", "KSampler", { model: ["5", 0], positive, negative, latent_image: latent,
seed: p.seed, steps: p.steps, cfg: p.cfg, sampler_name: "euler", scheduler: "normal", denoise: p.creativity });
pixels = node("10", "VAEDecode", { samples, vae: ["5", 2] });
}
node("11", "PreviewImage", { images: pixels });
return graph;
}
export class ComfyBackend implements ImageBackend {
constructor(private base: string) {}
async health(signal?: AbortSignal) { await json(this.base + "/system_stats", undefined, signal); }
async models(signal?: AbortSignal) {
const info = await json(this.base + "/object_info", undefined, signal);
return { models: info.CheckpointLoaderSimple?.input?.required?.ckpt_name?.[0] ?? [],
upscalers: ["pixel-lanczos", ...(info.UpscaleModelLoader?.input?.required?.model_name?.[0] ?? [])] };
}
async process(png: Blob, p: Preset, signal?: AbortSignal) {
const form = new FormData(); form.append("image", png, randomId() + ".png"); form.append("type", "temp");
const upload = await fetch(this.base + "/upload/image", { method: "POST", body: form, signal });
if (!upload.ok) throw new Error(`ComfyUI upload: HTTP ${upload.status}`);
const file = await upload.json();
const bitmap = await createImageBitmap(png);
const width = bitmap.width, height = bitmap.height; bitmap.close();
const name = (file.subfolder ? file.subfolder + "/" : "") + file.name + (file.type === "temp" ? " [temp]" : "");
const queued = await json(this.base + "/prompt", { prompt: workflow(name, p, width, height), client_id: randomId() }, signal);
if (!queued.prompt_id || Object.keys(queued.node_errors ?? {}).length) throw new Error("ComfyUI отклонил workflow: " + JSON.stringify(queued.node_errors));
const deadline = Date.now() + 30 * 60_000;
while (Date.now() < deadline) {
signal?.throwIfAborted();
const history = await json(this.base + "/history/" + encodeURIComponent(queued.prompt_id), undefined, signal);
const job = history[queued.prompt_id];
if (job?.status?.status_str === "error") throw new Error("Ошибка ComfyUI: " + JSON.stringify(job.status.messages));
const image = job?.outputs?.["11"]?.images?.[0];
if (image) return output("view?" + new URLSearchParams({ filename: image.filename, subfolder: image.subfolder, type: image.type }), this.base, signal);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error("ComfyUI не завершил задание за 30 минут");
}
}
+11
View File
@@ -0,0 +1,11 @@
import type { Preset } from "../presets";
import { SwarmBackend } from "./swarm";
import { A1111Backend } from "./a1111";
import { ComfyBackend } from "./comfy";
export function backend(p: Preset) {
switch (p.backend) {
case "swarm": return new SwarmBackend(p.url);
case "a1111": return new A1111Backend(p.url);
case "comfy": return new ComfyBackend(p.url);
}
}
+40
View File
@@ -0,0 +1,40 @@
import { dataUrl } from "../images";
import type { Preset } from "../presets";
import { GeneratorError, json, output, type ImageBackend } from "./types";
export class SwarmBackend implements ImageBackend {
private session = "";
constructor(private base: string) {}
private async call(route: string, body: object = {}, signal?: AbortSignal): Promise<any> {
if (!this.session) this.session = (await json(this.base + "/API/GetNewSession", {}, signal)).session_id;
const send = () => json(this.base + "/API/" + route, { ...body, session_id: this.session }, signal);
try { return await send(); } catch (e) {
if (!(e instanceof GeneratorError) || e.id !== "invalid_session_id") throw e;
this.session = (await json(this.base + "/API/GetNewSession", {}, signal)).session_id;
return send();
}
}
async health(signal?: AbortSignal) { await this.call("ListT2IParams", {}, signal); }
async models(signal?: AbortSignal) {
const r = await this.call("ListT2IParams", {}, signal);
const values = (id: string): string[] => (r.list?.find((p: any) => p.id === id)?.values ?? []).map((s: string) => s.split("///")[0]);
const modelList = r.models?.["Stable-Diffusion"];
return { models: Array.isArray(modelList) ? modelList.map((m: any) => typeof m === "string" ? m : Array.isArray(m) ? m[0] : m.name) : values("model"), upscalers: values("refinerupscalemethod") };
}
async process(png: Blob, p: Preset, signal?: AbortSignal) {
const bitmap = await createImageBitmap(png);
const width = bitmap.width, height = bitmap.height; bitmap.close();
if (!p.model) throw new Error("Выберите модель SwarmUI в настройках");
if (p.mode === "upscale" && !/^(pixel-|model-)/.test(p.upscaler)) throw new Error("Для режима без диффузии выберите pixel- или model-апскейлер из списка SwarmUI");
const r = await this.call("GenerateText2Image", { images: 1, initimage: await dataUrl(png),
model: p.model, width, height, prompt: p.prompt, negativeprompt: p.negative, seed: p.seed,
steps: p.steps, cfgscale: p.cfg, initimagecreativity: p.mode === "upscale" ? 0 : p.creativity,
refinermethod: "PostApply", refinerupscale: p.scale, refinerupscalemethod: p.upscaler, refinercontrolpercentage: 0,
imageformat: "PNG", donotsave: true, outputintermediateimages: false,
}, signal);
const image = r.images?.at(-1);
const url = typeof image === "string" ? image : image?.image;
if (!url) throw new Error("SwarmUI не вернул изображение");
return output(url, this.base, signal);
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { Preset } from "../presets";
export interface ImageBackend {
health(signal?: AbortSignal): Promise<void>;
models(signal?: AbortSignal): Promise<{ models: string[]; upscalers: string[] }>;
process(png: Blob, preset: Preset, signal?: AbortSignal): Promise<Blob>;
}
export class GeneratorError extends Error {
constructor(message: string, public id?: string) { super(message); }
}
export async function json(url: string, body?: unknown, signal?: AbortSignal): Promise<any> {
const response = await fetch(url, { method: body === undefined ? "GET" : "POST", signal,
headers: body === undefined ? undefined : { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body) });
if (!response.ok) throw new Error(`Генератор: HTTP ${response.status}`);
const value = await response.json();
if (value.error || value.error_id) throw new GeneratorError(value.error || value.error_id, value.error_id);
return value;
}
export async function output(url: string, base: string, signal?: AbortSignal): Promise<Blob> {
const target = new URL(url, base + "/");
if (target.protocol !== "data:" && target.origin !== new URL(base).origin) throw new Error("Генератор вернул изображение с другого адреса");
const r = await fetch(target, { signal });
if (!r.ok) throw new Error(`Не удалось скачать результат: HTTP ${r.status}`);
return r.blob();
}
+53
View File
@@ -0,0 +1,53 @@
import type { Preset } from "./presets";
export interface CachedImage { key: string; version: number; path: string; blob: Blob; scale: number; width: number; height: number }
export interface Task {
key: string; version: number; path: string; source: string; originalHash: string; preset: Preset;
status: "queued" | "running" | "done" | "failed" | "cancelled";
error?: string;
}
export interface ImageStore {
get(key: string): Promise<CachedImage | undefined>;
put(image: CachedImage): Promise<void>;
tasks(): Promise<Task[]>;
save(task: Task): Promise<void>;
}
let opened: Promise<IDBDatabase> | undefined;
function db(): Promise<IDBDatabase> {
return opened ??= new Promise((resolve, reject) => {
const request = indexedDB.open("pg.image-enhance", 1);
request.onupgradeneeded = () => {
request.result.createObjectStore("images", { keyPath: "key" }).createIndex("version", "version");
request.result.createObjectStore("tasks", { keyPath: "key" }).createIndex("version", "version");
};
request.onsuccess = () => { request.result.onversionchange = () => { request.result.close(); opened = undefined; }; resolve(request.result); };
request.onerror = () => { opened = undefined; reject(request.error); };
});
}
async function transaction<T>(store: string, mode: IDBTransactionMode, action: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
const database = await db();
return new Promise((resolve, reject) => {
const tx = database.transaction(store, mode);
const request = action(tx.objectStore(store));
tx.oncomplete = () => resolve(request.result);
tx.onabort = tx.onerror = () => reject(tx.error ?? new Error("Не удалось сохранить кэш изображений"));
});
}
export const imageStore: ImageStore = {
get: (key) => transaction("images", "readonly", (s) => s.get(key)),
put: async (value) => { await transaction("images", "readwrite", (s) => s.put(value)); },
tasks: () => transaction("tasks", "readonly", (s) => s.getAll()),
save: async (value) => { await transaction("tasks", "readwrite", (s) => s.put(value)); },
};
export async function clearVersion(version: number): Promise<void> {
const database = await db();
await new Promise<void>((resolve, reject) => {
const tx = database.transaction(["images", "tasks"], "readwrite");
for (const name of ["images", "tasks"]) {
const request = tx.objectStore(name).index("version").openCursor(IDBKeyRange.only(version));
request.onsuccess = () => { const c = request.result; if (c) { c.delete(); c.continue(); } };
}
tx.oncomplete = () => resolve();
tx.onabort = tx.onerror = () => reject(tx.error);
});
}
+46
View File
@@ -0,0 +1,46 @@
// SubtleCrypto is unavailable on a home server opened over plain HTTP. Keep cache
// identities identical on those origins without requiring a crypto dependency.
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
const rotate = (n: number, bits: number) => (n >>> bits) | (n << (32 - bits));
export function sha256Fallback(input: Uint8Array): string {
const padded = new Uint8Array(Math.ceil((input.length + 9) / 64) * 64);
padded.set(input); padded[input.length] = 0x80;
const view = new DataView(padded.buffer);
view.setUint32(padded.length - 8, Math.floor(input.length / 0x20000000));
view.setUint32(padded.length - 4, input.length * 8);
const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
const w = new Uint32Array(64);
for (let offset = 0; offset < padded.length; offset += 64) {
for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4);
for (let i = 16; i < 64; i++) {
const a = w[i - 15], b = w[i - 2];
w[i] = w[i - 16] + (rotate(a, 7) ^ rotate(a, 18) ^ (a >>> 3)) + w[i - 7] + (rotate(b, 17) ^ rotate(b, 19) ^ (b >>> 10));
}
let [a, b, c, d, e, f, g, z] = h;
for (let i = 0; i < 64; i++) {
const t1 = (z + (rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
const t2 = ((rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
z = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
}
const state = [a, b, c, d, e, f, g, z];
for (let i = 0; i < 8; i++) h[i] += state[i];
}
return [...h].map((n) => n.toString(16).padStart(8, "0")).join("");
}
export async function sha256(text: string): Promise<string> {
const bytes = new TextEncoder().encode(text);
if (!globalThis.crypto?.subtle) return sha256Fallback(bytes);
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((n) => n.toString(16).padStart(2, "0")).join("");
}
export function randomId(): string {
return [...crypto.getRandomValues(new Uint8Array(16))].map((n) => n.toString(16).padStart(2, "0")).join("");
}
+15
View File
@@ -0,0 +1,15 @@
.imager { max-width: 1200px; }
.imager .card { padding: 24px; margin-bottom: 20px; }
.imager h2 { margin: 0 0 16px; }
.imager fieldset { border: 0; padding: 0; min-width: 0; }
.imager label { display: flex; flex-direction: column; gap: 6px; margin: 12px 0; }
.imager-fields { display: grid; grid-template-columns: repeat(auto-fit,minmax(190px,1fr)); gap: 16px; align-items: end; }
.imager-jobs { max-height: 350px; overflow: auto; }
.imager-job { display: flex; align-items: center; gap: 12px; padding: 8px 0; overflow-wrap: anywhere; }
.imager-job span { flex: 1; }
.imager-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(210px,1fr)); gap: 16px; margin: 20px 0; }
.imager-tile { border: 1px solid var(--border); border-radius: 12px; padding: 12px; overflow-wrap: anywhere; }
.imager-tile img { width: 100%; height: 140px; object-fit: contain; background: repeating-conic-gradient(#8882 0% 25%,transparent 0% 50%) 0 / 16px 16px; }
.imager .imager-check { display: flex; flex-direction: row; align-items: center; }
.imager-compare { position: relative; height: min(55vh,500px); background: repeating-conic-gradient(#8882 0% 25%,transparent 0% 50%) 0 / 16px 16px; }
.imager-compare img { position: absolute; width: 100%; height: 100%; object-fit: contain; }
+70
View File
@@ -0,0 +1,70 @@
export function dataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const r = new FileReader(); r.onload = () => resolve(String(r.result)); r.onerror = () => reject(r.error); r.readAsDataURL(blob);
});
}
export function canvas(width: number, height: number): HTMLCanvasElement {
if (width < 1 || height < 1 || width > 32768 || height > 32768 || width * height > 100_000_000) throw new Error("Изображение слишком большое для обработки в браузере");
const c = document.createElement("canvas"); c.width = width; c.height = height; return c;
}
export function png(c: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => c.toBlob((b) => b ? resolve(b) : reject(new Error("Браузер не смог сохранить PNG")), "image/png"));
}
/** Reject animation rather than silently replacing a moving image with its first frame. */
export async function assertStatic(blob: Blob) {
const b = new Uint8Array(await blob.arrayBuffer());
const text = (at: number, length: number) => String.fromCharCode(...b.subarray(at, at + length));
let animated = text(0, 3) === "GIF";
const view = new DataView(b.buffer);
if (text(0, 4) === "RIFF" && text(8, 4) === "WEBP") {
for (let at = 12; at + 8 <= b.length;) {
if (text(at, 4) === "ANIM") { animated = true; break; }
const size = view.getUint32(at + 4, true); at += 8 + size + (size % 2);
}
} else if (b[0] === 137 && text(1, 3) === "PNG") {
for (let at = 8; at + 12 <= b.length;) {
if (text(at + 4, 4) === "acTL") { animated = true; break; }
at += 12 + view.getUint32(at);
}
}
if (animated) throw new Error("Анимированные изображения не поддерживаются");
}
export async function prepareImage(source: Blob, mode: "upscale" | "img2img") {
await assertStatic(source);
const bitmap = await createImageBitmap(source);
try {
const c = canvas(bitmap.width, bitmap.height); const ctx = c.getContext("2d")!;
ctx.drawImage(bitmap, 0, 0);
const pixels = ctx.getImageData(0, 0, c.width, c.height);
let transparent = false;
for (let i = 3; i < pixels.data.length; i += 4) if (pixels.data[i] !== 255) { transparent = true; break; }
if (transparent && mode === "img2img") throw new Error("У картинки есть прозрачность. Выберите «Только апскейлер» для сохранения альфа-канала");
// Keep straight RGB separate from alpha; never send an opaque white composite to the upscaler.
if (transparent) {
for (let i = 3; i < pixels.data.length; i += 4) pixels.data[i] = 255;
ctx.putImageData(pixels, 0, 0);
}
return { blob: await png(c), width: c.width, height: c.height,
alpha: transparent ? source : null };
} finally { bitmap.close(); }
}
export async function finishImage(result: Blob, original: { width: number; height: number; alpha: Blob | null }) {
const bitmap = await createImageBitmap(result);
try {
const scale = bitmap.width / original.width;
if (Math.abs(bitmap.height - original.height * scale) > 1) throw new Error("Генератор изменил пропорции изображения");
const c = canvas(bitmap.width, bitmap.height); const ctx = c.getContext("2d")!;
ctx.drawImage(bitmap, 0, 0);
if (original.alpha) {
const alpha = await createImageBitmap(original.alpha);
try {
const mask = canvas(c.width, c.height); const mc = mask.getContext("2d")!;
mc.drawImage(alpha, 0, 0, c.width, c.height);
const a = mc.getImageData(0, 0, c.width, c.height); const rgb = ctx.getImageData(0, 0, c.width, c.height);
for (let i = 3; i < rgb.data.length; i += 4) rgb.data[i] = a.data[i];
ctx.putImageData(rgb, 0, 0);
} finally { alpha.close(); }
}
return { blob: await png(c), scale, width: c.width, height: c.height };
} finally { bitmap.close(); }
}
+62
View File
@@ -0,0 +1,62 @@
import { imageStore, type CachedImage } from "./cache";
import { activePreset, cacheKey, presetHash, type Preset } from "./presets";
import { imageQueue } from "./queue";
/** Synchronous URL lookup for Engine.fileUrl; IndexedDB work stays off the render path. */
export class LiveImages {
enabled = false;
automatic = false;
private results = new Map<string, { url: string; image: CachedImage }>();
private pending = new Set<string>();
private dead = false;
private preset: Preset;
private hash: Promise<string>;
private reported = false;
private unsubscribe: () => void;
constructor(private version: number, private report: (error: string) => void = () => {}) {
this.preset = activePreset(); this.hash = presetHash(this.preset);
// Avoid an unhandled rejection in contexts with unavailable SubtleCrypto.
void this.hash.catch(() => {});
try { const prefs = JSON.parse(localStorage.getItem(`pg.imager.live.${version}`) || "{}"); this.enabled = prefs.enabled === true; this.automatic = prefs.automatic === true; } catch { /* default off */ }
this.unsubscribe = imageQueue.subscribe(() => {
for (const task of imageQueue.snapshot()) {
if (task.version !== version || task.status !== "done" || this.results.has(task.key) || this.pending.has(task.key)) continue;
this.pending.add(task.key);
void this.hash.then(async (hash) => {
if (task.key !== cacheKey(version, task.path, task.originalHash, hash)) return;
const result = await imageStore.get(task.key);
if (result && !this.dead && !this.results.has(task.key)) this.results.set(task.key, { url: URL.createObjectURL(result.blob), image: result });
}).catch(() => {}).finally(() => this.pending.delete(task.key));
}
});
}
save() { try { localStorage.setItem(`pg.imager.live.${this.version}`, JSON.stringify({ enabled: this.enabled, automatic: this.automatic })); } catch { /* private mode */ } }
private keys = new Map<string, string>();
url(path: string, originalHash: string, source: string): string | null {
if (!this.enabled || !/\.(png|webp|jpe?g|avif|bmp)$/i.test(path)) return null;
const identity = JSON.stringify([path, originalHash]);
const known = this.keys.get(identity);
if (known && this.results.has(known)) return this.results.get(known)!.url;
// Mark the identity before the asynchronous hash to avoid duplicate lookups in one frame.
if (!this.pending.has(identity)) {
this.pending.add(identity);
void this.hash.then(async (hash) => {
const key = cacheKey(this.version, path, originalHash, hash); this.keys.set(identity, key);
if (this.dead || this.results.has(key)) return;
const image = await imageStore.get(key);
if (this.dead) return;
if (image && !this.results.has(key)) this.results.set(key, { url: URL.createObjectURL(image.blob), image });
else if (this.automatic) {
await imageQueue.enqueue(this.version, path, originalHash, source, this.preset);
// Allow the next showing to read a completed result, without polling.
}
}).catch((e) => { if (!this.dead && !this.reported) { this.reported = true; this.report((e as Error).message); } }).finally(() => this.pending.delete(identity));
}
return null;
}
scale(url: string) {
for (const result of this.results.values()) if (result.url === url) return result.image.scale;
return 1;
}
destroy() { this.dead = true; this.unsubscribe(); for (const r of this.results.values()) URL.revokeObjectURL(r.url); this.results.clear(); }
}
+58
View File
@@ -0,0 +1,58 @@
import { sha256 } from "./hash";
export type BackendKind = "swarm" | "comfy" | "a1111";
export interface Preset {
name: string;
backend: BackendKind;
url: string;
mode: "upscale" | "img2img";
model: string;
upscaler: string;
scale: number;
creativity: number;
prompt: string;
negative: string;
steps: number;
cfg: number;
seed: number;
}
export const DEFAULT_PRESET: Preset = {
name: "Бережный апскейл ×2", backend: "swarm", url: "http://127.0.0.1:7801",
mode: "upscale", model: "", upscaler: "pixel-lanczos", scale: 2,
creativity: 0.2, prompt: "", negative: "", steps: 20, cfg: 7, seed: 0,
};
export function normalizeUrl(raw: string): string {
const url = new URL(/^https?:\/\//i.test(raw.trim()) ? raw.trim() : `http://${raw.trim()}`);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error("Нужен HTTP-адрес генератора без пароля и параметров");
return url.href.replace(/\/+$/, "");
}
export function validatePreset(value: unknown): Preset {
if (!value || typeof value !== "object") throw new Error("Некорректный пресет");
const p = value as Preset;
if (!["swarm", "comfy", "a1111"].includes(p.backend) || !["upscale", "img2img"].includes(p.mode)) throw new Error("Неизвестный режим или генератор");
for (const k of ["name", "model", "upscaler", "prompt", "negative"] as const) if (typeof p[k] !== "string") throw new Error(`Некорректное поле ${k}`);
for (const [k, min, max] of [["scale", 1, 8], ["creativity", 0, 1], ["steps", 1, 150], ["cfg", 0, 30], ["seed", 0, 2147483647]] as const) {
if (typeof p[k] !== "number" || !Number.isFinite(p[k]) || p[k] < min || p[k] > max) throw new Error(`Некорректное поле ${k}`);
}
if (!Number.isInteger(p.steps) || !Number.isInteger(p.seed)) throw new Error("Шаги и seed должны быть целыми");
return { ...p, url: normalizeUrl(p.url) };
}
export function loadPresets(): Preset[] {
try { const list = JSON.parse(localStorage.getItem("pg.imager.presets") || "null");
if (Array.isArray(list) && list.length) return list.map(validatePreset);
} catch { /* unavailable or old storage */ }
return [{ ...DEFAULT_PRESET }];
}
export function savePresets(presets: Preset[]) {
localStorage.setItem("pg.imager.presets", JSON.stringify(presets.map(validatePreset)));
}
export function activePreset(): Preset { return loadPresets()[0]; }
/** Stable cryptographic digest; names do not change the actual generation. */
export async function presetHash(preset: Preset): Promise<string> {
const p = validatePreset(preset);
const { name: _name, ...settings } = p;
const canonical = JSON.stringify(Object.fromEntries(Object.entries(settings).sort(([a], [b]) => a.localeCompare(b))));
return sha256(canonical);
}
export function cacheKey(version: number, path: string, originalHash: string, hash: string): string {
return JSON.stringify([version, path, originalHash, hash]);
}
+101
View File
@@ -0,0 +1,101 @@
import { clearVersion, imageStore, type ImageStore, type Task } from "./cache";
import { backend } from "./backends";
import { finishImage, prepareImage } from "./images";
import { cacheKey, presetHash, validatePreset, type Preset } from "./presets";
export async function processTask(t: Task) {
const signal = AbortSignal.timeout(30 * 60_000);
const r = await fetch(t.source, { credentials: "same-origin", signal });
if (!r.ok) throw new Error(`Не удалось загрузить оригинал: HTTP ${r.status}`);
const original = await prepareImage(await r.blob(), t.preset.mode);
return { ...await finishImage(await backend(t.preset).process(original.blob, t.preset, signal), original),
key: t.key, version: t.version, path: t.path };
}
export class ImageQueue {
private items: Task[] = [];
private listeners = new Set<() => void>();
private running = false;
private restored?: Promise<void>;
storageError = "";
constructor(private store: ImageStore = imageStore, private process = processTask) {}
snapshot = () => this.items;
subscribe = (fn: () => void) => { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; };
private changed() { this.items = [...this.items]; this.listeners.forEach((f) => f()); }
restore() {
return this.restored ??= this.store.tasks().then((items) => {
// No GPU work on page load. Resume is explicit, and interrupted tasks are retryable.
this.items = items.map((t) => t.status === "running" || t.status === "queued" ? { ...t, status: "cancelled", error: "Прервано перезагрузкой. Можно повторить" } : t);
this.changed();
});
}
async enqueue(version: number, path: string, originalHash: string, source: string, preset: Preset) {
await this.restore();
const p = validatePreset(preset);
const key = cacheKey(version, path, originalHash, await presetHash(p));
const existing = this.items.find((t) => t.key === key);
if (existing) {
if (existing.status === "done" && !await this.store.get(key)) {
existing.status = "queued"; await this.store.save(existing); this.changed(); void this.pump();
}
return key;
}
const task: Task = { key, version, path, originalHash, source, preset: p, status: "queued" };
await this.store.save(task);
if (this.items.some((t) => t.key === key)) return key;
this.items.push(task); this.changed(); void this.pump(); return key;
}
async retry(key: string) {
const task = this.items.find((t) => t.key === key);
if (!task || !["failed", "cancelled"].includes(task.status) || this.current === key) return;
task.status = "queued"; task.error = undefined;
await this.store.save(task); this.changed(); void this.pump();
}
cancel(key: string) {
const task = this.items.find((t) => t.key === key);
if (!task || !["running", "queued"].includes(task.status)) return;
task.status = "cancelled";
// Keep the GPU lane occupied until the in-flight request finishes. Do not interrupt
// another user's generation through a backend-global interrupt endpoint.
void this.store.save(task).catch((e) => { this.storageError = String(e); this.changed(); }); this.changed();
}
private current: string | null = null;
async clear(version: number) {
await this.restore();
if (this.items.some((t) => t.version === version && (t.status === "queued" || t.key === this.current))) throw new Error("Сначала дождитесь окончания обработки этой версии");
await clearVersion(version);
this.items = this.items.filter((t) => t.version !== version); this.changed();
}
private async pump() {
if (this.running) return;
this.running = true;
try {
let task: Task | undefined;
while ((task = this.items.find((t) => t.status === "queued"))) {
const t = task; this.current = t.key;
t.status = "running"; this.changed();
try {
await this.store.save(t);
const process = async () => {
if (this.cancelled(t)) return;
const cached = await this.store.get(t.key);
const image = cached ?? await this.process(t);
if (this.cancelled(t)) return;
if (!cached) await this.store.put(image);
t.status = "done";
};
// Editor and player can be open in different tabs. A shared browser lock
// keeps the single GPU lane across both, with cache rechecked inside it.
if (typeof navigator !== "undefined" && navigator.locks) await navigator.locks.request("pg.imager.gpu", process);
else await process();
} catch (e) {
if (!this.cancelled(t)) { t.status = "failed"; t.error = (e as Error).message; }
} finally {
try { await this.store.save(t); } catch (e) { this.storageError = String(e); }
this.current = null; this.changed();
}
}
} finally { this.running = false; }
}
private cancelled(t: Task) { return t.status === "cancelled"; }
}
export const imageQueue = new ImageQueue();
+1
View File
@@ -278,6 +278,7 @@ function VersionRow(props: {
{v.status === "ready" && <LanguageMenu title="Языки версии" entries={versionLanguages(v)} onPick={(e) => props.onPlayLang(e.code)} />} {v.status === "ready" && <LanguageMenu title="Языки версии" entries={versionLanguages(v)} onPick={(e) => props.onPlayLang(e.code)} />}
{v.status === "ready" && props.canFork && <button className="btn btn-sm" onClick={props.onFork}><Icon name="fork" size={16} />Форк</button>} {v.status === "ready" && props.canFork && <button className="btn btn-sm" onClick={props.onFork}><Icon name="fork" size={16} />Форк</button>}
{v.status === "ready" && v.kind === "mod" && <Link className="btn btn-sm" to={`/versions/${v.id}/edit`}>Редактор мода</Link>} {v.status === "ready" && v.kind === "mod" && <Link className="btn btn-sm" to={`/versions/${v.id}/edit`}>Редактор мода</Link>}
{v.status === "ready" && v.kind === "mod" && props.canFork && <Link className="btn btn-sm" to={`/versions/${v.id}/enhance`}>Улучшение графики</Link>}
{v.status === "ready" && props.canTranslate && v.kind === "translation" && v.visibility === "draft" && props.canManage ? ( {v.status === "ready" && props.canTranslate && v.kind === "translation" && v.visibility === "draft" && props.canManage ? (
<Link className="btn btn-sm" to={`/versions/${v.id}/translate`}><Icon name="globe" size={16} />Редактор перевода</Link> <Link className="btn btn-sm" to={`/versions/${v.id}/translate`}><Icon name="globe" size={16} />Редактор перевода</Link>
) : v.status === "ready" && props.canTranslate && v.visibility === "published" && ( ) : v.status === "ready" && props.canTranslate && v.visibility === "published" && (
+1
View File
@@ -160,6 +160,7 @@ export function ModEditor() {
return <div className="page mod-editor"><div className="section-title"><h1>{version.name}</h1> return <div className="page mod-editor"><div className="section-title"><h1>{version.name}</h1>
<Link className="btn" to={`/games/${version.game_id}`} onClick={(e) => { if (dirty && !window.confirm("Уйти без сохранения?")) e.preventDefault(); }}>К игре</Link> <Link className="btn" to={`/games/${version.game_id}`} onClick={(e) => { if (dirty && !window.confirm("Уйти без сохранения?")) e.preventDefault(); }}>К игре</Link>
<Link className="btn" target="_blank" to={`/play/${versionId}`}>Предпросмотр сохранённой версии</Link> <Link className="btn" target="_blank" to={`/play/${versionId}`}>Предпросмотр сохранённой версии</Link>
<Link className="btn" to={`/versions/${versionId}/enhance`}>Улучшение графики</Link>
</div>{error && <ErrorNote>{error}</ErrorNote>} </div>{error && <ErrorNote>{error}</ErrorNote>}
<p className="muted">{dirty ? "Есть несохранённые изменения" : "Изменения сохранены"}. Ревизия {data.revision.slice(0, 8)}. {data.baseRevision ? `База ${data.baseRevision.slice(0, 8)}` : "Историческая база неизвестна"}.</p> <p className="muted">{dirty ? "Есть несохранённые изменения" : "Изменения сохранены"}. Ревизия {data.revision.slice(0, 8)}. {data.baseRevision ? `База ${data.baseRevision.slice(0, 8)}` : "Историческая база неизвестна"}.</p>
{!data.editable && <p>Для редактирования создайте свой черновик мода через кнопку «Форк».</p>} {!data.editable && <p>Для редактирования создайте свой черновик мода через кнопку «Форк».</p>}
+29 -5
View File
@@ -2,6 +2,7 @@
import { AudioSystem } from "./audio"; import { AudioSystem } from "./audio";
import { VolumeStore } from "./volume-store"; import { VolumeStore } from "./volume-store";
import { LiveImages } from "../imager/live";
import { BlockingCall, JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, userBuiltins, type Scope } from "./python"; import { BlockingCall, JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, userBuiltins, type Scope } from "./python";
import { Renderer, type ResolvedImage } from "./renderer"; import { Renderer, type ResolvedImage } from "./renderer";
import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text"; import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
@@ -24,6 +25,8 @@ interface LabelRef {
} }
export class Engine { export class Engine {
private liveImages?: LiveImages;
private imageScales = new Map<string, number>();
private labels = new Map<string, LabelRef>(); private labels = new Map<string, LabelRef>();
private anchors = new Map<string, { label: string; path: number[]; params?: [string, Expr | null][] | null }>(); private anchors = new Map<string, { label: string; path: number[]; params?: [string, Expr | null][] | null }>();
private imagesByTag = new Map<string, { attrs: string[]; key: string }[]>(); private imagesByTag = new Map<string, { attrs: string[]; key: string }[]>();
@@ -136,6 +139,7 @@ export class Engine {
root.append(this.container); root.append(this.container);
this.renderer = new Renderer(this.container, w, h, { this.renderer = new Renderer(this.container, w, h, {
url: (f) => this.fileUrl(f), url: (f) => this.fileUrl(f),
assetScale: (url) => this.imageScales.get(url) ?? 1,
resolveRef: (name) => this.resolveImage(name.split(" "))?.def ?? null, resolveRef: (name) => this.resolveImage(name.split(" "))?.def ?? null,
evalCond: (c) => truthy(this.safeEval(c)), evalCond: (c) => truthy(this.safeEval(c)),
transform: (name) => game.transforms[name] ?? null, transform: (name) => game.transforms[name] ?? null,
@@ -153,6 +157,7 @@ export class Engine {
}); });
this.audio = new AudioSystem(this.settings, (f) => this.fileUrl(f), game.channels ?? {}); this.audio = new AudioSystem(this.settings, (f) => this.fileUrl(f), game.channels ?? {});
this.buildUI(); this.buildUI();
if (manifest.versionId !== undefined) this.liveImages = new LiveImages(manifest.versionId, (message) => this.notify(`Улучшение графики: ${message}`));
this.resizeObserver = new ResizeObserver(() => this.renderer.fit(this.container)); this.resizeObserver = new ResizeObserver(() => this.renderer.fit(this.container));
this.resizeObserver.observe(this.container); this.resizeObserver.observe(this.container);
this.renderer.fit(this.container); this.renderer.fit(this.container);
@@ -161,6 +166,7 @@ export class Engine {
} }
destroy() { destroy() {
this.liveImages?.destroy();
this.screens.destroy(); this.screens.destroy();
this.gen++; this.gen++;
this.wakeAll(); this.wakeAll();
@@ -184,7 +190,13 @@ export class Engine {
} }
} }
const entry = logical ? this.manifest.files[logical] : undefined; const entry = logical ? this.manifest.files[logical] : undefined;
return entry ? `/api/versions/${this.manifest.versionId}/files/${entry.hash}` : null; if (!entry || !logical) return null;
const source = `/api/versions/${this.manifest.versionId}/files/${entry.hash}`;
const improved = this.liveImages?.url(logical, entry.hash, source);
const url = improved || source;
const baseScale = this.game.assetScale?.[logical] ?? 1;
this.imageScales.set(url, baseScale * (improved ? this.liveImages!.scale(improved) : 1));
return url;
} }
private resolveImage(name: string[]): ResolvedImage | null { private resolveImage(name: string[]): ResolvedImage | null {
@@ -698,10 +710,7 @@ export class Engine {
if (resolved) return this.renderer.displayable(resolved.def); if (resolved) return this.renderer.displayable(resolved.def);
const url = this.fileUrl(value); const url = this.fileUrl(value);
if (!url) return null; if (!url) return null;
const img = new Image(); return this.renderer.displayable({ file: value });
img.src = url;
img.draggable = false;
return img;
} }
private screenFont(file: string): string | null { private screenFont(file: string): string | null {
@@ -2008,6 +2017,21 @@ export class Engine {
row(reading, "Скорость текста", 0, 150, 5, s.cps, (v) => (v === 0 ? "Мгновенно" : `${v} симв/с`), (v) => (s.cps = v), "reading"); row(reading, "Скорость текста", 0, 150, 5, s.cps, (v) => (v === 0 ? "Мгновенно" : `${v} симв/с`), (v) => (s.cps = v), "reading");
row(reading, "Задержка авто", 0.3, 4, 0.1, s.autoDelay, (v) => `${v.toFixed(1)} с`, (v) => (s.autoDelay = v), "reading"); row(reading, "Задержка авто", 0.3, 4, 0.1, s.autoDelay, (v) => `${v.toFixed(1)} с`, (v) => (s.autoDelay = v), "reading");
const display = section("Игра", "Изменения сохраняются автоматически в этом браузере."); const display = section("Игра", "Изменения сохраняются автоматически в этом браузере.");
const graphics = section("Графика", "Локальный генератор настраивается в меню профиля → Улучшение графики. Результат подхватывается при следующем показе картинки.");
const imageToggle = (label: string, checked: boolean, apply: (value: boolean) => void) => {
const row = el("label", "pg-check"); const box = el("input");
Object.assign(box, { type: "checkbox", checked });
box.onchange = () => apply(box.checked); row.append(box, el("span", "", label)); graphics.append(row);
};
imageToggle("Показывать улучшенные изображения", this.liveImages?.enabled ?? false, (value) => {
if (!this.liveImages) return;
this.liveImages.enabled = value;
this.liveImages.save();
// Refresh shown scene for immediate original/result comparison.
const shown = this.renderer.snapshot();
for (const image of shown) { const resolved = this.resolveImage(image.name); if (resolved) this.renderer.show(resolved, image.tag, image.at, { zorder: image.zorder }); }
});
imageToggle("Улучшать по ходу игры", this.liveImages?.automatic ?? false, (value) => { if (this.liveImages) { this.liveImages.automatic = value; this.liveImages.save(); } });
const langs = this.languages(); const langs = this.languages();
if (langs.length > 1) { if (langs.length > 1) {
const r = el("label", "pg-setting"); const r = el("label", "pg-setting");
+11 -4
View File
@@ -26,6 +26,7 @@ export interface ResolvedImage {
export interface RendererHost { export interface RendererHost {
url(file: string): string | null; url(file: string): string | null;
assetScale?(url: string): number;
resolveRef(name: string): ImageDef | null; resolveRef(name: string): ImageDef | null;
evalCond(e: any): boolean; evalCond(e: any): boolean;
transform(name: string): Record<string, any> | null; transform(name: string): Record<string, any> | null;
@@ -204,12 +205,18 @@ export class Renderer {
img.draggable = false; img.draggable = false;
img.decoding = "async"; img.decoding = "async";
const src = this.host.url(def.file); const src = this.host.url(def.file);
const scale = src ? this.host.assetScale?.(src) ?? 1 : 1;
if (Number.isFinite(scale) && scale > 0 && scale !== 1) {
img.addEventListener("load", () => {
// HTML width supplies a natural-size fallback. Explicit CSS size/ATL still wins,
// and the unspecified side continues to follow the picture's aspect ratio.
if (!img.style.width && !img.style.height) img.width = Math.max(1, Math.round(img.naturalWidth / scale));
}, { once: true });
}
const size = def.props?.size ?? def.size;
if (size) { img.style.width = `${size[0]}px`; img.style.height = `${size[1]}px`; }
if (src) img.src = src; if (src) img.src = src;
else console.warn("[pgvn] нет файла", def.file); else console.warn("[pgvn] нет файла", def.file);
if (def.props?.size) {
img.style.width = `${def.props.size[0]}px`;
img.style.height = `${def.props.size[1]}px`;
}
return img; return img;
} }
return document.createElement("div"); return document.createElement("div");
+2
View File
@@ -100,6 +100,8 @@ export interface ScriptModule {
} }
export interface GameData { export interface GameData {
/** Natural image dimensions are divided by this factor after an asset replacement. */
assetScale?: Record<string, number>;
runtime?: string; runtime?: string;
tomie?: Omit<import("./tomie-player").TomieContent, "scripts" | "resources">; tomie?: Omit<import("./tomie-player").TomieContent, "scripts" | "resources">;
engine?: string; engine?: string;
+85
View File
@@ -0,0 +1,85 @@
// Real browser checks for IndexedDB, Canvas alpha and actual layout; opt-in like tomie-browser.
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
import { test } from "node:test";
import { build } from "esbuild";
test("image enhancement preserves alpha, natural layout, persistent cache and local-only live URLs in Chromium", {
skip: !process.env.PGAMES_PLAYWRIGHT_PATH,
}, async () => {
const { chromium } = await import(pathToFileURL(process.env.PGAMES_PLAYWRIGHT_PATH + "/index.mjs").href);
const compiled = await build({ stdin: { contents: `export * from './images'; export * from './cache'; export * from './presets'; export * from './live'; export * from '../player/renderer';
import { createElement } from 'react'; import { createRoot } from 'react-dom/client'; import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { ImagerSetup } from './ImagerSetup'; import { ImageEnhance } from './ImageEnhance';
export function showPage(kind) { createRoot(document.querySelector('main')).render(createElement(MemoryRouter, {initialEntries: ['/versions/7/enhance']}, createElement(Routes, null, createElement(Route, {path: '/versions/:versionId/enhance', element: createElement(kind === 'setup' ? ImagerSetup : ImageEnhance)})))); }`,
resolveDir: fileURLToPath(new URL("../src/imager/", import.meta.url)), loader: "ts" }, bundle: true, write: false, format: "iife", globalName: "Imager", platform: "browser", loader: { '.css': 'empty' }, logLevel: "silent" });
let writes = 0;
const server = createServer((req, res) => {
if (req.method === 'PUT' || req.method === 'POST') writes++;
if (req.url === '/api/versions/7/editor') { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ revision: 'revision', editable: true, baseRevision: 'base', files: { 'images/sprite.webp': { hash: 'sourcehash', size: 1000 } }, document: { game: {} } })); }
else if (req.url === '/api/versions/7') { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ id: 7, name: 'Тестовый мод', kind: 'mod' })); }
else if (req.url === '/api/versions/7/files/sourcehash') { res.setHeader('Content-Type', 'image/png'); res.end(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jCTkAAAAASUVORK5CYII=', 'base64')); }
else if (req.url === '/bundle.js') { res.setHeader('Content-Type', 'application/javascript'); res.end(compiled.outputFiles[0].text); }
else { res.setHeader('Content-Type', 'text/html'); res.end('<!doctype html><main></main><script src="/bundle.js"></script>'); }
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
let browser;
try {
browser = await chromium.launch({ headless: true, executablePath: process.env.PGAMES_CHROMIUM_EXECUTABLE });
const page = await browser.newPage();
await page.goto(`http://127.0.0.1:${server.address().port}`);
const result = await page.evaluate(async () => {
const c = Imager.canvas(4, 2), ctx = c.getContext('2d');
ctx.fillStyle = 'rgba(255,0,0,0.5)'; ctx.fillRect(0, 0, 2, 2);
const original = await Imager.png(c);
const prepared = await Imager.prepareImage(original, 'upscale');
let rejected = false;
try { await Imager.prepareImage(original, 'img2img'); } catch { rejected = true; }
const large = Imager.canvas(8, 4); const gc = large.getContext('2d');
gc.fillStyle = 'blue'; gc.fillRect(0, 0, 8, 4);
const image = await Imager.finishImage(await Imager.png(large), prepared);
const bitmap = await createImageBitmap(image.blob); gc.clearRect(0, 0, 8, 4); gc.drawImage(bitmap, 0, 0); bitmap.close();
const a = gc.getImageData(0, 0, 8, 4).data;
const hash = await Imager.presetHash(Imager.DEFAULT_PRESET);
const key = Imager.cacheKey(7, 'images/sprite.webp', 'sourcehash', hash);
await Imager.imageStore.put({ ...image, key, version: 7, path: 'images/sprite.webp' });
window.key = key;
const live = new Imager.LiveImages(7); live.enabled = true;
const first = live.url('images/sprite.webp', 'sourcehash', '/original');
let improved;
for (let i = 0; i < 100 && !improved; i++) { await new Promise((r) => setTimeout(r, 5)); improved = live.url('images/sprite.webp', 'sourcehash', '/original'); }
const renderer = new Imager.Renderer(document.querySelector('main'), 100, 100, { url: () => improved, assetScale: () => 2 });
const img = renderer.displayable({ file: 'sprite.webp' }); document.querySelector('main').append(img); await img.decode();
const natural = { width: img.getBoundingClientRect().width, height: img.getBoundingClientRect().height };
const sized = renderer.displayable({ file: 'sprite.webp', size: [20, 10] }); document.querySelector('main').append(sized); await sized.decode();
const explicit = { width: sized.getBoundingClientRect().width, height: sized.getBoundingClientRect().height };
let revoked = false; const revoke = URL.revokeObjectURL; URL.revokeObjectURL = (url) => { if (url === improved) revoked = true; revoke(url); };
live.destroy();
return { rejected, alpha: [a[3], a[(7 * 4) + 3]], scale: image.scale, first, improved, natural, explicit, revoked };
});
assert.equal(result.rejected, true); assert.deepEqual(result.alpha, [128, 0]); assert.equal(result.scale, 2);
assert.equal(result.first, null); assert.match(result.improved, /^blob:/); assert.equal(result.revoked, true);
assert.deepEqual(result.natural, { width: 4, height: 2 }); assert.deepEqual(result.explicit, { width: 20, height: 10 });
assert.equal(writes, 0, 'on-the-fly does not upload anything to the library');
const key = await page.evaluate(() => window.key);
await page.reload();
assert.equal(await page.evaluate(async (key) => (await Imager.imageStore.get(key)).scale, key), 2);
await page.evaluate(async () => Imager.clearVersion(7));
assert.equal(await page.evaluate(async (key) => !!await Imager.imageStore.get(key), key), false);
const css = ['../src/styles.css', '../src/imager/imager.css'].map((file) => readFileSync(new URL(file, import.meta.url), 'utf8')).join('\n');
await page.addStyleTag({ content: css }); await page.evaluate(() => Imager.showPage('setup'));
await page.getByRole('heading', { name: 'Локальное улучшение графики', exact: true }).waitFor();
await page.getByRole('button', { name: 'Сохранить и использовать' }).click();
await page.getByRole('status').filter({ hasText: 'Пресет сохранён' }).waitFor();
await page.setViewportSize({ width: 390, height: 844 });
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, 'setup fits a mobile viewport');
if (process.env.PGAMES_IMAGER_SCREENSHOT) await page.screenshot({ path: process.env.PGAMES_IMAGER_SCREENSHOT, fullPage: true });
await page.reload(); await page.addStyleTag({ content: css }); await page.evaluate(() => Imager.showPage('editor'));
await page.getByRole('heading', { name: 'Улучшение графики', exact: true }).waitFor();
await page.getByRole('button', { name: 'Выбрать видимые (1)' }).click();
await page.getByRole('button', { name: 'Обработать (1)', exact: true }).waitFor();
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, 'editor fits a mobile viewport');
} finally { await browser?.close(); await new Promise((r) => server.close(r)); }
});
+151
View File
@@ -0,0 +1,151 @@
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { build } from "esbuild";
import { parseHTML } from "linkedom";
import { createHash } from "node:crypto";
const out = await build({ stdin: { contents: `export * from './hash'; export * from './presets'; export * from './queue'; export * from './backends/swarm'; export * from './backends/a1111'; export * from './backends/comfy'; export * from '../player/renderer';`,
resolveDir: fileURLToPath(new URL("../src/imager/", import.meta.url)), loader: "ts" }, bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
const { DEFAULT_PRESET: preset, validatePreset, presetHash, cacheKey, ImageQueue, SwarmBackend, A1111Backend, ComfyBackend, workflow, Renderer, sha256Fallback } = await import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString("base64")}`);
const tick = () => new Promise((r) => setTimeout(r, 0));
async function until(check) { for (let i = 0; i < 200; i++) { if (check()) return; await tick(); } assert.fail("timed out"); }
function store(tasks = []) {
const journal = new Map(tasks.map((t) => [t.key, structuredClone(t)])), images = new Map();
return { journal, images, get: async (k) => images.get(k), put: async (i) => { images.set(i.key, i); },
tasks: async () => [...journal.values()].map((t) => structuredClone(t)), save: async (t) => { journal.set(t.key, structuredClone(t)); } };
}
const image = (t) => ({ key: t.key, version: t.version, path: t.path, blob: new Blob(['png']), scale: 2, width: 20, height: 10 });
test("plain HTTP hash fallback matches SHA-256 for empty, Unicode and multiple blocks", () => {
for (const text of ['', 'abc', 'Апскейл 🎨', 'x'.repeat(55), 'x'.repeat(56), 'x'.repeat(64), 'x'.repeat(2000)]) {
assert.equal(sha256Fallback(new TextEncoder().encode(text)), createHash('sha256').update(text).digest('hex'));
}
});
test("preset validation and stable cache identity include source, version, backend and actual parameters", async () => {
const hash = await presetHash(preset);
assert.equal(hash, await presetHash({ ...preset, name: "Renamed" }));
for (const changed of [{ scale: 4 }, { url: "http://127.0.0.1:8000" }, { seed: 1 }, { creativity: .3 }, { backend: "a1111" }]) assert.notEqual(hash, await presetHash({ ...preset, ...changed }));
assert.notEqual(cacheKey(1, 'a|b', 'c', hash), cacheKey(1, 'a', 'b|c', hash));
assert.notEqual(cacheKey(1, 'a', 'old', hash), cacheKey(1, 'a', 'new', hash));
for (const bad of [{ scale: 0 }, { scale: NaN }, { creativity: 2 }, { steps: 1.5 }, { seed: -1 }, { url: 'javascript:alert(1)' }, { backend: "unknown" }]) assert.throws(() => validatePreset({ ...preset, ...bad }));
});
test("one GPU task, concurrent deduplication and cancelling does not overlap remote work", async () => {
const s = store(); const releases = []; let active = 0, maximum = 0;
const q = new ImageQueue(s, async (t) => { maximum = Math.max(maximum, ++active); await new Promise((r) => releases.push(r)); active--; return image(t); });
const [key, duplicate] = await Promise.all([q.enqueue(1, 'a.png', 'a', '/a', preset), q.enqueue(1, 'a.png', 'a', '/a', preset)]);
assert.equal(key, duplicate); assert.equal(q.snapshot().length, 1);
await until(() => releases.length === 1);
const second = await q.enqueue(1, 'b.png', 'b', '/b', preset);
q.cancel(key); await tick(); assert.equal(releases.length, 1);
releases.shift()(); await until(() => releases.length === 1);
releases.shift()(); await until(() => q.snapshot().find((t) => t.key === second).status === 'done');
assert.equal(maximum, 1); assert.equal(s.images.has(key), false); assert.equal(s.images.has(second), true);
assert.equal(s.journal.get(key).status, 'cancelled');
});
test("failed generation and storage quota are retryable; reload never starts the GPU automatically", async () => {
const s = store(); let fail = true, calls = 0;
const q = new ImageQueue(s, async (t) => { calls++; if (fail) throw new Error('GPU offline'); return image(t); });
const key = await q.enqueue(1, 'a.png', 'a', '/a', preset);
await until(() => q.snapshot()[0].status === 'failed'); assert.match(q.snapshot()[0].error, /offline/);
fail = false; await q.retry(key); await until(() => q.snapshot()[0].status === 'done'); assert.equal(calls, 2);
const task = { ...q.snapshot()[0], status: 'running' };
const recovered = new ImageQueue(store([task]), async () => assert.fail('must resume explicitly'));
await recovered.restore(); assert.equal(recovered.snapshot()[0].status, 'cancelled');
const quota = store(); quota.put = async () => { throw new Error('QuotaExceededError'); };
const noSpace = new ImageQueue(quota, async (t) => image(t));
await noSpace.enqueue(1, 'x.png', 'x', '/x', preset); await until(() => noSpace.snapshot()[0].status === 'failed');
assert.match(noSpace.snapshot()[0].error, /Quota/);
});
test("cached task bypasses GPU and cache loss permits regeneration", async () => {
const s = store(); let calls = 0;
const q = new ImageQueue(s, async (t) => { calls++; return image(t); });
const key = await q.enqueue(1, 'a.png', 'a', '/a', preset); await until(() => q.snapshot()[0].status === 'done');
await q.enqueue(1, 'a.png', 'a', '/a', preset); assert.equal(calls, 1);
s.images.clear(); await q.enqueue(1, 'a.png', 'a', '/a', preset); await until(() => q.snapshot()[0].status === 'done'); assert.equal(calls, 2);
});
test("Swarm creates a session, refreshes invalid session once and understands current model tuples", async () => {
const original = globalThis.fetch; let sessions = 0, lists = 0;
globalThis.fetch = async (url, options) => {
const body = JSON.parse(options.body);
if (url.endsWith('GetNewSession')) { sessions++; return Response.json({ session_id: 'session' + sessions }); }
assert.ok(body.session_id);
if (++lists === 1) return Response.json({ error: 'Session expired', error_id: 'invalid_session_id' });
return Response.json({ list: [{ id: 'refinerupscalemethod', values: ['pixel-lanczos///Lanczos', 'model-4x.pth'] }], models: { 'Stable-Diffusion': [['sdxl.safetensors', 'sdxl']] } });
};
try { const b = new SwarmBackend('http://127.0.0.1:7801'); const models = await b.models(); assert.equal(sessions, 2); assert.deepEqual(models.models, ['sdxl.safetensors']); assert.equal(models.upscalers[0], 'pixel-lanczos'); }
finally { globalThis.fetch = original; }
});
test("Comfy upscale is a pixel-only graph; img2img adds the sampling graph", () => {
const upscale = workflow('input.png', { ...preset, upscaler: '4x.pth' }, 100, 50);
assert.ok(Object.values(upscale).some((n) => n.class_type === 'ImageUpscaleWithModel'));
assert.ok(!Object.values(upscale).some((n) => n.class_type === 'KSampler' || n.class_type === 'VAEEncode'));
assert.equal(upscale['4'].inputs.width, 200);
const diffusion = workflow('input.png', { ...preset, mode: 'img2img', model: 'sdxl' }, 100, 50);
assert.equal(diffusion['9'].inputs.denoise, preset.creativity);
assert.deepEqual(diffusion['11'].inputs.images, ['10', 0]);
});
test("backend wire protocols send the source and return downloadable results", async () => {
const original = { fetch: globalThis.fetch, FileReader: globalThis.FileReader, createImageBitmap: globalThis.createImageBitmap };
globalThis.FileReader = class {
readAsDataURL(blob) { blob.arrayBuffer().then((buffer) => { this.result = 'data:image/png;base64,' + Buffer.from(buffer).toString('base64'); this.onload(); }); }
};
globalThis.createImageBitmap = async () => ({ width: 64, height: 128, close() {} });
const calls = [];
globalThis.fetch = async (url, options) => {
const path = new URL(url).pathname;
const body = typeof options?.body === 'string' ? JSON.parse(options.body) : options?.body;
calls.push({ path, body });
if (String(url).startsWith('data:')) return original.fetch(url);
if (path.endsWith('/GetNewSession')) return Response.json({ session_id: 's' });
if (path.endsWith('/GenerateText2Image')) return Response.json({ images: ['View/generated.png'] });
if (path.endsWith('/extra-single-image')) return Response.json({ image: 'AAAA' });
if (path.endsWith('/img2img')) return Response.json({ images: ['AAAA'] });
if (path.endsWith('/upload/image')) return Response.json({ name: 'source.png', subfolder: '', type: 'temp' });
if (path.endsWith('/prompt')) return Response.json({ prompt_id: 'job', node_errors: {} });
if (path.endsWith('/history/job')) return Response.json({ job: { outputs: { '11': { images: [{ filename: 'output.png', subfolder: '', type: 'temp' }] } } } });
return new Response(new Blob(['result'], { type: 'image/png' }));
};
try {
const source = new Blob(['original'], { type: 'image/png' });
const swarm = new SwarmBackend('http://127.0.0.1:7801');
assert.equal(await (await swarm.process(source, { ...preset, model: 'sdxl' })).text(), 'result');
const swarmBody = calls.find((c) => c.path.endsWith('/GenerateText2Image')).body;
assert.equal(swarmBody.refinermethod, 'PostApply'); assert.equal(swarmBody.refinercontrolpercentage, 0); assert.equal(swarmBody.initimagecreativity, 0);
assert.equal(swarmBody.width, 64); assert.equal(swarmBody.height, 128); assert.equal(swarmBody.refinerupscale, 2);
assert.match(swarmBody.initimage, /^data:image\/png;base64,/); assert.equal(swarmBody.donotsave, true);
const a = new A1111Backend('http://127.0.0.1:7860');
await a.process(source, { ...preset, upscaler: 'ESRGAN' });
assert.equal(calls.find((c) => c.path.endsWith('/extra-single-image')).body.upscaler_1, 'ESRGAN');
await a.process(source, { ...preset, mode: 'img2img', model: 'sdxl' });
const aBody = calls.find((c) => c.path.endsWith('/img2img')).body;
assert.equal(aBody.width, 128); assert.equal(aBody.height, 256); assert.equal(aBody.denoising_strength, preset.creativity); assert.equal(aBody.override_settings.sd_model_checkpoint, 'sdxl');
const comfy = new ComfyBackend('http://127.0.0.1:8188');
await comfy.process(source, preset);
const graph = calls.find((c) => c.path === '/prompt').body.prompt;
assert.equal(graph['1'].inputs.image, 'source.png [temp]'); assert.equal(graph['4'].inputs.width, 128);
assert.ok(calls.some((c) => c.path === '/view'));
} finally { Object.assign(globalThis, original); }
});
test("renderer compensates natural size but leaves explicit and ATL dimensions intact", () => {
const { document } = parseHTML('<html><body><main></main></body></html>');
const previous = globalThis.document; globalThis.document = document;
try {
const renderer = new Renderer(document.querySelector('main'), 1920, 1080, { url: () => 'blob:improved', assetScale: () => 2 });
const img = renderer.displayable({ file: 'a.png' });
Object.defineProperty(img, 'naturalWidth', { value: 800 }); img.dispatchEvent(new document.defaultView.Event('load'));
assert.equal(img.width, 400);
const sized = renderer.displayable({ file: 'a.png', size: [80, 40] });
sized.dispatchEvent(new document.defaultView.Event('load')); assert.equal(sized.style.width, '80px');
const atl = renderer.displayable({ file: 'a.png' }); atl.style.width = '100px';
atl.dispatchEvent(new document.defaultView.Event('load')); assert.equal(atl.style.width, '100px'); assert.equal(atl.hasAttribute('width'), false);
} finally { globalThis.document = previous; }
});