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/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);