feat(photo-ai): Stage 5 — фронтенд фото-ИИ (режимы, статус, метаданные)
Только фронтенд: 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 («задания ждут»)
и дописывает «· расчёт на <device>»
audit.js: 10 меток для кодов, которые раньше показывались сырыми (профиль и
фото ученика, главное фото, фото модуля, удаление/очистка уведомлений, логотип).
Проверено: node --check для всех четырёх js. Приёмка на живом стеке в этом
изменении не гонялась — чекбокс приёмки Stage 5 в TODO_PHOTO_FACE_AI.md
оставлен пустым, там же журнал раздела 5 с замечаниями для Stage 6.
This commit is contained in:
+45
-5
@@ -608,23 +608,63 @@ nvidia.com/gpu=all`) — так демон перезапускать не ну
|
|||||||
|
|
||||||
## 8. Stage 5. Фронтенд
|
## 8. Stage 5. Фронтенд
|
||||||
|
|
||||||
- [ ] `public/journal.html` + `public/js/journal.js`: рядом с «🤖 ИИ» селект режима
|
- [x] `public/journal.html` + `public/js/journal.js`: рядом с «🤖 ИИ» селект режима
|
||||||
(«Универсально (x2)», «Быстро (x4)», «Лица (GFPGAN)», «Лица + фон»); значение уходит в
|
(«Универсально (x2)», «Быстро (x4)», «Лица (GFPGAN)», «Лица + фон»); значение уходит в
|
||||||
`POST .../enhance-ai` телом `{model, face, face_model}`. Дефолт — из `photo_ai_face_mode`
|
`POST .../enhance-ai` телом `{model, face, face_model}`. Дефолт — из `photo_ai_face_mode`
|
||||||
(`/api/public-settings`, `loadEnhanceEngine`); при `device === 'cpu'` — подсказка «на CPU медленно».
|
(`/api/public-settings`, `loadEnhanceEngine`); при `device === 'cpu'` — подсказка «на CPU медленно».
|
||||||
Кнопка по-прежнему скрыта при `photo_ai_enabled === 'false'` (I3).
|
Кнопка по-прежнему скрыта при `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);
|
в модалке сравнения — `model`, `device`, `faces_found`, `elapsed_ms` (данные из `params`/аудита, I6);
|
||||||
строка состояния учитывает `service.reachable === false` и `service.device`.
|
строка состояния учитывает `service.reachable === false` и `service.device`.
|
||||||
- [ ] `public/settings.html` + `public/js/settings.js`: блок «Фото-ИИ» — селект face-режима, селект
|
- [x] `public/settings.html` + `public/js/settings.js`: блок «Фото-ИИ» — селект face-режима, селект
|
||||||
face-модели, селект желаемого устройства + строка «фактическое: …» с подсветкой расхождения;
|
face-модели, селект желаемого устройства + строка «фактическое: …» с подсветкой расхождения;
|
||||||
read-only статус (`device_name`, VRAM, список моделей) из `/api/photo-jobs/status`.
|
read-only статус (`device_name`, VRAM, список моделей) из `/api/photo-jobs/status`.
|
||||||
Новые id внести в `DIRTY_FIELDS` (`settings.js:1`) и в payload (`settings.js:739`).
|
Новые id внести в `DIRTY_FIELDS` (`settings.js:1`) и в payload (`settings.js:739`).
|
||||||
- [ ] `public/js/settings.js:372` `renderStackInfo()` — карточка «Фото-ИИ» из `stack.photo_ai`.
|
- [x] `public/js/settings.js:372` `renderStackInfo()` — карточка «Фото-ИИ» из `stack.photo_ai`.
|
||||||
- [ ] `public/js/audit.js` — метки для новых кодов аудита (иначе в UI будет сырой код).
|
- [x] `public/js/audit.js` — метки для новых кодов аудита (иначе в UI будет сырой код).
|
||||||
- [ ] **Приёмка:** из журнала доступны все 4 режима; после постановки видно «В очереди», затем сравнение
|
- [ ] **Приёмка:** из журнала доступны все 4 режима; после постановки видно «В очереди», затем сравнение
|
||||||
«Было/Стало» с моделью/устройством; в настройках видно фактическое устройство и VRAM.
|
«Было/Стало» с моделью/устройством; в настройках видно фактическое устройство и 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` («задания ждут») и дописывает `· расчёт на <device>` |
|
||||||
|
| Метаданные в модалке | `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. Документация и тесты
|
## 9. Stage 6. Документация и тесты
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
.enhance-sliders { flex: 0 0 240px; display: flex; flex-direction: column; gap: 14px; }
|
.enhance-sliders { flex: 0 0 240px; display: flex; flex-direction: column; gap: 14px; }
|
||||||
.enhance-sliders .settings-field input[type=range] { width: 100%; }
|
.enhance-sliders .settings-field input[type=range] { width: 100%; }
|
||||||
.enhance-foot { justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
.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; }
|
.photo-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; }
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.enhance-body { flex-direction: column; }
|
.enhance-body { flex-direction: column; }
|
||||||
@@ -224,6 +226,13 @@
|
|||||||
<div class="card-foot enhance-foot">
|
<div class="card-foot enhance-foot">
|
||||||
<button type="button" class="btn-primary ghost" id="enhanceHistoryBtn" title="История изменений фотографии">🕘 История</button>
|
<button type="button" class="btn-primary ghost" id="enhanceHistoryBtn" title="История изменений фотографии">🕘 История</button>
|
||||||
<button type="button" class="btn-primary ghost" id="enhanceRestoreBtn" style="display:none" title="Вернуть исходную фотографию">↩ Вернуть оригинал</button>
|
<button type="button" class="btn-primary ghost" id="enhanceRestoreBtn" style="display:none" title="Вернуть исходную фотографию">↩ Вернуть оригинал</button>
|
||||||
|
<select id="enhanceAiMode" class="settings-input enhance-ai-mode" style="display:none" title="Режим ИИ-обработки фото">
|
||||||
|
<option value="x2">Универсально (x2)</option>
|
||||||
|
<option value="x4">Быстро (x4)</option>
|
||||||
|
<option value="faces">Лица (GFPGAN)</option>
|
||||||
|
<option value="facesbg">Лица + фон</option>
|
||||||
|
</select>
|
||||||
|
<span class="enhance-ai-hint" id="enhanceAiHint" style="display:none"></span>
|
||||||
<button type="button" class="btn-primary ghost" id="enhanceAiBtn" style="display:none" title="ИИ-восстановление (Real-ESRGAN)">🤖 ИИ</button>
|
<button type="button" class="btn-primary ghost" id="enhanceAiBtn" style="display:none" title="ИИ-восстановление (Real-ESRGAN)">🤖 ИИ</button>
|
||||||
<button type="button" class="btn-primary ghost" id="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button>
|
<button type="button" class="btn-primary ghost" id="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button>
|
||||||
<button type="button" class="btn-primary ghost" id="enhanceCancelBtn">Оставить как есть</button>
|
<button type="button" class="btn-primary ghost" id="enhanceCancelBtn">Оставить как есть</button>
|
||||||
|
|||||||
@@ -26,10 +26,16 @@ const ACTION_LABELS = {
|
|||||||
'student.update': 'Изменён ученик',
|
'student.update': 'Изменён ученик',
|
||||||
'student.delete': 'Удалён ученик',
|
'student.delete': 'Удалён ученик',
|
||||||
'student.batch-group': 'Ученики перенесены в группу',
|
'student.batch-group': 'Ученики перенесены в группу',
|
||||||
|
'student.profile.update': 'Изменён профиль ученика',
|
||||||
|
'student.photo.create': 'Загружено фото ученика',
|
||||||
|
'student.photo.delete': 'Удалено фото ученика',
|
||||||
|
'student.photo.main': 'Главное фото ученика',
|
||||||
'module.create': 'Создан модуль',
|
'module.create': 'Создан модуль',
|
||||||
'module.update': 'Изменён модуль',
|
'module.update': 'Изменён модуль',
|
||||||
'module.delete': 'Модуль отключён',
|
'module.delete': 'Модуль отключён',
|
||||||
'module.restore': 'Модуль включён',
|
'module.restore': 'Модуль включён',
|
||||||
|
'module.photo.create': 'Загружено фото модуля',
|
||||||
|
'module.photo.delete': 'Удалено фото модуля',
|
||||||
'entry.update': 'Изменена запись',
|
'entry.update': 'Изменена запись',
|
||||||
'entry.photo.update': 'Изменено фото записи',
|
'entry.photo.update': 'Изменено фото записи',
|
||||||
'entry.photo.set_main': 'Главное фото записи',
|
'entry.photo.set_main': 'Главное фото записи',
|
||||||
@@ -47,6 +53,10 @@ const ACTION_LABELS = {
|
|||||||
'photo-jobs.wake': 'Фото-воркер разбужен',
|
'photo-jobs.wake': 'Фото-воркер разбужен',
|
||||||
'photo-jobs.enabled': 'Переключена обработка фото',
|
'photo-jobs.enabled': 'Переключена обработка фото',
|
||||||
'photo-jobs.requeue-failed': 'Ошибочные фото-задания возвращены в очередь',
|
'photo-jobs.requeue-failed': 'Ошибочные фото-задания возвращены в очередь',
|
||||||
|
'notifications.delete': 'Удалено уведомление',
|
||||||
|
'notifications.clear': 'Очищены уведомления',
|
||||||
|
'settings.logo.upload': 'Загружен логотип системы',
|
||||||
|
'settings.logo.remove': 'Удалён логотип системы',
|
||||||
'entry.soft-delete': 'Запись в корзину',
|
'entry.soft-delete': 'Запись в корзину',
|
||||||
'entry.restore': 'Запись восстановлена',
|
'entry.restore': 'Запись восстановлена',
|
||||||
'entry.permanent-delete': 'Запись удалена безвозвратно',
|
'entry.permanent-delete': 'Запись удалена безвозвратно',
|
||||||
|
|||||||
+66
-5
@@ -498,6 +498,9 @@ let enhanceSaving = false;
|
|||||||
let enhanceEngine = 'auto';
|
let enhanceEngine = 'auto';
|
||||||
let enhanceEngineLoaded = false;
|
let enhanceEngineLoaded = false;
|
||||||
let photoAiEnabled = false;
|
let photoAiEnabled = false;
|
||||||
|
let photoAiFaceMode = 'off';
|
||||||
|
let photoAiFaceModel = 'gfpgan';
|
||||||
|
let photoAiDevice = '';
|
||||||
let photoAiPollTimer = null;
|
let photoAiPollTimer = null;
|
||||||
let enhanceAiResultPath = null;
|
let enhanceAiResultPath = null;
|
||||||
let enhanceAiJobId = null;
|
let enhanceAiJobId = null;
|
||||||
@@ -603,6 +606,46 @@ function resetEnhanceZoom() {
|
|||||||
area.addEventListener('dblclick', 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() {
|
async function loadEnhanceEngine() {
|
||||||
if (enhanceEngineLoaded) return;
|
if (enhanceEngineLoaded) return;
|
||||||
enhanceEngineLoaded = true;
|
enhanceEngineLoaded = true;
|
||||||
@@ -611,18 +654,31 @@ async function loadEnhanceEngine() {
|
|||||||
const f = await r.json();
|
const f = await r.json();
|
||||||
if (['auto', 'server', 'client'].includes(f.photo_enhance_engine)) enhanceEngine = f.photo_enhance_engine;
|
if (['auto', 'server', 'client'].includes(f.photo_enhance_engine)) enhanceEngine = f.photo_enhance_engine;
|
||||||
photoAiEnabled = f.photo_ai_enabled === 'true';
|
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) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runPhotoAi() {
|
async function runPhotoAi() {
|
||||||
if (!enhanceEntryId || enhanceSaving) return;
|
if (!enhanceEntryId || enhanceSaving) return;
|
||||||
const entryId = enhanceEntryId;
|
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;
|
enhanceSaving = true;
|
||||||
const btn = document.getElementById('enhanceAiBtn');
|
const btn = document.getElementById('enhanceAiBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
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) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
throw new Error(err.error || 'Ошибка запуска ИИ');
|
throw new Error(err.error || 'Ошибка запуска ИИ');
|
||||||
@@ -630,12 +686,14 @@ async function runPhotoAi() {
|
|||||||
const { jobId } = await res.json();
|
const { jobId } = await res.json();
|
||||||
enhanceAiJobId = jobId;
|
enhanceAiJobId = jobId;
|
||||||
showEnhanceResult(false);
|
showEnhanceResult(false);
|
||||||
setEnhanceLoadingText('ИИ обрабатывает фото... (может занять до минуты)');
|
setEnhanceLoadingText(mode.face === 'off'
|
||||||
|
? 'ИИ обрабатывает фото... (может занять до минуты)'
|
||||||
|
: 'ИИ обрабатывает фото и восстанавливает лица...');
|
||||||
btn.textContent = '⏳ ИИ...';
|
btn.textContent = '⏳ ИИ...';
|
||||||
const result = await new Promise((resolve, reject) => {
|
const result = await new Promise((resolve, reject) => {
|
||||||
photoAiPollTimer = setInterval(async () => {
|
photoAiPollTimer = setInterval(async () => {
|
||||||
try {
|
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('Ошибка опроса статуса');
|
if (!r.ok) throw new Error('Ошибка опроса статуса');
|
||||||
const s = await r.json();
|
const s = await r.json();
|
||||||
if (s.status === 'done') { clearInterval(photoAiPollTimer); resolve(s); }
|
if (s.status === 'done') { clearInterval(photoAiPollTimer); resolve(s); }
|
||||||
@@ -715,7 +773,10 @@ function openEnhance(id) {
|
|||||||
orig.src = `${API}${mp}`;
|
orig.src = `${API}${mp}`;
|
||||||
document.getElementById('enhanceRestoreBtn').style.display = e.photo_original_path ? '' : 'none';
|
document.getElementById('enhanceRestoreBtn').style.display = e.photo_original_path ? '' : 'none';
|
||||||
loadEnhanceEngine().then(() => {
|
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');
|
const canvas = document.getElementById('enhanceCanvas');
|
||||||
setEnhanceClip(50);
|
setEnhanceClip(50);
|
||||||
|
|||||||
+89
-2
@@ -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 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 SPAM_MAX = 10080;
|
||||||
const BAN_REASONS = {
|
const BAN_REASONS = {
|
||||||
'honeypot': 'Антиспам-поле',
|
'honeypot': 'Антиспам-поле',
|
||||||
@@ -12,6 +12,7 @@ const BAN_REASONS = {
|
|||||||
let currentLogo = '';
|
let currentLogo = '';
|
||||||
let logoOriginal = '';
|
let logoOriginal = '';
|
||||||
let currentSystemName = '';
|
let currentSystemName = '';
|
||||||
|
let photoAiService = null;
|
||||||
|
|
||||||
function renderLogoPreview() {
|
function renderLogoPreview() {
|
||||||
const img = document.getElementById('logoPreviewImg');
|
const img = document.getElementById('logoPreviewImg');
|
||||||
@@ -217,6 +218,12 @@ async function loadSettings() {
|
|||||||
const pe = document.getElementById('photoEngine');
|
const pe = document.getElementById('photoEngine');
|
||||||
pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto';
|
pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto';
|
||||||
if (!pe.value) pe.value = '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';
|
document.getElementById('camEnabled').checked = s.camera_enabled !== 'false';
|
||||||
await renderNotifyTypes(s);
|
await renderNotifyTypes(s);
|
||||||
snapshotDirty();
|
snapshotDirty();
|
||||||
@@ -225,6 +232,7 @@ async function loadSettings() {
|
|||||||
loadAiProfiles();
|
loadAiProfiles();
|
||||||
renderStats(stRes);
|
renderStats(stRes);
|
||||||
loadSystemInfo();
|
loadSystemInfo();
|
||||||
|
loadPhotoAiStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderStats(res) {
|
async function renderStats(res) {
|
||||||
@@ -369,6 +377,52 @@ function stackRow(label, value) {
|
|||||||
return `<div class="sys-item"><span class="sys-label">${esc(label)}</span><span class="sys-value">${value}</span></div>`;
|
return `<div class="sys-item"><span class="sys-label">${esc(label)}</span><span class="sys-value">${value}</span></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = '<span class="hint">Статус фото-ИИ недоступен</span>'; 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(`<span class="chip" style="color:${color}">фактическое: <b>${esc(device)}</b></span>`);
|
||||||
|
if (svc.device_name) chips.push(`<span class="chip">${esc(String(svc.device_name))}</span>`);
|
||||||
|
if (svc.vram_total_mb) chips.push(`<span class="chip">VRAM: <b>${esc(String(svc.vram_free_mb ?? '—'))}</b> свободно из ${esc(String(svc.vram_total_mb))} МБ</span>`);
|
||||||
|
chips.push(`<span class="chip">Модели: <b>${esc((svc.models || []).join(', ') || '—')}</b></span>`);
|
||||||
|
chips.push(`<span class="chip">Модели лиц: <b>${esc((svc.face_models || []).join(', ') || '—')}</b></span>`);
|
||||||
|
if (svc.loaded && svc.loaded.length) chips.push(`<span class="chip">Загружено: <b>${esc(svc.loaded.join(', '))}</b></span>`);
|
||||||
|
if (!svc.configured) chips.push('<span class="chip" style="color:#ef4444">PHOTO_AI_URL не настроен — ИИ-фото отключено</span>');
|
||||||
|
else if (svc.reachable === false) chips.push(`<span class="chip" style="color:#ef4444">сервис недоступен${svc.error ? `: ${esc(String(svc.error))}` : ''}</span>`);
|
||||||
|
else if (svc.ready === false) chips.push('<span class="chip" style="color:#f59e0b">модели загружаются</span>');
|
||||||
|
if (mismatch) chips.push('<span class="chip" style="color:#f59e0b">желаемое устройство не совпадает с фактическим — задаётся переменной PHOTO_AI_DEVICE в контейнере</span>');
|
||||||
|
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 = '<span class="hint">Не удалось загрузить статус фото-ИИ</span>';
|
||||||
|
} finally {
|
||||||
|
if (btn) btn.classList.remove('spinning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderStackInfo(stack) {
|
function renderStackInfo(stack) {
|
||||||
const el = document.getElementById('stackInfo');
|
const el = document.getElementById('stackInfo');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -419,6 +473,16 @@ function renderStackInfo(stack) {
|
|||||||
? stackRow('Бакет', esc(`${st.bucket || '—'}${st.region ? ` · ${st.region}` : ''}`))
|
? stackRow('Бакет', esc(`${st.bucket || '—'}${st.region ? ` · ${st.region}` : ''}`))
|
||||||
: stackRow('Каталог', esc(st.dir || '—'));
|
: 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 = `
|
el.innerHTML = `
|
||||||
<div class="sys-card">
|
<div class="sys-card">
|
||||||
<div class="sys-title">Приложение</div>
|
<div class="sys-title">Приложение</div>
|
||||||
@@ -473,6 +537,20 @@ function renderStackInfo(stack) {
|
|||||||
${stackRow('Локальный кеш', s3 ? esc(st.local_fallback ? 'включён' : 'выключен') : esc('—'))}
|
${stackRow('Локальный кеш', s3 ? esc(st.local_fallback ? 'включён' : 'выключен') : esc('—'))}
|
||||||
${s3 ? stackRow('Локальные копии', esc(st.keep_local ? 'хранятся' : 'удаляются')) : ''}
|
${s3 ? stackRow('Локальные копии', esc(st.keep_local ? 'хранятся' : 'удаляются')) : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="sys-card">
|
||||||
|
<div class="sys-title">Фото-ИИ</div>
|
||||||
|
${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 ? `<div class="sys-meta" style="color:#ef4444">${esc(String(ai.error))}</div>` : ''}
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,6 +815,9 @@ async function saveSettings() {
|
|||||||
photo_capture_resolution: document.getElementById('photoResolution').value,
|
photo_capture_resolution: document.getElementById('photoResolution').value,
|
||||||
photo_capture_quality: String(document.getElementById('photoQuality').value),
|
photo_capture_quality: String(document.getElementById('photoQuality').value),
|
||||||
photo_enhance_engine: document.getElementById('photoEngine').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',
|
camera_enabled: document.getElementById('camEnabled').checked ? 'true' : 'false',
|
||||||
trash_purge_days: String(trashDays),
|
trash_purge_days: String(trashDays),
|
||||||
...notifySettingsPayload(notifyRetentionDays),
|
...notifySettingsPayload(notifyRetentionDays),
|
||||||
@@ -888,6 +969,12 @@ if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', () => loadSyste
|
|||||||
const stackRefreshBtn = document.getElementById('stackRefreshBtn');
|
const stackRefreshBtn = document.getElementById('stackRefreshBtn');
|
||||||
if (stackRefreshBtn) stackRefreshBtn.addEventListener('click', () => loadSystemInfo(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');
|
const notifyTestBtn = document.getElementById('notifyTestBtn');
|
||||||
if (notifyTestBtn) notifyTestBtn.addEventListener('click', async () => {
|
if (notifyTestBtn) notifyTestBtn.addEventListener('click', async () => {
|
||||||
notifyTestBtn.classList.add('spinning');
|
notifyTestBtn.classList.add('spinning');
|
||||||
|
|||||||
+52
-3
@@ -19,6 +19,7 @@ const STATUS_BADGE = {
|
|||||||
let state = null;
|
let state = null;
|
||||||
let photoState = null;
|
let photoState = null;
|
||||||
let photoJobRow = null;
|
let photoJobRow = null;
|
||||||
|
let photoJobMeta = {};
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
let busy = false;
|
let busy = false;
|
||||||
let photoBusy = false;
|
let photoBusy = false;
|
||||||
@@ -29,6 +30,8 @@ let photoTotal = 0;
|
|||||||
|
|
||||||
const PHOTO_ACTION_LABELS = {
|
const PHOTO_ACTION_LABELS = {
|
||||||
ai: 'ИИ-обработка',
|
ai: 'ИИ-обработка',
|
||||||
|
ai_face: 'ИИ + лица',
|
||||||
|
ai_upscale: 'ИИ-апскейл',
|
||||||
enhance: 'Улучшение',
|
enhance: 'Улучшение',
|
||||||
restore: 'Возврат оригинала',
|
restore: 'Возврат оригинала',
|
||||||
rollback: 'Откат',
|
rollback: 'Откат',
|
||||||
@@ -224,9 +227,15 @@ function render() {
|
|||||||
function renderPhoto() {
|
function renderPhoto() {
|
||||||
if (!photoState) return;
|
if (!photoState) return;
|
||||||
const w = photoState.worker || {};
|
const w = photoState.worker || {};
|
||||||
document.getElementById('photoStateHint').textContent = !photoState.enabled
|
const svc = photoState.service || {};
|
||||||
? 'Обработка фотографий выключена'
|
let hint;
|
||||||
: (!photoState.ai_configured ? 'PHOTO_AI_URL не настроен' : (w.processing ? `Обрабатывается задание #${w.current_id ?? ''}` : 'Ожидание очереди'));
|
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 ? '📷 Выключить фото-воркер' : '📷 Включить фото-воркер';
|
document.getElementById('photoToggleBtn').textContent = photoState.enabled ? '📷 Выключить фото-воркер' : '📷 Включить фото-воркер';
|
||||||
const c = photoState.counts || {};
|
const c = photoState.counts || {};
|
||||||
document.getElementById('photoCounts').innerHTML = [
|
document.getElementById('photoCounts').innerHTML = [
|
||||||
@@ -297,12 +306,52 @@ function setPhotoJobClip(p) {
|
|||||||
document.getElementById('photoJobDivider').style.left = v + '%';
|
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]) => `<span class="chip">${esc(k)}: <b>${v}</b></span>`).join('')
|
||||||
|
: '<span class="hint">Метаданные обработки недоступны</span>';
|
||||||
|
}
|
||||||
|
|
||||||
function openPhotoJob(r) {
|
function openPhotoJob(r) {
|
||||||
photoJobRow = r;
|
photoJobRow = r;
|
||||||
document.getElementById('photoJobTitle').textContent = `Обработка фото — запись #${r.entry_id} ${r.student_name || ''}`;
|
document.getElementById('photoJobTitle').textContent = `Обработка фото — запись #${r.entry_id} ${r.student_name || ''}`;
|
||||||
document.getElementById('photoJobBefore').src = thumbUrl(r.before_path);
|
document.getElementById('photoJobBefore').src = thumbUrl(r.before_path);
|
||||||
document.getElementById('photoJobAfter').src = r.after_path ? `${API}${r.after_path}` : '';
|
document.getElementById('photoJobAfter').src = r.after_path ? `${API}${r.after_path}` : '';
|
||||||
document.getElementById('photoJobRollbackBtn').style.display = (r.before_path && r.action !== 'rollback') ? '' : 'none';
|
document.getElementById('photoJobRollbackBtn').style.display = (r.before_path && r.action !== 'rollback') ? '' : 'none';
|
||||||
|
renderPhotoJobMeta(r);
|
||||||
|
loadPhotoJobMeta().then(() => { if (photoJobRow === r) renderPhotoJobMeta(r); });
|
||||||
setPhotoJobClip(50);
|
setPhotoJobClip(50);
|
||||||
document.getElementById('photoJobModal').classList.add('open');
|
document.getElementById('photoJobModal').classList.add('open');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
|
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
|
||||||
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
|
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
|
||||||
<a href="#sec-photo" data-nav="sec-photo"><i data-lucide="camera"></i> Фото</a>
|
<a href="#sec-photo" data-nav="sec-photo"><i data-lucide="camera"></i> Фото</a>
|
||||||
|
<a href="#sec-photo-ai" data-nav="sec-photo-ai"><i data-lucide="sparkles"></i> Фото-ИИ</a>
|
||||||
<a href="#sec-notify" data-nav="sec-notify"><i data-lucide="bell"></i> Уведомления</a>
|
<a href="#sec-notify" data-nav="sec-notify"><i data-lucide="bell"></i> Уведомления</a>
|
||||||
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
|
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
|
||||||
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
|
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
|
||||||
@@ -259,6 +260,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card" id="sec-photo-ai">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic"><i data-lucide="sparkles"></i></div>
|
||||||
|
<h3>Фото-ИИ</h3>
|
||||||
|
<button class="icon-btn" id="photoAiRefreshBtn" type="button" title="Обновить статус фото-ИИ" aria-label="Обновить статус фото-ИИ"><i data-lucide="refresh-cw"></i></button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Нейросетевая обработка фотографий: Real-ESRGAN увеличивает разрешение, GFPGAN восстанавливает лица. Лицевой режим включается только явно — модель дорисовывает лицо по приору и может изменить черты. Без `PHOTO_AI_URL` кнопка «🤖 ИИ» в журнале скрыта.</p>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="photoAiFaceMode">Режим по умолчанию</label>
|
||||||
|
<select id="photoAiFaceMode" class="settings-input">
|
||||||
|
<option value="off">Без лиц — только увеличение</option>
|
||||||
|
<option value="face">Лица — восстановить лица, фон увеличить</option>
|
||||||
|
<option value="all">Лица + фон — восстановить лица и снять шум фона</option>
|
||||||
|
</select>
|
||||||
|
<span class="hint">Предлагается в окне улучшения фото, режим можно переключить там же</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="photoAiFaceModel">Модель лиц</label>
|
||||||
|
<select id="photoAiFaceModel" class="settings-input">
|
||||||
|
<option value="gfpgan">GFPGAN v1.4</option>
|
||||||
|
<option value="codeformer">CodeFormer</option>
|
||||||
|
</select>
|
||||||
|
<span class="hint">CodeFormer работает, только если модуль установлен в образ photo-ai</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="photoAiDevicePref">Желаемое устройство</label>
|
||||||
|
<select id="photoAiDevicePref" class="settings-input">
|
||||||
|
<option value="auto">Авто (GPU, если доступен)</option>
|
||||||
|
<option value="cuda">GPU (CUDA)</option>
|
||||||
|
<option value="cpu">CPU</option>
|
||||||
|
</select>
|
||||||
|
<span class="hint">Документирующее значение: фактическое устройство задаёт переменная PHOTO_AI_DEVICE в контейнере</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-stats" id="photoAiStatus"><span class="hint">Загрузка статуса фото-ИИ…</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-card" id="sec-trash">
|
<div class="settings-card" id="sec-trash">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="ic"><i data-lucide="trash-2"></i></div>
|
<div class="ic"><i data-lucide="trash-2"></i></div>
|
||||||
|
|||||||
@@ -178,6 +178,7 @@
|
|||||||
<div class="enhance-badge left">Было</div>
|
<div class="enhance-badge left">Было</div>
|
||||||
<div class="enhance-badge right">Стало</div>
|
<div class="enhance-badge right">Стало</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="data-stats" id="photoJobMeta" style="margin-top:12px"></div>
|
||||||
<div class="card-foot" style="justify-content:flex-end;gap:8px;margin-top:14px">
|
<div class="card-foot" style="justify-content:flex-end;gap:8px;margin-top:14px">
|
||||||
<button type="button" class="btn-primary ghost" id="photoJobCloseBtn">Закрыть</button>
|
<button type="button" class="btn-primary ghost" id="photoJobCloseBtn">Закрыть</button>
|
||||||
<button type="button" class="btn-primary" id="photoJobRollbackBtn">↩ Откатить к версии «Было»</button>
|
<button type="button" class="btn-primary" id="photoJobRollbackBtn">↩ Откатить к версии «Было»</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user