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:
+112
-25
@@ -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` не ломают приложение.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user