feat: persistent photo job queue, worker, history with rollback

- Replace in-memory photoAiJobs Map with DB-backed photo_jobs table
- Background photo worker (worker.js) with retry, backoff, stale reset
  - Handles both AI (Real-ESRGAN) and server-side (sharp) enhancement
  - Controlled via photo_worker_enabled setting
- Photo job history in enhance modal with before/after thumbnails + rollback
- Worker dashboard: photo jobs section with status counts, recent table,
  compare slider for before/after, rollback from worker UI
- New endpoints: /api/photo-jobs/status|wake|enabled|requeue-failed,
  /api/entries/:id/photo/jobs (history), .../rollback
- swapEntryPhotoFiles logs every mutation to photo_jobs table
This commit is contained in:
dev
2026-09-17 17:36:56 +03:00
parent 27a2238082
commit 1145be02f3
8 changed files with 876 additions and 55 deletions
+97
View File
@@ -673,11 +673,98 @@ function cleanupAiPreview() {
function closeEnhance() {
cleanupAiPreview();
document.getElementById('enhanceModal').classList.remove('open');
document.getElementById('photoHistoryModal').classList.remove('open');
enhanceEntryId = null;
enhanceSourceImg = null;
enhanceAiResultPath = null;
}
function photoJobThumb(p) {
if (!p) return '';
if (p.startsWith('/uploads/.originals/')) return `${API}${p}`;
return `${API}/uploads/thumb/${p.split('/').pop()}`;
}
const PHOTO_JOB_ACTION_LABELS = { ai: 'ИИ-обработка', enhance: 'Улучшение', restore: 'Возврат оригинала', rollback: 'Откат' };
const PHOTO_JOB_STATUS_LABELS = { pending: 'В очереди', processing: 'Обрабатывается', done: 'Готово', error: 'Ошибка' };
async function loadPhotoHistory() {
if (!enhanceEntryId) return;
const list = document.getElementById('photoHistoryList');
list.innerHTML = '<div style="color:var(--muted);font-size:.85rem">Загрузка...</div>';
try {
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs`, { headers: hdr() });
if (!res.ok) throw new Error('Ошибка загрузки истории');
const rows = await res.json();
if (!rows.length) {
list.innerHTML = '<div style="color:var(--muted);font-size:.85rem">Изменений фотографии пока не было.</div>';
return;
}
list.innerHTML = rows.map(r => {
const before = r.before_path ? `<img class="photo-thumb" loading="lazy" src="${esc(photoJobThumb(r.before_path))}" alt="было">` : '<span style="color:var(--muted)">—</span>';
const after = r.after_path ? `<img class="photo-thumb" loading="lazy" src="${esc(photoJobThumb(r.after_path))}" alt="стало">` : '<span style="color:var(--muted)">—</span>';
const canRollback = r.status === 'done' && r.before_path && r.action !== 'rollback';
const err = r.error ? `<div style="color:#ef4444;font-size:.75rem;margin-top:4px">${esc(r.error)}</div>` : '';
const time = r.finished_at || r.created_at;
return `<div style="display:flex;gap:10px;align-items:center;border:1px solid var(--border);border-radius:10px;padding:10px">
<div style="display:flex;flex-direction:column;gap:4px;text-align:center">
${before}
<span style="font-size:.65rem;color:var(--muted)">было</span>
</div>
<div style="color:var(--muted)">→</div>
<div style="display:flex;flex-direction:column;gap:4px;text-align:center">
${after}
<span style="font-size:.65rem;color:var(--muted)">стало</span>
</div>
<div style="flex:1;min-width:0">
<div style="font-size:.85rem">${esc(PHOTO_JOB_ACTION_LABELS[r.action] || r.action)}
<span style="color:var(--muted);font-size:.75rem;margin-left:6px">${esc(PHOTO_JOB_STATUS_LABELS[r.status] || r.status)} · ${esc(new Date(time).toLocaleString('ru'))}</span>
</div>
${err}
</div>
${canRollback ? `<button type="button" class="btn-primary ghost" style="white-space:nowrap" data-job-rollback="${r.id}">↩ Откатить</button>` : ''}
</div>`;
}).join('');
} catch (e) {
list.innerHTML = `<div style="color:#ef4444;font-size:.85rem">${esc(e.message)}</div>`;
}
}
function openPhotoHistory() {
document.getElementById('photoHistoryTitle').textContent = `История изменений фотографии — запись #${enhanceEntryId}`;
document.getElementById('photoHistoryModal').classList.add('open');
loadPhotoHistory();
}
async function rollbackPhotoJob(jobId) {
if (!enhanceEntryId || enhanceSaving) return;
if (!confirm('Откатить фотографию к сохранённой версии? Текущая версия сохранится в истории.')) return;
enhanceSaving = true;
try {
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs/${jobId}/rollback`, { method: 'POST', headers: hdr() });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Ошибка отката');
showToast('Фотография отката');
document.getElementById('photoHistoryModal').classList.remove('open');
const mp = data.photo_path;
document.getElementById('enhanceOrig').src = `${API}${mp}`;
showEnhanceResult(false);
setEnhanceLoadingText('Загрузка...');
const img = new Image();
img.onload = () => {
enhanceSourceImg = img;
renderEnhancePreview();
};
img.onerror = () => setEnhanceLoadingText('Не удалось загрузить фото');
img.src = `${API}${mp}`;
refreshEntryCard(enhanceEntryId);
} catch (e) {
alert(e.message);
} finally {
enhanceSaving = false;
}
}
function medianFilterPass(ctx, w, h) {
const data = ctx.getImageData(0, 0, w, h);
const src = data.data;
@@ -1257,6 +1344,16 @@ document.getElementById('enhanceAutoBtn').addEventListener('click', autoEnhance)
document.getElementById('enhanceAiBtn').addEventListener('click', runPhotoAi);
document.getElementById('enhanceRestoreBtn').addEventListener('click', restoreOriginal);
document.getElementById('enhanceSaveBtn').addEventListener('click', saveEnhance);
document.getElementById('enhanceHistoryBtn').addEventListener('click', openPhotoHistory);
document.getElementById('photoHistoryCloseBtn').addEventListener('click', () => document.getElementById('photoHistoryModal').classList.remove('open'));
document.getElementById('photoHistoryModal').addEventListener('click', e => {
if (e.target === e.currentTarget) e.currentTarget.classList.remove('open');
});
document.getElementById('photoHistoryList').addEventListener('click', e => {
const btn = e.target.closest('[data-job-rollback]');
if (!btn) return;
rollbackPhotoJob(parseInt(btn.dataset.jobRollback, 10));
});
document.getElementById('enhanceModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEnhance(); });
document.getElementById('enhanceBrightness').addEventListener('input', scheduleEnhancePreview);
document.getElementById('enhanceContrast').addEventListener('input', scheduleEnhancePreview);