chore(photo-ai): раздел 0 — жёсткие инварианты I1–I7 зафиксированы
План фото-ИИ с восстановлением лиц разбит на этапы; этот коммит закрывает
раздел 0 — семь инвариантов, которые нельзя ломать дальше. Два из них были
нарушены в текущем коде и исправлены здесь.
I5 (мягкие ошибки не сжигают попытки). Раньше любой сбой photo-ai —
503, обрыв сети, таймаут — попадал в общий catch, инкрементил attempts и
через три попытки переводил задание в error. Теперь ошибки разделены:
5xx/429/425/408 и сетевая недоступность возвращают задание в pending без
инкремента attempts, с экспоненциальной паузой 10 с → 300 с; лимит мягких
повторов (по умолчанию 60) даёт одну честную ошибку с понятным текстом.
Таймаут AbortSignal.timeout — жёсткая ошибка с попытками, как и раньше.
Счётчик мягких повторов живёт в памяти процесса и в счётчиках воркера,
метаданные повтора — в audit_log.target (soft_attempt/soft_limit), без
новых колонок. Новый аудит-код photo.job.soft_retry и подпись в audit.js.
Переменные PHOTO_AI_SOFT_MAX_RETRIES, PHOTO_AI_SOFT_BACKOFF_MS,
PHOTO_AI_SOFT_BACKOFF_MAX_MS описаны в .env.example и отдаются в
worker.config в GET /api/photo-jobs/status.
I7 (никаких прямых fs.* по uploads/). runAiEnhance писал результат
fs.writeFileSync в uploads/ и только потом persist в S3; enhanceWithSharp
делал то же через sharp toFile. Оба теперь считают буфер и пишут его
через storage.put — драйвер выбирает сам, локальной копии не остаётся.
Из worker.js убраны require('fs'), require('path') и параметр uploadsDir.
I3 (photo-ai не обязателен). photo_ai_enabled вычислялся внутри
cacheWrap('public-settings'), поэтому после перезапуска с пустым
PHOTO_AI_URL кнопка «🤖 ИИ» оставалась видимой до истечения кэша (60 с),
хотя enhance-ai уже отдавал 503. Флаг вынесен из кэша: он выводится из
PHOTO_AI_URL в памяти процесса и всегда актуален.
Проверено на стенде (журнал — в TODO_PHOTO_FACE_AI.md, раздел 0):
- I1: эталон /enhance снят на 5 фото (3 реальных, 2 синтетических),
два независимых прогона и прогон после правок совпали байт-в-байт
(sha256), /health отдаёт ok. Скрипты и эталон — в backups/ (вне git)
- I2: задание с params IS NULL и action='ai' дошло до done при
attempts=0, результат отдан из S3 (200)
- I3: с пустым PHOTO_AI_URL photo_ai_enabled=false сразу после старта,
enhance-ai → 503, остальные маршруты API живы
- I4: nvidia-ctk и nvidia-container-runtime на хосте отсутствуют, runtime
только runc — фото-ИИ поднялся на CPU, /health не падает. Проверка
PHOTO_AI_DEVICE переносится на приёмку Stage 1 (переменной ещё нет)
- I6: db/ не тронут, состав колонок photo_jobs прежний
- I7: node --check для всех изменённых JS, комментариев в диффе нет,
весь SQL параметризован
api.smoketest.js: контракт фото-воркера — согласованность
photo_ai_enabled и ai_configured, ключи мягких повторов в worker.config,
503/404 для enhance-ai на несуществующей записи (тест не создаёт реальных
заданий). Вместе с планом и чек-листом этапов.
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
# 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-запуск.
|
||||
Reference in New Issue
Block a user