Files
WhatIDo/TODO_PHOTO_FACE_AI.md
T
dev 7367d66ac3 build(photo-ai): CPU/GPU сборка, gfpgan+facexlib без dev-зависимостей, healthcheck
Stage 2: photo-ai/Dockerfile получил ARG TORCH_VARIANT/TORCH_INDEX (один образ,
cu124 — вариант сборки) и явные пины gfpgan==1.3.8 / facexlib==0.3.0 через
--no-deps: tb-nightly и yapf (dev-зависимости gfpgan/basicsr) больше не попадают
в рантайм, matplotlib остаётся как зависимость filterpy (требование facexlib).
Патч basicsr/data/degradations.py сохранён байт-в-байт — без него basicsr падает
на torch >= 2.0.

Пин numpy<2 был невыполним: opencv-python-headless 5.0.0.93 требует numpy >= 2.
Зафиксирована фактическая версия numpy==2.2.6 в общем вызове pip install, а
opencv-python (не-headless) больше не ставится — раньше он приходил через gfpgan
и перезаписывал headless-сборку (активным был cv2 с GUI: QT5). I1 после этого
перепроверен: 5/5 MATCH байт-в-байт против эталона Stage 0.

ENV PHOTO_AI_MODELS_DIR=/models, MODEL_PATH остаётся валидным алиасом.
COPY vendor/ ./vendor/ + vendor/.gitkeep — вендоренный CodeFormer (D5)
подхватится из sys.path без правок Dockerfile.

docker-compose.yml: у photo-ai новые env (DEVICE, TILE, FACE_MODEL, LOAD_ALL,
JPEG_QUALITY, MODELS_DIR) и healthcheck со start_period 300s; MAX_INPUT_PIXELS
заменён на канонический PHOTO_AI_MAX_PIXELS (старое имя читается app.py как алиас).
У app — PHOTO_AI_FACE_MODEL и PHOTO_AI_FACE_TIMEOUT_MS (воркер читает их в Stage 4).
Новый docker-compose.gpu.yml (по образцу minio): TORCH_VARIANT=cu124,
PHOTO_AI_DEVICE=cuda, deploy.resources.reservations.devices для nvidia.

.env.example и таблица переменных README описывают ровно те переменные, которые
теперь подставляет compose; блок про GPU и NVIDIA Container Toolkit — Stage 6 (D6).

Проверено на живом стеке: сборка EXIT=0 (2.63 ГБ), /health отдаёт device=cpu,
face_models=[gfpgan], контейнер healthy, /api/photo-jobs/status отдаёт
service.device, I3 (пустой PHOTO_AI_URL -> 503 + 8 маршрутов 200), I4
(PHOTO_AI_DEVICE=cuda без CUDA -> WARN + CPU), docker compose config валиден для
базы, minio и gpu. GPU-пуск не выполнялся: nvidia-ctk на хосте отсутствует.
2026-09-29 09:35:40 +03:00

