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:
dev
2026-09-30 11:15:54 +03:00
parent 884188e9ec
commit 45033bffa8
8 changed files with 311 additions and 15 deletions
+10
View File
@@ -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': 'Запись удалена безвозвратно',
+66 -5
View File
@@ -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);
+89 -2
View File
@@ -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 `<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) {
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 = `
<div class="sys-card">
<div class="sys-title">Приложение</div>
@@ -473,6 +537,20 @@ function renderStackInfo(stack) {
${stackRow('Локальный кеш', s3 ? esc(st.local_fallback ? 'включён' : 'выключен') : esc('—'))}
${s3 ? stackRow('Локальные копии', esc(st.keep_local ? 'хранятся' : 'удаляются')) : ''}
</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_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');
+52 -3
View File
@@ -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]) => `<span class="chip">${esc(k)}: <b>${v}</b></span>`).join('')
: '<span class="hint">Метаданные обработки недоступны</span>';
}
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');
}