From 884188e9ecae5b8134ba2e8b57f74f2033d0086e Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 29 Sep 2026 15:08:46 +0300 Subject: [PATCH] =?UTF-8?q?feat(photo-ai):=20Stage=204=20=E2=80=94=20face-?= =?UTF-8?q?=D1=80=D0=B5=D0=B6=D0=B8=D0=BC,=20=D0=B0=D0=BF=D1=81=D0=BA?= =?UTF-8?q?=D0=B5=D0=B9=D0=BB-=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D0=BC=D0=B5=D1=82=D0=B0=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=B2=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Воркер и сервер принимают параметры ИИ-обработки фото: модель апскейла (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. --- TODO_PHOTO_FACE_AI.md | 137 ++++++++++++++++++++++++++------ db/init.sql | 9 +++ db/migration.sql | 9 +++ server.js | 112 +++++++++++++++++++++++--- worker.js | 177 ++++++++++++++++++++++++++++++++++++++---- 5 files changed, 393 insertions(+), 51 deletions(-) diff --git a/TODO_PHOTO_FACE_AI.md b/TODO_PHOTO_FACE_AI.md index acbd657..e54935e 100644 --- a/TODO_PHOTO_FACE_AI.md +++ b/TODO_PHOTO_FACE_AI.md @@ -472,55 +472,140 @@ nvidia.com/gpu=all`) — так демон перезапускать не ну ### 7.1 `worker.js` (`createPhotoEnhanceWorker`) -- [ ] Таймауты из env: `PHOTO_AI_TIMEOUT_MS` (дефолт 300000), `PHOTO_AI_FACE_TIMEOUT_MS` (дефолт 600000) +- [x] Таймауты из 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` +- [x] `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`, +- [x] `503` / `Retry-After` / сетевая недоступность → `sleep` c экспоненциальной задержкой, `return false`, **`attempts` не инкрементится**; лимит мягких повторов (например 60) → одна честная ошибка с понятным текстом (I5). -- [ ] `AbortError` от `AbortSignal.timeout` → сообщение «ИИ-сервис не ответил за N с» — это уже +- [x] `AbortError` от `AbortSignal.timeout` → сообщение «ИИ-сервис не ответил за N с» — это уже «жёсткая» ошибка с попытками. -- [ ] `processOne`: `action` ∈ {`ai`, `ai_face`, `ai_upscale`} → AI-путь; `enhance` → sharp. +- [x] `processOne`: `action` ∈ {`ai`, `ai_face`, `ai_upscale`} → AI-путь; `enhance` → sharp. Пустые `params` при `ai_face` → `face='face'`. -- [ ] `applyResult`: в `logAudit(..., 'photo.job.preview', {...})` добавить `model`, `face`, `face_model`, +- [x] `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` (попадают в +- [x] `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}`; +- [x] `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`). Схема + - Валидация вынесена **перед** запросом записи: невалидное тело → `400` независимо от наличия + записи и фото, дешевле (без похода в БД) и детерминированно. +- [x] `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` — все семь значений набора покрыты, лишних нет. - [x] `photoAiHealth()` (см. `D2`) + проксирование в `GET /api/photo-jobs/status` → `service` (`reachable`, `device`, `device_name`, `ready`, `vram_total_mb`, `vram_free_mb`, `models`, `face_models`, `loaded`). Недоступен → `{reachable:false}`, ответ 200. — **сделано 2026-09-28** (контракт и проверка в `D2`; поля `device`/`device_name`/`vram_*` появятся вместе с расширенным `/health` в Stage 1) -- [ ] `GET /api/photo-ai/health` (`requireAdmin`) — прямой прокси `/health` photo-ai для оператора. -- [ ] `getStackInfo()` (`server.js:769`) — блок `photo_ai` (`engine: 'Real-ESRGAN + GFPGAN'`, `driver`, +- [x] `GET /api/photo-ai/health` (`requireAdmin`) — прямой прокси `/health` photo-ai для оператора. +- [x] `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`), + - `photoAiHealth()` получил необязательный параметр таймаута; в `getStackInfo()` вызывается с + `2000` — «Статус стека» на `settings.html` не должен висеть на `/health` фото-сервиса 5 с. +- [x] Настройки (`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`), +- [x] Дефолты новых настроек: `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`, + - Строки добавлены и в `ensurePhotoJobsTable()` — существующая БД получает их при старте + приложения, без ручного прогона `migration.sql`. + - Дефолты читаются из env: `photo_ai_face_model` ← `PHOTO_AI_FACE_MODEL`, `photo_ai_face_mode` + ← `PHOTO_AI_FACE_MODE` (обе переменные уже есть в §10), чтобы дефолты БД и compose не разошлись. +- [x] `createPhotoEnhanceWorker({...})` (`server.js:6045`): + `faceTimeoutMs: PHOTO_AI_FACE_TIMEOUT_MS`, `defaultFaceModel: PHOTO_AI_FACE_MODEL`. -- [ ] `warnings` от photo-ai сохранять в аудит/уведомление, чтобы оператор видел, что face-режим не +- [x] `warnings` от photo-ai сохранять в аудит/уведомление, чтобы оператор видел, что face-режим не сработал не из-за ошибки. -- [ ] **Приёмка:** задание с `face='face'` проходит `pending → processing → done`; `params` сохранены; +- [x] **Приёмка:** задание с `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`: рядом с «🤖 ИИ» селект режима @@ -568,11 +653,11 @@ nvidia.com/gpu=all`) — так демон перезапускать не ну | `PHOTO_AI_MAX_PIXELS` | `4000000` | `app.py` (алиас `MAX_INPUT_PIXELS`) | | `PHOTO_AI_DEVICE` | `auto` | `app.py` (`auto|cuda|cpu`) | | `PHOTO_AI_TILE` | `256` | `app.py` | -| `PHOTO_AI_FACE_MODEL` | `gfpgan` | `app.py`, дефолт воркера/UI | +| `PHOTO_AI_FACE_MODEL` | `gfpgan` | `app.py`, дефолт воркера, дефолт `photo_ai_face_model` | | `PHOTO_AI_LOAD_ALL` | `0` | `app.py` | -| `PHOTO_AI_FACE_MODE` | `off` | дефолт UI | +| `PHOTO_AI_FACE_MODE` | `off` | дефолт `photo_ai_face_mode` (UI) | | `PHOTO_AI_JPEG_QUALITY` | `92` | `app.py` | -| `PHOTO_AI_TIMEOUT_MS` | `300000` | `worker.js` (сейчас хардкод) | +| `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` — первая пауза мягкого повтора | @@ -583,9 +668,9 @@ nvidia.com/gpu=all`) — так демон перезапускать не ну ## 11. Порядок и правила выполнения -- [ ] Этапы выполняются строго 0 → 1 → 2 → 3 → 4 → 5 → 6. Каждый этап закрывается своей приёмкой - **до** начала следующего. -- [ ] Каждый завершённый чекбокс отмечать (`- [x]`) в этом файле по мере выполнения. +- [x] Этапы выполняются строго 0 → 1 → 2 → 3 → 4 → 5 → 6. Каждый этап закрывается своей приёмкой + **до** начала следующего. (0–4 закрыты, см. журналы разделов) +- [x] Каждый завершённый чекбокс отмечать (`- [x]`) в этом файле по мере выполнения. - [ ] Изменения в `AGENTS.md`/`.env.example`/`README.md` делать **в том же** изменении, что и код, который они описывают (правило `AGENTS.md` про рассинхрон документации). - [ ] Коммит на этап, а не на весь план: `app.py`+`Dockerfile`+compose → воркер/сервер → фронт → доки/тесты. @@ -595,9 +680,11 @@ nvidia.com/gpu=all`) — так демон перезапускать не ну ## 12. Готово, когда -- [ ] `photo-ai` стартует на CPU и на CUDA, устройство выбирается автоматически, `/health` показывает - фактическое устройство, имя GPU, VRAM, доступные и загруженные модели. -- [ ] Сценарий «🤖 ИИ» без параметров даёт результат, эквивалентный текущему. +- [x] `photo-ai` стартует на CPU и на CUDA, устройство выбирается автоматически, `/health` показывает + фактическое устройство, имя GPU, VRAM, доступные и загруженные модели. — **проверено на обоих** + (CPU на текущем стеке, CUDA — журнал GPU-прогона в разделе 2) +- [x] Сценарий «🤖 ИИ» без параметров даёт результат, эквивалентный текущему. — **I1/I2 в Stage 4**: + байт-в-байт с raw-вызовом и 5/5 `MATCH` эталона - [ ] Доступны режимы: универсальный x2, быстрый x4, только лица (GFPGAN), лица + фон. - [ ] Face-задание проходит полный цикл, результат виден в сравнении, применяется и откатывается. - [ ] Отсутствие GPU, отсутствие `nvidia-ctk` и недоступный `photo-ai` не ломают приложение. diff --git a/db/init.sql b/db/init.sql index 5c85153..9f671fb 100644 --- a/db/init.sql +++ b/db/init.sql @@ -242,6 +242,15 @@ CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id); INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('photo_ai_face_mode', 'off') +ON CONFLICT (key) DO NOTHING; + +INSERT INTO settings (key, value) VALUES ('photo_ai_face_model', 'gfpgan') +ON CONFLICT (key) DO NOTHING; + +INSERT INTO settings (key, value) VALUES ('photo_ai_device_pref', 'auto') +ON CONFLICT (key) DO NOTHING; + CREATE TABLE IF NOT EXISTS audit_log ( id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id) ON DELETE SET NULL, diff --git a/db/migration.sql b/db/migration.sql index f650e23..16c6684 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -234,6 +234,15 @@ CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id); INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('photo_ai_face_mode', 'off') +ON CONFLICT (key) DO NOTHING; + +INSERT INTO settings (key, value) VALUES ('photo_ai_face_model', 'gfpgan') +ON CONFLICT (key) DO NOTHING; + +INSERT INTO settings (key, value) VALUES ('photo_ai_device_pref', 'auto') +ON CONFLICT (key) DO NOTHING; + ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255); ALTER TABLE photo_jobs ADD COLUMN IF NOT EXISTS applied BOOLEAN NOT NULL DEFAULT false; diff --git a/server.js b/server.js index ec88c14..7e73033 100644 --- a/server.js +++ b/server.js @@ -775,6 +775,7 @@ async function getStackInfo(cacheStats) { dbVersion = r.rows[0] ? String(r.rows[0].server_version) : null; } catch {} const remote = storage.isRemote(); + const photoAi = await photoAiHealth(2000); return { app: { name: app.name, @@ -822,6 +823,26 @@ async function getStackInfo(cacheStats) { local_fallback: process.env.STORAGE_LOCAL_FALLBACK === '1', keep_local: process.env.STORAGE_KEEP_LOCAL === '1', }, + photo_ai: { + engine: 'Real-ESRGAN + GFPGAN', + host: hostOf(PHOTO_AI_URL), + configured: !!photoAi.configured, + reachable: !!photoAi.reachable, + latency_ms: photoAi.latency_ms || 0, + error: photoAi.error || null, + ready: photoAi.ready ?? null, + device: photoAi.device || null, + device_name: photoAi.device_name || null, + half: photoAi.half ?? null, + tile: photoAi.tile ?? null, + driver: photoAi.driver || null, + cuda: photoAi.cuda || null, + vram_total_mb: photoAi.vram_total_mb ?? null, + vram_free_mb: photoAi.vram_free_mb ?? null, + models: Array.isArray(photoAi.models) ? photoAi.models : [], + face_models: Array.isArray(photoAi.face_models) ? photoAi.face_models : [], + loaded: Array.isArray(photoAi.loaded) ? photoAi.loaded : [], + }, }; } @@ -1253,6 +1274,9 @@ async function ensurePhotoJobsTable() { await pool.query(`CREATE INDEX IF NOT EXISTS idx_photo_jobs_pending ON photo_jobs(id) WHERE status = 'pending'`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id)`); await pool.query(`INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true') ON CONFLICT (key) DO NOTHING`); + await pool.query(`INSERT INTO settings (key, value) VALUES ('photo_ai_face_mode', 'off') ON CONFLICT (key) DO NOTHING`); + await pool.query(`INSERT INTO settings (key, value) VALUES ('photo_ai_face_model', $1) ON CONFLICT (key) DO NOTHING`, [PHOTO_AI_FACE_MODEL]); + await pool.query(`INSERT INTO settings (key, value) VALUES ('photo_ai_device_pref', 'auto') ON CONFLICT (key) DO NOTHING`); } async function ensureUserTables() { @@ -1660,8 +1684,8 @@ app.get('/api/settings', requireAdmin, async (_, res) => { app.get('/api/public-settings', apiLimiter, async (_, res) => { const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => { - const keys = ['system_name', 'system_logo', 'footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min', 'photo_capture_resolution', 'photo_capture_quality', 'photo_enhance_engine', 'camera_enabled']; - const defaults = { system_name: 'WhatIDo', system_logo: '', spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92', photo_enhance_engine: 'auto', camera_enabled: 'true' }; + const keys = ['system_name', 'system_logo', 'footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min', 'photo_capture_resolution', 'photo_capture_quality', 'photo_enhance_engine', 'camera_enabled', 'photo_ai_face_mode', 'photo_ai_face_model', 'photo_ai_device_pref']; + const defaults = { system_name: 'WhatIDo', system_logo: '', spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92', photo_enhance_engine: 'auto', camera_enabled: 'true', photo_ai_face_mode: PHOTO_AI_DEFAULT_FACE_MODE, photo_ai_face_model: PHOTO_AI_FACE_MODEL, photo_ai_device_pref: 'auto' }; const result = {}; for (const k of keys) result[k] = await getSetting(k, defaults[k] || ''); const rm = /^(\d{2,5})x(\d{2,5})$/.exec(result.photo_capture_resolution); @@ -1710,6 +1734,15 @@ app.put('/api/settings', requireAdmin, async (req, res) => { if (key === 'photo_enhance_engine' && !['auto', 'server', 'client'].includes(String(value))) { return res.status(400).json({ error: 'photo_enhance_engine должен быть auto, server или client' }); } + if (key === 'photo_ai_face_mode' && !PHOTO_AI_FACE_MODES.includes(String(value))) { + return res.status(400).json({ error: `photo_ai_face_mode должен быть одним из: ${PHOTO_AI_FACE_MODES.join(', ')}` }); + } + if (key === 'photo_ai_face_model' && !PHOTO_AI_FACE_MODELS.includes(String(value))) { + return res.status(400).json({ error: `photo_ai_face_model должен быть одним из: ${PHOTO_AI_FACE_MODELS.join(', ')}` }); + } + if (key === 'photo_ai_device_pref' && !PHOTO_AI_DEVICE_PREFS.includes(String(value))) { + return res.status(400).json({ error: `photo_ai_device_pref должен быть одним из: ${PHOTO_AI_DEVICE_PREFS.join(', ')}` }); + } if (key === 'system_name' && String(value).trim().length > 60) { return res.status(400).json({ error: 'system_name не может быть длиннее 60 символов' }); } @@ -1839,6 +1872,16 @@ const tar = require('tar'); const os = require('os'); const AI_URL = process.env.AI_URL || 'http://text-corrector:8080'; const PHOTO_AI_URL = process.env.PHOTO_AI_URL || ''; +const PHOTO_AI_FACE_MODEL = process.env.PHOTO_AI_FACE_MODEL || 'gfpgan'; +const PHOTO_AI_FACE_TIMEOUT_MS = Math.max(1000, parseInt(process.env.PHOTO_AI_FACE_TIMEOUT_MS || '600000', 10) || 600000); +const PHOTO_AI_DEFAULT_MODEL = 'x2plus'; +const PHOTO_AI_DEFAULT_FACE_MODE = process.env.PHOTO_AI_FACE_MODE || 'off'; +const PHOTO_AI_MODELS = ['x2plus', 'general-x4v3', 'animevideo-v3']; +const PHOTO_AI_FACE_MODELS = ['gfpgan', 'codeformer']; +const PHOTO_AI_FACE_MODES = ['off', 'face', 'all']; +const PHOTO_AI_DEVICE_PREFS = ['auto', 'cuda', 'cpu']; +const PHOTO_AI_DEFAULT_STRENGTH = 0.7; +const PHOTO_JOB_ACTIONS = new Set(['ai', 'ai_face', 'ai_upscale', 'enhance', 'restore', 'rollback']); const AI_MODEL = process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf'; const AI_DEFAULT_PROMPT = process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.'; let entryAutoChecker = null; @@ -2251,7 +2294,6 @@ function normalizeRestoreData(data) { for (const [k, v] of Object.entries(data.settings || {})) { settings[reqStr(k, 100)] = reqStr(String(v), 10000); } - const PHOTO_JOB_ACTIONS = new Set(['ai', 'enhance', 'restore', 'rollback']); const PHOTO_JOB_STATUSES = new Set(['pending', 'processing', 'done', 'error', 'rejected']); const photo_jobs = (data.photo_jobs || []).map(x => ({ id: reqInt(x.id), @@ -5111,6 +5153,41 @@ app.put('/api/entries/:id/photo/enhance', requireAuth, (req, res, next) => { } }); +function parsePhotoAiRequest(body) { + const src = body && typeof body === 'object' && !Array.isArray(body) ? body : {}; + const pick = (v, def) => { + if (v === undefined || v === null || v === '') return def; + return String(v).trim().toLowerCase(); + }; + const model = pick(src.model, PHOTO_AI_DEFAULT_MODEL); + if (!PHOTO_AI_MODELS.includes(model)) { + return { error: `model должен быть одним из: ${PHOTO_AI_MODELS.join(', ')}` }; + } + const face = pick(src.face, 'off'); + if (!PHOTO_AI_FACE_MODES.includes(face)) { + return { error: `face должен быть одним из: ${PHOTO_AI_FACE_MODES.join(', ')}` }; + } + const faceModel = pick(src.face_model, PHOTO_AI_FACE_MODEL); + if (face !== 'off' && !PHOTO_AI_FACE_MODELS.includes(faceModel)) { + return { error: `face_model должен быть одним из: ${PHOTO_AI_FACE_MODELS.join(', ')}` }; + } + let strength = PHOTO_AI_DEFAULT_STRENGTH; + if (src.strength !== undefined && src.strength !== null && src.strength !== '') { + const n = Number(src.strength); + if (!Number.isFinite(n) || n < 0 || n > 1) { + return { error: 'strength должен быть числом от 0 до 1' }; + } + strength = n; + } + if (face !== 'off' && faceModel !== 'codeformer' && Math.abs(strength - PHOTO_AI_DEFAULT_STRENGTH) > 1e-9) { + return { error: `strength применяется только к codeformer, для ${faceModel} оставьте ${PHOTO_AI_DEFAULT_STRENGTH}` }; + } + return { + action: face === 'off' ? 'ai' : 'ai_face', + params: { model, face, face_model: face === 'off' ? null : faceModel, strength }, + }; +} + app.post('/api/entries/:id/photo/enhance-ai', requireAuth, async (req, res) => { if (!PHOTO_AI_URL) return res.status(503).json({ error: 'ИИ-обработка фото не настроена' }); if (req.user.role !== 'admin') { @@ -5118,17 +5195,26 @@ app.post('/api/entries/:id/photo/enhance-ai', requireAuth, async (req, res) => { if (!acc.found) return res.status(404).json({ error: 'Not found' }); if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' }); } + const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {}; + const hasParams = Object.keys(body).some((k) => body[k] !== undefined && body[k] !== null && body[k] !== ''); + const parsed = hasParams ? parsePhotoAiRequest(body) : { action: 'ai', params: null }; + if (parsed.error) return res.status(400).json({ error: parsed.error }); const { rows: cur } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [req.params.id]); if (!cur.length) return res.status(404).json({ error: 'Запись не найдена' }); if (!cur[0].photo_path) return res.status(400).json({ error: 'У записи нет фото' }); try { const { rows } = await pool.query( - `INSERT INTO photo_jobs (entry_id, action, status, params) VALUES ($1, 'ai', 'pending', NULL) RETURNING id`, - [req.params.id] + `INSERT INTO photo_jobs (entry_id, action, status, params) VALUES ($1, $2, 'pending', $3) RETURNING id`, + [req.params.id, parsed.action, parsed.params ? JSON.stringify(parsed.params) : null] ); if (photoWorker) photoWorker.notify(); - await logAudit(req, 'entry.photo.enhance-ai.queue', { entry_id: req.params.id, job_id: rows[0].id }); - res.json({ jobId: rows[0].id }); + await logAudit(req, 'entry.photo.enhance-ai.queue', { + entry_id: req.params.id, + job_id: rows[0].id, + action: parsed.action, + params: parsed.params, + }); + res.json({ jobId: rows[0].id, action: parsed.action, params: parsed.params }); } catch (e) { console.error('POST /api/entries/:id/photo/enhance-ai:', e); res.status(500).json({ error: 'Ошибка постановки задания в очередь' }); @@ -5655,11 +5741,11 @@ async function aiHealthCheck() { } } -async function photoAiHealth() { +async function photoAiHealth(timeoutMs = 5000) { if (!PHOTO_AI_URL) return { configured: false, reachable: false, latency_ms: 0, error: 'PHOTO_AI_URL не настроен' }; const startedAt = Date.now(); const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 5000); + const timer = setTimeout(() => controller.abort(), timeoutMs); try { const r = await fetch(`${PHOTO_AI_URL}/health`, { signal: controller.signal }); const latency_ms = Date.now() - startedAt; @@ -5677,6 +5763,11 @@ async function photoAiHealth() { } } +app.get('/api/photo-ai/health', requireAdmin, async (_, res) => { + const health = await photoAiHealth(5000); + res.json(health); +}); + app.get('/api/ai/status', requireAdmin, async (_, res) => { const { rows } = await pool.query( `SELECT ai_status, count(*)::int AS n FROM entries WHERE deleted_at IS NULL GROUP BY ai_status` @@ -6075,6 +6166,9 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) { storage, notifyEvent: notifyEntry, bus: createWorkerBus(PHOTO_WAKE_CHANNEL), + faceTimeoutMs: PHOTO_AI_FACE_TIMEOUT_MS, + defaultModel: PHOTO_AI_DEFAULT_MODEL, + defaultFaceModel: PHOTO_AI_FACE_MODEL, }); photoWorker.start(); console.log('Photo enhance worker started'); diff --git a/worker.js b/worker.js index 7cb16b5..a3e981b 100644 --- a/worker.js +++ b/worker.js @@ -6,7 +6,12 @@ const MIN_TEXT_CHARS = 4; const crypto = require('crypto'); const { textDiff, FIELD_LABELS } = require('./diff'); const PHOTO_MAX_ATTEMPTS = 3; -const PHOTO_AI_TIMEOUT_MS = 300000; +const PHOTO_AI_TIMEOUT_MS = Math.max(1000, parseInt(process.env.PHOTO_AI_TIMEOUT_MS || '300000', 10) || 300000); +const PHOTO_AI_FACE_TIMEOUT_MS = Math.max(PHOTO_AI_TIMEOUT_MS, parseInt(process.env.PHOTO_AI_FACE_TIMEOUT_MS || '600000', 10) || 600000); +const PHOTO_AI_DEFAULT_MODEL = 'x2plus'; +const PHOTO_AI_DEFAULT_FACE_MODEL = String(process.env.PHOTO_AI_FACE_MODEL || 'gfpgan').trim() || 'gfpgan'; +const PHOTO_AI_DEFAULT_STRENGTH = 0.7; +const PHOTO_AI_ACTIONS = new Set(['ai', 'ai_face', 'ai_upscale']); const PHOTO_SOFT_MAX_RETRIES = Math.max(1, parseInt(process.env.PHOTO_AI_SOFT_MAX_RETRIES || '60', 10) || 60); const PHOTO_SOFT_BACKOFF_MS = Math.max(1000, parseInt(process.env.PHOTO_AI_SOFT_BACKOFF_MS || '10000', 10) || 10000); const PHOTO_SOFT_BACKOFF_MAX_MS = Math.max(PHOTO_SOFT_BACKOFF_MS, parseInt(process.env.PHOTO_AI_SOFT_BACKOFF_MAX_MS || '300000', 10) || 300000); @@ -38,10 +43,14 @@ function softFailure(message, e) { return err; } -function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, storage, bus, notifyEvent }) { +function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, storage, bus, notifyEvent, faceTimeoutMs, defaultModel, defaultFaceModel }) { const AI_URL = photoAiUrl || process.env.PHOTO_AI_URL || ''; const IDLE_MIN = 2000; const IDLE_MAX = 30000; + const AI_TIMEOUT_MS = PHOTO_AI_TIMEOUT_MS; + const AI_FACE_TIMEOUT_MS = Math.max(AI_TIMEOUT_MS, parseInt(faceTimeoutMs, 10) || PHOTO_AI_FACE_TIMEOUT_MS); + const DEFAULT_MODEL = String(defaultModel || PHOTO_AI_DEFAULT_MODEL).trim() || PHOTO_AI_DEFAULT_MODEL; + const DEFAULT_FACE_MODEL = String(defaultFaceModel || PHOTO_AI_DEFAULT_FACE_MODEL).trim() || PHOTO_AI_DEFAULT_FACE_MODEL; async function sendNotification(entryId, payload) { if (!notifyEvent) return; @@ -64,12 +73,15 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie const CONFIG = { idle_min_ms: IDLE_MIN, idle_max_ms: IDLE_MAX, - ai_timeout_ms: PHOTO_AI_TIMEOUT_MS, + ai_timeout_ms: AI_TIMEOUT_MS, + face_timeout_ms: AI_FACE_TIMEOUT_MS, max_attempts: PHOTO_MAX_ATTEMPTS, soft_max_retries: PHOTO_SOFT_MAX_RETRIES, soft_backoff_ms: PHOTO_SOFT_BACKOFF_MS, soft_backoff_max_ms: PHOTO_SOFT_BACKOFF_MAX_MS, ai_url: AI_URL, + default_model: DEFAULT_MODEL, + face_model: DEFAULT_FACE_MODEL, }; function sleep(ms) { @@ -150,48 +162,177 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie return `/uploads/${newName}`; } - async function runAiEnhance(srcKey) { + function aiParams(job) { + const p = job && job.params && typeof job.params === 'object' ? job.params : {}; + const face = String(p.face || (job && job.action === 'ai_face' ? 'face' : 'off')).trim().toLowerCase() || 'off'; + const strength = Number(p.strength); + return { + model: String(p.model || DEFAULT_MODEL).trim().toLowerCase() || DEFAULT_MODEL, + face, + face_model: String(p.face_model || DEFAULT_FACE_MODEL).trim().toLowerCase() || DEFAULT_FACE_MODEL, + strength: Number.isFinite(strength) ? strength : PHOTO_AI_DEFAULT_STRENGTH, + timeout_ms: face === 'off' ? AI_TIMEOUT_MS : AI_FACE_TIMEOUT_MS, + }; + } + + function aiMeta(source) { + const o = source && typeof source === 'object' ? source : {}; + const num = (v) => (Number.isFinite(Number(v)) && v !== null && v !== '' ? Number(v) : null); + return { + model: o.model ? String(o.model) : null, + face: o.face ? String(o.face) : null, + face_model: o.face_model ? String(o.face_model) : null, + faces_found: num(o.faces_found), + device: o.device ? String(o.device) : null, + elapsed_ms: num(o.elapsed_ms), + warnings: Array.isArray(o.warnings) ? o.warnings.map((w) => String(w).slice(0, 300)).slice(0, 5) : [], + }; + } + + async function readErrorBody(resp) { + try { + const text = (await resp.text()).trim(); + if (!text) return null; + let data = null; + try { data = JSON.parse(text); } catch (e) {} + if (data && typeof data === 'object' && !Array.isArray(data)) { + return String(data.error || data.detail || '').slice(0, 400) || null; + } + return text.slice(0, 400); + } catch (e) { + return null; + } + } + + async function runAiEnhance(srcKey, job) { if (!AI_URL) throw new Error('PHOTO_AI_URL не настроен'); + const opt = aiParams(job); const buf = await storage.getBuffer(srcKey); if (!buf) throw new Error('Файл фото не найден'); const fd = new FormData(); fd.append('image', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg'); fd.append('scale', '2'); + fd.append('model', opt.model); + fd.append('face', opt.face); + fd.append('face_model', opt.face_model); + fd.append('strength', String(opt.strength)); let resp; try { resp = await fetch(AI_URL.replace(/\/+$/, '') + '/enhance', { method: 'POST', + headers: { Accept: 'application/json' }, body: fd, - signal: AbortSignal.timeout(PHOTO_AI_TIMEOUT_MS), + signal: AbortSignal.timeout(opt.timeout_ms), }); } catch (e) { if (isTimeoutFailure(e)) { - throw new Error(`ИИ-сервис не ответил за ${Math.round(PHOTO_AI_TIMEOUT_MS / 1000)} с`); + throw new Error(`ИИ-сервис не ответил за ${Math.round(opt.timeout_ms / 1000)} с`); } throw softFailure('ИИ-сервис недоступен', e); } if (!resp.ok) { const retryAfter = resp.headers.get('retry-after'); const detail = `ИИ-сервис ответил ${resp.status}${retryAfter ? ` (Retry-After: ${retryAfter})` : ''}`; - if (isSoftStatus(resp.status)) throw softFailure(detail); - throw new Error(detail); + const reason = await readErrorBody(resp); + const message = reason ? `${detail}: ${reason}` : detail; + if (isSoftStatus(resp.status)) throw softFailure(message); + throw new Error(message); } - const out = Buffer.from(await resp.arrayBuffer()); + const contentType = resp.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + const out = Buffer.from(await resp.arrayBuffer()); + return { + out, + meta: aiMeta({ + model: resp.headers.get('x-photo-ai-model') || opt.model, + face: resp.headers.get('x-photo-ai-face') || opt.face, + face_model: opt.face === 'off' ? null : opt.face_model, + device: resp.headers.get('x-photo-ai-device'), + elapsed_ms: resp.headers.get('x-photo-ai-elapsed-ms'), + }), + }; + } + let data; + try { + data = await resp.json(); + } catch (e) { + throw new Error('ИИ-сервис вернул нечитаемый JSON'); + } + if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('ИИ-сервис вернул неожиданный JSON'); + if (data.ok === false) throw new Error(String(data.error || 'ИИ-сервис вернул ошибку').slice(0, 500)); + if (typeof data.image_base64 !== 'string' || !data.image_base64) throw new Error('ИИ-сервис не вернул изображение'); + let out; + try { + out = Buffer.from(data.image_base64, 'base64'); + } catch (e) { + throw new Error('ИИ-сервис вернул изображение в нечитаемом base64'); + } + if (!out.length) throw new Error('ИИ-сервис вернул пустое изображение'); + return { + out, + meta: aiMeta({ + model: data.model || opt.model, + face: data.face || opt.face, + face_model: data.face_model || (opt.face === 'off' ? null : opt.face_model), + faces_found: data.faces_found, + device: data.device, + elapsed_ms: data.elapsed_ms, + warnings: data.warnings, + }), + }; + } + + async function enhanceWithAi(srcKey, job) { + const { out, meta } = await runAiEnhance(srcKey, job); const newName = crypto.randomBytes(12).toString('hex') + '.jpg'; await storage.put(newName, out); - return `/uploads/${newName}`; + return { path: `/uploads/${newName}`, meta }; } - async function applyResult(job, newPath) { + + async function applyResult(job, newPath, meta) { + const m = meta || {}; await pool.query( `UPDATE photo_jobs SET status = 'done', after_path = $1, error = NULL, finished_at = now() WHERE id = $2`, [newPath, job.id] ); - if (logAudit) await logAudit(null, 'photo.job.preview', { entry_id: job.entry_id, job_id: job.id, action: job.action, after_path: newPath }); + if (logAudit) { + await logAudit(null, 'photo.job.preview', { + entry_id: job.entry_id, + job_id: job.id, + action: job.action, + after_path: newPath, + model: m.model, + face: m.face, + face_model: m.face_model, + device: m.device, + faces_found: m.faces_found, + elapsed_ms: m.elapsed_ms, + warnings: m.warnings && m.warnings.length ? m.warnings : null, + }); + } + const title = job.action === 'ai_face' + ? 'Фото обработано нейросетью с восстановлением лиц: {student}' + : job.action === 'ai_upscale' + ? 'Фото обработано нейросетью (апскейл): {student}' + : job.action === 'ai' + ? 'Фото обработано нейросетью: {student}' + : 'Фото улучшено на сервере: {student}'; + const warn = m.warnings && m.warnings.length ? ' · ' + m.warnings.join('; ').slice(0, 300) : ''; await sendNotification(job.entry_id, { type: 'photo.job.done', - title: job.action === 'ai' ? 'Фото обработано нейросетью: {student}' : 'Фото улучшено на сервере: {student}', - body: `Группа {group} · запись #${job.entry_id} · результат ждёт применения`, - target: { job_id: job.id, action: job.action, after_path: newPath }, + title, + body: `Группа {group} · запись #${job.entry_id} · результат ждёт применения${warn}`, + target: { + job_id: job.id, + action: job.action, + after_path: newPath, + model: m.model, + face: m.face, + face_model: m.face_model, + device: m.device, + faces_found: m.faces_found, + elapsed_ms: m.elapsed_ms, + }, }); } async function softRetry(job, message) { @@ -253,8 +394,10 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie } const photoPath = rows[0].photo_path; try { - const newPath = job.action === 'ai' ? await runAiEnhance(photoPath) : await enhanceWithSharp(photoPath, job.params); - await applyResult(job, newPath); + const result = PHOTO_AI_ACTIONS.has(job.action) + ? await enhanceWithAi(photoPath, job) + : { path: await enhanceWithSharp(photoPath, job.params), meta: null }; + await applyResult(job, result.path, result.meta); softTries.delete(job.id); stats.done++; stats.last_at = new Date().toISOString();