535 lines
57 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: 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.
- **Закрыто в Stage 2:** требования `numpy<2` и `opencv-python-headless 5.0.0.93` несовместимы
(opencv 5 требует numpy ≥ 2), поэтому зафиксирована фактическая версия `numpy==2.2.6` в том же
вызове `pip install`, что и остальные зависимости, а `opencv-python` (не-headless) больше не
устанавливается вовсе. Эталон I1 перепроверен — 5/5 байт-в-байт (см. «Журнал раздела 2»).
- Веса (проверено 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`: реестр моделей + авто-выбор устройства
- [x] `pick_device() -> (device, half, device_name)` по §4.1 плана: env → cuda → mps → cpu;
`half=True` только на CUDA; явный `cuda` без CUDA = WARN + cpu; явный `cpu` = всегда cpu.
- [x] `MODEL_REGISTRY`: `x2plus` (`RRDBNet(scale=2)`), `general-x4v3` (`SRVGGNetCompact(upscale=4)` + `wdn`),
`animevideo-v3` (`SRVGGNetCompact(upscale=4)`).
- [x] `FACE_REGISTRY`: `gfpgan` (`GFPGANer(arch='clean', channel_multiplier=2, upscale=2, bg_upsampler=…)`),
`codeformer` (за `D5`).
- [x] Загрузка весов: список URL из §3.4 плана, каталог `${PHOTO_AI_MODELS_DIR:-/models}/weights/`,
скачивание в `.tmp` → `os.replace`, проверка минимального размера, кэш в томе.
`MODEL_PATH` читается как алиас для `x2plus` (существующий `.env`/том не ломается).
- [x] `class ModelPool`: `threading.Lock`, ленивая загрузка по требованию, кеш, LRU с лимитом 2,
`PHOTO_AI_LOAD_ALL=1` — предзагрузка, прогрев на синтетическом шуме 64×64 после загрузки.
- [x] OOM-деградация: `RuntimeError` с CUDA OOM → `tile` пополам (256→128→64), один ретрай;
повтор → инвалидация модели, переход на CPU, ещё одна попытка; финал — `500` с понятным текстом.
- [x] `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`).
- [x] `GET /models` — список моделей, face-моделей, устройство, дефолты.
- [x] `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`.
- [x] Два формата ответа: сырой `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`.
- [x] Расширение выходного файла — по имени файла, не по `content_type` (воркер шлёт `image/jpeg` для всего).
- [x] **Проверка I1:** эталон из Stage 0 воспроизводится (сравнить размер/содержимое, `node --check`-эквивалент
для Python — `python -c "import ast;ast.parse(open('photo-ai/app.py').read())"`).
- [x] **Приёмка:** `/health` отдаёт `device`/`device_name`/`half`; `PHOTO_AI_DEVICE=cpu` при рабочей CUDA →
`cpu`; `PHOTO_AI_DEVICE=cuda` без CUDA → `cpu` + WARN, сервис поднялся; `PHOTO_AI_DEVICE=auto` без
CUDA → `cpu`.
### Журнал раздела 1 (проверено 2026-09-29)
Изменён только `photo-ai/app.py` (схема БД, воркер, `server.js`, compose, Dockerfile, фронтенд и `.env.example`
не тронуты — они в Stage 2…6).
| Проверка | Как | Результат |
|---|---|---|
| I1 | `capture.js after-stage1` + `verify.js after-stage1` | 5/5 `MATCH` байт-в-байт; повторно после двух правок `is_oom`/`BASE_TILE` — снова 5/5 |
| I2 | `INSERT INTO photo_jobs (entry_id, action, params, status) VALUES (390,'ai',NULL,'pending')` | `done`, `attempts=0`, `after_path` в S3; вход 939×875 PNG → 1878×1750 JPEG (ровно x2) |
| I4 | override-файлы с `PHOTO_AI_DEVICE=cuda\|cpu\|auto\|bogus` | `cuda` → WARN «CUDA недоступна — работаю на CPU» + `device=cpu`; `cpu`/`auto` → `cpu`; `bogus` → WARN об неизвестном значении и `auto`; сервис поднялся во всех случаях |
| I5 | 503 во время стартовой предзагрузки | `503` + `Retry-After: 5`; `worker.js:19` `isSoftStatus` относит `status >= 500` к мягким, попытка не тратится |
| I7 | `ast.parse`, `grep` на комментарии, неиспользуемые импорты | чисто, комментариев 0 |
| Реестр | `general-x4v3` (360×548 → 1440×2192), `animevideo-v3` | 200, веса скачаны в том, повторный запрос из кэша |
| LRU | последовательно `general-x4v3` → `animevideo-v3` → `face=all` | `loaded` держится ровно 2 записи, вытесняется самая старая (`x2plus` → `general-x4v3` → `animevideo-v3` → `gfpgan`) |
| Лица | `face=face` и `face=all` на реальных фото (800×450 и 1400×788) | `faces_found: 10`, 47 с / 42 с на CPU, `warnings: []` |
| Лица, их нет | `nofaces.jpg` 300×200 | `ok: true`, `faces_found: 0`, warning «лица не найдены, фон обработан апскейлом», 1.8 с — не ошибка |
| `jpeg_quality` | 70 / 92 / 100 и 30 / 101 / `abc` | 165935 / 327022 / 929904 байт, повтор 70 → те же байты; вне диапазона `400` с текстом «должен быть 70..100», нечисловое — `422` от pydantic |
| Валидация | неизвестные `model`/`face`/`face_model`, `strength` с gfpgan и вне 0..1, битое изображение | `400` с перечнем допустимых значений / `400 bad image` |
| OOM-лестница | подмена `process_image` в контейнере: OOM на 0/1/2/всех попытках | тайлы `[256]`, `[256,128]`, `[256,128,64]`, `[256,128,64]`; при полном провале `500` «не хватило памяти даже при tile=64: пересмотрите PHOTO_AI_TILE или PHOTO_AI_MAX_PIXELS» |
| Деградация | повторный вызов `degrade_to_cpu` | выполняется один раз (идемпотентно), `half=False`, предупреждение в ответе |
Решения и находки, которые нужно знать дальше:
- **GPU на хосте есть** — `nvidia-smi` работает, драйвер `615.71.09`, CUDA UMD 13.4. Не хватает только
`nvidia-container-toolkit`; его установка — решение оператора (в условиях задачи это стоп-условие),
поэтому ветка CUDA осталась непроверенной. Всё, что связано с `cuda`/`half`/`vram_*`, в Stage 1
покрыто только кодом и unit-проверками, а не прогоном.
- **`animevideo-v3`** — имя реестра взято по формулировке этого чек-листа (в плане §3.4 встречается
`animevideov3`, это имя файла весов). Ключ используется в API, при несовпадении с планом поправить
и то, и другое одним изменением.
- **`image_ext`** добавлен в JSON-ответ сверх полей, перечисленных в чек-листе: без него клиент
не может угадать формат (`.jpeg` нормализуется в `.jpg` ради байт-в-байтности, `.png`/`.webp`
не меняются).
- **D4 (веса facexlib в томе)**: `face_helper()` пишет в `${PHOTO_AI_MODELS_DIR}/weights` через
`model_rootpath`, а встроенный помощник `GFPGANer` ищет `gfpgan/weights` относительно cwd.
`link_default_facexlib_dir()` при первом же построении face-модели заменяет этот каталог
символической ссылкой на том — `/app/gfpgan/weights -> /models/weights`. Второй детектор не
создаётся, 195 МБ дубля нет (проверено `readlink`).
- **Ветка CodeFormer написана, но не проверена** — модуль не вендорен (Stage 2). Проверен только
путь отказа: `face_model=codeformer` → `400` «модель лиц codeformer не установлена в образ,
доступен gfpgan», и `strength` с gfpgan → `400`. Сам `build_face_codeformer` нужно прогнать после
того, как пакет появится в образе.
- **`tile` не залипает.** После OOM `state['tile']` восстанавливается на `PHOTO_AI_TILE` в `finally`
(`BASE_TILE`), иначе одна тяжёлая картинка навсегда замедляла бы сервис в 16 раз. `degrade_to_cpu`
больше не сбрасывает тайл на дефолт — операторское значение сохраняется.
- **`is_oom` расширен** на сообщения CPU-аллокатора PyTorch (`not enough memory`, `alloc_cpu`,
`can't allocate memory`): без этого исчерпание RAM на CPU (единственный тестируемый режим)
возвращало `500` с текстом «ошибка модели: …» вместо лестницы тайлов и подсказки про
`PHOTO_AI_TILE`/`PHOTO_AI_MAX_PIXELS`.
- **`portrait.jpg` — плохой тестовый вход**: на нём детектор facexlib не находит лица (порог
`get_face_landmarks_5` — 0.97, на реальных фото скор 0.999+). Ранние «0 лиц» в проверках были
ошибкой тест-скрипта (`len(h.cropped_faces)` заполняется только `align_warp_face()`), а не
поломкой детектора. Годится любой портрет из `uploads/` (4032×2268 и ещё 7 файлов).
- **`.env.example` не правился**: новые переменные ещё не подставляются в compose (Stage 2),
документировать их сейчас — задокументировать неработающее.
---
## 5. Stage 2. `photo-ai/Dockerfile` + compose
- [x] `ARG TORCH_VARIANT=cpu` и `ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}`;
один образ, `cu124` — вариант сборки.
- [x] Сохранить патч `basicsr/data/degradations.py` (`functional_tensor` → `functional`) — без него basicsr
падает на torch ≥ 2.0. Не «упрощать» Dockerfile без проверки.
- [x] Сохранить `libgl1 libglib2.0-0` (нужны facexlib), `numpy<2`, `opencv-python-headless`.
- [x] Добавить `gfpgan`/`facexlib` (или вендоринг по `D4`), вендоренный CodeFormer копировать в образ
при наличии (`D5`).
- [x] `ENV PHOTO_AI_MODELS_DIR=/models`; `MODEL_PATH` остаётся валидным алиасом.
- [x] `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 — сохранить.
- [x] Новый `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`).
Без него стек поднимается на любой машине.
- [x] Сервису `app` добавить env `PHOTO_AI_FACE_MODEL` и `PHOTO_AI_FACE_TIMEOUT_MS`.
- [x] **Приёмка:** 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 есть).
### Журнал раздела 2 (проверено 2026-09-29)
Изменены `photo-ai/Dockerfile`, `docker-compose.yml`, добавлены `docker-compose.gpu.yml` и
`photo-ai/vendor/.gitkeep`, а также `.env.example` и таблица переменных в `README.md` — Stage 1 отложил
их именно на Stage 2, потому что раньше compose эти переменные не подставлял. `app.py`, `worker.js`,
`server.js`, схема БД и фронтенд не тронуты — они в Stage 3…6.
| Проверка | Как | Результат |
|---|---|---|
| CPU-сборка | `docker compose build photo-ai` (слои pip с нуля) | `EXIT=0`, образ `whatido-photo-ai:latest` **2.63 ГБ** (план оценивал ~2.5 ГБ) |
| Состав пакетов | `docker exec photo-ai pip list` | `gfpgan 1.3.8`, `facexlib 0.3.0`, `basicsr 1.4.2`, `realesrgan 0.3.0`, `numpy 2.2.6`, `torch 2.14.0+cpu`, `matplotlib 3.10.9`; `opencv-python-headless 5.0.0.93` — **единственный** cv2; `tb-nightly` и `yapf` отсутствуют |
| Импорты | `python -c "import basicsr, gfpgan, facexlib, realesrgan, cv2"` | ок; cv2 `GUI: NONE` (до этого активной была не-headless сборка с `GUI: QT5`) |
| Патч basicsr | лог сборки | `basicsr patch applied to /usr/local/lib/python3.10/site-packages/basicsr/data/degradations.py` |
| **I1** | `capture.js after-stage2` + `verify.js after-stage2` | **5/5 `MATCH` байт-в-байт** (`795a519b9a9c70f6`, `fe2496f7ef798456`, `fce1949260590855`, `1b52a49d690ca8d7`, `e0e6e480f4799bc0`) — смена cv2 на headless и явный пин numpy результат не изменили |
| `service.device` | `GET /api/photo-jobs/status` (admin) | 200; `service` = `ok/ready/device=cpu/device_name/half/tile/driver/cuda/vram_total_mb/vram_free_mb/models/face_models/loaded/loading/max_pixels` + `configured/reachable/latency_ms/error` |
| healthcheck | `docker inspect photo-ai` | `healthy` после `start_period: 300s`; та же команда вручную в контейнере → exit 0 |
| env контейнера | `docker inspect photo-ai` | `PHOTO_AI_DEVICE=auto`, `PHOTO_AI_TILE=256`, `PHOTO_AI_FACE_MODEL=gfpgan`, `PHOTO_AI_LOAD_ALL=0`, `PHOTO_AI_JPEG_QUALITY=92`, `PHOTO_AI_MAX_PIXELS=4000000`, `PHOTO_AI_MODELS_DIR=/models`, `MODEL_PATH=/models/RealESRGAN_x2plus.pth` |
| env `app` | `docker compose exec app node -e ...` | `PHOTO_AI_URL=http://photo-ai:8080`, `PHOTO_AI_FACE_MODEL=gfpgan`, `PHOTO_AI_FACE_TIMEOUT_MS=600000` |
| I3 | override с `PHOTO_AI_URL: ""` (файл в `/tmp`, вне репозитория) | `photo_ai_enabled=false`, `POST /api/entries/1/photo/enhance-ai` → `503` «ИИ-обработка фото не настроена», 8 маршрутов API → 200; возврат к базовой конфигурации → `photo_ai_enabled=true` |
| I4 (ветка cuda) | `PHOTO_AI_DEVICE=cuda` на новом образе | WARN «PHOTO_AI_DEVICE=cuda, но CUDA недоступна — работаю на CPU», `/health` 200 и `device=cpu` |
| Compose | `docker compose config -q` — база, `+docker-compose.minio.yml`, `+docker-compose.gpu.yml` | три конфигурации валидны; GPU-оверрайд даёт `TORCH_VARIANT=cu124`, `PHOTO_AI_DEVICE=cuda` и `deploy.resources.reservations.devices[driver=nvidia, count=1, capabilities=[gpu]]` |
| GPU-пуск | не выполнялся | `nvidia-ctk` на хосте отсутствует — стоп-условие: установка toolkit только с разрешения оператора |
Решения и находки, которые нужно знать дальше:
- **`numpy<2` был невыполним.** `opencv-python-headless 5.0.0.93` требует numpy ≥ 2, поэтому пин заменён
на фактическую версию `numpy==2.2.6` и перенесён в тот же вызов `pip install`, что и остальные
зависимости (раньше пин стоял отдельным вызовом, и следующие установки его игнорировали). I1 после
изменения numpy перепроверен — байт-в-байт.
- **`--no-deps` для `basicsr`/`realesrgan`/`gfpgan`/`facexlib`** убирает dev-зависимости gfpgan
(`tb-nightly`, `yapf`). Проверено, что в рантайме они не нужны: `tensorboard` в basicsr импортируется
только лениво внутри `init_tb_logger`/`read_data_from_tensorboard`, `yapf` не импортируется вовсе,
а `matplotlib` приходит как зависимость `filterpy` (требование facexlib) и в образе остался.
- **`opencv-python` (не-headless) больше не ставится.** Раньше он приходил транзитивно через gfpgan и
перезаписывал headless-сборку (активным был cv2 с `GUI: QT5`). Теперь cv2 один, headless, и JPEG-байты
совпали с эталоном — GUI-обвязка на результат не влияла.
- **`libgl1 libglib2.0-0` оставлены** по требованию плана (facexlib), хотя при headless cv2 они нужны
меньше: экономия здесь не стоит риска незамеченной регрессии.
- **`photo-ai/vendor/.gitkeep`** — каталог вендоринга существует в репозитории, поэтому
`COPY vendor/ ./vendor/` не падает на сборке без CodeFormer, а положенный туда модуль подхватывается
`sys.path` в `app.py` без правок Dockerfile (`D5`).
- **env `PHOTO_AI_FACE_MODEL`/`PHOTO_AI_FACE_TIMEOUT_MS` у `app`** добавлены по чек-листу Stage 2;
`PHOTO_AI_FACE_MODEL` уже используется `app.py` как дефолт face-модели, воркер начнёт читать оба
в Stage 4 (сейчас `ai_timeout_ms` в `worker.config` всё ещё 300000 — это ожидаемо).
- **`TORCH_VARIANT` в `docker-compose.gpu.yml` захардкожен (`cu124`)**, а не берётся из `.env`: иначе
`TORCH_VARIANT=cpu` в `.env` молча собирал бы GPU-конфигурацию без CUDA.
- **`MAX_INPUT_PIXELS` в compose заменён на канонический `PHOTO_AI_MAX_PIXELS`**; `app.py` по-прежнему
читает старое имя как алиас, так что существующий `.env` не ломается. Порт `8081` на loopback (`D1`)
сохранён.
- **`.env.example`/`README.md`** описывают ровно те переменные, которые подставляет compose; блок про
GPU-запуск и установку NVIDIA Container Toolkit остаётся за Stage 6 (решение `D6`).
---
## 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-запуск.