chore(photo-ai): раздел 0 — жёсткие инварианты I1–I7 зафиксированы

План фото-ИИ с восстановлением лиц разбит на этапы; этот коммит закрывает
раздел 0 — семь инвариантов, которые нельзя ломать дальше. Два из них были
нарушены в текущем коде и исправлены здесь.

I5 (мягкие ошибки не сжигают попытки). Раньше любой сбой photo-ai —
503, обрыв сети, таймаут — попадал в общий catch, инкрементил attempts и
через три попытки переводил задание в error. Теперь ошибки разделены:
5xx/429/425/408 и сетевая недоступность возвращают задание в pending без
инкремента attempts, с экспоненциальной паузой 10 с → 300 с; лимит мягких
повторов (по умолчанию 60) даёт одну честную ошибку с понятным текстом.
Таймаут AbortSignal.timeout — жёсткая ошибка с попытками, как и раньше.
Счётчик мягких повторов живёт в памяти процесса и в счётчиках воркера,
метаданные повтора — в audit_log.target (soft_attempt/soft_limit), без
новых колонок. Новый аудит-код photo.job.soft_retry и подпись в audit.js.
Переменные PHOTO_AI_SOFT_MAX_RETRIES, PHOTO_AI_SOFT_BACKOFF_MS,
PHOTO_AI_SOFT_BACKOFF_MAX_MS описаны в .env.example и отдаются в
worker.config в GET /api/photo-jobs/status.

I7 (никаких прямых fs.* по uploads/). runAiEnhance писал результат
fs.writeFileSync в uploads/ и только потом persist в S3; enhanceWithSharp
делал то же через sharp toFile. Оба теперь считают буфер и пишут его
через storage.put — драйвер выбирает сам, локальной копии не остаётся.
Из worker.js убраны require('fs'), require('path') и параметр uploadsDir.

I3 (photo-ai не обязателен). photo_ai_enabled вычислялся внутри
cacheWrap('public-settings'), поэтому после перезапуска с пустым
PHOTO_AI_URL кнопка «🤖 ИИ» оставалась видимой до истечения кэша (60 с),
хотя enhance-ai уже отдавал 503. Флаг вынесен из кэша: он выводится из
PHOTO_AI_URL в памяти процесса и всегда актуален.

Проверено на стенде (журнал — в TODO_PHOTO_FACE_AI.md, раздел 0):
- I1: эталон /enhance снят на 5 фото (3 реальных, 2 синтетических),
  два независимых прогона и прогон после правок совпали байт-в-байт
  (sha256), /health отдаёт ok. Скрипты и эталон — в backups/ (вне git)
- I2: задание с params IS NULL и action='ai' дошло до done при
  attempts=0, результат отдан из S3 (200)
- I3: с пустым PHOTO_AI_URL photo_ai_enabled=false сразу после старта,
  enhance-ai → 503, остальные маршруты API живы
- I4: nvidia-ctk и nvidia-container-runtime на хосте отсутствуют, runtime
  только runc — фото-ИИ поднялся на CPU, /health не падает. Проверка
  PHOTO_AI_DEVICE переносится на приёмку Stage 1 (переменной ещё нет)
- I6: db/ не тронут, состав колонок photo_jobs прежний
- I7: node --check для всех изменённых JS, комментариев в диффе нет,
  весь SQL параметризован

