Files
WhatIDo/TODO_PHOTO_FACE_AI.md
T
dev 4c63a46d24 fix(photo-ai): лестница OOM на CUDA + приёмка face-режима и GPU-оверрайда
Stage 3 закрыт. Face-режим (off/face/all, strength, warnings) был написан в Stage 1;
здесь доведена приёмка и найден баг, который невозможно было увидеть без GPU-прогона.

Главное: realesные OOM никогда не доходили до run_guarded. И realessrgan/utils.py, и
gfpgan/utils.py ловят RuntimeError вокруг вызова сети и идут дальше
(`except RuntimeError as error: print('Error', error)`), поэтому на нехватку памяти
realesrgan падал уже не RuntimeError, а UnboundLocalError на присваивании
output_tile. Наружу уходил голый 500 «Internal Server Error»: ни лестницы тайлов,
ни деградации на CPU, ни внятного текста. В gfpgan это было тихое ухудшение —
при OOM лицо молча оставалось исходным, а задание уходило в «успех».

Починка: guard_forward() оборачивает forward сетей, которые строим мы
(RealESRGANer.model, restorer.gfpgan, CodeFormer net) и превращает OOM-RuntimeError
в TileOOM. TileOOM не наследует RuntimeError, поэтому проглатывающие except его
пропускают; is_oom и обе точки run_guarded ловят его явно. Обёртка вешается на
экземпляр, идемпотентна по флагу _photo_ai_guarded и не трогает класс.

Также добавлены два предупреждения из чек-листа, которых в коде не было: вход меньше
320×320 (лица могут не найтись) и CodeFormer на не-CUDA. Предупреждение «лица не
найдены» и деградация на CPU были на месте и не менялись.

Проверено на RTX 3050 Laptop (4096 МБ, драйвер 615.71.09, CUDA 12.6):
- tile=2048, x2plus face=all, полное фото: до правки 500 + UnboundLocalError,
  после 200 за 28.3 с с единственным warning «не хватило памяти при tile=2048»;
- инъекция OOM: лестница 2048 → 1024 → 512, затем переход на CPU (device: cpu,
  half: false) и успешный повтор; при повторе уже на CPU — честная 500 с подсказкой
  про PHOTO_AI_TILE / PHOTO_AI_MAX_PIXELS;
- 640×480: off 1.0 с / face 3.8 с / all 2.2 с, faces_found=6; фото без лиц даёт
  faces_found=0 и байты, равные face=off;
- I1: 6/6 MATCH байт-в-байт против Stage 2 на CPU (jpg/.jpeg/png+70/webp+100/x4v3/anime).

docker-compose.gpu.yml: GPU выдаётся через CDI (device_ids nvidia.com/gpu=all) —
не требует правки /etc/docker/daemon.json и перезапуска демона, в отличие от
классического резервирования driver: nvidia. TORCH_VARIANT cu124 → cu126: в индексе
cu124 последний torch 2.6.0, а cu126 даёт те же 2.14.0/0.29.0, что и CPU-образ, так
что варианты сборки отличаются только CUDA-библиотеками. Образ тегируется отдельно
(whatido-photo-ai:cu126), чтобы сборка GPU-варианта не перетирала CPU-образ
whatido-photo-ai:latest — откат остаётся обычным docker compose up -d photo-ai.

README: раздел «Запуск на NVIDIA GPU» с установкой NVIDIA Container Toolkit и генерацией
CDI-спеки, оговорками про 4 ГБ VRAM (x2plus + gfpgan влезают, general-x4v3 тяжелее,
LOAD_ALL=1 лучше не включать) и описанием параметров /enhance. .env.example: команда
GPU-запуска и рекомендация по PHOTO_AI_TILE. Журналы раздела 3 и GPU-прогона — в
TODO_PHOTO_FACE_AI.md.

worker.js, server.js, схема БД и фронтенд не тронуты — они в Stage 4…6.
2026-09-29 12:02:03 +03:00

67 KiB
Raw Blame History

TODO: фото-ИИ с восстановлением лиц (Real-ESRGAN + GFPGAN/CodeFormer), авто-выбор GPU/CPU

