feat: hybrid photo enhance engine (server sharp + client canvas), denoise

- enhance endpoint accepts JSON params, processes original on disk via sharp pipeline (median, modulate, linear, sharpen, mozjpeg)
- client fallback renders full-size canvas, keeps multipart path
- engine selection setting: auto/server/client, auto falls back to client on error/timeout
- denoise slider (median filter), noise-aware auto mode, preview capped at 1600px
This commit is contained in:
dev
2026-09-17 15:16:42 +03:00
parent 049df61e55
commit c4fd31cd53
5 changed files with 234 additions and 56 deletions
+1
View File
@@ -183,6 +183,7 @@
<div class="settings-field"><label>Яркость <span id="enhanceBrightnessVal">100%</span></label><input type="range" id="enhanceBrightness" min="50" max="150" value="100"></div> <div class="settings-field"><label>Яркость <span id="enhanceBrightnessVal">100%</span></label><input type="range" id="enhanceBrightness" min="50" max="150" value="100"></div>
<div class="settings-field"><label>Контраст <span id="enhanceContrastVal">100%</span></label><input type="range" id="enhanceContrast" min="50" max="150" value="100"></div> <div class="settings-field"><label>Контраст <span id="enhanceContrastVal">100%</span></label><input type="range" id="enhanceContrast" min="50" max="150" value="100"></div>
<div class="settings-field"><label>Насыщенность <span id="enhanceSaturateVal">100%</span></label><input type="range" id="enhanceSaturate" min="0" max="200" value="100"></div> <div class="settings-field"><label>Насыщенность <span id="enhanceSaturateVal">100%</span></label><input type="range" id="enhanceSaturate" min="0" max="200" value="100"></div>
<div class="settings-field"><label>Шумодав <span id="enhanceDenoiseVal">0%</span></label><input type="range" id="enhanceDenoise" 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 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">
+147 -26
View File
@@ -422,6 +422,18 @@ let enhanceEntryId = null;
let enhanceSourceImg = null; let enhanceSourceImg = null;
let enhanceTimer = null; let enhanceTimer = null;
let enhanceSaving = false; let enhanceSaving = false;
let enhanceEngine = 'auto';
let enhanceEngineLoaded = false;
async function loadEnhanceEngine() {
if (enhanceEngineLoaded) return;
enhanceEngineLoaded = true;
try {
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;
} catch (e) {}
}
function openEnhance(id) { function openEnhance(id) {
const e = currentEntries.find(x => x.id === id); const e = currentEntries.find(x => x.id === id);
@@ -463,41 +475,76 @@ function closeEnhance() {
enhanceSourceImg = null; enhanceSourceImg = null;
} }
function renderEnhancePreview() { function medianFilterPass(ctx, w, h) {
if (!enhanceSourceImg) return;
const img = enhanceSourceImg;
const canvas = document.getElementById('enhanceCanvas');
const w = img.naturalWidth;
const h = img.naturalHeight;
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
const b = document.getElementById('enhanceBrightness').value;
const c = document.getElementById('enhanceContrast').value;
const s = document.getElementById('enhanceSaturate').value;
ctx.filter = `brightness(${b}%) contrast(${c}%) saturate(${s}%)`;
ctx.drawImage(img, 0, 0, w, h);
ctx.filter = 'none';
const sharp = parseInt(document.getElementById('enhanceSharp').value, 10);
if (sharp > 0) {
const data = ctx.getImageData(0, 0, w, h); const data = ctx.getImageData(0, 0, w, h);
const src = data.data; const src = data.data;
const out = new Uint8ClampedArray(src); const out = new Uint8ClampedArray(src);
const row = w * 4;
const win = new Array(9);
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
const i = y * row + x * 4;
for (let ch = 0; ch < 3; ch++) {
win[0] = src[i - row - 4 + ch]; win[1] = src[i - row + ch]; win[2] = src[i - row + 4 + ch];
win[3] = src[i - 4 + ch]; win[4] = src[i + ch]; win[5] = src[i + 4 + ch];
win[6] = src[i + row - 4 + ch]; win[7] = src[i + row + ch]; win[8] = src[i + row + 4 + ch];
win.sort((a, b) => a - b);
out[i + ch] = win[4];
}
}
}
data.data.set(out);
ctx.putImageData(data, 0, 0);
}
function sharpenPass(ctx, w, h, sharp) {
const data = ctx.getImageData(0, 0, w, h);
const src = data.data;
const out = new Uint8ClampedArray(src);
const row = w * 4;
const a = (sharp / 100) * 1.5; const a = (sharp / 100) * 1.5;
for (let y = 1; y < h - 1; y++) { for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) { for (let x = 1; x < w - 1; x++) {
const i = (y * w + x) * 4; const i = y * row + x * 4;
for (let ch = 0; ch < 3; ch++) { for (let ch = 0; ch < 3; ch++) {
const v = (1 + 4 * a) * src[i + ch] const v = (1 + 4 * a) * src[i + ch]
- a * src[i - 4 + ch] - a * src[i + 4 + ch] - a * src[i - 4 + ch] - a * src[i + 4 + ch]
- a * src[i - w * 4 + ch] - a * src[i + w * 4 + ch]; - a * src[i - row + ch] - a * src[i + row + ch];
out[i + ch] = v; out[i + ch] = v;
} }
} }
} }
data.data.set(out); data.data.set(out);
ctx.putImageData(data, 0, 0); ctx.putImageData(data, 0, 0);
}
function drawEnhanced(ctx, img, w, h) {
const b = document.getElementById('enhanceBrightness').value;
const c = document.getElementById('enhanceContrast').value;
const s = document.getElementById('enhanceSaturate').value;
const dn = parseInt(document.getElementById('enhanceDenoise').value, 10);
const sp = parseInt(document.getElementById('enhanceSharp').value, 10);
ctx.filter = `brightness(${b}%) contrast(${c}%) saturate(${s}%)`;
ctx.drawImage(img, 0, 0, w, h);
ctx.filter = 'none';
if (dn > 0) {
medianFilterPass(ctx, w, h);
if (dn > 70) medianFilterPass(ctx, w, h);
} }
if (sp > 0) sharpenPass(ctx, w, h, sp);
}
function renderEnhancePreview() {
if (!enhanceSourceImg) return;
const img = enhanceSourceImg;
const scale = Math.min(1, 1600 / img.naturalWidth);
const w = Math.max(1, Math.round(img.naturalWidth * scale));
const h = Math.max(1, Math.round(img.naturalHeight * scale));
const canvas = document.getElementById('enhanceCanvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
drawEnhanced(ctx, img, w, h);
canvas.style.display = 'block'; canvas.style.display = 'block';
document.getElementById('enhanceLoading').style.display = 'none'; document.getElementById('enhanceLoading').style.display = 'none';
} }
@@ -506,11 +553,46 @@ function scheduleEnhancePreview() {
document.getElementById('enhanceBrightnessVal').textContent = document.getElementById('enhanceBrightness').value + '%'; document.getElementById('enhanceBrightnessVal').textContent = document.getElementById('enhanceBrightness').value + '%';
document.getElementById('enhanceContrastVal').textContent = document.getElementById('enhanceContrast').value + '%'; document.getElementById('enhanceContrastVal').textContent = document.getElementById('enhanceContrast').value + '%';
document.getElementById('enhanceSaturateVal').textContent = document.getElementById('enhanceSaturate').value + '%'; document.getElementById('enhanceSaturateVal').textContent = document.getElementById('enhanceSaturate').value + '%';
document.getElementById('enhanceDenoiseVal').textContent = document.getElementById('enhanceDenoise').value + '%';
document.getElementById('enhanceSharpVal').textContent = document.getElementById('enhanceSharp').value + '%'; document.getElementById('enhanceSharpVal').textContent = document.getElementById('enhanceSharp').value + '%';
if (enhanceTimer) clearTimeout(enhanceTimer); if (enhanceTimer) clearTimeout(enhanceTimer);
enhanceTimer = setTimeout(renderEnhancePreview, 120); enhanceTimer = setTimeout(renderEnhancePreview, 120);
} }
function enhanceParams() {
return {
brightness: parseInt(document.getElementById('enhanceBrightness').value, 10),
contrast: parseInt(document.getElementById('enhanceContrast').value, 10),
saturate: parseInt(document.getElementById('enhanceSaturate').value, 10),
denoise: parseInt(document.getElementById('enhanceDenoise').value, 10),
sharp: parseInt(document.getElementById('enhanceSharp').value, 10),
};
}
function estimateNoise(img) {
const aw = 160;
const ah = Math.max(1, Math.round(img.naturalHeight * aw / img.naturalWidth));
const ac = document.createElement('canvas');
ac.width = aw;
ac.height = ah;
const actx = ac.getContext('2d');
actx.drawImage(img, 0, 0, aw, ah);
const d = actx.getImageData(0, 0, aw, ah).data;
let sum = 0;
let n = 0;
for (let y = 0; y < ah - 1; y++) {
for (let x = 0; x < aw - 1; x++) {
const i = (y * aw + x) * 4;
const l = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
const lr = 0.299 * d[i + 4] + 0.587 * d[i + 5] + 0.114 * d[i + 6];
const lb = 0.299 * d[i + aw * 4] + 0.587 * d[i + aw * 4 + 1] + 0.114 * d[i + aw * 4 + 2];
sum += Math.abs(l - lr) + Math.abs(l - lb);
n += 2;
}
}
return n ? sum / n : 0;
}
function autoEnhance() { function autoEnhance() {
if (!enhanceSourceImg) return; if (!enhanceSourceImg) return;
const img = enhanceSourceImg; const img = enhanceSourceImg;
@@ -546,35 +628,73 @@ function autoEnhance() {
} }
const contrast = Math.round(Math.min(150, Math.max(100, 25500 / (hi - lo)))); const contrast = Math.round(Math.min(150, Math.max(100, 25500 / (hi - lo))));
const brightness = Math.round(Math.min(120, Math.max(85, 12800 / Math.max(1, (lo + hi) / 2)))); const brightness = Math.round(Math.min(120, Math.max(85, 12800 / Math.max(1, (lo + hi) / 2))));
const noise = estimateNoise(img);
let denoise = 0;
let sharp = 30;
if (noise > 6) {
denoise = Math.min(50, Math.round(noise * 6));
sharp = 15;
}
const saturate = 106; const saturate = 106;
const sharp = 30;
document.getElementById('enhanceBrightness').value = brightness; document.getElementById('enhanceBrightness').value = brightness;
document.getElementById('enhanceContrast').value = contrast; document.getElementById('enhanceContrast').value = contrast;
document.getElementById('enhanceSaturate').value = saturate; document.getElementById('enhanceSaturate').value = saturate;
document.getElementById('enhanceDenoise').value = denoise;
document.getElementById('enhanceSharp').value = sharp; document.getElementById('enhanceSharp').value = sharp;
scheduleEnhancePreview(); scheduleEnhancePreview();
} }
async function saveEnhance() { async function saveEnhance() {
if (!enhanceEntryId || !enhanceSourceImg || enhanceSaving) return; if (!enhanceEntryId || !enhanceSourceImg || enhanceSaving) return;
const canvas = document.getElementById('enhanceCanvas'); await loadEnhanceEngine();
enhanceSaving = true;
const btn = document.getElementById('enhanceSaveBtn'); const btn = document.getElementById('enhanceSaveBtn');
enhanceSaving = true;
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Сохранение...'; btn.textContent = 'Сохранение...';
const entryId = enhanceEntryId;
try { try {
let done = false;
let engineLabel = 'клиент';
if (enhanceEngine !== 'client') {
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 8000);
const res = await fetch(`${API}/api/entries/${entryId}/photo/enhance`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ params: enhanceParams() }),
signal: ctrl.signal,
});
clearTimeout(timer);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Ошибка сервера');
}
engineLabel = 'сервер';
done = true;
} catch (e) {
if (enhanceEngine === 'server') throw e;
}
}
if (!done) {
const img = enhanceSourceImg;
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
drawEnhanced(ctx, img, canvas.width, canvas.height);
const blob = await new Promise((resolve, reject) => canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/jpeg', 0.92)); const blob = await new Promise((resolve, reject) => canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/jpeg', 0.92));
const fd = new FormData(); const fd = new FormData();
fd.append('photo', blob, 'enhanced.jpg'); fd.append('photo', blob, 'enhanced.jpg');
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance`, { method: 'PUT', headers: hdr(), body: fd }); const res = await fetch(`${API}/api/entries/${entryId}/photo/enhance`, { method: 'PUT', headers: hdr(), body: fd });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Ошибка сохранения'); throw new Error(err.error || 'Ошибка сохранения');
} }
const savedId = enhanceEntryId; }
showToast('Фото улучшено и сохранено'); showToast(`Фото улучшено (${engineLabel}) и сохранено`);
closeEnhance(); closeEnhance();
refreshEntryCard(savedId); refreshEntryCard(entryId);
} catch (e) { } catch (e) {
alert(e.message); alert(e.message);
} finally { } finally {
@@ -937,6 +1057,7 @@ document.getElementById('enhanceModal').addEventListener('click', e => { if (e.t
document.getElementById('enhanceBrightness').addEventListener('input', scheduleEnhancePreview); document.getElementById('enhanceBrightness').addEventListener('input', scheduleEnhancePreview);
document.getElementById('enhanceContrast').addEventListener('input', scheduleEnhancePreview); document.getElementById('enhanceContrast').addEventListener('input', scheduleEnhancePreview);
document.getElementById('enhanceSaturate').addEventListener('input', scheduleEnhancePreview); document.getElementById('enhanceSaturate').addEventListener('input', scheduleEnhancePreview);
document.getElementById('enhanceDenoise').addEventListener('input', scheduleEnhancePreview);
document.getElementById('enhanceSharp').addEventListener('input', scheduleEnhancePreview); document.getElementById('enhanceSharp').addEventListener('input', scheduleEnhancePreview);
document.getElementById('aiAcceptBtn').addEventListener('click', acceptAiCorrect); document.getElementById('aiAcceptBtn').addEventListener('click', acceptAiCorrect);
document.getElementById('aiRejectBtn').addEventListener('click', closeAiCorrect); document.getElementById('aiRejectBtn').addEventListener('click', closeAiCorrect);
+5 -1
View File
@@ -1,4 +1,4 @@
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality']; const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine'];
const SECTION_IDS = ['sec-system', 'sec-antispam', 'sec-share', 'sec-form', 'sec-ai', 'sec-backup', 'sec-bans']; const SECTION_IDS = ['sec-system', 'sec-antispam', 'sec-share', 'sec-form', 'sec-ai', 'sec-backup', 'sec-bans'];
const SPAM_MAX = 10080; const SPAM_MAX = 10080;
const BAN_REASONS = { const BAN_REASONS = {
@@ -157,6 +157,9 @@ async function loadSettings() {
if (s.photo_capture_resolution) pr.value = s.photo_capture_resolution; if (s.photo_capture_resolution) pr.value = s.photo_capture_resolution;
if (!pr.value) pr.value = '640x480'; if (!pr.value) pr.value = '640x480';
document.getElementById('photoQuality').value = s.photo_capture_quality !== undefined && s.photo_capture_quality !== '' ? s.photo_capture_quality : '0.92'; document.getElementById('photoQuality').value = s.photo_capture_quality !== undefined && s.photo_capture_quality !== '' ? s.photo_capture_quality : '0.92';
const pe = document.getElementById('photoEngine');
pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto';
if (!pe.value) pe.value = 'auto';
snapshotDirty(); snapshotDirty();
updateSpamUi(); updateSpamUi();
loadAiQueue(); loadAiQueue();
@@ -496,6 +499,7 @@ async function saveSettings() {
cookie_notice_text: document.getElementById('cookieNoticeText').value.trim(), cookie_notice_text: document.getElementById('cookieNoticeText').value.trim(),
photo_capture_resolution: document.getElementById('photoResolution').value, photo_capture_resolution: document.getElementById('photoResolution').value,
photo_capture_quality: String(document.getElementById('photoQuality').value), photo_capture_quality: String(document.getElementById('photoQuality').value),
photo_enhance_engine: document.getElementById('photoEngine').value,
} }) } })
}); });
if (res.ok) { if (res.ok) {
+9
View File
@@ -194,6 +194,15 @@
</div> </div>
<span class="hint">Больше значение — лучше качество и крупнее файл</span> <span class="hint">Больше значение — лучше качество и крупнее файл</span>
</div> </div>
<div class="settings-field">
<label for="photoEngine">Обработка улучшения фото</label>
<select id="photoEngine" class="settings-input">
<option value="auto">Авто (сервер, иначе клиент)</option>
<option value="server">Сервер (sharp)</option>
<option value="client">Клиент (браузер)</option>
</select>
<span class="hint">Серверный режим обрабатывает оригинал без пережатия и быстрее на больших фото</span>
</div>
</div> </div>
</div> </div>
+55 -12
View File
@@ -965,8 +965,8 @@ app.get('/api/settings', requireAdmin, async (_, res) => {
app.get('/api/public-settings', apiLimiter, async (_, res) => { app.get('/api/public-settings', apiLimiter, async (_, res) => {
const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => { const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => {
const keys = ['footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min', 'photo_capture_resolution', 'photo_capture_quality']; const keys = ['footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min', 'photo_capture_resolution', 'photo_capture_quality', 'photo_enhance_engine'];
const defaults = { spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92' }; const defaults = { spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92', photo_enhance_engine: 'auto' };
const result = {}; const result = {};
for (const k of keys) result[k] = await getSetting(k, defaults[k] || ''); for (const k of keys) result[k] = await getSetting(k, defaults[k] || '');
const rm = /^(\d{2,5})x(\d{2,5})$/.exec(result.photo_capture_resolution); const rm = /^(\d{2,5})x(\d{2,5})$/.exec(result.photo_capture_resolution);
@@ -1005,6 +1005,9 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
return res.status(400).json({ error: 'photo_capture_quality должен быть числом от 0.5 до 1' }); return res.status(400).json({ error: 'photo_capture_quality должен быть числом от 0.5 до 1' });
} }
} }
if (key === 'photo_enhance_engine' && !['auto', 'server', 'client'].includes(String(value))) {
return res.status(400).json({ error: 'photo_enhance_engine должен быть auto, server или client' });
}
} }
const client = await pool.connect(); const client = await pool.connect();
try { try {
@@ -3662,7 +3665,25 @@ app.put('/api/entries/:id/photos/:photoId/main', requireAuth, async (req, res) =
invalidateEntries(); invalidateEntries();
res.json({ ok: true, photo_path: rows[0].photo_path }); res.json({ ok: true, photo_path: rows[0].photo_path });
}); });
app.put('/api/entries/:id/photo/enhance', requireAuth, (req, res, next) => { upload.single('photo')(req, res, next); }, async (req, res) => { function clampEnhanceParam(v, min, max, def) {
const n = parseFloat(v);
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]);
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 });
invalidateEntries();
}
app.put('/api/entries/:id/photo/enhance', requireAuth, (req, res, next) => {
if (req.is('json')) return next();
upload.single('photo')(req, res, next);
}, 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);
if (!acc.found) return res.status(404).json({ error: 'Not found' }); if (!acc.found) return res.status(404).json({ error: 'Not found' });
@@ -3679,17 +3700,39 @@ app.put('/api/entries/:id/photo/enhance', requireAuth, (req, res, next) => { upl
if (req.file) safeUnlink(req.file.path); if (req.file) safeUnlink(req.file.path);
return res.status(400).json({ error: 'У записи нет фото' }); return res.status(400).json({ error: 'У записи нет фото' });
} }
let newPath;
let engine = 'client';
if (req.is('json')) {
if (!sharp) return res.status(503).json({ error: 'sharp недоступен на сервере' });
const p = req.body.params || {};
const brightness = clampEnhanceParam(p.brightness, 10, 300, 100);
const contrast = clampEnhanceParam(p.contrast, 10, 300, 100);
const saturate = clampEnhanceParam(p.saturate, 0, 300, 100);
const sharpAmt = clampEnhanceParam(p.sharp, 0, 100, 0);
const denoise = clampEnhanceParam(p.denoise, 0, 100, 0);
const srcPath = path.join(__dirname, oldPath.replace(/^\/+/, ''));
let pipeline = sharp(srcPath).rotate();
if (denoise > 0) pipeline = pipeline.median(denoise > 70 ? 5 : 3);
pipeline = pipeline.modulate({ brightness: brightness / 100, saturation: saturate / 100 });
if (contrast !== 100) {
const a = contrast / 100;
pipeline = pipeline.linear(a, 128 * (1 - a));
}
if (sharpAmt > 0) {
pipeline = pipeline.sharpen({ sigma: 0.5 + (sharpAmt / 100) * 1.5, m1: 0, m2: 1 + sharpAmt / 50 });
}
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
const outPath = path.join(__dirname, 'uploads', newName);
await pipeline.jpeg({ quality: 92, mozjpeg: true }).toFile(outPath);
newPath = `/uploads/${newName}`;
engine = 'sharp';
} else {
if (!req.file) return res.status(400).json({ error: 'Нет файла' }); if (!req.file) return res.status(400).json({ error: 'Нет файла' });
await convertPhoto(req.file); await convertPhoto(req.file);
const newPath = `/uploads/${req.file.filename}`; newPath = `/uploads/${req.file.filename}`;
await pool.query('UPDATE entries SET photo_path = $1 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]); await swapEntryPhotoFiles(req, req.params.id, oldPath, newPath);
safeUnlink(oldPath); res.json({ ok: true, photo_path: newPath, engine });
const oldThumb = path.join('uploads', '.thumbs', path.basename(oldPath).replace(/\.[^.]+$/, '') + '.webp');
safeUnlink(oldThumb);
await logAudit(req, 'entry.photo.enhance', { entry_id: req.params.id, old_path: oldPath, new_path: newPath });
invalidateEntries();
res.json({ ok: true, photo_path: newPath });
} catch (e) { } catch (e) {
if (req.file) safeUnlink(req.file.path); if (req.file) safeUnlink(req.file.path);
console.error('PUT /api/entries/:id/photo/enhance:', e); console.error('PUT /api/entries/:id/photo/enhance:', e);