Files
WhatIDo/TODO_PHOTO_FACE_AI.md
T
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

346 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`/`expose`** | новые 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. Расхождения плана с кодом — решить ДО правок
- [ ] **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 — правило «не публиковать наружу» соблюдено) **или** выполнять ручные проверки
через `docker compose exec -T app node -e ...`. Выбрать одно и применить; в `README` и `.env.example`
отразить выбранный способ.
- [ ] **D2. `service` в статусе фото-воркера.** `server.js:5825` вызывает `aiHealthCheck()` (это health
**текстового** ИИ), результат не возвращается. Нужен отдельный `photoAiHealth()` с таймаутом 5 с,
обращающийся к `${PHOTO_AI_URL}/health`, и его результат в ответе `/api/photo-jobs/status`.
При недоступности — `{ reachable: false }`, без исключения.
- [ ] **D3. Где живёт белый список действий.** План говорит про два места (`ensurePhotoJobsTable()` и restore).
По факту `PHOTO_JOB_ACTIONS` используется **только** в нормализации restore-данных (`server.js:2254`),
а в `ensurePhotoJobsTable()` CHECK-ограничения на `action` нет. Обновить одно место; при желании
вынести набор на уровень модуля, чтобы его нельзя было забыть.
- [ ] **D4. Доступность пакетов.** Проверить `pip index`/зеркало наличие `gfpgan` и `facexlib`
(`pip download gfpgan==1.3.8 facexlib==0.3.0 -d /tmp/x --no-deps`). Если недоступны — вендорить
в `photo-ai/vendor/` и копировать в образ. CodeFormer официальным pip-пакетом не распространяется.
- [ ] **D5. CodeFormer — опционален по умолчанию.** Реализовать как запись реестра, которая включается,
только если вендоренный модуль импортируется. Если нет: `face_models` в `/health` его не содержит,
API отвечает `400` с понятным текстом, UI не показывает его в списке. Сборка и CPU-режим не падают.
- [ ] **D6. Поведение без `photo-ai` в compose.** Сейчас `PHOTO_AI_URL` по умолчанию
`http://photo-ai:8080` (`docker-compose.yml:79`) — то есть «ИИ» включён по умолчанию.
Не менять дефолт молча; если меняется — явно записать в `.env.example` и `README`.
---
## 3. Stage 0. Подготовка и эталон «до» (обязательно до любых правок кода)
- [ ] Зафиксировать окружение: `docker --version`, `docker compose version`, `nvidia-smi`,
наличие/отсутствие `nvidia-ctk`, `docker info | grep -i runtime`.
- [ ] Если `nvidia-ctk` нет — зафиксировать это как «GPU-режим не проверяем на этом хосте»,
**не** пытаться ставить системные пакеты без явного разрешения оператора.
- [ ] Сохранить текущее состояние `.env` (`PHOTO_AI_URL` пусто или задан).
- [ ] Поднять текущий стек как есть: `docker compose up -d --build`.
- [ ] Снять эталон «до» на 3–5 фото (портрет, групповое, без лиц, зашумлённое 640×480):
результат `/enhance` с `scale=2` без других параметров + `/health`. Сохранить файлы и
размеры в `backups/photo-face-ai-baseline/` (вне git).
- [ ] **Приёмка:** эталон сохранён, `curl /health` отвечает, текущий сценарий «🤖 ИИ» даёт `done`.
---
## 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`.
- [ ] Новый `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)` вмещает новые значения — миграция не нужна.
- [ ] `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.
- [ ] `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-запуск.