Источник требований: PLAN_PHOTO_FACE_AI.md. Этот файл — исполняемый чек-лист для агента, который пишет код. План не дублируется: здесь только задачи, якоря в коде, контракты и приёмка.

Перед стартом агент обязан прочитать AGENTS.md (правила проекта) и PLAN_PHOTO_FACE_AI.md целиком.


0. Жёсткие инварианты (нарушение = регресс, работа не принята)

  • 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.
  • I2. Старые задания. photo_jobs со params IS NULL и action='ai' продолжают обрабатываться воркером как раньше (дефолты подставляются на стороне воркера/сервиса).
  • I3. photo-ai не обязателен. Пустой PHOTO_AI_URL → кнопка «🤖 ИИ» скрыта (public/js/journal.js:718), POST /api/entries/:id/photo/enhance-ai → 503 (server.js:5115), приложение полностью работоспособно.
  • 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, ставить без разрешения оператора нельзя).
  • I5. Воркер не сжигает попытки на «мягких» ошибках. 503 / недоступный сервис → задание возвращается в pending без инкремента attempts; лимит мягких повторов (например 60) → одна честная ошибка.
  • I6. Никаких новых колонок в БД. Метаданные обработки (модель, устройство, время, faces_found) живут в photo_jobs.params (JSONB уже есть) и в audit_log.target.
  • 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 (решения ниже, правки кода — в своих этапах).

  • 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).

  • 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'а).

  • 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, а не реестр маршрутов.

  • 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 — обязательное требование этапа.

  • 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.

  • 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. Подготовка и эталон «до» (обязательно до любых правок кода)

  • Зафиксировать окружение: 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.

Stage 0 закрыт 2026-09-28, журнал проверок — «Журнал раздела 0» выше. Повторная сверка при закрытии: node backups/photo-face-ai-baseline/verify.js after-section0 → 5/5 MATCH (I1 байт-в-байт), photo-ai /health → {"ok":true} из контейнера app, сценарий «🤖 ИИ» → done (I2).


4. Stage 1. photo-ai/app.py: реестр моделей + авто-выбор устройства

  • pick_device() -> (device, half, device_name) по §4.1 плана: env → cuda → mps → cpu; half=True только на CUDA; явный cuda без CUDA = WARN + cpu; явный cpu = всегда cpu.
  • MODEL_REGISTRY: x2plus (RRDBNet(scale=2)), general-x4v3 (SRVGGNetCompact(upscale=4) + wdn), animevideo-v3 (SRVGGNetCompact(upscale=4)).
  • FACE_REGISTRY: gfpgan (GFPGANer(arch='clean', channel_multiplier=2, upscale=2, bg_upsampler=…)), codeformer (за D5).
  • Загрузка весов: список URL из §3.4 плана, каталог ${PHOTO_AI_MODELS_DIR:-/models}/weights/, скачивание в .tmp → os.replace, проверка минимального размера, кэш в томе. MODEL_PATH читается как алиас для x2plus (существующий .env/том не ломается).
  • class ModelPool: threading.Lock, ленивая загрузка по требованию, кеш, LRU с лимитом 2, PHOTO_AI_LOAD_ALL=1 — предзагрузка, прогрев на синтетическом шуме 64×64 после загрузки.
  • OOM-деградация: RuntimeError с CUDA OOM → tile пополам (256→128→64), один ретрай; повтор → инвалидация модели, переход на CPU, ещё одна попытка; финал — 500 с понятным текстом.
  • GET /health — контракт §4.3 плана (поле ok сохраняется, добавляются ready, device, device_name, half, tile, driver, cuda, vram_total_mb, vram_free_mb, models, face_models, loaded, loading, max_pixels).
  • GET /models — список моделей, face-моделей, устройство, дефолты.
  • POST /enhance: поля image, scale, model (дефолт x2plus), face (off|face|all, дефолт off), face_model (gfpgan|codeformer), strength (0..1, только CodeFormer, дефолт 0.7), jpeg_quality (70..100, дефолт 92). Неизвестная модель → 400 со списком допустимых. Модель не готова → 503 + Retry-After: 5.
  • Два формата ответа: сырой image/jpeg по умолчанию (совместимость) и JSON при Accept: application/json: {ok, image_base64, model, face, face_model, faces_found, device, elapsed_ms, warnings}. При face != off — enhance(..., has_aligned=False, only_center_face=False, paste_back=True); лица не найдены — не ошибка, faces_found: 0 + чистый bg_upsampler.
  • Расширение выходного файла — по имени файла, не по content_type (воркер шлёт image/jpeg для всего).
  • Проверка I1: эталон из Stage 0 воспроизводится (сравнить размер/содержимое, node --check-эквивалент для Python — python -c "import ast;ast.parse(open('photo-ai/app.py').read())").
  • Приёмка: /health отдаёт device/device_name/half; PHOTO_AI_DEVICE=cpu при рабочей CUDA → cpu; PHOTO_AI_DEVICE=cuda без CUDA → cpu + WARN, сервис поднялся; PHOTO_AI_DEVICE=auto без CUDA → cpu.

