Воркер и сервер принимают параметры ИИ-обработки фото: модель апскейла (x2plus / general-x4v3 / animevideo-v3), режим лиц (off / face / all), модель лиц (gfpgan / codeformer) и strength. Пустое тело запроса ведёт себя как раньше: action='ai', params=NULL (инвариант I2). worker.js: - таймаут выбирается по params.face: PHOTO_AI_TIMEOUT_MS для апскейла, PHOTO_AI_FACE_TIMEOUT_MS (600000) для face-режима - runAiEnhance шлёт model/face/face_model/strength и понимает оба контракта: JSON с image_base64 и сырой image/jpeg старого сервиса - тело не-2xx ответа больше не выбрасывается: readErrorBody() добавляет причину к сообщению, иначе оператор видит «ИИ-сервис ответил 400» без объяснения - applyResult пишет в аудит model/face/face_model/device/faces_found/ elapsed_ms/warnings и выбирает текст уведомления по факту режима; warnings видны оператору, если лица не нашлись - CONFIG: + face_timeout_ms, default_model, face_model server.js: - POST /api/entries/:id/photo/enhance-ai принимает и валидирует тело до запроса записи — невалидный вход даёт 400, а не 404/500 - PHOTO_JOB_ACTIONS вынесен на уровень модуля, + ai_face и ai_upscale - GET /api/photo-ai/health (requireAdmin) — прямой прокси /health - photoAiHealth(timeoutMs), в «Статусе стека» вызывается с 2000 мс - getStackInfo(): блок photo_ai (engine, host, device, vram, модели) - настройки photo_ai_face_mode / photo_ai_face_model / photo_ai_device_pref с валидацией в PUT /api/settings, дефолты в init.sql, migration.sql, public-settings и ensurePhotoJobsTable() Приёмка (живой стек, CPU + отдельно CUDA) — в TODO_PHOTO_FACE_AI.md, журнал раздела 4: I1 байт-в-байт 5/5 и совпадение sha256 с raw-путём, I2, I3 при пустом PHOTO_AI_URL, I5 на обрыве и на 503 с Retry-After, 7 невалидных тел → 400, api.smoketest.js 57 PASS.
80 KiB
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):x2plusv0.2.1 — 200;general-x4v3иanimevideov3v0.2.5.0 — 200;codeformer.pthv0.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жёстко передаёт facexlibmodel_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не залипает. После OOMstate['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: envPHOTO_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добавить envPHOTO_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-фон + GFPGANpaste_back. — сделано в Stage 1face=all— ESRGAN(+wdnдляgeneral-x4v3) + мягкий денойз фона + лица. — сделано в Stage 1strength→fidelity_weightCodeFormer,-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/ сетевая недоступность →sleepc экспоненциальной задержкой,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. - Валидация вынесена перед запросом записи: невалидное тело →400независимо от наличия записи и фото, дешевле (без похода в БД) и детерминированно.PHOTO_JOB_ACTIONS(server.js:2254): +'ai_face','ai_upscale'(см.D3). Схемаaction VARCHAR(20)вмещает новые значения — миграция не нужна. - Набор вынесен на уровень модуля (server.js:1884) рядом сPHOTO_AI_URL; единственное применение — restore-нормализация (server.js:2301). - Сверка списка с тем, что реально пишет код (INSERT INTO photo_jobs× 5):ai(пустое тело иface=off),ai_face(parsePhotoAiRequest),enhance(swapEntryPhotoFiles),restore,rollback— все семь значений набора покрыты, лишних нет.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) — прямой прокси/healthphoto-ai для оператора.getStackInfo()(server.js:769) — блокphoto_ai(engine: 'Real-ESRGAN + GFPGAN',driver,device,device_name,vram_total_mb,models). Без credentials, только hostname. -photoAiHealth()получил необязательный параметр таймаута; вgetStackInfo()вызывается с2000— «Статус стека» наsettings.htmlне должен висеть на/healthфото-сервиса 5 с.- Настройки (
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) сохранить. - Строки добавлены и вensurePhotoJobsTable()— существующая БД получает их при старте приложения, без ручного прогонаmigration.sql. - Дефолты читаются из env:photo_ai_face_model←PHOTO_AI_FACE_MODEL,photo_ai_face_mode←PHOTO_AI_FACE_MODE(обе переменные уже есть в §10), чтобы дефолты БД и compose не разошлись. 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); результат применяется и откатывается как раньше.
Журнал раздела 4 (проверено 2026-09-29)
Изменён worker.js и server.js. photo-ai/app.py, Dockerfile, compose, схема БД (кроме трёх
INSERT в settings) и фронтенд не тронуты — они в Stage 5…6. Проверки шли на живом стеке, фото-сервис
в CPU-режиме (device: cpu, RTX 3050 проверен отдельно в Stage 3).
| Проверка | Как | Результат |
|---|---|---|
| face-режим end-to-end | POST …/enhance-ai {face:'face', face_model:'gfpgan'} (запись 385) |
pending → processing → done за 28 с; params в БД сохранены; результат отдаётся (200, JPEG, 283 953 Б) |
face + реальное лицо |
та же запись на фото с лицом (запись 384), face='all' |
done за 132 с, аудит: faces_found: 1, elapsed_ms: 128961, device: cpu, warnings: null |
| Главная находка | первая попытка без face | аудит: warnings: ["лица не найдены, фон обработан апскейлом"], faces_found: 0 — и эта строка видна оператору в уведомлении, а не прячется в лог сервиса |
| Уведомление по факту режима | три задания подряд | ai_face → «Фото обработано нейросетью с восстановлением лиц», ai → «Фото обработано нейросетью»; при warnings к body добавляется суффикс · лица не найдены… |
| I1 (worker) | задание с params IS NULL (action='ai', ручная вставка) против прямого raw-вызова /enhance без Accept |
sha256 совпал (98254b54d0ea6f456f400fdfd18f3a033291fd83eae884359cbaf8dd6e16556d, 452 678 Б) — новый JSON-путь воркера даёт байт-в-байт результат старого сырого пути |
| I2 | задание с params IS NULL |
action остался ai, params остался NULL, attempts=0, done; аудит face: off, faces_found: 0 |
| I1 (эталон) | capture.js after-stage4 + verify.js |
5/5 MATCH байт-в-байт |
| I3 | docker compose -f … -f override.yml up -d app с PHOTO_AI_URL="" |
photo_ai_enabled=false; enhance-ai ({}, {face:'face'}, {model,face:'all'}) → 503; /api/photo-ai/health → 200 {configured:false}; status → 200 service.configured=false; stack.photo_ai.configured=false, host=null; 8 маршрутов API живы; воркер виден, ai_url="" |
| I5 (обрыв) | stop photo-ai → задание в очередь → 45 с → start photo-ai |
в простое: pending, attempts=0, error='ИИ-сервис недоступен (ENOTFOUND)'; аудит photo.job.soft_retry (soft_attempt: 1, soft_limit: 60); после возврата — done, attempts=0, error очищен; status и system-info отдавали 200 с reachable=false |
| I5 (503) | заглушка-прокси перед сервисом: 503 + Retry-After: 5 |
status вернулся в pending, attempts остался 0; текст сохранил и код, и заголовок, и тело ответа: «ИИ-сервис ответил 503 (Retry-After: 5): модель x2plus ещё загружается, повторите позже»; после переключения заглушки на реальный сервис оба задания дошли до done с attempts=0 (задержка ~60 с — мягкий backoff 10 с с удвоением) |
Валидация enhance-ai |
7 невалидных тел | 400 на каждое: неизвестные model/face/face_model, strength вне 0..1, strength не число, strength при gfpgan, model+strength при gfpgan |
| Валидация настроек | 3 невалидных + 3 валидных значения | 400 / 200; после PUT новые значения видны в /api/public-settings сразу (кэш public-settings сбрасывается invalidateSettings) |
GET /api/photo-ai/health |
админ / без токена | 200 с полями device, device_name, models, face_models, loaded, vram_*, driver, half, tile, max_pixels; без токена → 401 |
stack.photo_ai |
/api/system-info |
engine, host (photo-ai:8080, тот же hostOf, что у database/cache/storage), device, device_name, vram_*, models, face_models, loaded; кредов и пароля в payload нет |
worker.config |
GET /api/photo-jobs/status |
+ face_timeout_ms: 600000, default_model: x2plus, face_model: gfpgan рядом с ai_timeout_ms: 300000 |
| Реальная ошибка сервиса | face_model: 'codeformer' (модуль не вендорен, D5) |
400 от сервиса дошёл до оператора текстом: «ИИ-сервис ответил 400: модель лиц codeformer не установлена в образ, доступен gfpgan»; attempts=3 (жёсткая ошибка), аудит photo.job.error |
| I7 | grep по диффу |
комментариев 0, require('fs')/require('path')/uploads в worker.js нет, SQL параметризован (в settings-вставках литералы — ключи, как у соседней photo_worker_enabled) |
| I6 | дифф db/ |
только INSERT INTO settings … ON CONFLICT DO NOTHING × 3, ни ALTER, ни CREATE TABLE; action VARCHAR(20) не менялся |
| Compose | config -q для base, .gpu.yml, .minio.yml |
валидны все три |
api.smoketest.js |
существующий набор | 57 PASS, 0 FAIL |
Решения и находки, которые нужно знать дальше:
Accept: application/jsonничего не ломает в сервисе, но меняет контракт воркера. Ответ приходит base64 в JSON — это в ~1.33 раза больше трафика на стыке app↔photo-ai. Оставлено осознанно: без метаданных (device,elapsed_ms,faces_found,warnings) в аудите и уведомлении оператор не видит, отработала ли модель. Сыройimage/jpegворкер по-прежнему принимает — на случай отката сервиса на старый контракт; метаданные тогда берутся из заголовковx-photo-ai-*.- Валидация тела вынесена перед запросом записи. Иначе невалидное тело на несуществующей записи
давало
404, и чекбокс «невалидное → 400» нельзя было бы проверить, не заводя запись с фото. Побочная выгода: невалидное тело не стоит похода в БД. strengthприface_model != 'codeformer'отвергается на входе в API, а не на стороне сервиса. Иначе ошибка приходила бы после трёх попыток воркера и после 2 минут ожидания — валидация вPUT /api/settings-стиле дешевле и сразу видима пользователю.- Тело ответа при не-2xx больше не выбрасывается (найдено на приёмке:
ИИ-сервис ответил 400без причины). ТеперьreadErrorBody()разбирает JSON{error}/{detail}или сырой текст и добавляет к сообщению — именно так оператор узнал, что CodeFormer не вендорен. - Один и тот же таймаут на
aiиai_faceбыл бы неверной настройкой:x2plus face=allна 4032×2268 на CPU занимает ~2 мин, и приPHOTO_AI_TIMEOUT_MS=300000задание с лицом упало бы в жёсткую ошибку после трёх попыток там, где GPU-оверрайд уложился бы в 28 с. ОтсюдаPHOTO_AI_FACE_TIMEOUT_MS=600000и выбор таймаута поparams.face. 503— не «сервис недоступен»:status.service.reachableпри этомtrue(сервис отвечает). Разводить эти два состояния важно для Stage 5 — иначе UI покажет «сервис лежит» при обычной прогревочной паузе.- Прокси-заглушка вместо гонки за 503. Первый запрос к холодной модели грузит её синхронно
(
ModelPool.get()→factory()), а503получают только конкурентные запросы к той же модели (key in self.loading→ModelNotReady). Воркер обрабатывает одно задание за раз, поэтому в бою такой 503 почти не воспроизводится — ветку проверили детерминированно, проксируя ответы сервиса заглушкой с флипом, иначе проверка была бы гонкой. photo.job.soft_retryпишется не на каждый повтор, а на 1-й и каждый 10-й (n === 1 || n % 10 === 0) — это было в коде и до Stage 4. Практическое следствие для проверок: точный текст последнего мягкого повтора надёжнее читать изphoto_jobs.error, а не из аудита; в журнале выше 503 виден именно там.- Живой стек используется параллельно. Во время приёмки оператор применил результат задания 102
через UI и вернул оригинал — это и есть проверка «результат применяется и откатывается как раньше»
на реальных данных (аудит
entry.photo.apply→entry.photo.restore_original). Поэтому рабочие задания и результаты оператора не тронуты: удалены только созданные тестами строкиphoto_jobs, их аудит-строки, уведомления и файлы в S3. Одно замечание для Stage 5:photo_ai_face_modeиphoto_ai_face_modelпоявились вsettingsу уже работающей БД — приложение создало их сам вensurePhotoJobsTable()при старте, поэтому значения по умолчанию действительны без ручного прогонаmigration.sql.
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:372renderStackInfo()— карточка «Фото-ИИ» из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, дефолт воркера, дефолт photo_ai_face_model |
PHOTO_AI_LOAD_ALL |
0 |
app.py |
PHOTO_AI_FACE_MODE |
off |
дефолт photo_ai_face_mode (UI) |
PHOTO_AI_JPEG_QUALITY |
92 |
app.py |
PHOTO_AI_TIMEOUT_MS |
300000 |
worker.js (раньше хардкод — сделан в Stage 4) |
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. Каждый этап закрывается своей приёмкой до начала следующего. (0–4 закрыты, см. журналы разделов)
- Каждый завершённый чекбокс отмечать (
- [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, доступные и загруженные модели. — проверено на обоих (CPU на текущем стеке, CUDA — журнал GPU-прогона в разделе 2)- Сценарий «🤖 ИИ» без параметров даёт результат, эквивалентный текущему. — I1/I2 в Stage 4:
байт-в-байт с raw-вызовом и 5/5
MATCHэталона - Доступны режимы: универсальный x2, быстрый x4, только лица (GFPGAN), лица + фон.
- Face-задание проходит полный цикл, результат виден в сравнении, применяется и откатывается.
- Отсутствие GPU, отсутствие
nvidia-ctkи недоступныйphoto-aiне ломают приложение. node api.smoketest.jsпроходит;AGENTS.md,README.md,.env.exampleописывают новые переменные и GPU-запуск.