From c4fd31cd532ea15fa31435faa062ba4b86d5cecd Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 17 Sep 2026 15:16:42 +0300 Subject: [PATCH] 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 --- public/journal.html | 1 + public/js/journal.js | 203 +++++++++++++++++++++++++++++++++--------- public/js/settings.js | 6 +- public/settings.html | 9 ++ server.js | 71 ++++++++++++--- 5 files changed, 234 insertions(+), 56 deletions(-) diff --git a/public/journal.html b/public/journal.html index e54ed8a..13335c4 100644 --- a/public/journal.html +++ b/public/journal.html @@ -183,6 +183,7 @@
+
diff --git a/public/js/journal.js b/public/js/journal.js index 600cb17..4cc330d 100644 --- a/public/js/journal.js +++ b/public/js/journal.js @@ -422,6 +422,18 @@ let enhanceEntryId = null; let enhanceSourceImg = null; let enhanceTimer = null; 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) { const e = currentEntries.find(x => x.id === id); @@ -463,41 +475,76 @@ function closeEnhance() { enhanceSourceImg = null; } -function renderEnhancePreview() { - 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'); +function medianFilterPass(ctx, w, h) { + const data = ctx.getImageData(0, 0, w, h); + const src = data.data; + 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; + 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++) { + const v = (1 + 4 * a) * src[i + ch] + - a * src[i - 4 + ch] - a * src[i + 4 + ch] + - a * src[i - row + ch] - a * src[i + row + ch]; + out[i + ch] = v; + } + } + } + data.data.set(out); + 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'; - const sharp = parseInt(document.getElementById('enhanceSharp').value, 10); - if (sharp > 0) { - const data = ctx.getImageData(0, 0, w, h); - const src = data.data; - const out = new Uint8ClampedArray(src); - const a = (sharp / 100) * 1.5; - for (let y = 1; y < h - 1; y++) { - for (let x = 1; x < w - 1; x++) { - const i = (y * w + x) * 4; - for (let ch = 0; ch < 3; ch++) { - const v = (1 + 4 * a) * src[i + ch] - - a * src[i - 4 + ch] - a * src[i + 4 + ch] - - a * src[i - w * 4 + ch] - a * src[i + w * 4 + ch]; - out[i + ch] = v; - } - } - } - data.data.set(out); - ctx.putImageData(data, 0, 0); + 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'; document.getElementById('enhanceLoading').style.display = 'none'; } @@ -506,11 +553,46 @@ function scheduleEnhancePreview() { document.getElementById('enhanceBrightnessVal').textContent = document.getElementById('enhanceBrightness').value + '%'; document.getElementById('enhanceContrastVal').textContent = document.getElementById('enhanceContrast').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 + '%'; if (enhanceTimer) clearTimeout(enhanceTimer); 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() { if (!enhanceSourceImg) return; const img = enhanceSourceImg; @@ -546,35 +628,73 @@ function autoEnhance() { } 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 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 sharp = 30; document.getElementById('enhanceBrightness').value = brightness; document.getElementById('enhanceContrast').value = contrast; document.getElementById('enhanceSaturate').value = saturate; + document.getElementById('enhanceDenoise').value = denoise; document.getElementById('enhanceSharp').value = sharp; scheduleEnhancePreview(); } async function saveEnhance() { if (!enhanceEntryId || !enhanceSourceImg || enhanceSaving) return; - const canvas = document.getElementById('enhanceCanvas'); - enhanceSaving = true; + await loadEnhanceEngine(); const btn = document.getElementById('enhanceSaveBtn'); + enhanceSaving = true; btn.disabled = true; btn.textContent = 'Сохранение...'; + const entryId = enhanceEntryId; try { - 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(); - fd.append('photo', blob, 'enhanced.jpg'); - const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance`, { method: 'PUT', headers: hdr(), body: fd }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(err.error || 'Ошибка сохранения'); + 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; + } } - const savedId = enhanceEntryId; - showToast('Фото улучшено и сохранено'); + 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 fd = new FormData(); + fd.append('photo', blob, 'enhanced.jpg'); + const res = await fetch(`${API}/api/entries/${entryId}/photo/enhance`, { method: 'PUT', headers: hdr(), body: fd }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || 'Ошибка сохранения'); + } + } + showToast(`Фото улучшено (${engineLabel}) и сохранено`); closeEnhance(); - refreshEntryCard(savedId); + refreshEntryCard(entryId); } catch (e) { alert(e.message); } finally { @@ -937,6 +1057,7 @@ document.getElementById('enhanceModal').addEventListener('click', e => { if (e.t document.getElementById('enhanceBrightness').addEventListener('input', scheduleEnhancePreview); document.getElementById('enhanceContrast').addEventListener('input', scheduleEnhancePreview); document.getElementById('enhanceSaturate').addEventListener('input', scheduleEnhancePreview); +document.getElementById('enhanceDenoise').addEventListener('input', scheduleEnhancePreview); document.getElementById('enhanceSharp').addEventListener('input', scheduleEnhancePreview); document.getElementById('aiAcceptBtn').addEventListener('click', acceptAiCorrect); document.getElementById('aiRejectBtn').addEventListener('click', closeAiCorrect); diff --git a/public/js/settings.js b/public/js/settings.js index 38f8fcc..030c456 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -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 SPAM_MAX = 10080; const BAN_REASONS = { @@ -157,6 +157,9 @@ async function loadSettings() { if (s.photo_capture_resolution) pr.value = s.photo_capture_resolution; 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'; + 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(); updateSpamUi(); loadAiQueue(); @@ -496,6 +499,7 @@ async function saveSettings() { cookie_notice_text: document.getElementById('cookieNoticeText').value.trim(), photo_capture_resolution: document.getElementById('photoResolution').value, photo_capture_quality: String(document.getElementById('photoQuality').value), + photo_enhance_engine: document.getElementById('photoEngine').value, } }) }); if (res.ok) { diff --git a/public/settings.html b/public/settings.html index 85eb262..098b110 100644 --- a/public/settings.html +++ b/public/settings.html @@ -194,6 +194,15 @@
Больше значение — лучше качество и крупнее файл +
+ + + Серверный режим обрабатывает оригинал без пережатия и быстрее на больших фото +
diff --git a/server.js b/server.js index 24e8472..82f0f22 100644 --- a/server.js +++ b/server.js @@ -965,8 +965,8 @@ app.get('/api/settings', requireAdmin, async (_, res) => { app.get('/api/public-settings', apiLimiter, async (_, res) => { 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 defaults = { spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92' }; + 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', photo_enhance_engine: 'auto' }; const result = {}; 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); @@ -1005,6 +1005,9 @@ app.put('/api/settings', requireAdmin, async (req, res) => { 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(); try { @@ -3662,7 +3665,25 @@ app.put('/api/entries/:id/photos/:photoId/main', requireAuth, async (req, res) = invalidateEntries(); 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') { const acc = await entryAccessible(req.user, req.params.id); 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); return res.status(400).json({ error: 'У записи нет фото' }); } - if (!req.file) return res.status(400).json({ error: 'Нет файла' }); - await convertPhoto(req.file); - const 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]); - safeUnlink(oldPath); - 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 }); + 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: 'Нет файла' }); + await convertPhoto(req.file); + newPath = `/uploads/${req.file.filename}`; + } + await swapEntryPhotoFiles(req, req.params.id, oldPath, newPath); + res.json({ ok: true, photo_path: newPath, engine }); } catch (e) { if (req.file) safeUnlink(req.file.path); console.error('PUT /api/entries/:id/photo/enhance:', e);