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:
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user