Журнал раздела 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

  • ARG TORCH_VARIANT=cpu и ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}; один образ, cu124 — вариант сборки.
  • Сохранить патч basicsr/data/degradations.py (functional_tensor → functional) — без него basicsr падает на torch ≥ 2.0. Не «упрощать» Dockerfile без проверки.
  • Сохранить libgl1 libglib2.0-0 (нужны facexlib), numpy<2, opencv-python-headless.
  • Добавить gfpgan/facexlib (или вендоринг по D4), вендоренный CodeFormer копировать в образ при наличии (D5).
  • ENV PHOTO_AI_MODELS_DIR=/models; MODEL_PATH остаётся валидным алиасом.
  • docker-compose.yml, сервис photo-ai: env PHOTO_AI_DEVICE, PHOTO_AI_FACE_MODEL, PHOTO_AI_TILE, PHOTO_AI_MAX_PIXELS, PHOTO_AI_LOAD_ALL, PHOTO_AI_JPEG_QUALITY; healthcheck с start_period: 300s; порт по D1 уже проброшен на loopback — сохранить.
  • Новый docker-compose.gpu.yml (по образцу docker-compose.minio.yml): build.args.TORCH_VARIANT=cu124, PHOTO_AI_DEVICE=cuda, deploy.resources.reservations.devices (driver: nvidia, count: 1). Без него стек поднимается на любой машине.
  • Сервису app добавить env PHOTO_AI_FACE_MODEL и PHOTO_AI_FACE_TIMEOUT_MS.
  • Приёмка: CPU-сборка стартует; /api/photo-jobs/status показывает service.device; при пустом PHOTO_AI_URL приложение работает полностью (I3); docker compose -f docker-compose.yml -f docker-compose.gpu.yml config валиден (сам GPU-пуск — только если toolkit есть).

