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
+5 -1
View File
@@ -26,4 +26,8 @@ CLOUDFLARE_TUNNEL_TOKEN=
CLOUDFLARE_TUNNEL_URL=http://app:3003
# Максимальное ожидание WireGuard handshake перед fallback на прямой запуск
# туннеля без VPN (сек). Если VPN-провайдер не отвечает — сайт всё равно поднимется.
WG_HANDSHAKE_TIMEOUT=60
WG_HANDSHAKE_TIMEOUT=60
# ИИ-улучшение фото (Real-ESRGAN), пусто = контейнер photo-ai
PHOTO_AI_URL=
PHOTO_AI_MAX_PIXELS=4000000
+1
View File
@@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS entries (
ai_checked_at TIMESTAMPTZ,
ai_error TEXT,
photo_path VARCHAR(255),
photo_original_path VARCHAR(255),
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
+2
View File
@@ -199,3 +199,5 @@ $$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_entries_notify ON entries;
CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries
FOR EACH ROW EXECUTE FUNCTION notify_entries_changed();
ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255);
+14
View File
@@ -37,6 +37,7 @@ services:
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
AI_PROMPT: ${AI_PROMPT:-}
AI_REQUEST_TIMEOUT_MS: ${AI_REQUEST_TIMEOUT_MS:-120000}
PHOTO_AI_URL: ${PHOTO_AI_URL:-http://photo-ai:8080}
NODE_ENV: production
TZ: Europe/Moscow
depends_on:
@@ -113,7 +114,20 @@ services:
- "8080:8080"
# ИИ-улучшение фотографий (Real-ESRGAN x2, CPU).
# Поднимается вместе со стеком; если не нужен — PHOTO_AI_URL пустой в .env.
photo-ai:
build: ./photo-ai
container_name: photo-ai
restart: unless-stopped
environment:
MODEL_PATH: /models/RealESRGAN_x2plus.pth
MAX_INPUT_PIXELS: ${PHOTO_AI_MAX_PIXELS:-4000000}
TZ: Europe/Moscow
volumes:
- photo-ai-models:/models
volumes:
pgdata:
photo-ai-models:
+26
View File
@@ -0,0 +1,26 @@
FROM python:3.10-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu
RUN pip install --no-cache-dir realesrgan==0.3.0 fastapi "uvicorn[standard]" python-multipart opencv-python-headless numpy
RUN python - <<'EOF'
import basicsr, os
p = os.path.join(os.path.dirname(basicsr.__file__), 'data', 'degradations.py')
s = open(p).read()
s = s.replace('torchvision.transforms.functional_tensor', 'torchvision.transforms.functional')
open(p, 'w').write(s)
print('basicsr patch applied')
EOF
COPY app.py .
ENV MODEL_PATH=/models/RealESRGAN_x2plus.pth
VOLUME /models
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]
+73
View File
@@ -0,0 +1,73 @@
import io
import os
import asyncio
import threading
import urllib.request
from fastapi import FastAPI, File, Form, UploadFile
from fastapi.responses import Response
import numpy as np
import cv2
import torch
from basicsr.archs.rrdbnet_arch import RRDBNet
from realesrgan import RealESRGANer
MODEL_PATH = os.environ.get('MODEL_PATH', '/models/RealESRGAN_x2plus.pth')
MODEL_URL = 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth'
MAX_PIXELS = int(os.environ.get('MAX_INPUT_PIXELS', str(4_000_000)))
lock = threading.Lock()
upsampler = None
app = FastAPI()
def ensure_model():
if not os.path.exists(MODEL_PATH):
os.makedirs(os.path.dirname(MODEL_PATH), exist_ok=True)
tmp = MODEL_PATH + '.tmp'
urllib.request.urlretrieve(MODEL_URL, tmp)
os.replace(tmp, MODEL_PATH)
def load_model():
global upsampler
ensure_model()
model = RRDBNet(num_in_ch=3, num_out_ch=3, scale=2, num_feat=64, num_block=23, num_grow_ch=32)
upsampler = RealESRGANer(
scale=2,
model_path=MODEL_PATH,
model=model,
tile=256,
tile_pad=10,
pre_pad=0,
half=False,
device='cpu',
)
@app.on_event('startup')
async def startup():
await asyncio.to_thread(load_model)
@app.get('/health')
def health():
return {'ok': upsampler is not None}
@app.post('/enhance')
async def enhance(image: UploadFile = File(...), scale: int = Form(2)):
data = await image.read()
img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
if img is None:
return Response('bad image', status_code=400)
if img.shape[0] * img.shape[1] > MAX_PIXELS:
r = (MAX_PIXELS / (img.shape[0] * img.shape[1])) ** 0.5
img = cv2.resize(img, (int(img.shape[1] * r), int(img.shape[0] * r)), interpolation=cv2.INTER_AREA)
outscale = min(max(int(scale), 2), 4)
with lock:
out, _ = upsampler.enhance(img, outscale=outscale)
ok, enc = cv2.imencode('.jpg', out, [int(cv2.IMWRITE_JPEG_QUALITY), 92])
if not ok:
return Response('encode failed', status_code=500)
return Response(enc.tobytes(), media_type='image/jpeg')
+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);
+132 -4
View File
@@ -257,12 +257,14 @@ app.use(helmet({
app.use(express.json({ limit: '1mb' }));
app.use(ipGuard);
const THUMBS_DIR = path.join(__dirname, 'uploads', '.thumbs');
const ORIGINALS_DIR = path.join(__dirname, 'uploads', '.originals');
const THUMB_WIDTH = 480;
let sharp = null;
try { sharp = require('sharp'); } catch {}
if (sharp) {
try { fs.mkdirSync(THUMBS_DIR, { recursive: true }); } catch {}
}
try { fs.mkdirSync(ORIGINALS_DIR, { recursive: true }); } catch {}
function thumbFileFor(fp) {
const base = path.basename(fp).replace(/\.[^.]+$/, '') + '.webp';
@@ -666,6 +668,10 @@ async function ensureEntryPhotosTable() {
await pool.query('CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id)');
}
async function ensurePhotoOriginalColumn() {
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255)`);
}
async function ensureEntryAiColumns() {
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_original TEXT`);
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT`);
@@ -979,6 +985,7 @@ app.get('/api/public-settings', apiLimiter, async (_, res) => {
}
const q = parseFloat(result.photo_capture_quality);
result.photo_capture_quality = Number.isFinite(q) && q >= 0.5 && q <= 1 ? String(q) : '0.92';
result.photo_ai_enabled = PHOTO_AI_URL ? 'true' : 'false';
return result;
});
res.json(out);
@@ -1060,6 +1067,7 @@ const zlib = require('zlib');
const tar = require('tar');
const os = require('os');
const AI_URL = process.env.AI_URL || 'http://text-corrector:8080';
const PHOTO_AI_URL = process.env.PHOTO_AI_URL || '';
const AI_MODEL = process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
const AI_DEFAULT_PROMPT = process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
let entryAutoChecker = null;
@@ -3670,13 +3678,31 @@ function clampEnhanceParam(v, min, max, def) {
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : def;
}
async function swapEntryPhotoFiles(req, entryId, oldPath, newPath) {
await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [newPath, entryId]);
async function swapEntryPhotoFiles(req, entryId, oldPath, newPath, { keepOriginal = true } = {}) {
let originalPath = null;
const { rows: prevRows } = await pool.query('SELECT photo_original_path FROM entries WHERE id = $1', [entryId]);
const prevOriginal = prevRows.length ? prevRows[0].photo_original_path : null;
const oldAbs = path.join(UPLOADS_DIR, String(oldPath).replace(/^\/+/, '').replace(/^uploads\//, ''));
if (prevOriginal) {
originalPath = prevOriginal;
try { if (fs.existsSync(oldAbs)) fs.unlinkSync(oldAbs); } catch {}
} else if (keepOriginal && fs.existsSync(oldAbs)) {
try {
const backupName = crypto.randomBytes(12).toString('hex') + (path.extname(oldAbs) || '.jpg');
const backupPath = path.join(ORIGINALS_DIR, backupName);
fs.renameSync(oldAbs, backupPath);
originalPath = `/uploads/.originals/${backupName}`;
} catch (e) {
console.error('photo original backup failed:', e);
}
} else {
try { if (fs.existsSync(oldAbs)) fs.unlinkSync(oldAbs); } catch {}
}
await pool.query('UPDATE entries SET photo_path = $1, photo_original_path = $2 WHERE id = $3', [newPath, originalPath, entryId]);
await pool.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [newPath, entryId, oldPath]);
safeUnlink(oldPath);
const oldThumb = path.join('uploads', '.thumbs', path.basename(oldPath).replace(/\.[^.]+$/, '') + '.webp');
safeUnlink(oldThumb);
await logAudit(req, 'entry.photo.enhance', { entry_id: entryId, old_path: oldPath, new_path: newPath });
await logAudit(req, 'entry.photo.enhance', { entry_id: entryId, old_path: oldPath, new_path: newPath, original_path: originalPath });
invalidateEntries();
}
@@ -3740,6 +3766,107 @@ app.put('/api/entries/:id/photo/enhance', requireAuth, (req, res, next) => {
}
});
const photoAiJobs = new Map();
let photoAiChain = Promise.resolve();
async function runPhotoAiJob(job) {
job.status = 'processing';
try {
const { rows } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [job.entryId]);
if (!rows.length || !rows[0].photo_path) throw new Error('У записи нет фото');
const photoPath = rows[0].photo_path;
const srcAbs = path.join(UPLOADS_DIR, photoPath.replace(/^\/+/, '').replace(/^uploads\//, ''));
const buf = fs.readFileSync(srcAbs);
const fd = new FormData();
fd.append('image', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg');
fd.append('scale', '2');
const resp = await fetch(PHOTO_AI_URL.replace(/\/+$/, '') + '/enhance', {
method: 'POST',
body: fd,
signal: AbortSignal.timeout(300000),
});
if (!resp.ok) throw new Error('AI service error: ' + resp.status);
const out = Buffer.from(await resp.arrayBuffer());
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
fs.writeFileSync(path.join(UPLOADS_DIR, newName), out);
await swapEntryPhotoFiles(job.req, job.entryId, photoPath, `/uploads/${newName}`);
job.photo_path = `/uploads/${newName}`;
job.status = 'done';
} catch (e) {
job.status = 'error';
job.error = e.message || String(e);
}
}
app.post('/api/entries/:id/photo/enhance-ai', requireAuth, async (req, res) => {
if (!PHOTO_AI_URL) return res.status(503).json({ error: 'ИИ-обработка фото не настроена' });
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
const { rows: cur } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [req.params.id]);
if (!cur.length) return res.status(404).json({ error: 'Запись не найдена' });
if (!cur[0].photo_path) return res.status(400).json({ error: 'У записи нет фото' });
const job = {
id: crypto.randomBytes(8).toString('hex'),
entryId: parseInt(req.params.id, 10),
req: { user: req.user, ip: req.ip },
status: 'pending',
created_at: new Date().toISOString(),
};
photoAiJobs.set(job.id, job);
for (const [id, j] of photoAiJobs) {
if (j.status === 'done' || j.status === 'error') {
if (Date.now() - new Date(j.created_at).getTime() > 3600000) photoAiJobs.delete(id);
}
}
photoAiChain = photoAiChain.then(() => runPhotoAiJob(job)).catch(() => {});
res.json({ jobId: job.id });
});
app.get('/api/entries/:id/photo/enhance-ai/:jobId', requireAuth, async (req, res) => {
const job = photoAiJobs.get(req.params.jobId);
if (!job || job.entryId !== parseInt(req.params.id, 10)) return res.status(404).json({ error: 'Задание не найдено' });
const out = { status: job.status };
if (job.status === 'error') out.error = job.error;
if (job.status === 'done') out.photo_path = job.photo_path;
res.json(out);
});
app.post('/api/entries/:id/photo/restore-original', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
try {
const { rows } = await pool.query('SELECT photo_path, photo_original_path FROM entries WHERE id = $1', [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Запись не найдена' });
const origPath = rows[0].photo_original_path;
if (!origPath) return res.status(400).json({ error: 'Оригинал не сохранён' });
const origName = path.basename(origPath);
if (!/^[A-Za-z0-9._-]+$/.test(origName)) return res.status(400).json({ error: 'Некорректный путь оригинала' });
const origAbs = path.join(ORIGINALS_DIR, origName);
if (!fs.existsSync(origAbs)) return res.status(400).json({ error: 'Файл оригинала не найден' });
const newPath = `/uploads/${crypto.randomBytes(12).toString('hex')}${path.extname(origName) || '.jpg'}`;
fs.renameSync(origAbs, path.join(UPLOADS_DIR, path.basename(newPath)));
const oldPath = rows[0].photo_path;
await pool.query('UPDATE entries SET photo_path = $1, photo_original_path = NULL WHERE id = $2', [newPath, req.params.id]);
await pool.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [newPath, req.params.id, oldPath]);
const oldAbs = path.join(UPLOADS_DIR, String(oldPath).replace(/^\/+/, '').replace(/^uploads\//, ''));
try { if (fs.existsSync(oldAbs)) fs.unlinkSync(oldAbs); } catch {}
const oldThumb = path.join('uploads', '.thumbs', path.basename(oldPath).replace(/\.[^.]+$/, '') + '.webp');
safeUnlink(oldThumb);
await logAudit(req, 'entry.photo.restore_original', { entry_id: req.params.id, restored_path: newPath });
invalidateEntries();
res.json({ ok: true, photo_path: newPath });
} catch (e) {
console.error('POST /api/entries/:id/photo/restore-original:', e);
res.status(500).json({ error: 'Ошибка восстановления оригинала' });
}
});
app.delete('/api/entries/:id', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
@@ -4216,6 +4343,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
try { await loadBans(); } catch (err) { console.error('Load bans:', err); }
setInterval(() => { loadBans().catch(err => console.error('Load bans:', err)); }, 60 * 1000).unref();
try { await ensureEntryPhotosTable(); } catch (err) { console.error('Entry photos table:', err); }
try { await ensurePhotoOriginalColumn(); } catch (err) { console.error('Entry original photo column:', err); }
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }
try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); }
entryAutoChecker = createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl: AI_URL, defaultPrompt: AI_DEFAULT_PROMPT });