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
+57 -14
View File
@@ -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);