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:
+5
-1
@@ -26,4 +26,8 @@ CLOUDFLARE_TUNNEL_TOKEN=
|
|||||||
CLOUDFLARE_TUNNEL_URL=http://app:3003
|
CLOUDFLARE_TUNNEL_URL=http://app:3003
|
||||||
# Максимальное ожидание WireGuard handshake перед fallback на прямой запуск
|
# Максимальное ожидание WireGuard handshake перед fallback на прямой запуск
|
||||||
# туннеля без VPN (сек). Если VPN-провайдер не отвечает — сайт всё равно поднимется.
|
# туннеля без VPN (сек). Если VPN-провайдер не отвечает — сайт всё равно поднимется.
|
||||||
WG_HANDSHAKE_TIMEOUT=60
|
WG_HANDSHAKE_TIMEOUT=60
|
||||||
|
|
||||||
|
# ИИ-улучшение фото (Real-ESRGAN), пусто = контейнер photo-ai
|
||||||
|
PHOTO_AI_URL=
|
||||||
|
PHOTO_AI_MAX_PIXELS=4000000
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS entries (
|
|||||||
ai_checked_at TIMESTAMPTZ,
|
ai_checked_at TIMESTAMPTZ,
|
||||||
ai_error TEXT,
|
ai_error TEXT,
|
||||||
photo_path VARCHAR(255),
|
photo_path VARCHAR(255),
|
||||||
|
photo_original_path VARCHAR(255),
|
||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -199,3 +199,5 @@ $$ LANGUAGE plpgsql;
|
|||||||
DROP TRIGGER IF EXISTS trg_entries_notify ON entries;
|
DROP TRIGGER IF EXISTS trg_entries_notify ON entries;
|
||||||
CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries
|
CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries
|
||||||
FOR EACH ROW EXECUTE FUNCTION notify_entries_changed();
|
FOR EACH ROW EXECUTE FUNCTION notify_entries_changed();
|
||||||
|
|
||||||
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255);
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ services:
|
|||||||
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
|
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
|
||||||
AI_PROMPT: ${AI_PROMPT:-}
|
AI_PROMPT: ${AI_PROMPT:-}
|
||||||
AI_REQUEST_TIMEOUT_MS: ${AI_REQUEST_TIMEOUT_MS:-120000}
|
AI_REQUEST_TIMEOUT_MS: ${AI_REQUEST_TIMEOUT_MS:-120000}
|
||||||
|
PHOTO_AI_URL: ${PHOTO_AI_URL:-http://photo-ai:8080}
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
TZ: Europe/Moscow
|
TZ: Europe/Moscow
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -113,7 +114,20 @@ services:
|
|||||||
- "8080:8080"
|
- "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:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
photo-ai-models:
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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')
|
||||||
@@ -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 class="settings-field"><label>Резкость <span id="enhanceSharpVal">0%</span></label><input type="range" id="enhanceSharp" min="0" max="100" value="0"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-foot" style="justify-content:flex-end;gap:8px;margin-top:6px">
|
<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="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button>
|
||||||
<button type="button" class="btn-primary ghost" id="enhanceCancelBtn">Оставить как есть</button>
|
<button type="button" class="btn-primary ghost" id="enhanceCancelBtn">Оставить как есть</button>
|
||||||
<button type="button" class="btn-primary" id="enhanceSaveBtn">Применить</button>
|
<button type="button" class="btn-primary" id="enhanceSaveBtn">Применить</button>
|
||||||
|
|||||||
@@ -424,6 +424,8 @@ let enhanceTimer = null;
|
|||||||
let enhanceSaving = false;
|
let enhanceSaving = false;
|
||||||
let enhanceEngine = 'auto';
|
let enhanceEngine = 'auto';
|
||||||
let enhanceEngineLoaded = false;
|
let enhanceEngineLoaded = false;
|
||||||
|
let photoAiEnabled = false;
|
||||||
|
let photoAiPollTimer = null;
|
||||||
|
|
||||||
async function loadEnhanceEngine() {
|
async function loadEnhanceEngine() {
|
||||||
if (enhanceEngineLoaded) return;
|
if (enhanceEngineLoaded) return;
|
||||||
@@ -432,9 +434,79 @@ async function loadEnhanceEngine() {
|
|||||||
const r = await fetch(`${API}/api/public-settings`);
|
const r = await fetch(`${API}/api/public-settings`);
|
||||||
const f = await r.json();
|
const f = await r.json();
|
||||||
if (['auto', 'server', 'client'].includes(f.photo_enhance_engine)) enhanceEngine = f.photo_enhance_engine;
|
if (['auto', 'server', 'client'].includes(f.photo_enhance_engine)) enhanceEngine = f.photo_enhance_engine;
|
||||||
|
photoAiEnabled = f.photo_ai_enabled === 'true';
|
||||||
} catch (e) {}
|
} 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) {
|
function openEnhance(id) {
|
||||||
const e = currentEntries.find(x => x.id === id);
|
const e = currentEntries.find(x => x.id === id);
|
||||||
if (!e) return;
|
if (!e) return;
|
||||||
@@ -452,6 +524,10 @@ function openEnhance(id) {
|
|||||||
document.getElementById('enhanceSharpVal').textContent = '0%';
|
document.getElementById('enhanceSharpVal').textContent = '0%';
|
||||||
const orig = document.getElementById('enhanceOrig');
|
const orig = document.getElementById('enhanceOrig');
|
||||||
orig.src = `${API}${mp}`;
|
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');
|
const canvas = document.getElementById('enhanceCanvas');
|
||||||
canvas.style.display = 'none';
|
canvas.style.display = 'none';
|
||||||
const loading = document.getElementById('enhanceLoading');
|
const loading = document.getElementById('enhanceLoading');
|
||||||
@@ -1052,6 +1128,8 @@ document.getElementById('editPhotoPreview').addEventListener('click', e => {
|
|||||||
document.getElementById('aiCorrectBtn').addEventListener('click', aiCorrect);
|
document.getElementById('aiCorrectBtn').addEventListener('click', aiCorrect);
|
||||||
document.getElementById('enhanceCancelBtn').addEventListener('click', closeEnhance);
|
document.getElementById('enhanceCancelBtn').addEventListener('click', closeEnhance);
|
||||||
document.getElementById('enhanceAutoBtn').addEventListener('click', autoEnhance);
|
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('enhanceSaveBtn').addEventListener('click', saveEnhance);
|
||||||
document.getElementById('enhanceModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEnhance(); });
|
document.getElementById('enhanceModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEnhance(); });
|
||||||
document.getElementById('enhanceBrightness').addEventListener('input', scheduleEnhancePreview);
|
document.getElementById('enhanceBrightness').addEventListener('input', scheduleEnhancePreview);
|
||||||
|
|||||||
@@ -257,12 +257,14 @@ app.use(helmet({
|
|||||||
app.use(express.json({ limit: '1mb' }));
|
app.use(express.json({ limit: '1mb' }));
|
||||||
app.use(ipGuard);
|
app.use(ipGuard);
|
||||||
const THUMBS_DIR = path.join(__dirname, 'uploads', '.thumbs');
|
const THUMBS_DIR = path.join(__dirname, 'uploads', '.thumbs');
|
||||||
|
const ORIGINALS_DIR = path.join(__dirname, 'uploads', '.originals');
|
||||||
const THUMB_WIDTH = 480;
|
const THUMB_WIDTH = 480;
|
||||||
let sharp = null;
|
let sharp = null;
|
||||||
try { sharp = require('sharp'); } catch {}
|
try { sharp = require('sharp'); } catch {}
|
||||||
if (sharp) {
|
if (sharp) {
|
||||||
try { fs.mkdirSync(THUMBS_DIR, { recursive: true }); } catch {}
|
try { fs.mkdirSync(THUMBS_DIR, { recursive: true }); } catch {}
|
||||||
}
|
}
|
||||||
|
try { fs.mkdirSync(ORIGINALS_DIR, { recursive: true }); } catch {}
|
||||||
|
|
||||||
function thumbFileFor(fp) {
|
function thumbFileFor(fp) {
|
||||||
const base = path.basename(fp).replace(/\.[^.]+$/, '') + '.webp';
|
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)');
|
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() {
|
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_original TEXT`);
|
||||||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai 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);
|
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_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;
|
return result;
|
||||||
});
|
});
|
||||||
res.json(out);
|
res.json(out);
|
||||||
@@ -1060,6 +1067,7 @@ const zlib = require('zlib');
|
|||||||
const tar = require('tar');
|
const tar = require('tar');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const AI_URL = process.env.AI_URL || 'http://text-corrector:8080';
|
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_MODEL = process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
|
||||||
const AI_DEFAULT_PROMPT = process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
|
const AI_DEFAULT_PROMPT = process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
|
||||||
let entryAutoChecker = null;
|
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;
|
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : def;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function swapEntryPhotoFiles(req, entryId, oldPath, newPath) {
|
async function swapEntryPhotoFiles(req, entryId, oldPath, newPath, { keepOriginal = true } = {}) {
|
||||||
await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [newPath, entryId]);
|
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]);
|
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');
|
const oldThumb = path.join('uploads', '.thumbs', path.basename(oldPath).replace(/\.[^.]+$/, '') + '.webp');
|
||||||
safeUnlink(oldThumb);
|
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();
|
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) => {
|
app.delete('/api/entries/:id', requireAuth, async (req, res) => {
|
||||||
if (req.user.role !== 'admin') {
|
if (req.user.role !== 'admin') {
|
||||||
const acc = await entryAccessible(req.user, req.params.id);
|
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); }
|
try { await loadBans(); } catch (err) { console.error('Load bans:', err); }
|
||||||
setInterval(() => { loadBans().catch(err => console.error('Load bans:', err)); }, 60 * 1000).unref();
|
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 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 ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }
|
||||||
try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); }
|
try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); }
|
||||||
entryAutoChecker = createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl: AI_URL, defaultPrompt: AI_DEFAULT_PROMPT });
|
entryAutoChecker = createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl: AI_URL, defaultPrompt: AI_DEFAULT_PROMPT });
|
||||||
|
|||||||
Reference in New Issue
Block a user