diff --git a/README.md b/README.md index 1d71c16..e3cc8ae 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ http://192.168.31.158:8099 - фильтр по Telegram `user_id` (с подсказкой известных пользователей); - фильтр по статусу и режиму (`t2i`/`i2i`); - пагинация; +- превью результата в списке (кэшируются в `storage/.thumbnails`); - карточка запроса с полным промптом, ошибкой, seed и изображениями. Доступ только с локальной сети: Compose публикует порт на `WEB_BIND_IP`. Для доступа из LAN: diff --git a/checklist.md b/checklist.md index fe44faa..5d20d1d 100644 --- a/checklist.md +++ b/checklist.md @@ -297,6 +297,7 @@ ON requests(user_id) WHERE status IN ('queued', 'running'); - [x] `web.py`: FastAPI + Jinja2, отдельный контейнер `web`. - [x] Список запросов с фильтрами `user_id`, `status`, `mode` и пагинацией. +- [x] Превью результата в списке, генерация и кэш в `storage/.thumbnails`. - [x] Карточка запроса: промпт, seed, ошибка, `comfy_prompt_id`, вход/результат. - [x] Отдача изображений только из `STORAGE_DIR` (защита от path traversal). - [x] Доступ только из локальной сети: публикация на `WEB_BIND_IP`, без авторизации. diff --git a/requirements.txt b/requirements.txt index 5229124..76f3e41 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ python-dotenv>=1.0,<2 fastapi>=0.115,<1 uvicorn>=0.30,<1 jinja2>=3.1,<4 +pillow>=10,<13 diff --git a/tests/test_web.py b/tests/test_web.py index 0febc72..19dd8a0 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -4,6 +4,7 @@ import sqlite3 from pathlib import Path from fastapi.testclient import TestClient +from PIL import Image from config import Settings from web import create_app @@ -70,7 +71,8 @@ def test_detail_and_media(tmp_path: Path) -> None: settings = make_settings(tmp_path) output = settings.storage_dir / "1" / "1_output.png" output.parent.mkdir(parents=True) - output.write_bytes(b"fake-png") + with Image.new("RGB", (320, 200), (180, 40, 40)) as image: + image.save(output) app = create_app(settings) with TestClient(app) as client: @@ -85,9 +87,17 @@ def test_detail_and_media(tmp_path: Path) -> None: assert detail.status_code == 200 assert "detail prompt" in detail.text + index = client.get("/") + assert f"/thumb/{request_id}" in index.text + media = client.get(f"/media/{request_id}/output") assert media.status_code == 200 - assert media.content == b"fake-png" + assert media.content.startswith(b"\x89PNG") + + thumb = client.get(f"/thumb/{request_id}") + assert thumb.status_code == 200 + assert thumb.headers["content-type"] == "image/jpeg" + assert thumb.content.startswith(b"\xff\xd8") missing = client.get(f"/media/{request_id}/input") assert missing.status_code == 404 diff --git a/web.py b/web.py index ada4e98..fa272ee 100644 --- a/web.py +++ b/web.py @@ -1,16 +1,21 @@ from __future__ import annotations +import asyncio +import logging import math +import os from contextlib import asynccontextmanager from datetime import datetime from pathlib import Path from typing import Any from urllib.parse import urlencode +from uuid import uuid4 from fastapi import FastAPI, HTTPException, Request from fastapi.responses import FileResponse, HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from PIL import Image, ImageOps from config import Settings from db import Database @@ -23,6 +28,37 @@ STATIC_DIR = BASE_DIR / "web" / "static" STATUSES = ("queued", "running", "done", "failed") MODES = ("t2i", "i2i") +logger = logging.getLogger(__name__) + + +def _safe_storage_path(raw_path: str, storage_root: Path) -> Path | None: + path = Path(raw_path).resolve() + try: + path.relative_to(storage_root) + except ValueError: + return None + return path + + +def _make_thumbnail(source: Path, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + temp_path = destination.with_name(f"{destination.name}.{uuid4().hex}.tmp") + try: + with Image.open(source) as image: + image = ImageOps.exif_transpose(image) + image.thumbnail((320, 320)) + if image.mode in {"RGBA", "LA"} or "transparency" in image.info: + rgba = image.convert("RGBA") + background = Image.new("RGB", rgba.size, (15, 17, 21)) + background.paste(rgba, mask=rgba.getchannel("A")) + image = background + else: + image = image.convert("RGB") + image.save(temp_path, format="JPEG", quality=85, optimize=True) + os.replace(temp_path, destination) + finally: + temp_path.unlink(missing_ok=True) + def fmt_dt(value: str | None) -> str: if not value: @@ -163,15 +199,45 @@ def create_app(settings: Settings | None = None) -> FastAPI: raise HTTPException(status_code=404, detail="Файл не найден") storage_root = settings.storage_dir.resolve() - path = Path(raw_path).resolve() - try: - path.relative_to(storage_root) - except ValueError: - raise HTTPException(status_code=404, detail="Файл не найден") from None - if not path.is_file(): + path = _safe_storage_path(raw_path, storage_root) + if path is None or not path.is_file(): raise HTTPException(status_code=404, detail="Файл не найден") return FileResponse(path) + @app.get("/thumb/{request_id}") + async def thumbnail(request: Request, request_id: int): + db: Database = request.app.state.db + settings: Settings = request.app.state.settings + item: HistoryRequest | None = await db.get_request(request_id) + if item is None or not item.output_file_path: + raise HTTPException(status_code=404, detail="Файл не найден") + + storage_root = settings.storage_dir.resolve() + source = _safe_storage_path(item.output_file_path, storage_root) + if source is None or not source.is_file(): + raise HTTPException(status_code=404, detail="Файл не найден") + + cache_dir = storage_root / ".thumbnails" + cache_path = cache_dir / f"{request_id}.jpg" + if cache_path.is_file() and cache_path.stat().st_mtime >= source.stat().st_mtime: + return FileResponse( + cache_path, + media_type="image/jpeg", + headers={"Cache-Control": "public, max-age=3600"}, + ) + + try: + await asyncio.to_thread(_make_thumbnail, source, cache_path) + except Exception: + logger.exception("Не удалось создать превью для запроса %s", request_id) + return FileResponse(source) + + return FileResponse( + cache_path, + media_type="image/jpeg", + headers={"Cache-Control": "public, max-age=3600"}, + ) + return app diff --git a/web/static/style.css b/web/static/style.css index a38bfdb..c792191 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -120,6 +120,17 @@ tbody tr:last-child td { border-bottom: 0; } .empty { text-align: center; color: var(--muted); padding: 28px; } .details { white-space: nowrap; } +.thumb-cell { width: 78px; } +.thumb { + width: 64px; + height: 64px; + object-fit: cover; + display: block; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel-2); +} + .badge { display: inline-block; padding: 2px 8px; diff --git a/web/templates/index.html b/web/templates/index.html index 490c8c4..8d7acba 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -52,6 +52,7 @@ ID + Превью Дата Пользователь Режим @@ -65,6 +66,15 @@ {% for item in rows %} #{{ item.id }} + + {% if item.output_file_path %} + + Результат #{{ item.id }} + + {% else %} + — + {% endif %} + {{ item.timestamp|fmt_dt }} {{ item.user_id }} {{ item.mode }} @@ -74,7 +84,7 @@ Детали {% else %} - Ничего не найдено + Ничего не найдено {% endfor %}