feat(photo-ai): Stage 4 — face-режим, апскейл-модели и метаданные в аудите

Воркер и сервер принимают параметры ИИ-обработки фото: модель апскейла
(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.
This commit is contained in:
dev
2026-09-29 15:08:46 +03:00
parent 4c63a46d24
commit 884188e9ec
5 changed files with 393 additions and 51 deletions
+112 -25
View File
@@ -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` не ломают приложение.
+9
View File
@@ -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,
+9
View File
@@ -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;
+103 -9
View File
@@ -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');
+159 -16
View File
@@ -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 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();