diff --git a/.env.example b/.env.example index 6205191..ed645dd 100644 --- a/.env.example +++ b/.env.example @@ -26,4 +26,8 @@ CLOUDFLARE_TUNNEL_TOKEN= CLOUDFLARE_TUNNEL_URL=http://app:3003 # Максимальное ожидание WireGuard handshake перед fallback на прямой запуск # туннеля без VPN (сек). Если VPN-провайдер не отвечает — сайт всё равно поднимется. -WG_HANDSHAKE_TIMEOUT=60 \ No newline at end of file +WG_HANDSHAKE_TIMEOUT=60 + +# ИИ-улучшение фото (Real-ESRGAN), пусто = контейнер photo-ai +PHOTO_AI_URL= +PHOTO_AI_MAX_PIXELS=4000000 diff --git a/db/init.sql b/db/init.sql index dbe8511..a5d614c 100644 --- a/db/init.sql +++ b/db/init.sql @@ -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() ); diff --git a/db/migration.sql b/db/migration.sql index 003efce..4fe8bd9 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -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); diff --git a/docker-compose.yml b/docker-compose.yml index 9e202cf..c129c2e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/photo-ai/Dockerfile b/photo-ai/Dockerfile new file mode 100644 index 0000000..168748b --- /dev/null +++ b/photo-ai/Dockerfile @@ -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"] diff --git a/photo-ai/app.py b/photo-ai/app.py new file mode 100644 index 0000000..25399ab --- /dev/null +++ b/photo-ai/app.py @@ -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') diff --git a/public/journal.html b/public/journal.html index 13335c4..0edcab9 100644 --- a/public/journal.html +++ b/public/journal.html @@ -187,6 +187,8 @@
+ + diff --git a/public/js/journal.js b/public/js/journal.js index 4cc330d..fd53ec4 100644 --- a/public/js/journal.js +++ b/public/js/journal.js @@ -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); diff --git a/server.js b/server.js index 82f0f22..690fe70 100644 --- a/server.js +++ b/server.js @@ -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 });