Compare commits

..
4 Commits
Author SHA1 Message Date
dev 88dbff0136 chore(photo-ai): Stage 0 закрыт, решения D1–D6 зафиксированы
Stage 0: чекбоксы отмечены, приёмка перепроверена — эталон воспроизводится
байт-в-байт (5/5 MATCH), /health -> {"ok":true}, сценарий «🤖 ИИ» -> done.

D1 — порт photo-ai на 127.0.0.1:8081 (loopback), применён и отражён в
README/.env.example.
D2 — отдельная photoAiHealth() вместо aiHealthCheck() в статусе заданий,
контракт service = {configured, reachable, latency_ms, error} + passthrough
полей photo-ai; правка и проверка в предыдущем коммите.
D3 — PHOTO_JOB_ACTIONS выносится на уровень модуля и используется в restore
и в валидации enhance-ai; CHECK в БД не добавляем (I6).
D4 — вендорить gfpgan/facexlib не нужно: пакеты есть на PyPI и уже в образе
(реalesrgan тянет их транзитивно). Зафиксированы проверенные URL весов и
расхождение: пин numpy<2 в Dockerfile не действует (в образе 2.2.6).
D5 — CodeFormer внедряется только при явной необходимости; на PyPI лишь
сторонняя обёртка, дефолт gfpgan, при отсутствии модуля -> 400 с текстом.
D6 — дефолт PHOTO_AI_URL не меняем (фото-ИИ включено из коробки на CPU).
2026-09-28 23:38:48 +03:00
dev 00fbf41efb build(photo-ai): loopback-порт 8081 и раздел про фото-ИИ в README
photo-ai не имел ports, а хостовый 8080 уже занят text-corrector: ручные
проверки из плана попадали не туда. Проброшено 127.0.0.1:8081:8080 — только
loopback, наружу ничего не публикуется.

