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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user