api.smoketest.js: контракт фото-воркера — согласованность
photo_ai_enabled и ai_configured, ключи мягких повторов в worker.config,
503/404 для enhance-ai на несуществующей записи (тест не создаёт реальных
заданий). Вместе с планом и чек-листом этапов.
This commit is contained in:
dev
2026-09-28 23:19:16 +03:00
parent 31542de33b
commit 403574fe79
7 changed files with 1036 additions and 25 deletions
+553
View File
@@ -0,0 +1,553 @@
# План: Улучшение лиц на фотографиях (Real-ESRGAN + GFPGAN/CodeFormer) с автовыбором GPU/CPU
Статус: **план, реализация не начата**. Документ описывает целевую архитектуру, изменения по файлам,
порядок внедрения и критерии приёмки. Реализацию начинать после знакомства с этим файлом.
---
## 1. Постановка задачи
Сейчас система улучшает фотографии одной моделью `RealESRGAN_x2plus` (x2, CPU, `half=False`,
`device='cpu'`, захардкожено в `photo-ai/app.py:44`). Модель хорошо восстанавливает текстуры
(одежда, фон, бумага), но **лица** при апскейле часто получают артефакты, «пластиковую» кожу и
искажённые черты, потому что у `RealESRGAN_x2plus` нет приора на структуру лица.
Нужно:
1. Добавить модели восстановления **лиц** — GFPGAN v1.4 и/или CodeFormer (оба — face restoration
с prior-сетью, работают в связке с `RealESRGANer` как `bg_upsampler`).
2. Дать пользователю **выбор модели** для обработки: универсальная x2, face-модель, комбинация
(фон + лица), быстрая VGG-модель `realesr-general-x4v3`.
3. Поддержать работу **на GPU и на CPU** с **автоматическим выбором** устройства: есть рабочий
CUDA — используем GPU, нет — молча и без падений уходим на CPU.
4. Не допустить простоя GPU-контейнера: пока модели грузятся — сервис отвечает `503`, воркер
ждёт, а не теряет задания.
5. Не сломать существующие контракты: `photo_jobs`, `/enhance`, `PHOTO_AI_URL`, автономную работу
без `photo-ai` (`PHOTO_AI_URL` пустой → кнопка «ИИ» недоступна).
### Ограничения окружения (проверено на текущем хосте)
| Параметр | Значение | Следствие |
|----------|----------|-----------|
| GPU | `NVIDIA GeForce RTX 3050 ...`, 4096 MiB VRAM, драйвер 615.71.09, CUDA UMD 13.4 | GPU-режим реален, но 4 ГБ VRAM — тесно, нужен `tile` и запас |
| `/dev/dri` | `card1`, `card2`, `renderD128`, `renderD129` | iGPU тоже виден, но torch будет использовать CUDA |
| `nvidia-ctk` | **не установлен** | нужен NVIDIA Container Toolkit на хосте, иначе `--gpus` не заработает |
| Runtime Docker | только `runc` (нет `nvidia`) | требуется установка toolkit + `docker compose` override |
| CPU | Intel i5-12500H, 16 потоков | CPU-режим приемлем как fallback, но медленный |
| RAM | 15 GiB (занято ~9 ГБ), swap 31 GiB | CPU-модели + torch требуют ~2–3 ГБ; следить за OOM |
| Диск | 59 ГБ свободно на `/home` | веса: x2plus 64 МБ + GFPGAN 333 МБ + CodeFormer 360 МБ + wdn 64 МБ — ок |
| Docker / Compose | 29.8.1 / 5.5.1 | поддерживают `deploy.resources.reservations.devices` (Compose v5) |
Важно: **GPU-режим не должен быть обязательным условием запуска**. Если toolkit не установлен,
compose-файл с `devices` не поднимется — поэтому GPU выносим в отдельный override-файл.
---
## 2. Что уже есть (точки интеграции)
| Место | Что делает | Что меняем |
|-------|-----------|-----------|
| `photo-ai/app.py` | FastAPI, `POST /enhance` (`image`, `scale`), одна модель, `device='cpu'` | Расширяем до реестра моделей + автовыбор device + `POST /enhance` с `model`/`face` |
| `photo-ai/Dockerfile` | `python:3.10-slim`, torch CPU-only, правка `basicsr/data/degradations.py` | Разделяем на CPU-базу и GPU-базу (`ARG`), добавляем `gfpgan`, `facexlib` |
| `docker-compose.yml` (`photo-ai`) | build `./photo-ai`, `MODEL_PATH`, `MAX_INPUT_PIXELS`, том `photo-ai-models` | Добавляем env `PHOTO_AI_DEVICE`, `PHOTO_AI_FACE_MODEL`, `PHOTO_AI_TILE`, healthcheck |
| `worker.js` → `createPhotoEnhanceWorker` | `runAiEnhance(srcKey)` шлёт `image` + `scale=2`, ждёт `image/jpeg` (таймаут 300 с, 3 попытки) | Передаём `model`/`face`/`strength` из `job.params`, разбираем JSON-ответ, 503 ждёт без траты попыток |
| `server.js` → `POST /api/entries/:id/photo/enhance-ai` | `INSERT INTO photo_jobs (entry_id, action, status, params) VALUES ($1,'ai','pending',NULL)` | Принимаем `model`/`face`/`face_model`/`strength`, валидируем, пишем в `params` (колонка уже есть) |
| `server.js` → `GET /api/photo-jobs/status` | `ai_configured`, `ai_url`, `worker`, `counts` | Добавляем `service.device`, `service.models`, `service.face_models`, `service.ready` |
| `server.js` → `PHOTO_JOB_ACTIONS` | белый список действий, `action VARCHAR(20)` | Новые действия `ai_face`, `ai_upscale` в двух местах (валидация + restore) |
| `public/js/journal.js` (кнопка «🤖 ИИ») | Ставит задание `action='ai'` | Выбор модели: универсальная / быстрая / лица / обе |
| `public/js/worker.js` + `worker.html` | Таблица заданий, `PHOTO_ACTION_LABELS`, сравнение «Было/Стало» | Новые метки, показ модели/устройства/времени обработки |
| `settings.html` / `settings.js` | `photo_enhance_engine` (`auto`/`server`/`client`) | Новые настройки: `photo_ai_face_mode`, `photo_ai_device_pref` |
Текущее поведение, которое **сохраняем байт-в-байт**:
`/enhance` с `scale=2` без указания модели → та же картинка, что и сегодня (x2plus, JPEG q92).
Это нужно, чтобы старые `photo_jobs` со `params = NULL` и все закешированные превью не изменились.
---
## 3. Модели: что именно добавляем
### 3.1 Каталог моделей
| Ключ | Класс | Веса | Размер | Назначение |
|------|-------|------|--------|-----------|
| `x2plus` | `RRDBNet(scale=2)` | `RealESRGAN_x2plus.pth` | 64 МБ | **текущая**, универсальный апскейл x2, дефолт |
| `general-x4v3` | `SRVGGNetCompact(upscale=4)` | `realesr-general-x4v3.pth` | 4.7 МБ | быстрый апскейл x4 + денойз через DNI (`realesr-general-wdn-x4v3.pth`) |
| `animevideo-v3` | `SRVGGNetCompact(upscale=4)` | `realesr-animevideov3.pth` | 2.4 МБ | быстрый x4, для скриншотов/иллюстраций |
| `gfpgan` | `GFPGANer(arch='clean', channel_multiplier=2)` | `GFPGANv1.4.pth` | 333 МБ | **восстановление лиц**, `bg_upsampler` = выбранный ESRGAN |
| `codeformer` | `CodeFormer` | `codeformer.pth` + `detection_Resnet50_Final.pth` + `parsing_parsenet.pth` | 360 МБ + ~110 МБ | **восстановление лиц**, регулируемая сила `fidelity_weight` (`-w`), лучше на сильных искажениях |
Рекомендация: **GFPGAN v1.4 как основная face-модель** (меньше весов, стабильнее на 4 ГБ VRAM,
это модель по умолчанию в апстриме `inference_realesrgan.py` через `--face_enhance`), CodeFormer —
опционально, как альтернатива с регулируемой силой. На CPU CodeFormer практически
неработоспособен по времени (facexlib + parsing) — оставляем его «только GPU, если включён явно».
### 3.2 Режимы обработки (`face_mode`)
| `face_mode` | Что происходит | Модель | Когда использовать |
|-------------|----------------|--------|--------------------|
| `off` | только апскейл фона, лицо не трогается | ESRGAN | текущее поведение, фон/текстуры |
| `face` | апскейл фона + **только лица** вставлены восстановленными (paste-back) | ESRGAN + GFPGAN/CodeFormer | портреты, крупные лица |
| `all` | как `face`, плюс мягкий денойз фона | ESRGAN(+wdn) + GFPGAN | зашумлённые снимки с веб-камеры 640×480 |
Технически GFPGAN работает так: детектирует лица (facexlib/RetinaFace), кропает → восстанавливает
→ paste-back в альбом. Если лиц не найдено — возвращает чистый результат `bg_upsampler`: это
безопасный no-op и не должно считаться ошибкой.
### 3.3 Почему face-модель нельзя ставить «всегда»
1. **Скорость.** Детекция + face-restore + paste-back даёт +40…150 % ко времени. На CPU это
разница между ~40 с и ~90 с на фото 640×480.
2. **Гарантий нет.** GFPGAN «дорисовывает» лица по приору: на сильно замытых, боковых или
закрытых лицах он может сделать человека похожим на другого. Для журнала посещаемости ошибка
идентификации недопустима, поэтому face-режим — **явный выбор пользователя**, а не молчаливый
дефолт.
3. **VRAM.** GFPGAN + x2plus одновременно держат два графа на GPU; на 4 ГБ нужен `tile <= 256`.
### 3.4 Точные URL весов (скачиваются при первом запуске)
```
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-x4v3.pth
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-wdn-x4v3.pth
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-animevideov3.pth
https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.4.pth
https://github.com/sczhou/CodeFormer/releases/download/v0.1.0/codeformer.pth
```
Каждый файл кладём в `/models/weights/<name>.pth` (том `photo-ai-models`), загрузка через
`.tmp` + `os.replace` и проверку минимального размера — иначе оборванная закачка оставит «битые»
веса, и сервис будет падать при загрузке.
---
## 4. Автоматический выбор GPU/CPU
### 4.1 Приоритет выбора (в `app.py` при старте)
```
1. PHOTO_AI_DEVICE=cuda|cpu|auto (env, дефолт auto)
2. если auto:
a. torch.cuda.is_available() and torch.cuda.device_count() > 0
-> device = 'cuda:0', half = True (fp16 быстрее и экономит VRAM)
b. иначе если torch.backends.mps.is_available()
-> device = 'mps', half = False (Apple Silicon, на случай dev-машины)
c. иначе -> device = 'cpu', half = False
3. если явно cuda, но cuda недоступна -> НЕ падать: WARN в лог и уйти на cpu
(контейнер обязан подниматься даже без GPU — требование отказоустойчивости)
4. явно cpu — всегда cpu, даже если GPU есть (для отладки и воспроизводимости)
```
### 4.2 Прогрев, ленивая загрузка и деградация
- `load_model()` вызывается в `startup` (сейчас через `asyncio.to_thread`), модели грузятся
**лениво по требованию** и кешируются в `MODELS` dict под `threading.Lock`. Первый запрос к
новой модели оплачивает её загрузку (x2plus 64 МБ грузится быстрее, чем GFPGAN 333 МБ).
- Пока нужная модель не готова, `/enhance` возвращает `503` + `Retry-After: 5`, а `/health`
отдаёт `{"ok": true, "ready": false, "loading": ["gfpgan"]}`. Воркер на `503` **не** тратит
попытку из `PHOTO_MAX_ATTEMPTS`: он ждёт и повторяет (см. §5.4).
- `CUDA out of memory` при инференсе — ловим `RuntimeError`, уменьшаем `tile` вдвое
(256 → 128 → 64) и повторяем **один раз**; если снова OOM — переключаемся на `cpu`,
инвалидируем модель и повторяем. Если и на CPU не получилось — `500` с понятным текстом.
- Прогрев (warm-up) на синтетическом шуме 64×64 сразу после загрузки: убирает «первый запрос в
3 раза дольше» и немедленно выявляет OOM.
### 4.3 Что показывать оператору
`GET /health` (расширяем, обратно совместимо: поле `ok` остаётся):
```json
{
"ok": true,
"ready": true,
"device": "cuda:0",
"device_name": "NVIDIA GeForce RTX 3050 ...",
"half": true,
"tile": 256,
"driver": "615.71.09",
"cuda": "13.4",
"vram_total_mb": 4096,
"vram_free_mb": 3780,
"models": ["x2plus", "general-x4v3", "animevideo-v3"],
"face_models": ["gfpgan", "codeformer"],
"loaded": ["x2plus", "gfpgan"],
"max_pixels": 4000000
}
```
`server.js` проксирует это в `GET /api/photo-jobs/status` → `service` и в блок «Статус стека»
(`getStackInfo()`, `public/settings.html`), чтобы было видно, на чём реально считает фото-ИИ.
### 4.4 Docker: как отдать GPU контейнеру
Два обязательных шага на хосте (сейчас **не выполнены** — `nvidia-ctk` отсутствует):
```bash
# 1. NVIDIA Container Toolkit
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | \
sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \
sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
sudo nvidia-ctk runtime configure --runtime=docker && sudo systemctl restart docker
# 2. Проверка
docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
```
Compose делим на два файла, чтобы GPU был **опцией, а не требованием** (по образцу
существующего `docker-compose.minio.yml`):
- `docker-compose.yml` — `photo-ai` без GPU (CPU-образ, как сейчас). Стек поднимается на любой
машине.
- `docker-compose.gpu.yml` (новый) — override:
```yaml
services:
photo-ai:
build:
context: ./photo-ai
args:
TORCH_VARIANT: cu124
environment:
PHOTO_AI_DEVICE: cuda
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
```
Запуск GPU-режима:
```bash
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
```
Если `photo-ai` уже запущен в CPU-режиме — сначала `docker compose stop photo-ai`, затем команда
выше (иначе Compose ругается на конфликт конфигурации сервиса).
---
## 5. Изменения по файлам
### 5.1 `photo-ai/app.py` (переписываем, ~250–300 строк)
Новая структура:
```
ENV: PHOTO_AI_DEVICE, PHOTO_AI_FACE_MODEL, PHOTO_AI_TILE, PHOTO_AI_MAX_PIXELS,
PHOTO_AI_MODELS_DIR, PHOTO_AI_LOAD_ALL, PHOTO_AI_WARMUP, PHOTO_AI_JPEG_QUALITY
pick_device() -> (device, half, device_name) # §4.1
MODEL_REGISTRY = { 'x2plus': {...}, 'general-x4v3': {...}, 'animevideo-v3': {...} }
FACE_REGISTRY = { 'gfpgan': {...}, 'codeformer': {...} }
class ModelPool: # Lock, dict, lazy load, OOM-ретрай
get_esrgan(name) -> RealESRGANer
get_face(name, bg_upsampler) -> GFPGANer | CodeFormer
encode_jpeg(out, quality)
@app.get('/health') # расширенный контракт §4.3
@app.get('/models') # список моделей + устройство
@app.post('/enhance') # image, scale, model, face, face_model, strength
```
Ключевые детали реализации:
- `POST /enhance` принимает те же `image` и `scale`, плюс новые необязательные поля:
`model` (дефолт `x2plus`), `face` (`off`|`face`|`all`, дефолт `off`),
`face_model` (`gfpgan`|`codeformer`), `strength` (0.0–1.0, только CodeFormer, дефолт 0.7),
`jpeg_quality` (70–100, дефолт 92). Неизвестная модель → `400` со списком допустимых.
- Полная обратная совместимость: без `model`/`face` → путь «x2plus, JPEG q92», как сегодня.
- `face != off` → `face_enhancer.enhance(img, has_aligned=False, only_center_face=False,
paste_back=True)`; в ответ добавляем `faces_found`.
- **Два формата ответа**: JSON (`{ok, image_base64, model, face, face_model, faces_found,
device, elapsed_ms, warnings}`) при `Accept: application/json` — новый путь для воркера;
сырой `image/jpeg` без заголовка — текущее поведение (совместимость с ручными `curl`).
- `upsampler.enhance()` уже делает тайлинг сам — `tile`/`tile_pad=10` остаются, `tile` берём из env.
- `half=True` только при CUDA; `dni_weight` — только для `general-x4v3` при `denoise_strength != 1`.
- Расширения определяем по имени файла, а не по `UploadFile.content_type` (браузер и `FormData`
в `worker.js` шлют `image/jpeg` для любого исходника — сейчас это уже так, сохраняем).
### 5.2 `photo-ai/Dockerfile`
```dockerfile
FROM python:3.10-slim
ARG TORCH_VARIANT=cpu # cpu | cu124
ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}
```
- `TORCH_VARIANT=cpu` → `torch torchvision --index-url .../whl/cpu` (как сейчас);
`cu124` → тот же `pip` с `.../whl/cu124`. Образ один, вариант — аргумент сборки.
- Добавить `gfpgan==1.3.8` и `facexlib==0.3.0` (CodeFormer — из TencentARC, пакет/вендоринг).
**Проверить доступность пакетов в зеркале pip заранее** — это риск сборки, если недоступны,
вендорим исходники в `photo-ai/vendor/` и копируем каталог в образ.
- `libgl1 libglib2.0-0` уже есть — facexlib их требует.
- Патч `basicsr/data/degradations.py` (`torchvision.transforms.functional_tensor` → `functional`)
**сохранить** — без него basicsr падает на torch >= 2.0.
- `ENV PHOTO_AI_MODELS_DIR=/models`; старый `MODEL_PATH` продолжаем читать как алиас, чтобы
существующий `.env`/том не сломался.
- Размер образа: CPU ~1.2 ГБ → ~2.5 ГБ; cu124 ~6–7 ГБ. Учитывать при `--build`.
- Тома: `photo-ai-models` уже есть — все веса в `/models/weights/*.pth`.
### 5.3 `docker-compose.yml`
```yaml
photo-ai:
environment:
PHOTO_AI_DEVICE: ${PHOTO_AI_DEVICE:-auto}
PHOTO_AI_FACE_MODEL: ${PHOTO_AI_FACE_MODEL:-gfpgan}
PHOTO_AI_TILE: ${PHOTO_AI_TILE:-256}
PHOTO_AI_MAX_PIXELS: ${PHOTO_AI_MAX_PIXELS:-4000000}
PHOTO_AI_LOAD_ALL: ${PHOTO_AI_LOAD_ALL:-0}
PHOTO_AI_JPEG_QUALITY: ${PHOTO_AI_JPEG_QUALITY:-92}
MODEL_PATH: /models/RealESRGAN_x2plus.pth
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3).status==200 else 1)\""]
interval: 30s
timeout: 5s
retries: 5
start_period: 300s
```
`start_period: 300s` — загрузка x2plus + GFPGAN на CPU занимает минуты; короткий период даст
`unhealthy` на старте. `PHOTO_AI_LOAD_ALL=1` предзагружает все модели (для GPU-сервера с запасом
RAM), по умолчанию `0` — ленивая загрузка.
### 5.4 `worker.js` (функция `createPhotoEnhanceWorker`)
- `runAiEnhance(srcKey, params)`:
- читает `params.model`, `params.face`, `params.face_model`, `params.strength`;
- отправляет их вместе с `image` и `scale`; ставит `Accept: application/json`, разбирает
JSON-ответ, декодирует base64 в буфер;
- если сервис вернул `image/jpeg` (старая версия photo-ai) — работает как сейчас, без ошибок;
- `503` (`Retry-After`) обрабатывает отдельно: `sleep` 10 с, `return false` — **без** инкремента
`attempts` (мягкий повтор, задание не сгорает);
- `AbortError` от `AbortSignal.timeout(PHOTO_AI_TIMEOUT_MS)` → сообщение
«ИИ-сервис не ответил за N с» (уже ошибка с попытками);
- в аудит пишет модель/устройство/время: `logAudit(null, 'photo.job.preview', { ..., model,
face, device, elapsed_ms })`.
- `processOne`: `job.action === 'ai' || job.action === 'ai_face'` → AI-путь; `enhance` → sharp.
Если `params` пусты, `action='ai_face'` даёт дефолт `face='face'`.
- `CONFIG` воркера дополняем `face_model`, `default_model`, `face_timeout_ms` — они попадают в
`GET /api/photo-jobs/status.worker.config`.
- Для face-режима на CPU вводим отдельный, больший таймаут: `PHOTO_AI_FACE_TIMEOUT_MS`
(дефолт 600 000) вместо 300 000.
### 5.5 `server.js`
1. `POST /api/entries/:id/photo/enhance-ai` (строка 5114) — тело
`{ model, face, face_model, strength }`:
- `model` ∈ `['x2plus', 'general-x4v3', 'animevideo-v3']`;
- `face` ∈ `['off', 'face', 'all']`;
- `face_model` ∈ `['gfpgan', 'codeformer']`;
- `strength` — число 0…1, только для CodeFormer;
- `params = JSON.stringify({ model, face, face_model, strength })` в `photo_jobs.params`;
- `action = face === 'off' ? 'ai' : 'ai_face'`;
- `400` при невалидных значениях; пустое тело → сегодняшнее поведение (`params = NULL`,
`action = 'ai'`). Валидация — инлайн-хелперами (`optInt`) и явными списками, как в
`PUT /api/settings`.
2. `PHOTO_JOB_ACTIONS` — добавить `'ai_face'`, `'ai_upscale'`. Схема не меняется
(`action VARCHAR(20)`), но белый список используется в двух местах: `ensurePhotoJobsTable()`
(~1238–1256) и при разборе restore-данных (~2259) — обновить **оба**.
3. `GET /api/photo-jobs/status` (строка 5782) — прокинуть `service.device`,
`service.device_name`, `service.ready`, `service.vram_free_mb`, `service.models`,
`service.face_models` из `GET /health` photo-ai (таймаут 5 с, по образцу `aiHealthCheck()`).
Запрос делать без падения: photo-ai недоступен → `service = { reachable: false }`.
4. Новый `GET /api/photo-ai/health` (`requireAdmin`) — прямой прокси `/health` для оператора.
В `getStackInfo()` добавить блок `photo_ai` (`device`, `device_name`, `vram_total_mb`,
`models`) — рендерится в «Статус стека» на `public/settings.html`.
5. Настройки — валидация рядом со строкой 1710:
- `photo_ai_face_mode` ∈ `['off', 'face', 'all']`, дефолт `off`;
- `photo_ai_device_pref` ∈ `['auto', 'cuda', 'cpu']`, дефолт `auto` — **только для UI и
документации**: фактическое устройство определяет контейнер через env, UI показывает,
совпадает ли желаемое с фактическим (если нет — подсветить).
- дефолты дописать в `db/init.sql`, `db/migration.sql` (`INSERT ... ON CONFLICT DO NOTHING`)
и в `keys`/`defaults` `/api/public-settings` (строка 1663–1664);
- `photo_ai_enabled` там уже отдаётся (строка 1677) — сохранить.
6. `worker.js` вызывается с новыми параметрами: в `createPhotoEnhanceWorker({...})` (строка 6045)
добавить `faceTimeoutMs: PHOTO_AI_FACE_TIMEOUT_MS`, `defaultFaceModel: PHOTO_AI_FACE_MODEL`.
7. Предупреждения сервиса (`warnings`, например «вход меньше 320×320») сохранять в аудит, чтобы
оператор понимал, что face-режим не сработал не из-за ошибки.
### 5.6 Фронтенд
- `public/js/journal.js` (кнопка «🤖 ИИ», ~строка 620) — рядом dropdown: «Универсально (x2)»,
«Быстро (x4)», «Лица (GFPGAN)», «Лица + фон». Значение уходит в
`POST /api/entries/:id/photo/enhance-ai` телом `{ model, face, face_model }`. Дефолт — из
`photo_ai_face_mode` (публичные настройки уже читаются на этой странице).
- `public/js/worker.js` — `PHOTO_ACTION_LABELS` дополнить (`ai_face`: «ИИ + лица»,
`ai_upscale`: «ИИ-апскейл»); в модалке сравнения «Было/Стало» показать `model`, `device`,
`faces_found`, `elapsed_ms`. Новых колонок в БД не нужно — данные берём из `params` и аудита.
- `public/settings.html` + `public/js/settings.js` — блок «Фото-ИИ»: селект face-режима, селект
устройства («желаемое» + строка «фактическое»), read-only статус (`device_name`, VRAM, список
моделей) из `/api/photo-jobs/status`. Новые поля добавить в `DIRTY_FIELDS` (строка 1
`settings.js`) и в сборку payload (строка ~739).
- `public/js/audit.js` — новые коды действий аудита прописать в словарь меток, иначе в UI будет
сырой код (`photo.job.preview` уже есть, при добавлении `photo.job.ai` — добавить и там).
### 5.7 Документация и тесты
- `AGENTS.md` — раздел про photo-ai: реестр моделей, device-политика, новые env, GPU-override,
обновлённый контракт `/enhance` и `/health`.
- `README.md` — установка NVIDIA Container Toolkit, команда запуска с `docker-compose.gpu.yml`,
проверка `GET /api/photo-jobs/status` → `service.device`.
- `.env.example` — новые переменные с комментариями (см. §7).
- `api.smoketest.js` — добавить проверки:
- `POST /api/entries/:id/photo/enhance-ai` с `model: 'нет такой'` → `400`;
- `face: 'face'` без `PHOTO_AI_URL` → `503`;
- `GET /api/photo-jobs/status` содержит `service` (или `ai_configured: false`).
Это соответствует правилу из `AGENTS.md`: контракт авторизации и API фиксируется в smoke-тесте
в том же изменении.
---
## 6. Порядок внедрения (этапы)
Каждый этап заканчивается проверяемым результатом и не ломает предыдущий.
### Этап 0. Подготовка (0.5 дня)
- Установить NVIDIA Container Toolkit, проверить `docker run --gpus all ... nvidia-smi`.
- Зафиксировать текущее состояние `.env` (`PHOTO_AI_URL=` пусто или `http://photo-ai:8080`).
- Сохранить эталон «до»: 3–5 фото (портрет, групповое, без лиц, зашумлённое), прогнать
`curl -F image=@photo.jpg -F scale=2 http://localhost:8080/enhance`.
**Приёмка:** `nvidia-smi` внутри контейнера видит RTX 3050; эталонные JPEG сохранены для
сравнения на следующих этапах.
### Этап 1. `app.py`: реестр моделей + автовыбор device (1–1.5 дня)
- `pick_device()`, `ModelPool`, расширенный `/health`, ленивая загрузка x2plus, warm-up.
- Старый `/enhance` по поведению не меняется (проверить визуально и по размеру файла).
**Приёмка:** `/health` отдаёт `device`, `device_name`, `half`; `PHOTO_AI_DEVICE=cpu` при рабочей
CUDA даёт `cpu`; `PHOTO_AI_DEVICE=cuda` без toolkit даёт `cpu` + WARN, сервис поднимается.
### Этап 2. Dockerfile + compose (0.5–1 день)
- `ARG TORCH_VARIANT`, `docker-compose.gpu.yml`, healthcheck, новые env, алиас `MODEL_PATH`.
**Приёмка:** CPU- и GPU-сборка стартуют; `/api/photo-jobs/status` показывает
`service.device = "cuda:0"` в GPU-режиме и `"cpu"` в CPU-режиме; при пустом `PHOTO_AI_URL`
приложение полностью работает без photo-ai.
### Этап 3. GFPGAN — face-режим (1–2 дня)
- `FACE_REGISTRY`, поля `face`/`face_model`/`strength` в `/enhance`, JSON-ответ, `faces_found`,
OOM-ретрай и fallback на CPU.
**Приёмка:** на тестовом портрете 640×480 в режиме `face` лицо резче, `faces_found = 1`;
на фото без лиц — `faces_found = 0` и результат не хуже, чем `x2plus`; искусственный OOM
(`tile=1024` на 4 ГБ) деградирует до CPU без падения сервиса.
### Этап 4. Воркер и API (1 день)
- `worker.js`: `params` → модель/face, JSON-разбор, `503`-ожидание без траты попыток, аудит.
- `server.js`: валидация `params`, `action = 'ai_face'`, `service` в статусе, новые настройки.
**Приёмка:** задание через API с `face='face'` доходит до `done`, параметры сохранены в
`photo_jobs.params`, в аудите — модель/устройство/время; остановка photo-ai на лету даёт задание,
которое дообработается после возврата сервиса (без `error`).
### Этап 5. Фронтенд (1 день)
- Выбор модели в журнале, статус устройства в настройках, новые метки в воркере.
**Приёмка:** из журнала доступны все 4 варианта; после постановки видно «В очереди», затем
сравнение «Было/Стало»; в настройках показано фактическое устройство и VRAM.
### Этап 6. Docs + smoke (0.5 дня)
- `AGENTS.md`, `README.md`, `.env.example`, `api.smoketest.js`.
**Приёмка:** `node api.smoketest.js` проходит; тесты валидации возвращают `400`; документация
совпадает с кодом.
**Итого:** ~5–7 рабочих дней. Этапы 1–3 дают ценность уже без фронтенда (ручные вызовы `curl`).
---
## 7. Новые переменные окружения
```bash
# === Фото-ИИ (Real-ESRGAN + восстановление лиц) ===
# Адрес сервиса; пусто = фото-ИИ выключен (кнопка «ИИ» недоступна)
PHOTO_AI_URL=
# Максимум входных пикселей (даунскейл перед обработкой)
PHOTO_AI_MAX_PIXELS=4000000
# Устройство: auto | cuda | cpu. auto = CUDA, если доступна, иначе CPU
PHOTO_AI_DEVICE=auto
# Тайл для апскейла: меньше = меньше VRAM, но медленнее (256 на 4 ГБ, 512+ при 8 ГБ+)
PHOTO_AI_TILE=256
# Модель лиц: gfpgan | codeformer | none
PHOTO_AI_FACE_MODEL=gfpgan
# Предзагружать все модели при старте (1 — да, нужно больше RAM/VRAM)
PHOTO_AI_LOAD_ALL=0
# Режим лиц по умолчанию для UI: off | face | all
PHOTO_AI_FACE_MODE=off
# Качество JPEG результата (70–100)
PHOTO_AI_JPEG_QUALITY=92
# Отдельный таймаут для face-режима, мс (на CPU медленно)
PHOTO_AI_FACE_TIMEOUT_MS=600000
```
---
## 8. Риски и как их закрываем
| Риск | Вероятность | Митигация |
|------|-------------|-----------|
| `nvidia-container-toolkit` не установлен / политика хоста запрещает | средняя | GPU — отдельный override-файл; CPU-путь остаётся дефолтом и полностью рабочим; в `/health` видно фактическое устройство |
| 4 ГБ VRAM не хватает для GFPGAN + x2plus | высокая | `tile=256` по умолчанию, авто-снижение до 128/64 при OOM, `half=True` только на CUDA, при повторном OOM — fallback на CPU |
| GFPGAN «портит» лица (артефакты идентичности) | средняя | face-режим только по явному выбору; результат попадает в `photo_jobs.after_path` и **не применяется автоматически** (нужно нажать «Применить»), история и «Вернуть оригинал» сохраняются |
| Долгая загрузка весов (333 МБ) на первом запросе | высокая | ленивая загрузка + `503`/`Retry-After` вместо ошибки; `PHOTO_AI_LOAD_ALL=1` для прогрева; том `photo-ai-models` сохраняет веса между перезапусками; `start_period: 300s` в healthcheck |
| Сборка ломается на `pip install gfpgan/facexlib` (пакета нет в зеркале) | средняя | **проверить доступность пакетов до начала работ**; при отсутствии — вендорить исходники в `photo-ai/vendor/` и копировать каталог в образ |
| Рост образа до ~6–7 ГБ (cu124) | средняя | CPU-образ по умолчанию, GPU-образ собирается отдельно; на `/home` свободно 59 ГБ |
| CPU-инференс с GFPGAN медленнее таймаута воркера | средняя | отдельный `PHOTO_AI_FACE_TIMEOUT_MS` (600 с) для face-режима; в UI предупреждение «на CPU медленно»; для слабых машин — `off` |
| Регресс текущего `/enhance` | низкая | контракт по умолчанию не меняется; эталонное сравнение на этапах 1–2; старые `photo_jobs.params = NULL` продолжают работать |
| Нехватка RAM (15 ГБ, занято ~9 ГБ) при загрузке моделей на CPU | средняя | `PHOTO_AI_LOAD_ALL=0`, кеш моделей с вытеснением (LRU, максимум 2), контроль через `docker stats` |
| Воркер зацикливается на «вечно недоступном» сервисе | низкая | при `503`/`unreachable` — экспоненциальный `sleep`, лимит мягких повторов (например 60) → затем одна честная ошибка с понятным текстом |
---
## 9. Критерии готовности
1. `photo-ai` стартует и на CPU-хосте, и с CUDA, определяя устройство автоматически; `/health`
сообщает фактическое устройство, имя GPU, VRAM, доступные и загруженные модели.
2. Существующий сценарий «🤖 ИИ» без параметров даёт результат, эквивалентный текущему.
3. Доступны режимы: универсальный x2, быстрый x4, только лица (GFPGAN), лица + фон.
4. Задание с face-моделью проходит полный цикл `pending → processing → done`; результат виден в
сравнении «Было/Стало», применим вручную и откатывается.
5. Отсутствие GPU, отсутствие `nvidia-ctk` и недоступный `photo-ai` **не** ломают приложение:
`PHOTO_AI_URL=` пусто → кнопка «ИИ» недоступна; сервис упал → задания пережидают (`503`), не
сжигая попытки; пустой `.env` полностью совместим.
6. `node api.smoketest.js` проходит; `AGENTS.md`, `README.md`, `.env.example` описывают новые
переменные и GPU-запуск.
---
## 10. Быстрые команды для проверки после реализации
```bash
# Статус устройства и моделей
docker compose exec -T app node -e "fetch(process.env.PHOTO_AI_URL+'/health').then(r=>r.json()).then(console.log)"
curl -s -H "X-Auth-Token: $TOKEN" http://localhost:3003/api/photo-jobs/status | python3 -m json.tool
# Прямой вызов с лицами (ручная проверка)
curl -s -X POST http://localhost:8080/enhance \
-F image=@photo.jpg -F scale=2 -F model=x2plus -F face=face -F face_model=gfpgan \
-H 'Accept: application/json' | python3 -c "import json,sys,base64; d=json.load(sys.stdin); open('out.jpg','wb').write(base64.b64decode(d['image_base64'])); print({k:v for k,v in d.items() if k!='image_base64'})"
# CPU-режим принудительно
docker compose stop photo-ai
PHOTO_AI_DEVICE=cpu docker compose -f docker-compose.yml up -d --build photo-ai
# GPU-режим
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
# Проверка обратной совместимости (без model/face — как раньше)
curl -s -X POST http://localhost:8080/enhance -F image=@photo.jpg -F scale=2 -o old_behavior.jpg
# Смоук
node api.smoketest.js
```