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