From 45033bffa8287bc1790340fb9cc7ddf5f08fc159 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 30 Sep 2026 11:15:54 +0300 Subject: [PATCH] =?UTF-8?q?feat(photo-ai):=20Stage=205=20=E2=80=94=20?= =?UTF-8?q?=D1=84=D1=80=D0=BE=D0=BD=D1=82=D0=B5=D0=BD=D0=B4=20=D1=84=D0=BE?= =?UTF-8?q?=D1=82=D0=BE-=D0=98=D0=98=20(=D1=80=D0=B5=D0=B6=D0=B8=D0=BC?= =?UTF-8?q?=D1=8B,=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81,=20=D0=BC=D0=B5?= =?UTF-8?q?=D1=82=D0=B0=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Только фронтенд: server.js, worker.js, photo-ai/, compose и схема БД не тронуты. Контракт /enhance-ai, /api/photo-ai/health и service в /api/photo-jobs/status взят из Stage 4 как есть (I6 — новых колонок нет, метаданные из photo_jobs.params и audit_log.target). journal.html + journal.js: - селект режима рядом с «🤖 ИИ»: Универсально (x2) / Быстро (x4) / Лица / Лица + фон; PHOTO_AI_MODES — единственная таблица режим→{model, face} - значение уходит в POST .../enhance-ai телом {model, face, face_model}; face_model подставляется только когда face !== 'off' - дефолт селекта из photo_ai_face_mode (face=all → facesbg, face=face → faces) - подсказка про медленный CPU: loadPhotoAiDevice() читает GET /api/photo-ai/health (только для админа) - текст подтверждения и надпись «ИИ обрабатывает…» различают апскейл и face-режим - кнопка и селект скрываются вместе при photo_ai_enabled === 'false' (I3) settings.html + settings.js: - новый раздел «Фото-ИИ»: режим по умолчанию, модель лиц, желаемое устройство - read-only статус из /api/photo-jobs/status: фактическое устройство, device_name, VRAM, модели/модели лиц/загруженные; жёлтым — расхождение с желаемым устройством, красным — недоступность и незаданный PHOTO_AI_URL - renderStackInfo() рисует карточку «Фото-ИИ» из stack.photo_ai - новые id в DIRTY_FIELDS и в payload PUT /api/settings - photo_ai_device_pref документирующий: фактическое устройство задаёт PHOTO_AI_DEVICE в контейнере worker.html + worker.js: - PHOTO_ACTION_LABELS: ai_face «ИИ + лица», ai_upscale «ИИ-апскейл» - в модалке сравнения — чипы с моделью, режимом лиц, найденными лицами, устройством, временем обработки и предупреждениями - строка состояния учитывает service.reachable === false («задания ждут») и дописывает «· расчёт на » audit.js: 10 меток для кодов, которые раньше показывались сырыми (профиль и фото ученика, главное фото, фото модуля, удаление/очистка уведомлений, логотип). Проверено: node --check для всех четырёх js. Приёмка на живом стеке в этом изменении не гонялась — чекбокс приёмки Stage 5 в TODO_PHOTO_FACE_AI.md оставлен пустым, там же журнал раздела 5 с замечаниями для Stage 6. --- TODO_PHOTO_FACE_AI.md | 50 +++++++++++++++++++++--- public/journal.html | 9 +++++ public/js/audit.js | 10 +++++ public/js/journal.js | 71 ++++++++++++++++++++++++++++++--- public/js/settings.js | 91 ++++++++++++++++++++++++++++++++++++++++++- public/js/worker.js | 55 ++++++++++++++++++++++++-- public/settings.html | 39 +++++++++++++++++++ public/worker.html | 1 + 8 files changed, 311 insertions(+), 15 deletions(-) diff --git a/TODO_PHOTO_FACE_AI.md b/TODO_PHOTO_FACE_AI.md index e54935e..25abaaf 100644 --- a/TODO_PHOTO_FACE_AI.md +++ b/TODO_PHOTO_FACE_AI.md @@ -608,23 +608,63 @@ nvidia.com/gpu=all`) — так демон перезапускать не ну ## 8. Stage 5. Фронтенд -- [ ] `public/journal.html` + `public/js/journal.js`: рядом с «🤖 ИИ» селект режима +- [x] `public/journal.html` + `public/js/journal.js`: рядом с «🤖 ИИ» селект режима («Универсально (x2)», «Быстро (x4)», «Лица (GFPGAN)», «Лица + фон»); значение уходит в `POST .../enhance-ai` телом `{model, face, face_model}`. Дефолт — из `photo_ai_face_mode` (`/api/public-settings`, `loadEnhanceEngine`); при `device === 'cpu'` — подсказка «на CPU медленно». Кнопка по-прежнему скрыта при `photo_ai_enabled === 'false'` (I3). -- [ ] `public/js/worker.js`: `PHOTO_ACTION_LABELS` + `ai_face: 'ИИ + лица'`, `ai_upscale: 'ИИ-апскейл'`; + - `PHOTO_AI_MODES` (`journal.js:609`) — единственная таблица режим→`{model, face}`; значение + селекта → `photoAiMode()`, `face_model` подставляется только при `face !== 'off'`. +- [x] `public/js/worker.js`: `PHOTO_ACTION_LABELS` + `ai_face: 'ИИ + лица'`, `ai_upscale: 'ИИ-апскейл'`; в модалке сравнения — `model`, `device`, `faces_found`, `elapsed_ms` (данные из `params`/аудита, I6); строка состояния учитывает `service.reachable === false` и `service.device`. -- [ ] `public/settings.html` + `public/js/settings.js`: блок «Фото-ИИ» — селект face-режима, селект +- [x] `public/settings.html` + `public/js/settings.js`: блок «Фото-ИИ» — селект face-режима, селект face-модели, селект желаемого устройства + строка «фактическое: …» с подсветкой расхождения; read-only статус (`device_name`, VRAM, список моделей) из `/api/photo-jobs/status`. Новые id внести в `DIRTY_FIELDS` (`settings.js:1`) и в payload (`settings.js:739`). -- [ ] `public/js/settings.js:372` `renderStackInfo()` — карточка «Фото-ИИ» из `stack.photo_ai`. -- [ ] `public/js/audit.js` — метки для новых кодов аудита (иначе в UI будет сырой код). +- [x] `public/js/settings.js:372` `renderStackInfo()` — карточка «Фото-ИИ» из `stack.photo_ai`. +- [x] `public/js/audit.js` — метки для новых кодов аудита (иначе в UI будет сырой код). - [ ] **Приёмка:** из журнала доступны все 4 режима; после постановки видно «В очереди», затем сравнение «Было/Стало» с моделью/устройством; в настройках видно фактическое устройство и VRAM. +### Журнал раздела 5 (код сделан 2026-09-30, приёмка на живом стеке не гонялась) + +Изменены только файлы фронтенда: `journal.html`, `worker.html`, `settings.html`, `js/journal.js`, +`js/worker.js`, `js/settings.js`, `js/audit.js`. `server.js`, `worker.js` (воркер), `photo-ai/`, compose +и схема БД не тронуты — контракт `/enhance-ai`, `/api/photo-ai/health` и `service` в +`/api/photo-jobs/status` взят из Stage 4 без изменений (I6 соблюдён: новых колонок нет, метаданные +приходят из `photo_jobs.params` и `audit_log.target`). + +| Пункт | Реализация | +|---|---| +| Селект режима в журнале | `#enhanceAiMode` рядом с «🤖 ИИ»; `PHOTO_AI_MODES` → `photoAiMode()` → тело `{model, face, face_model}`; селект и кнопка скрываются вместе при `photo_ai_enabled === 'false'` (I3) | +| Дефолт режима | `defaultPhotoAiModeKey()`: `face=all → facesbg`, `face=face → faces`, иначе `x2`; значение берётся из `photo_ai_face_mode` в `loadEnhanceEngine()` | +| Подсказка про CPU | `loadPhotoAiDevice()` (админ) читает `GET /api/photo-ai/health`; при `device` с `cpu` — «Фото-ИИ считает на CPU — лицевые режимы могут занять несколько минут». Не-админу запрос не делается | +| Тексты подтверждения | `runPhotoAi()` различает апскейл («до минуты») и face-режим («несколько минут», с названием модели лиц) — по той же логике выбирается таймаут в воркере | +| Статус фото-воркера | `photoStateHint` учитывает `enabled`, `ai_configured`, `service.reachable === false` («задания ждут») и дописывает `· расчёт на ` | +| Метаданные в модалке | `renderPhotoJobMeta()`: `params` (модель/режим) из строки задания, `device`/`faces_found`/`elapsed_ms`/`warnings` — из `audit_log.target` по `job_id` (один запрос `GET /api/audit?action=photo.job.preview&limit=200`, мапа кэшируется) | +| Блок «Фото-ИИ» в настройках | три селекта + read-only чипы: фактическое устройство, `device_name`, VRAM, модели/модели лиц/загруженные; жёлтым подсвечивается расхождение с желаемым устройством, красным — недоступность и незаданный `PHOTO_AI_URL` | +| Карточка в «Статусе стека» | `renderStackInfo()` рисует `stack.photo_ai`: состояние (`не настроен` / `нет связи` / `модели грузятся` / `отвечает`), движок, host, устройство, fp16/fp32, VRAM, модели | +| Метки аудита | 10 новых кодов в `audit.js`: профиль и фото ученика, главное фото ученика, фото модуля, удаление и очистка уведомлений, логотип; плюс `ai_face`/`ai_upscale` в `PHOTO_ACTION_LABELS` (`worker.js`) | +| Синтаксис | `node --check` для `js/journal.js`, `js/settings.js`, `js/worker.js`, `js/audit.js` — чисто | + +Замечания, важные для приёмки и для Stage 6: + +- **Метаданные в UI берутся из аудита, а не из `photo_jobs`.** В `params` лежат только модель, режим, + модель лиц и `strength`; `device`, `faces_found`, `elapsed_ms` и `warnings` пишет воркер в + `audit_log.target` при `photo.job.preview`. Поэтому `GET /api/audit?action=photo.job.preview&limit=200` + — один запрос на всё открытие модалки; у заданий старше 200 последних метаданных не будет, UI честно + покажет «Метаданные обработки недоступны». Новых колонок не добавляли (I6). +- **`reachable` и «модели грузятся» — разные состояния.** Прогрев даёт `reachable: true` при + `ready: false` (см. замечание Stage 4 про 503), поэтому строка статуса показывает «Ожидание очереди», + а не «сервис недоступен»; жёлтая подсветка в настройках — только при реальном расхождении устройства. +- **`photo_ai_device_pref` — документирующее значение.** Фактическое устройство задаёт + `PHOTO_AI_DEVICE` в контейнере, поэтому в UI это не переключатель, а сверка «желаемое vs фактическое» + с явной подсказкой, как это исправить. +- **Приёмка на живом стеке не выполнялась** в этом изменении: чекбокс приёмки выше остаётся пустым. + Проверять нужно на стеке с поднятым `photo-ai` (иначе селект и блок статуса скрыты по I3), отдельно — + расхождение `photo_ai_device_pref=cuda` при CPU-сервисе, чтобы увидеть жёлтую подсветку. + --- ## 9. Stage 6. Документация и тесты diff --git a/public/journal.html b/public/journal.html index 4231960..fa86527 100644 --- a/public/journal.html +++ b/public/journal.html @@ -27,6 +27,8 @@ .enhance-sliders { flex: 0 0 240px; display: flex; flex-direction: column; gap: 14px; } .enhance-sliders .settings-field input[type=range] { width: 100%; } .enhance-foot { justify-content: flex-end; gap: 8px; margin-top: 14px; } +.enhance-ai-mode { width: auto; min-width: 200px; padding: 9px 12px; font-size: .85rem; } +.enhance-ai-hint { font-size: .75rem; color: var(--muted); max-width: 340px; } .photo-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; } @media (max-width: 800px) { .enhance-body { flex-direction: column; } @@ -224,6 +226,13 @@
+ + diff --git a/public/js/audit.js b/public/js/audit.js index f224717..6dc2f34 100644 --- a/public/js/audit.js +++ b/public/js/audit.js @@ -26,10 +26,16 @@ const ACTION_LABELS = { 'student.update': 'Изменён ученик', 'student.delete': 'Удалён ученик', 'student.batch-group': 'Ученики перенесены в группу', + 'student.profile.update': 'Изменён профиль ученика', + 'student.photo.create': 'Загружено фото ученика', + 'student.photo.delete': 'Удалено фото ученика', + 'student.photo.main': 'Главное фото ученика', 'module.create': 'Создан модуль', 'module.update': 'Изменён модуль', 'module.delete': 'Модуль отключён', 'module.restore': 'Модуль включён', + 'module.photo.create': 'Загружено фото модуля', + 'module.photo.delete': 'Удалено фото модуля', 'entry.update': 'Изменена запись', 'entry.photo.update': 'Изменено фото записи', 'entry.photo.set_main': 'Главное фото записи', @@ -47,6 +53,10 @@ const ACTION_LABELS = { 'photo-jobs.wake': 'Фото-воркер разбужен', 'photo-jobs.enabled': 'Переключена обработка фото', 'photo-jobs.requeue-failed': 'Ошибочные фото-задания возвращены в очередь', + 'notifications.delete': 'Удалено уведомление', + 'notifications.clear': 'Очищены уведомления', + 'settings.logo.upload': 'Загружен логотип системы', + 'settings.logo.remove': 'Удалён логотип системы', 'entry.soft-delete': 'Запись в корзину', 'entry.restore': 'Запись восстановлена', 'entry.permanent-delete': 'Запись удалена безвозвратно', diff --git a/public/js/journal.js b/public/js/journal.js index d6e82ff..c641ce8 100644 --- a/public/js/journal.js +++ b/public/js/journal.js @@ -498,6 +498,9 @@ let enhanceSaving = false; let enhanceEngine = 'auto'; let enhanceEngineLoaded = false; let photoAiEnabled = false; +let photoAiFaceMode = 'off'; +let photoAiFaceModel = 'gfpgan'; +let photoAiDevice = ''; let photoAiPollTimer = null; let enhanceAiResultPath = null; let enhanceAiJobId = null; @@ -603,6 +606,46 @@ function resetEnhanceZoom() { area.addEventListener('dblclick', resetEnhanceZoom); })(); +const PHOTO_AI_MODES = { + x2: { model: 'x2plus', face: 'off', face_model: null, title: 'Универсально (x2)' }, + x4: { model: 'general-x4v3', face: 'off', face_model: null, title: 'Быстро (x4)' }, + faces: { model: 'x2plus', face: 'face', face_model: null, title: 'Лица (восстановление)' }, + facesbg: { model: 'x2plus', face: 'all', face_model: null, title: 'Лица + фон' }, +}; + +function photoAiMode() { + const sel = document.getElementById('enhanceAiMode'); + const key = sel && PHOTO_AI_MODES[sel.value] ? sel.value : 'x2'; + const m = { ...PHOTO_AI_MODES[key] }; + m.face_model = m.face === 'off' ? null : photoAiFaceModel; + return m; +} + +function defaultPhotoAiModeKey() { + if (photoAiFaceMode === 'all') return 'facesbg'; + if (photoAiFaceMode === 'face') return 'faces'; + return 'x2'; +} + +function updateAiHint() { + const hint = document.getElementById('enhanceAiHint'); + if (!hint) return; + const cpu = photoAiDevice.startsWith('cpu'); + hint.textContent = cpu ? 'Фото-ИИ считает на CPU — лицевые режимы могут занять несколько минут' : ''; + hint.style.display = photoAiEnabled && cpu ? '' : 'none'; +} + +async function loadPhotoAiDevice() { + if (!isAdmin()) return; + try { + const r = await fetch(`${API}/api/photo-ai/health`, { headers: hdr() }); + if (!r.ok) return; + const h = await r.json(); + if (h && h.device) photoAiDevice = String(h.device); + updateAiHint(); + } catch (e) {} +} + async function loadEnhanceEngine() { if (enhanceEngineLoaded) return; enhanceEngineLoaded = true; @@ -611,18 +654,31 @@ async function loadEnhanceEngine() { const f = await r.json(); if (['auto', 'server', 'client'].includes(f.photo_enhance_engine)) enhanceEngine = f.photo_enhance_engine; photoAiEnabled = f.photo_ai_enabled === 'true'; + if (['off', 'face', 'all'].includes(f.photo_ai_face_mode)) photoAiFaceMode = f.photo_ai_face_mode; + if (['gfpgan', 'codeformer'].includes(f.photo_ai_face_model)) photoAiFaceModel = f.photo_ai_face_model; + const sel = document.getElementById('enhanceAiMode'); + if (sel) sel.value = defaultPhotoAiModeKey(); + loadPhotoAiDevice(); } catch (e) {} } async function runPhotoAi() { if (!enhanceEntryId || enhanceSaving) return; const entryId = enhanceEntryId; - if (!confirm('ИИ-восстановление фото (Real-ESRGAN). Может занять до минуты на CPU. Продолжить?')) return; + const mode = photoAiMode(); + const faceNote = mode.face === 'off' + ? 'Может занять до минуты на CPU.' + : `Восстановление лиц (${mode.face_model}), на CPU может занять несколько минут.`; + if (!confirm(`ИИ-обработка фото: ${mode.title}. ${faceNote} Продолжить?`)) return; enhanceSaving = true; const btn = document.getElementById('enhanceAiBtn'); btn.disabled = true; try { - const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance-ai`, { method: 'POST', headers: hdr() }); + const res = await fetch(`${API}/api/entries/${entryId}/photo/enhance-ai`, { + method: 'POST', + headers: hdrJson(), + body: JSON.stringify({ model: mode.model, face: mode.face, face_model: mode.face_model }), + }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Ошибка запуска ИИ'); @@ -630,12 +686,14 @@ async function runPhotoAi() { const { jobId } = await res.json(); enhanceAiJobId = jobId; showEnhanceResult(false); - setEnhanceLoadingText('ИИ обрабатывает фото... (может занять до минуты)'); + setEnhanceLoadingText(mode.face === 'off' + ? 'ИИ обрабатывает фото... (может занять до минуты)' + : 'ИИ обрабатывает фото и восстанавливает лица...'); btn.textContent = '⏳ ИИ...'; const result = await new Promise((resolve, reject) => { photoAiPollTimer = setInterval(async () => { try { - const r = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance-ai/${jobId}`, { headers: hdr() }); + const r = await fetch(`${API}/api/entries/${entryId}/photo/enhance-ai/${jobId}`, { headers: hdr() }); if (!r.ok) throw new Error('Ошибка опроса статуса'); const s = await r.json(); if (s.status === 'done') { clearInterval(photoAiPollTimer); resolve(s); } @@ -715,7 +773,10 @@ function openEnhance(id) { orig.src = `${API}${mp}`; document.getElementById('enhanceRestoreBtn').style.display = e.photo_original_path ? '' : 'none'; loadEnhanceEngine().then(() => { - document.getElementById('enhanceAiBtn').style.display = photoAiEnabled ? '' : 'none'; + const show = photoAiEnabled ? '' : 'none'; + document.getElementById('enhanceAiBtn').style.display = show; + document.getElementById('enhanceAiMode').style.display = show; + updateAiHint(); }); const canvas = document.getElementById('enhanceCanvas'); setEnhanceClip(50); diff --git a/public/js/settings.js b/public/js/settings.js index 6f964eb..af63213 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -1,6 +1,6 @@ -const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays']; +const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'photoAiFaceMode', 'photoAiFaceModel', 'photoAiDevicePref', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays']; const NOTIFY_IDS = []; -const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-bans']; +const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-photo', 'sec-photo-ai', 'sec-bans']; const SPAM_MAX = 10080; const BAN_REASONS = { 'honeypot': 'Антиспам-поле', @@ -12,6 +12,7 @@ const BAN_REASONS = { let currentLogo = ''; let logoOriginal = ''; let currentSystemName = ''; +let photoAiService = null; function renderLogoPreview() { const img = document.getElementById('logoPreviewImg'); @@ -217,6 +218,12 @@ async function loadSettings() { const pe = document.getElementById('photoEngine'); pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto'; if (!pe.value) pe.value = 'auto'; + const afm = document.getElementById('photoAiFaceMode'); + afm.value = ['off', 'face', 'all'].includes(s.photo_ai_face_mode) ? s.photo_ai_face_mode : 'off'; + const afm2 = document.getElementById('photoAiFaceModel'); + afm2.value = ['gfpgan', 'codeformer'].includes(s.photo_ai_face_model) ? s.photo_ai_face_model : 'gfpgan'; + const adp = document.getElementById('photoAiDevicePref'); + adp.value = ['auto', 'cuda', 'cpu'].includes(s.photo_ai_device_pref) ? s.photo_ai_device_pref : 'auto'; document.getElementById('camEnabled').checked = s.camera_enabled !== 'false'; await renderNotifyTypes(s); snapshotDirty(); @@ -225,6 +232,7 @@ async function loadSettings() { loadAiProfiles(); renderStats(stRes); loadSystemInfo(); + loadPhotoAiStatus(); } async function renderStats(res) { @@ -369,6 +377,52 @@ function stackRow(label, value) { return `
${esc(label)}${value}
`; } +function photoAiDeviceMismatch(pref, device) { + const d = String(device || ''); + if (pref === 'cuda') return !d.startsWith('cuda'); + if (pref === 'cpu') return d !== 'cpu'; + return false; +} + +function renderPhotoAiStatus() { + const el = document.getElementById('photoAiStatus'); + if (!el) return; + const svc = photoAiService; + if (!svc) { el.innerHTML = 'Статус фото-ИИ недоступен'; return; } + const pref = document.getElementById('photoAiDevicePref').value; + const device = svc.device || '—'; + const mismatch = photoAiDeviceMismatch(pref, svc.device); + const color = svc.reachable === false ? '#ef4444' : mismatch ? '#f59e0b' : '#16a34a'; + const chips = []; + chips.push(`фактическое: ${esc(device)}`); + if (svc.device_name) chips.push(`${esc(String(svc.device_name))}`); + if (svc.vram_total_mb) chips.push(`VRAM: ${esc(String(svc.vram_free_mb ?? '—'))} свободно из ${esc(String(svc.vram_total_mb))} МБ`); + chips.push(`Модели: ${esc((svc.models || []).join(', ') || '—')}`); + chips.push(`Модели лиц: ${esc((svc.face_models || []).join(', ') || '—')}`); + if (svc.loaded && svc.loaded.length) chips.push(`Загружено: ${esc(svc.loaded.join(', '))}`); + if (!svc.configured) chips.push('PHOTO_AI_URL не настроен — ИИ-фото отключено'); + else if (svc.reachable === false) chips.push(`сервис недоступен${svc.error ? `: ${esc(String(svc.error))}` : ''}`); + else if (svc.ready === false) chips.push('модели загружаются'); + if (mismatch) chips.push('желаемое устройство не совпадает с фактическим — задаётся переменной PHOTO_AI_DEVICE в контейнере'); + el.innerHTML = chips.join(''); +} + +async function loadPhotoAiStatus(btn) { + const el = document.getElementById('photoAiStatus'); + if (!el) return; + if (btn) btn.classList.add('spinning'); + try { + const res = await fetch(`${API}/api/photo-jobs/status?recent_limit=1`, { headers: hdr() }); + if (!res.ok) throw new Error('status'); + photoAiService = (await res.json()).service || null; + renderPhotoAiStatus(); + } catch { + el.innerHTML = 'Не удалось загрузить статус фото-ИИ'; + } finally { + if (btn) btn.classList.remove('spinning'); + } +} + function renderStackInfo(stack) { const el = document.getElementById('stackInfo'); if (!el) return; @@ -419,6 +473,16 @@ function renderStackInfo(stack) { ? stackRow('Бакет', esc(`${st.bucket || '—'}${st.region ? ` · ${st.region}` : ''}`)) : stackRow('Каталог', esc(st.dir || '—')); + const ai = stack.photo_ai || {}; + const aiState = !ai.configured + ? stackState('warn', 'не настроен') + : ai.reachable + ? stackState(ai.ready === false ? 'warn' : 'on', ai.ready === false ? 'модели грузятся' : 'отвечает') + : stackState('err', 'нет связи'); + const aiVram = ai.vram_total_mb + ? `${ai.vram_free_mb ?? '—'} свободно из ${ai.vram_total_mb} МБ` + : null; + el.innerHTML = `
Приложение
@@ -473,6 +537,20 @@ function renderStackInfo(stack) { ${stackRow('Локальный кеш', s3 ? esc(st.local_fallback ? 'включён' : 'выключен') : esc('—'))} ${s3 ? stackRow('Локальные копии', esc(st.keep_local ? 'хранятся' : 'удаляются')) : ''}
+ +
+
Фото-ИИ
+ ${stackRow('Состояние', aiState)} + ${stackRow('Движок', esc(ai.engine || 'Real-ESRGAN + GFPGAN'))} + ${stackRow('Адрес', esc(ai.host || '—'))} + ${stackRow('Устройство', esc(`${ai.device || '—'}${ai.device_name ? ` · ${ai.device_name}` : ''}`))} + ${ai.half === null || ai.half === undefined ? '' : stackRow('Режим', esc(ai.half ? 'fp16 (half)' : 'fp32'))} + ${aiVram ? stackRow('VRAM', esc(aiVram)) : ''} + ${stackRow('Модели', esc((ai.models || []).join(', ') || '—'))} + ${stackRow('Модели лиц', esc((ai.face_models || []).join(', ') || '—'))} + ${stackRow('Загружено', esc((ai.loaded || []).join(', ') || '—'))} + ${ai.error ? `
${esc(String(ai.error))}
` : ''} +
`; } @@ -737,6 +815,9 @@ async function saveSettings() { photo_capture_resolution: document.getElementById('photoResolution').value, photo_capture_quality: String(document.getElementById('photoQuality').value), photo_enhance_engine: document.getElementById('photoEngine').value, + photo_ai_face_mode: document.getElementById('photoAiFaceMode').value, + photo_ai_face_model: document.getElementById('photoAiFaceModel').value, + photo_ai_device_pref: document.getElementById('photoAiDevicePref').value, camera_enabled: document.getElementById('camEnabled').checked ? 'true' : 'false', trash_purge_days: String(trashDays), ...notifySettingsPayload(notifyRetentionDays), @@ -888,6 +969,12 @@ if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', () => loadSyste const stackRefreshBtn = document.getElementById('stackRefreshBtn'); if (stackRefreshBtn) stackRefreshBtn.addEventListener('click', () => loadSystemInfo(stackRefreshBtn)); +const photoAiRefreshBtn = document.getElementById('photoAiRefreshBtn'); +if (photoAiRefreshBtn) photoAiRefreshBtn.addEventListener('click', () => loadPhotoAiStatus(photoAiRefreshBtn)); + +const photoAiDevicePref = document.getElementById('photoAiDevicePref'); +if (photoAiDevicePref) photoAiDevicePref.addEventListener('change', () => renderPhotoAiStatus()); + const notifyTestBtn = document.getElementById('notifyTestBtn'); if (notifyTestBtn) notifyTestBtn.addEventListener('click', async () => { notifyTestBtn.classList.add('spinning'); diff --git a/public/js/worker.js b/public/js/worker.js index fb4c3df..1faa8d2 100644 --- a/public/js/worker.js +++ b/public/js/worker.js @@ -19,6 +19,7 @@ const STATUS_BADGE = { let state = null; let photoState = null; let photoJobRow = null; +let photoJobMeta = {}; let refreshTimer = null; let busy = false; let photoBusy = false; @@ -29,6 +30,8 @@ let photoTotal = 0; const PHOTO_ACTION_LABELS = { ai: 'ИИ-обработка', + ai_face: 'ИИ + лица', + ai_upscale: 'ИИ-апскейл', enhance: 'Улучшение', restore: 'Возврат оригинала', rollback: 'Откат', @@ -224,9 +227,15 @@ function render() { function renderPhoto() { if (!photoState) return; const w = photoState.worker || {}; - document.getElementById('photoStateHint').textContent = !photoState.enabled - ? 'Обработка фотографий выключена' - : (!photoState.ai_configured ? 'PHOTO_AI_URL не настроен' : (w.processing ? `Обрабатывается задание #${w.current_id ?? ''}` : 'Ожидание очереди')); + const svc = photoState.service || {}; + let hint; + if (!photoState.enabled) hint = 'Обработка фотографий выключена'; + else if (!photoState.ai_configured) hint = 'PHOTO_AI_URL не настроен'; + else if (svc.reachable === false) hint = 'Сервис фото-ИИ недоступен — задания ждут'; + else if (w.processing) hint = `Обрабатывается задание #${w.current_id ?? ''}`; + else hint = 'Ожидание очереди'; + if (photoState.enabled && photoState.ai_configured && svc.reachable && svc.device) hint += ` · расчёт на ${svc.device}`; + document.getElementById('photoStateHint').textContent = hint; document.getElementById('photoToggleBtn').textContent = photoState.enabled ? '📷 Выключить фото-воркер' : '📷 Включить фото-воркер'; const c = photoState.counts || {}; document.getElementById('photoCounts').innerHTML = [ @@ -297,12 +306,52 @@ function setPhotoJobClip(p) { document.getElementById('photoJobDivider').style.left = v + '%'; } +async function loadPhotoJobMeta() { + try { + const res = await fetch(`${API}/api/audit?action=photo.job.preview&limit=200`, { headers: hdr() }); + if (!res.ok) return; + const rows = await res.json(); + const map = {}; + rows.forEach(r => { + const t = r.target; + if (t && t.job_id && !map[t.job_id]) map[t.job_id] = t; + }); + photoJobMeta = map; + } catch { + } +} + +function renderPhotoJobMeta(r) { + const el = document.getElementById('photoJobMeta'); + if (!el) return; + const p = r.params && typeof r.params === 'object' ? r.params : {}; + const m = photoJobMeta[r.id] || {}; + const chips = []; + const model = m.model || p.model; + if (model) chips.push(['модель', esc(String(model))]); + const face = m.face || p.face; + if (face && face !== 'off') { + const fm = m.face_model || p.face_model; + const title = face === 'all' ? 'лица + фон' : 'только лица'; + chips.push(['режим', esc(title) + (fm ? ` (${esc(String(fm))})` : '')]); + } + if (m.faces_found !== null && m.faces_found !== undefined) chips.push(['найдено лиц', String(m.faces_found)]); + if (m.device) chips.push(['устройство', esc(String(m.device))]); + if (m.elapsed_ms !== null && m.elapsed_ms !== undefined) chips.push(['обработка', esc(fmtDur(m.elapsed_ms))]); + if (Array.isArray(m.warnings) && m.warnings.length) chips.push(['предупреждения', esc(m.warnings.join('; '))]); + el.innerHTML = chips.length + ? chips.map(([k, v]) => `${esc(k)}: ${v}`).join('') + : 'Метаданные обработки недоступны'; +} + function openPhotoJob(r) { photoJobRow = r; document.getElementById('photoJobTitle').textContent = `Обработка фото — запись #${r.entry_id} ${r.student_name || ''}`; document.getElementById('photoJobBefore').src = thumbUrl(r.before_path); document.getElementById('photoJobAfter').src = r.after_path ? `${API}${r.after_path}` : ''; document.getElementById('photoJobRollbackBtn').style.display = (r.before_path && r.action !== 'rollback') ? '' : 'none'; + renderPhotoJobMeta(r); + loadPhotoJobMeta().then(() => { if (photoJobRow === r) renderPhotoJobMeta(r); }); setPhotoJobClip(50); document.getElementById('photoJobModal').classList.add('open'); } diff --git a/public/settings.html b/public/settings.html index 04b2d6b..5103328 100644 --- a/public/settings.html +++ b/public/settings.html @@ -26,6 +26,7 @@ Публичные ссылки Форма ответа Фото + Фото-ИИ Уведомления ИИ Бэкапы @@ -259,6 +260,44 @@
+
+
+
+

Фото-ИИ

+ +
+

Нейросетевая обработка фотографий: Real-ESRGAN увеличивает разрешение, GFPGAN восстанавливает лица. Лицевой режим включается только явно — модель дорисовывает лицо по приору и может изменить черты. Без `PHOTO_AI_URL` кнопка «🤖 ИИ» в журнале скрыта.

+
+
+ + + Предлагается в окне улучшения фото, режим можно переключить там же +
+
+ + + CodeFormer работает, только если модуль установлен в образ photo-ai +
+
+ + + Документирующее значение: фактическое устройство задаёт переменная PHOTO_AI_DEVICE в контейнере +
+
+
Загрузка статуса фото-ИИ…
+
+
diff --git a/public/worker.html b/public/worker.html index 1dfdc30..42984a9 100644 --- a/public/worker.html +++ b/public/worker.html @@ -178,6 +178,7 @@
Было
Стало
+