feat: photo quality improvements
- webcam capture resolution/quality configurable in admin settings (defaults 640x480 / 0.92) - enhance photo modal in journal: original vs preview with sliders (brightness, contrast, saturation, sharpen) and auto-levels button - new endpoint PUT /api/entries/:id/photo/enhance replaces photo, cleans old file and thumb - sharper HEIC conversion (0.92) and webp thumbnails (85) - worker: fail explicitly on empty AI response
This commit is contained in:
@@ -278,7 +278,7 @@ async function sendImageThumb(res, fp) {
|
||||
try {
|
||||
if (!fs.existsSync(tp)) {
|
||||
const tmp = tp + '.' + crypto.randomBytes(4).toString('hex') + '.tmp';
|
||||
await sharp(fp).rotate().resize({ width: THUMB_WIDTH, withoutEnlargement: true }).webp({ quality: 72 }).toFile(tmp);
|
||||
await sharp(fp).rotate().resize({ width: THUMB_WIDTH, withoutEnlargement: true }).webp({ quality: 85 }).toFile(tmp);
|
||||
fs.renameSync(tmp, tp);
|
||||
}
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
@@ -579,7 +579,7 @@ async function convertPhoto(file) {
|
||||
// HEIC/HEIF conversion
|
||||
const outName = `${path.basename(file.path, path.extname(file.path))}.jpg`;
|
||||
const outPath = path.join(path.dirname(file.path), outName);
|
||||
const jpeg = await heicConvert({ buffer: fs.readFileSync(file.path), format: 'JPEG', quality: 0.85 });
|
||||
const jpeg = await heicConvert({ buffer: fs.readFileSync(file.path), format: 'JPEG', quality: 0.92 });
|
||||
fs.writeFileSync(outPath, jpeg);
|
||||
safeUnlink(file.path);
|
||||
file.path = outPath;
|
||||
@@ -673,6 +673,20 @@ async function ensureEntryAiColumns() {
|
||||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ`);
|
||||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL`);
|
||||
await pool.query(`
|
||||
CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF (TG_OP = 'INSERT') THEN
|
||||
PERFORM pg_notify('entries_changed', json_build_object('type', 'entry_created', 'id', NEW.id)::text);
|
||||
ELSIF (TG_OP = 'UPDATE' AND OLD.ai_status IS DISTINCT FROM NEW.ai_status) THEN
|
||||
PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error, 'description', NEW.description, 'description_ai', NEW.description_ai, 'description_original', NEW.description_original)::text);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
`);
|
||||
await pool.query(`DROP TRIGGER IF EXISTS trg_entries_notify ON entries`);
|
||||
await pool.query(`CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries FOR EACH ROW EXECUTE FUNCTION notify_entries_changed()`);
|
||||
await pool.query(`INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true') ON CONFLICT (key) DO NOTHING`);
|
||||
const marker = await getSetting('ai_autocheck_migrated', '');
|
||||
if (marker !== '1') {
|
||||
@@ -951,10 +965,20 @@ 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'];
|
||||
const defaults = { spam_interval_min: '30' };
|
||||
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 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);
|
||||
if (rm) {
|
||||
result.photo_capture_width = rm[1];
|
||||
result.photo_capture_height = rm[2];
|
||||
} else {
|
||||
result.photo_capture_width = '640';
|
||||
result.photo_capture_height = '480';
|
||||
}
|
||||
const q = parseFloat(result.photo_capture_quality);
|
||||
result.photo_capture_quality = Number.isFinite(q) && q >= 0.5 && q <= 1 ? String(q) : '0.92';
|
||||
return result;
|
||||
});
|
||||
res.json(out);
|
||||
@@ -970,6 +994,17 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
|
||||
return res.status(400).json({ error: 'spam_interval_min должен быть целым числом от 1 до 10080 (7 дней)' });
|
||||
}
|
||||
}
|
||||
if (key === 'photo_capture_resolution') {
|
||||
if (!/^\d{2,5}x\d{2,5}$/.test(String(value))) {
|
||||
return res.status(400).json({ error: 'photo_capture_resolution должен быть в формате ШИРИНАxВЫСОТА, например 640x480' });
|
||||
}
|
||||
}
|
||||
if (key === 'photo_capture_quality') {
|
||||
const q = parseFloat(String(value));
|
||||
if (!Number.isFinite(q) || q < 0.5 || q > 1) {
|
||||
return res.status(400).json({ error: 'photo_capture_quality должен быть числом от 0.5 до 1' });
|
||||
}
|
||||
}
|
||||
}
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
@@ -3627,6 +3662,40 @@ 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) => {
|
||||
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' });
|
||||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||
}
|
||||
try {
|
||||
const { rows: cur } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [req.params.id]);
|
||||
if (!cur.length) {
|
||||
if (req.file) safeUnlink(req.file.path);
|
||||
return res.status(404).json({ error: 'Запись не найдена' });
|
||||
}
|
||||
const oldPath = cur[0].photo_path;
|
||||
if (!oldPath) {
|
||||
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 });
|
||||
} catch (e) {
|
||||
if (req.file) safeUnlink(req.file.path);
|
||||
console.error('PUT /api/entries/:id/photo/enhance:', e);
|
||||
res.status(500).json({ error: 'Ошибка замены фото' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/entries/:id', requireAuth, async (req, res) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
@@ -3695,11 +3764,18 @@ function validateProfileBody(body) {
|
||||
const base = String(body?.base_url || '').trim().replace(/\/+$/, '');
|
||||
const model = String(body?.model || '').trim();
|
||||
const apiKey = String(body?.api_key || '').trim();
|
||||
let maxTokens = null;
|
||||
if (body?.max_tokens !== null && body?.max_tokens !== undefined && String(body.max_tokens).trim() !== '') {
|
||||
maxTokens = parseInt(String(body.max_tokens), 10);
|
||||
if (!Number.isFinite(maxTokens) || maxTokens < 16 || maxTokens > 32768) {
|
||||
return { error: 'max_tokens должен быть целым числом от 16 до 32768' };
|
||||
}
|
||||
}
|
||||
if (!name || name.length > 100) return { error: 'Укажите название профиля (до 100 символов)' };
|
||||
if (!/^https?:\/\//i.test(base) || base.length > 300) return { error: 'Base URL должен быть корректным http(s)://… (до 300 символов)' };
|
||||
if (!model || model.length > 150) return { error: 'Укажите название модели (до 150 символов)' };
|
||||
if (apiKey.length > 300) return { error: 'API-ключ слишком длинный' };
|
||||
return { name, base_url: base, model, api_key: apiKey };
|
||||
return { name, base_url: base, model, api_key: apiKey, max_tokens: maxTokens };
|
||||
}
|
||||
|
||||
app.get('/api/ai/profiles', requireAdmin, async (_, res) => {
|
||||
|
||||
Reference in New Issue
Block a user