Журнал раздела 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-пуск up -d photo-ai с оверрайдом, когда nvidia-ctk есть на хосте сделан 2026-09-29, отдельный журнал «GPU-прогон» в разделе 6: device=cuda:0, half=true, vram_total_mb=3822. Изначально был стоп-условием (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 захардкожен, а не берётся из .env: иначе TORCH_VARIANT=cpu в .env молча собирал бы GPU-конфигурацию без CUDA. Значение изменено cu124 → cu126 при GPU-прогоне — причина в журнале раздела 3.
  • MAX_INPUT_PIXELS в compose заменён на канонический PHOTO_AI_MAX_PIXELS; app.py по-прежнему читает старое имя как алиас, так что существующий .env не ломается. Порт 8081 на loopback (D1) сохранён.
  • .env.example/README.md описывают ровно те переменные, которые подставляет compose; блок про GPU-запуск и установку NVIDIA Container Toolkit добавлен в Stage 3 вместе с проверкой GPU-оверрайда (решение D6), остальные документы — за Stage 6.

6. Stage 3. Face-режим (GFPGAN) в app.py

  • face=off — только ESRGAN (текущее поведение). — сделано в Stage 1 (журнал раздела 1)
  • face=face — ESRGAN-фон + GFPGAN paste_back. — сделано в Stage 1
  • face=all — ESRGAN(+wdn для general-x4v3) + мягкий денойз фона + лица. — сделано в Stage 1
  • strength → fidelity_weight CodeFormer, -w; вне диапазона → 400. — сделано в Stage 1 (ветка CodeFormer написана, но не проверена: модуль не вендорен, D5; проверен путь отказа)
  • Предупреждения (warnings: вход меньше 320×320, лиц не найдено, CodeFormer на CPU) в JSON-ответе. Первые два были в Stage 1; вход меньше 320×320 и CodeFormer на CPU добавлены при приёмке Stage 3 — их не было в коде
  • Приёмка: портрет 640×480 в face → faces_found=6, лицо резче; фото без лиц → faces_found=0 и результат не хуже x2plus; искусственный OOM (PHOTO_AI_TILE=2048 на 4 ГБ) деградирует до CPU без падения сервиса.

Журнал раздела 3 (проверено 2026-09-29)

Изменён photo-ai/app.py (лестница OOM на CUDA + два предупреждения) и docker-compose.gpu.yml (GPU-оверрайд: cu126 + CDI). worker.js, server.js, схема БД и фронтенд не тронуты — они в Stage 4…6. Проверки шли на работающем GPU (RTX 3050 Laptop, 4096 МБ, драйвер 615.71.09, CUDA 12.6) — см. журнал GPU-прогона в разделе 2.

Проверка Как Результат
Главная находка PHOTO_AI_TILE=2048, x2plus face=all на 4032×2268 до правки: 500 Internal Server Error, в логе UnboundLocalError: local variable 'output_tile' referenced before assignment (realesrgan/utils.py:179); после: 200, warnings: ["не хватило памяти при tile=2048"], 28.3 с
Причина чтение кода realesrgan/utils.py и gfpgan/utils.py обе библиотеки проглатывают RuntimeError в своих try/except вокруг вызова сети (except RuntimeError as error: print('Error', error)) и идут дальше, поэтому output_tile/restored_face остаётся неприсвоенным. Для run_guarded это был уже не RuntimeError → ни лестницы тайлов, ни деградации на CPU, ни внятного текста
Как починено guard_forward() в app.py оборачивает forward сетей, построенных нами (RealESRGANer.model, restorer.gfpgan, CodeFormer net): RuntimeError с признаком OOM превращается в TileOOM TileOOM не наследует RuntimeError, поэтому проглатывающие except его пропускают; is_oom и run_guarded (обе точки) ловят TileOOM явно
Лестница тайлов на GPU инъекция TileOOM('CUDA out of memory') вместо process_image tile=2048 → 1024 → 512, затем degrade_to_cpu и повтор: warnings = 4 пункта, device: cpu, half: false; при повторе с уже-CPU — честная 500 «не хватило памяти даже при tile=512: пересмотрите PHOTO_AI_TILE или PHOTO_AI_MAX_PIXELS»
x2plus face=all, tile=2048 полное фото 200, 6 лиц, единственное предупреждение — про тайл; сервис не упал, следующие запросы проходят
640×480, три режима портрет, приведённый к 640×480 off 1.0 с / face 3.8 с / all 2.2 с, faces_found=6, device=cuda:0
Фото без лиц синтетический фон 800×600 face → faces_found=0, warning «лица не найдены, фон обработан апскейлом», 0.8 с; байты результата равны face=off (39861) — без деградации качества
Вход меньше 320×320 256×256 два предупреждения: «вход меньше 320×320 — лица могут не найтись» + «лица не найдены»
strength 0.5 с gfpgan / 1.4 400 «strength применяется только к CodeFormer, для gfpgan оставьте 0.7» / 400 «strength должен быть 0..1»
CodeFormer face_model=codeformer 400 «модель лиц codeformer не установлена в образ, доступен gfpgan» (D5)
I1 6 сценариев (jpg/.jpeg/.png+q70/.webp+q100/x4v3/animevideo-v3) на новом и на Stage 2 образе, оба на CPU 6/6 MATCH байт-в-байт (420757881a31f12dec174e4873e260dd, cdbf59d1eb460d26a3b83af2e0543d82, 654928b60ec43dbd41c6644121041a75, 557e76d79869230058e1acf75017c670, 9d3d731c8858bacbba1ee88bf3f373e5)
I7 ast.parse, поиск комментариев чисто, комментариев 0

Решения и находки, которые нужно знать дальше:

  • Лестница OOM не работала ни на одном GPU — только на CPU, где OOM приходит из нашего кода и не проглатывается. Это наш главный аргумент за то, что GPU-ветку надо было прогнать, а не собрать.
  • guard_forward вешается на экземпляр (model.forward), идемпотентен по флагу _photo_ai_guarded и не трогает класс — обёртка не накапливается при пересборке пула. Сетей, которые строит не мы (retinaface в facexlib, ESRGANer внутри GFPGANer), обёртка не покрывает: там OOM уходит нашим же except RuntimeError, и это правильно.
  • Проглатывание в gfpgan (except RuntimeError вокруг self.gfpgan(...)) было даже опаснее тихого: при OOM лицо молча оставалось исходным, задание завершалось «успешно» без единого признака в warnings. Теперь такой случай уходит в лестницу тайлов, а если не помогло — в деградацию на CPU.
  • 4 ГБ VRAM — это про PHOTO_AI_LOAD_ALL и PHOTO_AI_TILE, а не про лестницу. x2plus и gfpgan влезают (свободно ~100–300 МБ), general-x4v3 втрое тяжелее. Проверено: после x4v3 face=all поднимается tile=2048, но дефолтные 256 проходят без единого предупреждения.
  • tile по-прежнему не залипает — после OOM в finally восстанавливается PHOTO_AI_TILE, и degrade_to_cpu больше не сбрасывает операторское значение.

Журнал GPU-прогона (закрывает строку «GPU-пуск» раздела 2)

Стоп-условие снято: nvidia-ctk и спека /etc/cdi/nvidia.yaml появились на хосте, поэтому GPU-оверрейд переведён с классического резервирования (driver: nvidia, count: 1) на CDI (device_ids: nvidia.com/gpu=all) — так демон перезапускать не нужно, а /etc/docker/daemon.json на хосте нет.

Проверка Как Результат
Сборка GPU-образа docker compose -f docker-compose.yml -f docker-compose.gpu.yml build photo-ai whatido-photo-ai:cu126, 12 ГБ; torch 2.14.0+cu126, cuda: 12.6 — те же версии, что и в CPU-образе
Старт оверрайд + up -d photo-ai healthy, в лог устройство: cuda:0 (NVIDIA GeForce RTX 3050 Laptop GPU), half=True
/health curl device: cuda:0, half: true, driver: 615.71.09, cuda: 12.6, vram_total_mb: 3822, ready: true
Обратная совместимость базовый docker compose up -d photo-ai без оверрайда сервис возвращается на CPU, PHOTO_AI_DEVICE=auto

Решения, которые нужно знать дальше:

  • TORCH_VARIANT в оверрайде захардкожен (cu126), а не берётся из .env: TORCH_VARIANT=cpu в .env иначе молча собрал бы GPU-конфигурацию без CUDA. Причина смены cu124 → cu126: в индексе cu124 последний torch — 2.6.0, а cu126 даёт ровно 2.14.0/0.29.0, как CPU-образ.
  • GPU-образ тегируется отдельно (whatido-photo-ai:cu126), поэтому сборка GPU-варианта не перетирает whatido-photo-ai:latest и откат на CPU — обычный docker compose up -d photo-ai.
  • Ветка CUDA в плане остаётся непроверенной ровно в одном месте — печать realesных GPU-байтов: на CPU/CUDA результаты x2 совпадают побайтно, но half=True на CUDA считает в fp16, поэтому байты CPU и GPU для одной картинки не равны (это не регресс I1: эталон снимается на 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. — сделано 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
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-запуск.