feat: AI photo enhancement (Real-ESRGAN container) + restore original

- new photo-ai service: FastAPI + Real-ESRGAN x2plus on CPU, internal only
- async job queue in server (POST enhance-ai / GET status), 5min timeout, sequential processing
- keep original photo backup (entries.photo_original_path, uploads/.originals), restore-original endpoint
- UI: AI button and restore-original button in enhance modal
- db: photo_original_path column (init.sql, migration.sql, runtime ensure)
This commit is contained in:
dev
2026-09-17 15:37:49 +03:00
parent c4fd31cd53
commit 3a345cbefd
9 changed files with 333 additions and 5 deletions
+2
View File
@@ -187,6 +187,8 @@
<div class="settings-field"><label>Резкость <span id="enhanceSharpVal">0%</span></label><input type="range" id="enhanceSharp" min="0" max="100" value="0"></div>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px;margin-top:6px">
<button type="button" class="btn-primary ghost" id="enhanceRestoreBtn" style="display:none" title="Вернуть исходную фотографию">↩ Вернуть оригинал</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="enhanceCancelBtn">Оставить как есть</button>
<button type="button" class="btn-primary" id="enhanceSaveBtn">Применить</button>
+78
View File
@@ -424,6 +424,8 @@ let enhanceTimer = null;
let enhanceSaving = false;
let enhanceEngine = 'auto';
let enhanceEngineLoaded = false;
let photoAiEnabled = false;
let photoAiPollTimer = null;
async function loadEnhanceEngine() {
if (enhanceEngineLoaded) return;
@@ -432,9 +434,79 @@ async function loadEnhanceEngine() {
const r = await fetch(`${API}/api/public-settings`);
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';
} catch (e) {}
}
async function runPhotoAi() {
if (!enhanceEntryId || enhanceSaving) return;
const entryId = enhanceEntryId;
if (!confirm('ИИ-восстановление фото (Real-ESRGAN). Может занять до минуты на CPU. Продолжить?')) return;
enhanceSaving = true;
const btn = document.getElementById('enhanceAiBtn');
btn.disabled = true;
const loading = document.getElementById('enhanceLoading');
const canvas = document.getElementById('enhanceCanvas');
try {
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance-ai`, { method: 'POST', headers: hdr() });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Ошибка запуска ИИ');
}
const { jobId } = await res.json();
canvas.style.display = 'none';
loading.style.display = '';
loading.textContent = 'ИИ обрабатывает фото... (может занять до минуты)';
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() });
if (!r.ok) throw new Error('Ошибка опроса статуса');
const s = await r.json();
if (s.status === 'done') { clearInterval(photoAiPollTimer); resolve(s); }
else if (s.status === 'error') { clearInterval(photoAiPollTimer); reject(new Error(s.error || 'Ошибка ИИ')); }
} catch (e) { clearInterval(photoAiPollTimer); reject(e); }
}, 2000);
});
showToast('Фото восстановлено ИИ');
closeEnhance();
refreshEntryCard(entryId);
} catch (e) {
loading.style.display = 'none';
canvas.style.display = 'block';
alert(e.message);
} finally {
enhanceSaving = false;
btn.disabled = false;
btn.textContent = '🤖 ИИ';
}
}
async function restoreOriginal() {
if (!enhanceEntryId || enhanceSaving) return;
if (!confirm('Вернуть исходную фотографию? Текущая улучшенная версия будет удалена.')) return;
enhanceSaving = true;
const btn = document.getElementById('enhanceRestoreBtn');
btn.disabled = true;
try {
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/restore-original`, { method: 'POST', headers: hdr() });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Ошибка восстановления');
}
const savedId = enhanceEntryId;
showToast('Оригинал возвращён');
closeEnhance();
refreshEntryCard(savedId);
} catch (e) {
alert(e.message);
} finally {
enhanceSaving = false;
btn.disabled = false;
}
}
function openEnhance(id) {
const e = currentEntries.find(x => x.id === id);
if (!e) return;
@@ -452,6 +524,10 @@ function openEnhance(id) {
document.getElementById('enhanceSharpVal').textContent = '0%';
const orig = document.getElementById('enhanceOrig');
orig.src = `${API}${mp}`;
document.getElementById('enhanceRestoreBtn').style.display = e.photo_original_path ? '' : 'none';
loadEnhanceEngine().then(() => {
document.getElementById('enhanceAiBtn').style.display = photoAiEnabled ? '' : 'none';
});
const canvas = document.getElementById('enhanceCanvas');
canvas.style.display = 'none';
const loading = document.getElementById('enhanceLoading');
@@ -1052,6 +1128,8 @@ document.getElementById('editPhotoPreview').addEventListener('click', e => {
document.getElementById('aiCorrectBtn').addEventListener('click', aiCorrect);
document.getElementById('enhanceCancelBtn').addEventListener('click', closeEnhance);
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('enhanceModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEnhance(); });
document.getElementById('enhanceBrightness').addEventListener('input', scheduleEnhancePreview);