Заодно исправлен рассинхрон .env.example: там стоял пустой PHOTO_AI_URL с
комментарием «пусто = контейнер photo-ai», из-за чего копирование примера
молча выключало фото-ИИ (в compose дефолт http://photo-ai:8080). Теперь
дефолт указан явно, пустое значение описано как «выключить сервис».
2026-09-28 23:38:39 +03:00
dev 1a25ce7170 fix(photo-ai): отдавать health фото-сервиса в статусе заданий
В GET /api/photo-jobs/status вызывался aiHealthCheck() — это health
текстового ИИ, — а результат в ответ не попадал: поле service отсутствовало,
и оператор не видел состояние photo-ai.

Добавлена photoAiHealth(): GET ${PHOTO_AI_URL}/health с таймаутом 5 с, без
исключений; пустой PHOTO_AI_URL -> {configured:false, reachable:false}, обрыв
или таймаут -> {configured:true, reachable:false, error}. Вызов уходит в тот
же Promise.all, что и запросы к БД, поэтому статус не получает лишние 5 с.
Контракт: {configured, reachable, latency_ms, error} + passthrough полей
photo-ai; первые четыре совпадают с контрактом текстового ИИ, который уже
читает public/js/worker.js.

Контракт зафиксирован в api.smoketest.js двумя проверками.
2026-09-28 23:38:32 +03:00
dev 403574fe79 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 на несуществующей записи (тест не создаёт реальных
заданий). Вместе с планом и чек-листом этапов.
2026-09-28 23:19:16 +03:00
9 changed files with 1186 additions and 29 deletions
+13 -2
View File
@@ -36,9 +36,20 @@ CLOUDFLARE_TUNNEL_URL=http://app:3003
# туннеля без VPN (сек). Если VPN-провайдер не отвечает — сайт всё равно поднимется.
WG_HANDSHAKE_TIMEOUT=60
# ИИ-улучшение фото (Real-ESRGAN), пусто = контейнер photo-ai
PHOTO_AI_URL=
# === ИИ-улучшение фото (контейнер photo-ai, Real-ESRGAN) ===
# По умолчанию фото-ИИ включено: сервис photo-ai поднимается вместе со стеком
# и работает на CPU. Оставьте значение пустым, чтобы выключить сервис —
# тогда кнопка «🤖 ИИ» скрыта, а /api/entries/:id/photo/enhance-ai отвечает 503.
# Ручные проверки сервиса идут по loopback-порту 127.0.0.1:8081 (наружу не публикуется):
# curl http://127.0.0.1:8081/health
PHOTO_AI_URL=http://photo-ai:8080
PHOTO_AI_MAX_PIXELS=4000000
# Сколько раз воркер повторит задание, если photo-ai недоступен (503/обрыв сети),
# не увеличивая attempts; после исчерпания — одна честная ошибка в задании.
PHOTO_AI_SOFT_MAX_RETRIES=60
# Пауза перед мягким повтором и её потолок (мс), задержка растёт вдвое до потолка.
PHOTO_AI_SOFT_BACKOFF_MS=10000
PHOTO_AI_SOFT_BACKOFF_MAX_MS=300000
# === Хранилище файлов (S3: SeaweedFS по умолчанию / MinIO) ===
# local — файлы в ./uploads (по умолчанию), s3 — объекты в бакете S3/MinIO.
+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
```
+26
View File
@@ -119,6 +119,32 @@ REDIS_PASSWORD=случайная-длинная-строка
Имя узла Tailscale задаётся в `docker-compose.yml` (`tailscale.hostname`, по умолчанию `whatido`).
## ИИ-улучшение фото (photo-ai)
Сервис `photo-ai` (Real-ESRGAN) поднимается вместе со стеком и **включён по умолчанию**: `PHOTO_AI_URL`
в `docker-compose.yml` равен `http://photo-ai:8080`, кнопка «🤖 ИИ» активна, а задания обрабатывает
фоновый воркер. Работает на CPU, GPU не требуется.
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `PHOTO_AI_URL` | `http://photo-ai:8080` | Адрес сервиса. **Пустое значение = сервис выключен**: кнопка «🤖 ИИ» скрыта, `POST /api/entries/:id/photo/enhance-ai` отвечает `503`, приложение при этом полностью работоспособно |
| `PHOTO_AI_MAX_PIXELS` | `4000000` | Максимум пикселей входного изображения, вход большего размера уменьшается |
| `PHOTO_AI_SOFT_MAX_RETRIES` | `60` | Сколько раз задание ждёт недоступный сервис, не увеличивая счётчик попыток; после исчерпания — честная ошибка |
| `PHOTO_AI_SOFT_BACKOFF_MS` | `10000` | Первая пауза перед мягким повтором |
| `PHOTO_AI_SOFT_BACKOFF_MAX_MS` | `300000` | Потолок паузы (задержка растёт вдвое) |
Порт `8081` на `127.0.0.1` — только loopback хоста, наружу ничего не публикуется (хостовый `8080` занят
`text-corrector`). Ручные проверки сервиса:
```bash
curl http://127.0.0.1:8081/health
curl -F "image=@photo.jpg" -F "scale=2" http://127.0.0.1:8081/enhance -o out.jpg
```
Состояние сервиса и воркера — в `GET /api/photo-jobs/status` (admin): `service` отдаёт health фото-сервиса
(`configured`, `reachable`, `latency_ms`, `error`), `ai_configured` — задан ли `PHOTO_AI_URL`,
`worker` — состояние очереди и конфигурация воркера.
## Публичный доступ через Tailscale
Стек не требует внешнего IP и проброса портов: контейнер `tailscale` запускается с `network_mode: host`, входит в вашу tailnet-сеть и через **Serve** открывает приложение внутри tailnet, а через **Funnel** — в публичном интернете.
+422
View File
@@ -0,0 +1,422 @@
# TODO: фото-ИИ с восстановлением лиц (Real-ESRGAN + GFPGAN/CodeFormer), авто-выбор GPU/CPU
Источник требований: `PLAN_PHOTO_FACE_AI.md`. Этот файл — **исполняемый чек-лист для агента**,
который пишет код. План не дублируется: здесь только задачи, якоря в коде, контракты и приёмка.
Перед стартом агент обязан прочитать `AGENTS.md` (правила проекта) и `PLAN_PHOTO_FACE_AI.md` целиком.
---
## 0. Жёсткие инварианты (нарушение = регресс, работа не принята)
- [x] **I1. Обратная совместимость `/enhance`.** Запрос только с `image` + `scale=2` (без `model`/`face`)
обязан давать тот же JPEG, что и до изменений: `x2plus`, `half=False` на CPU, `IMWRITE_JPEG_QUALITY=92`.
Проверяется сравнением с эталоном из Stage 0. Эталон снят (см. «Журнал раздела 0»), прогон
`node backups/photo-face-ai-baseline/capture.js <label> && node backups/photo-face-ai-baseline/verify.js <label>`
даёт байт-в-байт совпадение; повторная проверка обязательна на приёмке Stage 1.
- [x] **I2. Старые задания.** `photo_jobs` со `params IS NULL` и `action='ai'` продолжают обрабатываться
воркером как раньше (дефолты подставляются на стороне воркера/сервиса).
- [x] **I3. photo-ai не обязателен.** Пустой `PHOTO_AI_URL` → кнопка «🤖 ИИ» скрыта
(`public/js/journal.js:718`), `POST /api/entries/:id/photo/enhance-ai` → `503`
(`server.js:5115`), приложение полностью работоспособно.
- [x] **I4. GPU не обязателен.** Нет `nvidia-ctk` / нет CUDA → контейнер поднимается, `PHOTO_AI_DEVICE=cuda`
даёт WARN в лог и молчаливый откат на CPU. Падение `/health` из-за отсутствия GPU недопустимо.
Проверено на этом хосте: `nvidia-ctk` **отсутствует**, nvidia-runtime в `docker info` нет, `photo-ai`
поднялся и `/health` → `{"ok":true}` (GPU недоступен → сервис не падает). Переменная
`PHOTO_AI_DEVICE` появится только в Stage 1 — проверка `cuda` → WARN + CPU откат переносится
на приёмку Stage 1, сам GPU-режим на этом хосте не проверяется (стоп-условие: нужен
`nvidia-container-toolkit`, ставить без разрешения оператора нельзя).
- [x] **I5. Воркер не сжигает попытки на «мягких» ошибках.** `503` / недоступный сервис → задание возвращается
в `pending` **без** инкремента `attempts`; лимит мягких повторов (например 60) → одна честная ошибка.
- [x] **I6. Никаких новых колонок в БД.** Метаданные обработки (модель, устройство, время, `faces_found`)
живут в `photo_jobs.params` (JSONB уже есть) и в `audit_log.target`.
- [x] **I7. Код без комментариев** (правило `AGENTS.md`), CommonJS в Node-части, параметризованный SQL,
никаких прямых `fs.*` по `uploads/` (только `storage.*`).
### Журнал раздела 0 (проверено 2026-09-28)
Что сделано, кроме проверки: в `worker.js` устранены два нарушения инвариантов — прямой
`fs.writeFileSync` в `uploads/` вместо `storage.put` (I7) и сжигание `attempts` на `503`/недоступности
(I5). В `server.js` флаг `photo_ai_enabled` вынесен из кэша `public-settings` (I3). Новые переменные
`PHOTO_AI_SOFT_*` описаны в `.env.example`.
| Инвариант | Как проверено | Результат |
|---|---|---|
| I1 | `capture.js` → `before/`, повтор `repeat1/`, прогон после правок `after-section0/`; `verify.js` сравнивает sha256 | 5/5 байт-в-байт, `/health` `{"ok":true}` |
| I2 | запись `INSERT INTO photo_jobs (entry_id, action, status, params) VALUES (385,'ai','pending',NULL)` | `done`, `attempts=0`, результат отдаётся из S3 (200) |
| I3 | `docker compose -f docker-compose.yml -f no-photo-ai.yml up -d app` (`PHOTO_AI_URL: ""`) | `photo_ai_enabled=false`, `enhance-ai` → 503, 8 маршрутов API живы |
| I4 | `backups/photo-face-ai-baseline/env.txt` | `nvidia-ctk` NOT_FOUND, `nvidia-container-runtime` NOT_FOUND, runtimes `runc`/`io.containerd.runc.v2`, `photo-ai` Up, `/health` ok |
| I5 | `photo-ai` остановлен → задание ждало 70 с → вернулось `pending` при `attempts=0`; после `docker compose start photo-ai` → `done` при `attempts=0`. Лимит проверен прогоном с `PHOTO_AI_SOFT_MAX_RETRIES=2` | мягкий повтор не тратит попытки; после лимита `status=error`, `attempts=0`, текст «мягкие повторы исчерпаны (2)» |
| I6 | `git status db/` пуст, `information_schema.columns` для `photo_jobs` — те же 12 колонок; метаданные повторов в `audit_log.target` (`soft_attempt`, `soft_limit`) | без изменений схемы |
| I7 | `node --check worker.js server.js api.smoketest.js public/js/audit.js`; `grep` по `worker.js` — ни `require('fs')`, ни `require('path')`, ни `uploadsDir`; в диффе нет строк с комментариями; все SQL — на `$1/$2` | чисто |
Эталон и скрипты — в `backups/photo-face-ai-baseline/` (вне git): `env.txt`, `inputs/`, `inputs.json`,
`before/`, `repeat1/`, `after-section0/`, `capture.js`, `verify.js`, `make-inputs.js`.
Тестовые задания (id 90–94), их аудит, уведомления и 3 объекта результата в S3 удалены.
---
## 1. Что уже есть (сверено в коде, не перепроверять)
| Место | Сейчас | Что меняем |
|---|---|---|
| `photo-ai/app.py:15–73` | одна модель, `MODEL_PATH`, `MAX_INPUT_PIXELS`, `lock`+`upsampler`, `/health` → `{ok}`, `/enhance(image, scale)` | реестр моделей, `pick_device()`, `ModelPool`, расширенные `/health` и `/models` |
| `photo-ai/Dockerfile` | `python:3.10-slim`, `torch --index-url .../whl/cpu`, `realesrgan==0.3.0`, патч `basicsr/data/degradations.py` | `ARG TORCH_VARIANT`, `gfpgan`/`facexlib`, вендоринг CodeFormer |
| `docker-compose.yml:194–204` | `photo-ai`: `MODEL_PATH`, `MAX_INPUT_PIXELS`, том `photo-ai-models`, `ports: 127.0.0.1:8081:8080` (D1) | новые env + `healthcheck` |
| `docker-compose.yml:208` | том `photo-ai-models` | без изменений |
| `server.js:1841` | `PHOTO_AI_URL` | + константы `PHOTO_AI_FACE_MODEL`, `PHOTO_AI_FACE_TIMEOUT_MS` |
| `server.js:1237–1256` | `ensurePhotoJobsTable()`: колонка `action VARCHAR(20)`, `params JSONB`, CHECK на action **нет** | миграция схемы не требуется |
| `server.js:2254` | `PHOTO_JOB_ACTIONS = new Set(['ai','enhance','restore','rollback'])` | + `'ai_face'`, `'ai_upscale'` |
| `server.js:5114–5136` | `POST .../enhance-ai`: без тела → `action='ai'`, `params=NULL` | приём/валидация `{model, face, face_model, strength}` |
| `server.js:5782–5837` | `GET /api/photo-jobs/status`: `service = await aiHealthCheck()` вычисляется, но **в ответ не попадает** | вернуть `service` (это баг-дыра, см. D2) |
| `server.js:769–826` | `getStackInfo()`: `app/deps/runtime/database/cache/storage` | + блок `photo_ai` |
| `server.js:1663–1664` | `keys`/`defaults` для `/api/public-settings` | + `photo_ai_face_mode`, `photo_ai_face_model`, `photo_ai_device_pref` |
| `server.js:1710` | валидация `photo_enhance_engine` в `PUT /api/settings` | + валидация трёх новых ключей |
| `server.js:6045–6056` | `createPhotoEnhanceWorker({...})` | + `faceTimeoutMs`, `defaultFaceModel` |
| `worker.js:11` | `PHOTO_AI_TIMEOUT_MS = 300000` (хардкод) | читать env, добавить face-таймаут |
| `worker.js:35–41` | `CONFIG` воркера | + `face_model`, `default_model`, `face_timeout_ms` |
| `worker.js:122–141` | `runAiEnhance(srcKey)`: `image` + `scale=2`, ждёт сырой `image/jpeg` | проброс `params`, `Accept: application/json`, разбор JSON, `503` без траты попыток |
| `worker.js:176` | `job.action === 'ai' ? runAiEnhance : enhanceWithSharp` | + `'ai_face'`, `'ai_upscale'` |
| `worker.js:186–192` | инкремент `attempts` и `photo.job.retry` | не инкрементить при `503`/недоступности |
| `public/js/journal.js:606–615` | `loadEnhanceEngine()` читает `/api/public-settings` | + чтение дефолта face-режима |
| `public/js/journal.js:617–671` | `runPhotoAi()`: `confirm()` + POST без тела | + селект модели, тело `{model, face, face_model}` |
| `public/js/journal.js:718` | показ кнопки по `photo_ai_enabled` | без изменений (I3) |
| `public/js/worker.js:30–35` | `PHOTO_ACTION_LABELS` | + `ai_face`, `ai_upscale` |
| `public/js/worker.js:300–308` | `openPhotoJob(r)` — модалка «Было/Стало» | + `model`/`device`/`faces_found`/`elapsed_ms` |
| `public/js/settings.js:1` | `DIRTY_FIELDS` | + новые поля |
| `public/js/settings.js:217–218, 739` | загрузка/сохранение `photo_enhance_engine` | + face-режим, face-модель, желаемое устройство |
| `public/js/settings.js:372–477` | `renderStackInfo()` — 5 карточек | + карточка «Фото-ИИ» |
| `public/settings.html:221–260` | карточка `#sec-photo` (камера) | + блок «Фото-ИИ» (или новая карточка) |
| `public/js/audit.js:34–48` | словарь `photo.*` | + новые коды аудита |
| `db/init.sql:242`, `db/migration.sql:234` | `INSERT ... 'photo_worker_enabled'` | + дефолты новых настроек |
| `.env.example:39–41` | `PHOTO_AI_URL`, `PHOTO_AI_MAX_PIXELS` | + остальные переменные |
| `photo-ai` в compose | **порт не проброшен**; `text-corrector` занимает `8080:8080` | см. D1 — ручные `curl` из плана не сработают |
---
## 2. Расхождения плана с кодом — решить ДО правок
Все шесть расхождений закрыты 2026-09-28 (решения ниже, правки кода — в своих этапах).
- [x] **D1. Порт для ручных проверок.** `photo-ai` не имеет `ports`/`expose`, а хостовый `8080` занят
`text-corrector`. Команды вида `curl http://localhost:8080/enhance` из §10 плана попадут
в `text-corrector`. Решение выбрано одно: пробросить `ports: ["127.0.0.1:8081:8080"]` в сервис
`photo-ai` — только loopback, наружу (`0.0.0.0`) ничего не публикуется, хостовый `8080`
(`text-corrector`) не затрагивается. **Правка применена 2026-09-28:** порт добавлен в
`docker-compose.yml`, способ отражён в `README.md` (раздел «ИИ-улучшение фото») и `.env.example`.
Проверено: `docker compose up -d photo-ai` → `curl http://127.0.0.1:8081/health` → `{"ok":true}`,
`ss -tulpn` → `LISTEN 127.0.0.1:8081` (не `0.0.0.0`).
- [x] **D2. `service` в статусе фото-воркера.** `server.js:5825` вызывает `aiHealthCheck()` (это health
**текстового** ИИ), результат не возвращается. Решение: отдельная функция, общий `aiHealthCheck()`
не переиспользуем и из `/api/photo-jobs/status` убираем.
- `photoAiHealth()` рядом с `aiHealthCheck()`: `GET ${PHOTO_AI_URL}/health`, таймаут 5 с, без throw;
пустой `PHOTO_AI_URL` → `{ configured: false, reachable: false, latency_ms: 0, error: 'PHOTO_AI_URL не настроен' }`,
недоступность/таймаут → `{ configured: true, reachable: false, latency_ms, error }`.
- `service` в ответе = `{ configured, reachable, latency_ms, error }` + passthrough полей photo-ai
(`ok, ready, device, device_name, half, tile, driver, cuda, vram_total_mb, vram_free_mb, models,
face_models, loaded, loading, max_pixels`). Первые четыре — тот же контракт, который уже читает
фронт текстового ИИ (`public/js/worker.js:89–116`: `reachable`, `latency_ms`, `error`),
поэтому карточки переиспользуются без правок.
- Вызов уходит в тот же `Promise.all`, что и запросы к БД, — иначе статус получает лишние 5 с;
HTTP 200 в любом случае, недоступный сервис — не ошибка API.
- Прямой прокси для оператора — `GET /api/photo-ai/health` (`requireAdmin`), отдельным пунктом Stage 4.
- **Правка применена 2026-09-28:** `photoAiHealth()` в `server.js` рядом с `aiHealthCheck()`,
вызов ушёл в `Promise.all` запроса `/api/photo-jobs/status`, `service` возвращается.
Проверено на живом стеке: сервис поднят → `{"ok":true,"configured":true,"reachable":true,"latency_ms":3,"error":null}`;
`docker compose stop photo-ai` → HTTP 200 и `{"configured":true,"reachable":false,"latency_ms":3661,"error":"fetch failed"}`
(без исключения); после `start` → снова `reachable: true`. Контракт зафиксирован в
`api.smoketest.js` (два новых assert'а).
- [x] **D3. Где живёт белый список действий.** План говорит про два места (`ensurePhotoJobsTable()` и restore).
По факту `PHOTO_JOB_ACTIONS` используется **только** в нормализации restore-данных (`server.js:2254`,
единственное применение — `server.js:2259`; `grep` по репозиторию больше нигде), а в
`ensurePhotoJobsTable()` (`server.js:1237–1254`) CHECK-ограничения на `action` нет:
`action VARCHAR(20) NOT NULL DEFAULT 'ai'` — новые значения (`ai_face`, `ai_upscale`) помещаются.
Решение: обновить одно место.
- Набор выносится на уровень модуля (рядом с `PHOTO_AI_URL`, `server.js:1841`), а не внутрь
функции restore; используется в restore-нормализации **и** в валидации
`POST /api/entries/:id/photo/enhance-ai` (Stage 4) — забыть маршрут нельзя.
- CHECK в БД **не добавляем** (I6: никаких изменений схемы, миграция не нужна). Сверка списка
с фактическими `action`, которые пишет код, — `grep` на приёмке этапа.
- `ai_upscale` в список попадает сразу, хотя маршрута, его создающего, пока нет: список —
это допустимые значения для restore, а не реестр маршрутов.
- [x] **D4. Доступность пакетов.** Проверено 2026-09-28 в работающем контейнере `photo-ai`
(`pip download gfpgan==1.3.8 facexlib==0.3.0 --no-deps` — оба колеса с PyPI, 52 и 59 КБ;
`pip list` в образе: `gfpgan 1.3.8`, `facexlib 0.3.0`, `basicsr 1.4.2`, `realesrgan 0.3.0`,
`filterpy 1.4.5`, `numba 0.67.0`, `lmdb 2.3.0`, `scipy 1.15.3`; `import gfpgan, facexlib` — ок).
Решение: **вендорить не нужно**, пакеты есть на PyPI и уже стоят в образе — `realesrgan==0.3.0`
тянет `gfpgan>=1.3.5` и `facexlib>=0.2.5` транзитивно. CodeFormer официальным pip-пакетом
не распространяется (см. D5).
- Stage 2 фиксирует версии явно (`gfpgan==1.3.8 facexlib==0.3.0`) и убирает dev-зависимости
gfpgan из рантайма (`tb-nightly`, `yapf`): ставить gfpgan с `--no-deps` и перечислить
реальные зависимости явно.
- **Найденное расхождение (Stage 2):** пин `numpy<2` в `photo-ai/Dockerfile` не действует — в образе
`numpy 2.2.6`, потому что пин живёт в отдельном вызове `pip install`, который следующие установки
не учитывают; там же одновременно стоят `opencv-python 5.0.0.93` (через gfpgan) и
`opencv-python-headless 5.0.0.93`. До Stage 2 numpy не трогаем (Stage 1 меряет I1 на текущем
2.2.6); в Stage 2 пин либо переносится в один общий вызов `pip install`, либо фиксируется
фактическая версия — с обязательной перепроверкой эталона I1 после любого изменения numpy.
- Веса (проверено HEAD): `x2plus` v0.2.1 — 200; `general-x4v3` и `animevideov3` v0.2.5.0 — 200;
`codeformer.pth` v0.1.0 — 200. GFPGAN: берём `GFPGANv1.4.pth` из релиза `v1.3.0` (200, `arch='clean'`,
`channel_multiplier=2` — как у официального `inference_gfpgan.py -v 1.4`); `GFPGANCleanv1-NoCE-C2.pth`
в релизах `v1.3.8`/`v1.3.4`/`v1.3.0` отсутствует (404), в `v0.2.0` есть (200) — как запасной вариант.
- **Учтётся в Stage 1:** `GFPGANer` жёстко передаёт facexlib `model_rootpath='gfpgan/weights'`
(относительный путь → каталог образа, не том `/models`), поэтому веса детектора/парсера facexlib
сейчас скачиваются мимо тома и теряются при пересборке. Свой `FaceRestoreHelper`/каталог
`/models/weights` — обязательное требование этапа.
- [x] **D5. CodeFormer — опционален по умолчанию.** Проверено 2026-09-28: на PyPI есть только
сторонняя обёртка `codeformer 0.0.11` (`github.com/rohitkhatri/codeformer`, тянет `lpips`) —
это не официальный `sczhou/CodeFormer`, использовать его не будем. Официальные веса доступны.
Решение: официальный модуль вендорится в `photo-ai/vendor/codeformer/` **только если** face-режим
CodeFormer реально понадобится; в рамках текущего плана не вендорим, дефолт
`PHOTO_AI_FACE_MODEL=gfpgan` (Stage 1–3), чтобы дефолтный путь работал без вендоринга.
- `FACE_REGISTRY` всегда содержит обе записи, но запись `codeformer` активна только если
`import codeformer` (с `photo-ai/vendor` в `sys.path`) успешен.
- Нет модуля → запись не попадает в `face_models` в `/health` и в список `/models`, UI её не показывает,
`POST /enhance` с `face_model=codeformer` → `400` с текстом «CodeFormer не установлен в образ,
доступен gfpgan». Сборка и CPU-режим не падают.
- `strength` валиден только для CodeFormer: при `face_model=gfpgan` и `strength`, отличном от 0.7,
→ `400` с пояснением, иначе параметр молча игнорировался бы.
- Веса CodeFormer качаются тем же загрузчиком в `/models/weights/codeformer.pth`.
- [x] **D6. Поведение без `photo-ai` в compose.** Сейчас `PHOTO_AI_URL` по умолчанию
`http://photo-ai:8080` (`docker-compose.yml:79`) — то есть «ИИ» включён по умолчанию.
Решение: дефолт **не меняем** — фото-ИИ включено из коробки и работает на CPU; выключается
только явно (`PHOTO_AI_URL=` в `.env` → кнопка «🤖 ИИ» скрыта, `enhance-ai` → 503, I3).
Заодно исправлен найденный рассинхрон: `.env.example` содержал `PHOTO_AI_URL=` (пусто) с комментарием
«пусто = контейнер photo-ai», то есть инструкция «скопируй `.env.example`» молча выключала ИИ-фото.
В этом же изменении `.env.example` приведён к дефолту compose с явным описанием обоих состояний;
блок про photo-ai в `README` (включая GPU-запуск) появится в Stage 6 с той же формулировкой.
Текущий `.env` переменной не содержит → на этом хосте действует дефолт compose (включено).
---
## 3. Stage 0. Подготовка и эталон «до» (обязательно до любых правок кода)
- [x] Зафиксировать окружение: `docker --version`, `docker compose version`, `nvidia-smi`,
наличие/отсутствие `nvidia-ctk`, `docker info | grep -i runtime`.
- [x] Если `nvidia-ctk` нет — зафиксировать это как «GPU-режим не проверяем на этом хосте»,
**не** пытаться ставить системные пакеты без явного разрешения оператора.
- [x] Сохранить текущее состояние `.env` (`PHOTO_AI_URL` пусто или задан).
- [x] Поднять текущий стек как есть: `docker compose up -d --build`.
- [x] Снять эталон «до» на 3–5 фото (портрет, групповое, без лиц, зашумлённое 640×480):
результат `/enhance` с `scale=2` без других параметров + `/health`. Сохранить файлы и
размеры в `backups/photo-face-ai-baseline/` (вне git).
- [x] **Приёмка:** эталон сохранён, `curl /health` отвечает, текущий сценарий «🤖 ИИ» даёт `done`.
Stage 0 закрыт 2026-09-28, журнал проверок — «Журнал раздела 0» выше. Повторная сверка при закрытии:
`node backups/photo-face-ai-baseline/verify.js after-section0` → 5/5 `MATCH` (I1 байт-в-байт),
`photo-ai /health` → `{"ok":true}` из контейнера `app`, сценарий «🤖 ИИ» → `done` (I2).
---
## 4. Stage 1. `photo-ai/app.py`: реестр моделей + авто-выбор устройства
- [ ] `pick_device() -> (device, half, device_name)` по §4.1 плана: env → cuda → mps → cpu;
`half=True` только на CUDA; явный `cuda` без CUDA = WARN + cpu; явный `cpu` = всегда cpu.
- [ ] `MODEL_REGISTRY`: `x2plus` (`RRDBNet(scale=2)`), `general-x4v3` (`SRVGGNetCompact(upscale=4)` + `wdn`),
`animevideo-v3` (`SRVGGNetCompact(upscale=4)`).
- [ ] `FACE_REGISTRY`: `gfpgan` (`GFPGANer(arch='clean', channel_multiplier=2, upscale=2, bg_upsampler=…)`),
`codeformer` (за `D5`).
- [ ] Загрузка весов: список URL из §3.4 плана, каталог `${PHOTO_AI_MODELS_DIR:-/models}/weights/`,
скачивание в `.tmp` → `os.replace`, проверка минимального размера, кэш в томе.
`MODEL_PATH` читается как алиас для `x2plus` (существующий `.env`/том не ломается).
- [ ] `class ModelPool`: `threading.Lock`, ленивая загрузка по требованию, кеш, LRU с лимитом 2,
`PHOTO_AI_LOAD_ALL=1` — предзагрузка, прогрев на синтетическом шуме 64×64 после загрузки.
- [ ] OOM-деградация: `RuntimeError` с CUDA OOM → `tile` пополам (256→128→64), один ретрай;
повтор → инвалидация модели, переход на CPU, ещё одна попытка; финал — `500` с понятным текстом.
- [ ] `GET /health` — контракт §4.3 плана (поле `ok` сохраняется, добавляются `ready`, `device`,
`device_name`, `half`, `tile`, `driver`, `cuda`, `vram_total_mb`, `vram_free_mb`, `models`,
`face_models`, `loaded`, `loading`, `max_pixels`).
- [ ] `GET /models` — список моделей, face-моделей, устройство, дефолты.
- [ ] `POST /enhance`: поля `image`, `scale`, `model` (дефолт `x2plus`), `face` (`off|face|all`, дефолт `off`),
`face_model` (`gfpgan|codeformer`), `strength` (0..1, только CodeFormer, дефолт 0.7),
`jpeg_quality` (70..100, дефолт 92). Неизвестная модель → `400` со списком допустимых.
Модель не готова → `503` + `Retry-After: 5`.
- [ ] Два формата ответа: сырой `image/jpeg` по умолчанию (совместимость) и JSON при
`Accept: application/json`: `{ok, image_base64, model, face, face_model, faces_found, device,
elapsed_ms, warnings}`. При `face != off` — `enhance(..., has_aligned=False, only_center_face=False,
paste_back=True)`; лица не найдены — не ошибка, `faces_found: 0` + чистый `bg_upsampler`.
- [ ] Расширение выходного файла — по имени файла, не по `content_type` (воркер шлёт `image/jpeg` для всего).
- [ ] **Проверка I1:** эталон из Stage 0 воспроизводится (сравнить размер/содержимое, `node --check`-эквивалент
для Python — `python -c "import ast;ast.parse(open('photo-ai/app.py').read())"`).
- [ ] **Приёмка:** `/health` отдаёт `device`/`device_name`/`half`; `PHOTO_AI_DEVICE=cpu` при рабочей CUDA →
`cpu`; `PHOTO_AI_DEVICE=cuda` без CUDA → `cpu` + WARN, сервис поднялся; `PHOTO_AI_DEVICE=auto` без
CUDA → `cpu`.
---
## 5. Stage 2. `photo-ai/Dockerfile` + compose
- [ ] `ARG TORCH_VARIANT=cpu` и `ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}`;
один образ, `cu124` — вариант сборки.
- [ ] Сохранить патч `basicsr/data/degradations.py` (`functional_tensor` → `functional`) — без него basicsr
падает на torch ≥ 2.0. Не «упрощать» Dockerfile без проверки.
- [ ] Сохранить `libgl1 libglib2.0-0` (нужны facexlib), `numpy<2`, `opencv-python-headless`.
- [ ] Добавить `gfpgan`/`facexlib` (или вендоринг по `D4`), вендоренный CodeFormer копировать в образ
при наличии (`D5`).
- [ ] `ENV PHOTO_AI_MODELS_DIR=/models`; `MODEL_PATH` остаётся валидным алиасом.
- [ ] `docker-compose.yml`, сервис `photo-ai`: env `PHOTO_AI_DEVICE`, `PHOTO_AI_FACE_MODEL`, `PHOTO_AI_TILE`,
`PHOTO_AI_MAX_PIXELS`, `PHOTO_AI_LOAD_ALL`, `PHOTO_AI_JPEG_QUALITY`; `healthcheck` с
`start_period: 300s`; порт по `D1` уже проброшен на loopback — сохранить.
- [ ] Новый `docker-compose.gpu.yml` (по образцу `docker-compose.minio.yml`): `build.args.TORCH_VARIANT=cu124`,
`PHOTO_AI_DEVICE=cuda`, `deploy.resources.reservations.devices` (`driver: nvidia`, `count: 1`).
Без него стек поднимается на любой машине.
- [ ] Сервису `app` добавить env `PHOTO_AI_FACE_MODEL` и `PHOTO_AI_FACE_TIMEOUT_MS`.
- [ ] **Приёмка:** CPU-сборка стартует; `/api/photo-jobs/status` показывает `service.device`;
при пустом `PHOTO_AI_URL` приложение работает полностью (I3); `docker compose -f docker-compose.yml
-f docker-compose.gpu.yml config` валиден (сам GPU-пуск — только если toolkit есть).
---
## 6. Stage 3. Face-режим (GFPGAN) в `app.py`
- [ ] `face=off` — только ESRGAN (текущее поведение).
- [ ] `face=face` — ESRGAN-фон + GFPGAN `paste_back`.
- [ ] `face=all` — ESRGAN(+`wdn` для `general-x4v3`) + мягкий денойз фона + лица.
- [ ] `strength` → `fidelity_weight` CodeFormer, `-w`; вне диапазона → `400`.
- [ ] Предупреждения (`warnings`: вход меньше 320×320, лиц не найдено, CodeFormer на CPU) в JSON-ответе.
- [ ] **Приёмка:** портрет 640×480 в `face` → `faces_found=1`, лицо резче; фото без лиц → `faces_found=0`
и результат не хуже `x2plus`; искусственный OOM (`PHOTO_AI_TILE=1024` на 4 ГБ) деградирует до CPU
без падения сервиса.
---
## 7. Stage 4. `worker.js` + `server.js`
### 7.1 `worker.js` (`createPhotoEnhanceWorker`)
- [ ] Таймауты из env: `PHOTO_AI_TIMEOUT_MS` (дефолт 300000), `PHOTO_AI_FACE_TIMEOUT_MS` (дефолт 600000)
для `face != 'off'`. Таймаут выбирается на основе `params`, а не глобально.
- [ ] `runAiEnhance(srcKey, params)`: отправляет `image`, `scale`, `model`, `face`, `face_model`, `strength`
из `params` (дефолты при пустых `params`); ставит `Accept: application/json`; принимает и JSON
(base64 → буфер), и сырой `image/jpeg` (старый photo-ai) без ошибки.
- [ ] `503` / `Retry-After` / сетевая недоступность → `sleep` c экспоненциальной задержкой, `return false`,
**`attempts` не инкрементится**; лимит мягких повторов (например 60) → одна честная ошибка
с понятным текстом (I5).
- [ ] `AbortError` от `AbortSignal.timeout` → сообщение «ИИ-сервис не ответил за N с» — это уже
«жёсткая» ошибка с попытками.
- [ ] `processOne`: `action` ∈ {`ai`, `ai_face`, `ai_upscale`} → AI-путь; `enhance` → sharp.
Пустые `params` при `ai_face` → `face='face'`.
- [ ] `applyResult`: в `logAudit(..., 'photo.job.preview', {...})` добавить `model`, `face`, `face_model`,
`device`, `elapsed_ms`, `faces_found`, `warnings`. Текст уведомления `photo.job.done` — по факту
режима (`job.action === 'ai_face'` → «Фото обработано нейросетью с восстановлением лиц»).
- [ ] `CONFIG` воркера: + `face_model`, `default_model`, `face_timeout_ms` (попадают в
`GET /api/photo-jobs/status` → `worker.config`).
### 7.2 `server.js`
- [ ] `POST /api/entries/:id/photo/enhance-ai` (`server.js:5114`): принять `{model, face, face_model, strength}`;
валидация инлайн-хелперами и явными списками (как `PUT /api/settings`), невалидное → `400`.
`params = JSON.stringify({model, face, face_model, strength})`; `action = face === 'off' ? 'ai' : 'ai_face'`.
**Пустое тело → сегодняшнее поведение** (`params = NULL`, `action='ai'`) — I2.
- [ ] `PHOTO_JOB_ACTIONS` (`server.js:2254`): + `'ai_face'`, `'ai_upscale'` (см. `D3`). Схема
`action VARCHAR(20)` вмещает новые значения — миграция не нужна.
- [x] `photoAiHealth()` (см. `D2`) + проксирование в `GET /api/photo-jobs/status` → `service`
(`reachable`, `device`, `device_name`, `ready`, `vram_total_mb`, `vram_free_mb`, `models`, `face_models`,
`loaded`). Недоступен → `{reachable:false}`, ответ 200. — **сделано 2026-09-28** (контракт и проверка
в `D2`; поля `device`/`device_name`/`vram_*` появятся вместе с расширенным `/health` в Stage 1)
- [ ] `GET /api/photo-ai/health` (`requireAdmin`) — прямой прокси `/health` photo-ai для оператора.
- [ ] `getStackInfo()` (`server.js:769`) — блок `photo_ai` (`engine: 'Real-ESRGAN + GFPGAN'`, `driver`,
`device`, `device_name`, `vram_total_mb`, `models`). Без credentials, только hostname.
- [ ] Настройки (`PUT /api/settings`, рядом `server.js:1710`): `photo_ai_face_mode` ∈ `off|face|all` (дефолт `off`),
`photo_ai_face_model` ∈ `gfpgan|codeformer` (дефолт `gfpgan`), `photo_ai_device_pref` ∈ `auto|cuda|cpu`
(дефолт `auto`, только для UI/доков — фактическое устройство задаёт контейнер).
- [ ] Дефолты новых настроек: `db/init.sql`, `db/migration.sql` (`INSERT ... ON CONFLICT DO NOTHING`),
`keys`/`defaults` в `/api/public-settings` (`server.js:1663–1664`). `photo_ai_enabled` (строка 1677)
сохранить.
- [ ] `createPhotoEnhanceWorker({...})` (`server.js:6045`): + `faceTimeoutMs: PHOTO_AI_FACE_TIMEOUT_MS`,
`defaultFaceModel: PHOTO_AI_FACE_MODEL`.
- [ ] `warnings` от photo-ai сохранять в аудит/уведомление, чтобы оператор видел, что face-режим не
сработал не из-за ошибки.
- [ ] **Приёмка:** задание с `face='face'` проходит `pending → processing → done`; `params` сохранены;
аудит содержит модель/устройство/время; остановка photo-ai на лету → задание дорабатывается после
возврата сервиса **без** `error` (I5); результат применяется и откатывается как раньше.
---
## 8. Stage 5. Фронтенд
- [ ] `public/journal.html` + `public/js/journal.js`: рядом с «🤖 ИИ» селект режима
(«Универсально (x2)», «Быстро (x4)», «Лица (GFPGAN)», «Лица + фон»); значение уходит в
`POST .../enhance-ai` телом `{model, face, face_model}`. Дефолт — из `photo_ai_face_mode`
(`/api/public-settings`, `loadEnhanceEngine`); при `device === 'cpu'` — подсказка «на CPU медленно».
Кнопка по-прежнему скрыта при `photo_ai_enabled === 'false'` (I3).
- [ ] `public/js/worker.js`: `PHOTO_ACTION_LABELS` + `ai_face: 'ИИ + лица'`, `ai_upscale: 'ИИ-апскейл'`;
в модалке сравнения — `model`, `device`, `faces_found`, `elapsed_ms` (данные из `params`/аудита, I6);
строка состояния учитывает `service.reachable === false` и `service.device`.
- [ ] `public/settings.html` + `public/js/settings.js`: блок «Фото-ИИ» — селект face-режима, селект
face-модели, селект желаемого устройства + строка «фактическое: …» с подсветкой расхождения;
read-only статус (`device_name`, VRAM, список моделей) из `/api/photo-jobs/status`.
Новые id внести в `DIRTY_FIELDS` (`settings.js:1`) и в payload (`settings.js:739`).
- [ ] `public/js/settings.js:372` `renderStackInfo()` — карточка «Фото-ИИ» из `stack.photo_ai`.
- [ ] `public/js/audit.js` — метки для новых кодов аудита (иначе в UI будет сырой код).
- [ ] **Приёмка:** из журнала доступны все 4 режима; после постановки видно «В очереди», затем сравнение
«Было/Стало» с моделью/устройством; в настройках видно фактическое устройство и VRAM.
---
## 9. Stage 6. Документация и тесты
- [ ] `AGENTS.md`: раздел про photo-ai (реестр моделей, device-политика, новые env, GPU-override,
контракт `/enhance` и `/health`), строки в таблице «File Map» для `photo-ai/app.py`,
`photo-ai/Dockerfile`, `docker-compose.gpu.yml`.
- [ ] `README.md`: установка NVIDIA Container Toolkit, запуск с `docker-compose.gpu.yml`,
проверка `GET /api/photo-jobs/status` → `service.device`, ручной вызов `/enhance` (с учётом `D1`).
- [ ] `.env.example`: все переменные из §7 плана с комментариями.
- [ ] `api.smoketest.js` (по правилу `AGENTS.md` — контракт API фиксируется в том же изменении):
- `POST /api/entries/:id/photo/enhance-ai` с `model: 'нет такой'` → `400`;
- `face: 'face'` без `PHOTO_AI_URL` → `503` (условно: если `ai_configured === false`);
- `GET /api/photo-jobs/status` содержит ключ `service`.
- [ ] **Приёмка:** `node api.smoketest.js` проходит; `node --check server.js`, `node --check worker.js`,
`python -c "import ast;ast.parse(...)"` для `app.py`; `docker compose config` валиден для обоих
compose-файлов; документация совпадает с кодом.
---
## 10. Новые переменные окружения (итог)
| Переменная | Умолчание | Где используется |
|---|---|---|
| `PHOTO_AI_URL` | `http://photo-ai:8080` (compose), пусто = выкл. | `server.js`, воркер |
| `PHOTO_AI_MAX_PIXELS` | `4000000` | `app.py` (алиас `MAX_INPUT_PIXELS`) |
| `PHOTO_AI_DEVICE` | `auto` | `app.py` (`auto|cuda|cpu`) |
| `PHOTO_AI_TILE` | `256` | `app.py` |
| `PHOTO_AI_FACE_MODEL` | `gfpgan` | `app.py`, дефолт воркера/UI |
| `PHOTO_AI_LOAD_ALL` | `0` | `app.py` |
| `PHOTO_AI_FACE_MODE` | `off` | дефолт UI |
| `PHOTO_AI_JPEG_QUALITY` | `92` | `app.py` |
| `PHOTO_AI_TIMEOUT_MS` | `300000` | `worker.js` (сейчас хардкод) |
| `PHOTO_AI_FACE_TIMEOUT_MS` | `600000` | `worker.js` |
| `PHOTO_AI_SOFT_MAX_RETRIES` | `60` | `worker.js` — лимит мягких повторов (I5) |
| `PHOTO_AI_SOFT_BACKOFF_MS` | `10000` | `worker.js` — первая пауза мягкого повтора |
| `PHOTO_AI_SOFT_BACKOFF_MAX_MS` | `300000` | `worker.js` — потолок паузы |
| `PHOTO_AI_MODELS_DIR` | `/models` | `app.py` (внутри контейнера) |
---
## 11. Порядок и правила выполнения
- [ ] Этапы выполняются строго 0 → 1 → 2 → 3 → 4 → 5 → 6. Каждый этап закрывается своей приёмкой
**до** начала следующего.
- [ ] Каждый завершённый чекбокс отмечать (`- [x]`) в этом файле по мере выполнения.
- [ ] Изменения в `AGENTS.md`/`.env.example`/`README.md` делать **в том же** изменении, что и код,
который они описывают (правило `AGENTS.md` про рассинхрон документации).
- [ ] Коммит на этап, а не на весь план: `app.py`+`Dockerfile`+compose → воркер/сервер → фронт → доки/тесты.
- [ ] Стоп-условия (сообщить оператору, не «чинить» самостоятельно): требуется `sudo` на хосте;
требуется установка `nvidia-container-toolkit`; нашёлся конфликт миграции БД; текущий
`/enhance` перестал давать эталонный результат (I1).
## 12. Готово, когда
- [ ] `photo-ai` стартует на CPU и на CUDA, устройство выбирается автоматически, `/health` показывает
фактическое устройство, имя GPU, VRAM, доступные и загруженные модели.
- [ ] Сценарий «🤖 ИИ» без параметров даёт результат, эквивалентный текущему.
- [ ] Доступны режимы: универсальный x2, быстрый x4, только лица (GFPGAN), лица + фон.
- [ ] Face-задание проходит полный цикл, результат виден в сравнении, применяется и откатывается.
- [ ] Отсутствие GPU, отсутствие `nvidia-ctk` и недоступный `photo-ai` не ломают приложение.
- [ ] `node api.smoketest.js` проходит; `AGENTS.md`, `README.md`, `.env.example` описывают новые
переменные и GPU-запуск.
+35
View File
@@ -175,6 +175,41 @@ async function main() {
const notifyDelGone = await api('/api/notifications/' + notifyCreate.data.id + '/read', { token, method: 'POST' });
ok('notifications: удалённое уведомление -> 404', notifyDelGone.status === 404, notifyDelGone.status);
// Фото-ИИ: photo_ai_enabled обязан совпадать с ai_configured — оба выводятся из
// PHOTO_AI_URL, и флаг не попадает в кэш public-settings, иначе после перезапуска
// с пустым PHOTO_AI_URL кнопка «🤖 ИИ» висела бы до истечения кэша (I3).
// enhance-ai проверяем на несуществующей записи: 503 без фото-ИИ и 404 с фото-ИИ,
// чтобы дымовой тест не создавал реальных заданий фото-воркеру.
const photoStatus = await api('/api/photo-jobs/status', { token });
ok('photo-jobs/status -> 200', photoStatus.status === 200, photoStatus.status);
ok('photo_ai_enabled согласован с ai_configured', pub.data.photo_ai_enabled === String(!!photoStatus.data.ai_configured), {
photo_ai_enabled: pub.data.photo_ai_enabled,
ai_configured: photoStatus.data.ai_configured,
});
const workerCfg = photoStatus.data.worker && photoStatus.data.worker.config;
ok('worker.config содержит лимит мягких повторов', Boolean(workerCfg && workerCfg.soft_max_retries > 0), workerCfg);
// service обязан быть health фото-сервиса, а не текстового ИИ: configured совпадает
// с ai_configured, reachable — булево, а при выключенном photo-ai сервис недоступен.
const photoSvc = photoStatus.data.service;
ok('photo-jobs/status -> service от фото-сервиса', Boolean(photoSvc) && photoSvc.configured === photoStatus.data.ai_configured && typeof photoSvc.reachable === 'boolean', {
service: photoSvc,
ai_configured: photoStatus.data.ai_configured,
});
ok('service: без photo-ai reachable=false', photoStatus.data.ai_configured === false ? photoSvc.reachable === false : typeof photoSvc.latency_ms === 'number', {
ai_configured: photoStatus.data.ai_configured,
reachable: photoSvc.reachable,
});
ok('worker.config.ai_url соответствует наличию фото-ИИ', Boolean(workerCfg) && workerCfg.ai_url === photoStatus.data.ai_url, {
worker_ai_url: workerCfg && workerCfg.ai_url,
ai_url: photoStatus.data.ai_url,
});
const enhanceAi = await api('/api/entries/99999999/photo/enhance-ai', { token, method: 'POST' });
ok('enhance-ai: 503 без photo-ai либо 404 с photo-ai (запись не существует)', (photoStatus.data.ai_configured === false && enhanceAi.status === 503) || (photoStatus.data.ai_configured === true && enhanceAi.status === 404), {
ai_configured: photoStatus.data.ai_configured,
status: enhanceAi.status,
body: enhanceAi.data,
});
const logout = await api('/api/auth/logout', { token, method: 'POST' });
ok('logout', logout.status === 200, logout.status);
const afterLogout = await api('/api/auth/me', { token });
+4
View File
@@ -191,6 +191,8 @@ services:
# ИИ-улучшение фотографий (Real-ESRGAN x2, CPU).
# Поднимается вместе со стеком; если не нужен — PHOTO_AI_URL пустой в .env.
# Порт 8081 пробрасывается только на loopback хоста — наружу ничего не публикуется,
# хостовый 8080 уже занят text-corrector. Ручные проверки: curl http://127.0.0.1:8081/health
photo-ai:
build: ./photo-ai
container_name: photo-ai
@@ -201,6 +203,8 @@ services:
TZ: Europe/Moscow
volumes:
- photo-ai-models:/models
ports:
- "127.0.0.1:8081:8080"
volumes:
+1
View File
@@ -42,6 +42,7 @@ const ACTION_LABELS = {
'entry.photo.restore_original': 'Возвращён оригинал фото',
'photo.job.preview': 'Создан предпросмотр обработки фото',
'photo.job.retry': 'Повтор обработки фото',
'photo.job.soft_retry': 'ИИ-сервис недоступен, ожидание',
'photo.job.error': 'Ошибка обработки фото',
'photo-jobs.wake': 'Фото-воркер разбужен',
'photo-jobs.enabled': 'Переключена обработка фото',
+26 -4
View File
@@ -1674,9 +1674,9 @@ app.get('/api/public-settings', apiLimiter, async (_, res) => {
}
const q = parseFloat(result.photo_capture_quality);
result.photo_capture_quality = Number.isFinite(q) && q >= 0.5 && q <= 1 ? String(q) : '0.92';
result.photo_ai_enabled = PHOTO_AI_URL ? 'true' : 'false';
return result;
});
out.photo_ai_enabled = PHOTO_AI_URL ? 'true' : 'false';
res.json(out);
});
@@ -5655,6 +5655,28 @@ async function aiHealthCheck() {
}
}
async function photoAiHealth() {
if (!PHOTO_AI_URL) return { configured: false, reachable: false, latency_ms: 0, error: 'PHOTO_AI_URL не настроен' };
const startedAt = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const r = await fetch(`${PHOTO_AI_URL}/health`, { signal: controller.signal });
const latency_ms = Date.now() - startedAt;
if (!r.ok) return { configured: true, reachable: false, latency_ms, error: `HTTP ${r.status}` };
let data = null;
try { data = await r.json(); } catch (e) { data = null; }
const payload = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
return { ...payload, configured: true, reachable: true, latency_ms, error: null };
} catch (e) {
const latency_ms = Date.now() - startedAt;
const error = e && e.name === 'AbortError' ? 'timeout' : (e && e.message ? e.message : 'unreachable');
return { configured: true, reachable: false, latency_ms, error };
} finally {
clearTimeout(timer);
}
}
app.get('/api/ai/status', requireAdmin, async (_, res) => {
const { rows } = await pool.query(
`SELECT ai_status, count(*)::int AS n FROM entries WHERE deleted_at IS NULL GROUP BY ai_status`
@@ -5787,7 +5809,7 @@ app.get('/api/photo-jobs/status', requireAdmin, async (req, res) => {
);
const counts = { pending: 0, processing: 0, done: 0, error: 0 };
rows.forEach(r => { counts[r.status] = r.n; });
const [pending, recentTotal, recent, errors] = await Promise.all([
const [pending, recentTotal, recent, errors, service] = await Promise.all([
pool.query(
`SELECT j.id, j.entry_id, j.action, j.created_at, e.student_name, g.name AS group_name
FROM photo_jobs j
@@ -5820,13 +5842,14 @@ app.get('/api/photo-jobs/status', requireAdmin, async (req, res) => {
WHERE j.status = 'error'
ORDER BY j.finished_at DESC NULLS LAST, j.id DESC LIMIT 20`
),
photoAiHealth(),
]);
const enabled = String(await getSetting('photo_worker_enabled', 'true')) !== 'false';
const service = await aiHealthCheck();
res.json({
enabled,
ai_configured: !!PHOTO_AI_URL,
ai_url: PHOTO_AI_URL,
service,
worker: photoWorker ? photoWorker.getInfo() : null,
counts,
pending: pending.rows,
@@ -6049,7 +6072,6 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
invalidateEntries,
sharp,
photoAiUrl: PHOTO_AI_URL,
uploadsDir: UPLOADS_DIR,
storage,
notifyEvent: notifyEntry,
bus: createWorkerBus(PHOTO_WAKE_CHANNEL),
+106 -23
View File
@@ -3,14 +3,42 @@ const IDLE_MAX_MS = 60000;
const REQUEST_TIMEOUT_MS = parseInt(process.env.AI_REQUEST_TIMEOUT_MS || '120000', 10);
const MAX_INPUT_CHARS = 2000;
const MIN_TEXT_CHARS = 4;
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { textDiff, FIELD_LABELS } = require('./diff');
const PHOTO_MAX_ATTEMPTS = 3;
const PHOTO_AI_TIMEOUT_MS = 300000;
const PHOTO_SOFT_MAX_RETRIES = Math.max(1, parseInt(process.env.PHOTO_AI_SOFT_MAX_RETRIES || '60', 10) || 60);
const PHOTO_SOFT_BACKOFF_MS = Math.max(1000, parseInt(process.env.PHOTO_AI_SOFT_BACKOFF_MS || '10000', 10) || 10000);
const PHOTO_SOFT_BACKOFF_MAX_MS = Math.max(PHOTO_SOFT_BACKOFF_MS, parseInt(process.env.PHOTO_AI_SOFT_BACKOFF_MAX_MS || '300000', 10) || 300000);
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, uploadsDir, storage, bus, notifyEvent }) {
function isTimeoutFailure(e) {
const name = e && e.name;
return name === 'TimeoutError' || name === 'AbortError';
}
function isSoftStatus(status) {
return status === 408 || status === 425 || status === 429 || status >= 500;
}
function failureReason(e) {
const parts = [];
let cur = e;
for (let i = 0; cur && i < 5; i++) {
const code = cur.code || (cur.errors && cur.errors.code);
if (code && !parts.includes(code)) parts.push(code);
cur = cur.cause;
}
return parts.join(', ');
}
function softFailure(message, e) {
const reason = e ? failureReason(e) || (e.message || '') : '';
const err = new Error(reason ? `${message} (${reason})` : message);
err.soft = true;
return err;
}
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, storage, bus, notifyEvent }) {
const AI_URL = photoAiUrl || process.env.PHOTO_AI_URL || '';
const IDLE_MIN = 2000;
const IDLE_MAX = 30000;
@@ -31,12 +59,16 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
let processing = false;
let currentId = null;
let startedAt = null;
const stats = { jobs: 0, done: 0, errors: 0, last_at: null, last_error: null };
const stats = { jobs: 0, done: 0, errors: 0, soft_retries: 0, last_at: null, last_error: null };
const softTries = new Map();
const CONFIG = {
idle_min_ms: IDLE_MIN,
idle_max_ms: IDLE_MAX,
ai_timeout_ms: PHOTO_AI_TIMEOUT_MS,
max_attempts: PHOTO_MAX_ATTEMPTS,
soft_max_retries: PHOTO_SOFT_MAX_RETRIES,
soft_backoff_ms: PHOTO_SOFT_BACKOFF_MS,
soft_backoff_max_ms: PHOTO_SOFT_BACKOFF_MAX_MS,
ai_url: AI_URL,
};
@@ -113,9 +145,8 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
pipeline = pipeline.sharpen({ sigma: 0.5 + (sharpAmt / 100) * 1.5, m1: 0, m2: 1 + sharpAmt / 50 });
}
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
const outPath = path.join(uploadsDir, newName);
await pipeline.jpeg({ quality: 92, mozjpeg: true }).toFile(outPath);
await storage.persist(newName, outPath);
const out = await pipeline.jpeg({ quality: 92, mozjpeg: true }).toBuffer();
await storage.put(newName, out);
return `/uploads/${newName}`;
}
@@ -126,17 +157,28 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
const fd = new FormData();
fd.append('image', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg');
fd.append('scale', '2');
const resp = await fetch(AI_URL.replace(/\/+$/, '') + '/enhance', {
method: 'POST',
body: fd,
signal: AbortSignal.timeout(PHOTO_AI_TIMEOUT_MS),
});
if (!resp.ok) throw new Error('AI service error: ' + resp.status);
let resp;
try {
resp = await fetch(AI_URL.replace(/\/+$/, '') + '/enhance', {
method: 'POST',
body: fd,
signal: AbortSignal.timeout(PHOTO_AI_TIMEOUT_MS),
});
} catch (e) {
if (isTimeoutFailure(e)) {
throw new Error(`ИИ-сервис не ответил за ${Math.round(PHOTO_AI_TIMEOUT_MS / 1000)} с`);
}
throw softFailure('ИИ-сервис недоступен', e);
}
if (!resp.ok) {
const retryAfter = resp.headers.get('retry-after');
const detail = `ИИ-сервис ответил ${resp.status}${retryAfter ? ` (Retry-After: ${retryAfter})` : ''}`;
if (isSoftStatus(resp.status)) throw softFailure(detail);
throw new Error(detail);
}
const out = Buffer.from(await resp.arrayBuffer());
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
const outPath = path.join(uploadsDir, newName);
fs.writeFileSync(outPath, out);
await storage.persist(newName, outPath);
await storage.put(newName, out);
return `/uploads/${newName}`;
}
async function applyResult(job, newPath) {
@@ -152,6 +194,44 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
target: { job_id: job.id, action: job.action, after_path: newPath },
});
}
async function softRetry(job, message) {
const n = (softTries.get(job.id) || 0) + 1;
if (n <= PHOTO_SOFT_MAX_RETRIES) {
softTries.set(job.id, n);
stats.soft_retries++;
await pool.query(`UPDATE photo_jobs SET status = 'pending', error = $1 WHERE id = $2`, [message, job.id]);
if (logAudit && (n === 1 || n % 10 === 0)) {
await logAudit(null, 'photo.job.soft_retry', {
entry_id: job.entry_id,
job_id: job.id,
soft_attempt: n,
soft_limit: PHOTO_SOFT_MAX_RETRIES,
error: message,
});
}
const delay = Math.min(PHOTO_SOFT_BACKOFF_MS * Math.pow(2, n - 1), PHOTO_SOFT_BACKOFF_MAX_MS);
return { ok: false, delay };
}
softTries.delete(job.id);
const text = `${message} — ИИ-сервис недоступен, мягкие повторы исчерпаны (${PHOTO_SOFT_MAX_RETRIES}), задание остановлено`;
await pool.query(
`UPDATE photo_jobs SET status = 'error', error = $1, finished_at = now() WHERE id = $2`,
[text, job.id]
);
stats.errors++;
stats.last_error = text;
if (logAudit) {
await logAudit(null, 'photo.job.error', { entry_id: job.entry_id, job_id: job.id, error: text, soft_attempts: PHOTO_SOFT_MAX_RETRIES });
}
await sendNotification(job.entry_id, {
type: 'photo.job.error',
title: 'Ошибка обработки фото: {student}',
body: `Группа {group} · запись #${job.entry_id} · ${text}`,
target: { job_id: job.id, action: job.action, error: text },
});
return { ok: true, delay: 0 };
}
async function processOne(job) {
stats.jobs++;
const { rows } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [job.entry_id]);
@@ -169,27 +249,30 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
body: `Группа {group} · запись #${job.entry_id} · у записи нет фото для обработки`,
target: { job_id: job.id, action: job.action },
});
return true;
return { ok: true, delay: 0 };
}
const photoPath = rows[0].photo_path;
try {
const newPath = job.action === 'ai' ? await runAiEnhance(photoPath) : await enhanceWithSharp(photoPath, job.params);
await applyResult(job, newPath);
softTries.delete(job.id);
stats.done++;
stats.last_at = new Date().toISOString();
stats.last_error = null;
return true;
return { ok: true, delay: 0 };
} catch (e) {
const message = (e && e.message ? e.message : 'error').slice(0, 500);
stats.last_at = new Date().toISOString();
stats.last_error = message;
if (e && e.soft) return await softRetry(job, message);
const { rows: cur } = await pool.query('SELECT attempts FROM photo_jobs WHERE id = $1', [job.id]);
const tries = ((cur[0] && cur[0].attempts) || 0) + 1;
if (tries < PHOTO_MAX_ATTEMPTS) {
await pool.query(`UPDATE photo_jobs SET status = 'pending', attempts = $1, error = $2 WHERE id = $3`, [tries, message, job.id]);
if (logAudit) await logAudit(null, 'photo.job.retry', { entry_id: job.entry_id, job_id: job.id, attempt: tries, error: message });
return false;
return { ok: false, delay: 0 };
}
softTries.delete(job.id);
await pool.query(
`UPDATE photo_jobs SET status = 'error', attempts = $1, error = $2, finished_at = now() WHERE id = $3`,
[tries, message, job.id]
@@ -202,7 +285,7 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
body: `Группа {group} · запись #${job.entry_id} · ${message}`,
target: { job_id: job.id, action: job.action, error: message },
});
return true;
return { ok: true, delay: 0 };
}
}
@@ -228,16 +311,16 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
idleMs = IDLE_MIN;
processing = true;
currentId = job.id;
let ok = true;
let res = { ok: true, delay: 0 };
try {
ok = await processOne(job);
res = await processOne(job);
} catch (e) {
console.error('Photo worker process error:', e);
} finally {
processing = false;
currentId = null;
}
if (!ok) await sleep(IDLE_MAX);
if (!res || !res.ok) await sleep((res && res.delay) || IDLE_MAX);
}
}