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:
@@ -105,6 +105,7 @@ function gridEntryHTML(e) {
|
||||
<div class="time">${new Date(e.created_at).toLocaleString('ru')}</div>
|
||||
</div>
|
||||
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
|
||||
${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''}
|
||||
<button class="del" data-del="${e.id}" title="Удалить">×</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -127,6 +128,7 @@ function cardEntryHTML(e) {
|
||||
<span>${new Date(e.created_at).toLocaleString('ru')}</span>
|
||||
<span>
|
||||
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
|
||||
${mp ? `<button class="edit" data-enhance="${e.id}" title="Улучшить фото">✦</button>` : ''}
|
||||
<button class="del" data-del="${e.id}" title="Удалить">×</button>
|
||||
</span>
|
||||
</div>
|
||||
@@ -416,6 +418,172 @@ function closeEdit() {
|
||||
editDeletePhoto = false;
|
||||
}
|
||||
|
||||
let enhanceEntryId = null;
|
||||
let enhanceSourceImg = null;
|
||||
let enhanceTimer = null;
|
||||
let enhanceSaving = false;
|
||||
|
||||
function openEnhance(id) {
|
||||
const e = currentEntries.find(x => x.id === id);
|
||||
if (!e) return;
|
||||
const mp = mainPhotoPath(e);
|
||||
if (!mp) { showToast('У записи нет фото'); return; }
|
||||
enhanceEntryId = id;
|
||||
enhanceSourceImg = null;
|
||||
['Brightness', 'Contrast', 'Saturate'].forEach(k => {
|
||||
const el = document.getElementById('enhance' + k);
|
||||
el.value = 100;
|
||||
document.getElementById('enhance' + k + 'Val').textContent = '100%';
|
||||
});
|
||||
const sharp = document.getElementById('enhanceSharp');
|
||||
sharp.value = 0;
|
||||
document.getElementById('enhanceSharpVal').textContent = '0%';
|
||||
const orig = document.getElementById('enhanceOrig');
|
||||
orig.src = `${API}${mp}`;
|
||||
const canvas = document.getElementById('enhanceCanvas');
|
||||
canvas.style.display = 'none';
|
||||
const loading = document.getElementById('enhanceLoading');
|
||||
loading.style.display = '';
|
||||
loading.textContent = 'Загрузка...';
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
enhanceSourceImg = img;
|
||||
renderEnhancePreview();
|
||||
};
|
||||
img.onerror = () => {
|
||||
loading.textContent = 'Не удалось загрузить фото';
|
||||
};
|
||||
img.src = `${API}${mp}`;
|
||||
document.getElementById('enhanceModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeEnhance() {
|
||||
document.getElementById('enhanceModal').classList.remove('open');
|
||||
enhanceEntryId = null;
|
||||
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');
|
||||
const b = document.getElementById('enhanceBrightness').value;
|
||||
const c = document.getElementById('enhanceContrast').value;
|
||||
const s = document.getElementById('enhanceSaturate').value;
|
||||
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);
|
||||
}
|
||||
canvas.style.display = 'block';
|
||||
document.getElementById('enhanceLoading').style.display = 'none';
|
||||
}
|
||||
|
||||
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('enhanceSharpVal').textContent = document.getElementById('enhanceSharp').value + '%';
|
||||
if (enhanceTimer) clearTimeout(enhanceTimer);
|
||||
enhanceTimer = setTimeout(renderEnhancePreview, 120);
|
||||
}
|
||||
|
||||
function autoEnhance() {
|
||||
if (!enhanceSourceImg) return;
|
||||
const img = enhanceSourceImg;
|
||||
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;
|
||||
const hist = new Array(256).fill(0);
|
||||
let total = 0;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
if (d[i + 3] < 16) continue;
|
||||
hist[Math.round(0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2])]++;
|
||||
total++;
|
||||
}
|
||||
const pct = p => {
|
||||
let acc = 0;
|
||||
const target = total * p;
|
||||
for (let v = 0; v < 256; v++) {
|
||||
acc += hist[v];
|
||||
if (acc >= target) return v;
|
||||
}
|
||||
return 255;
|
||||
};
|
||||
let lo = pct(0.005);
|
||||
let hi = pct(0.995);
|
||||
if (hi - lo < 30) {
|
||||
lo = Math.max(0, lo - 15);
|
||||
hi = Math.min(255, hi + 15);
|
||||
}
|
||||
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 saturate = 106;
|
||||
const sharp = 30;
|
||||
document.getElementById('enhanceBrightness').value = brightness;
|
||||
document.getElementById('enhanceContrast').value = contrast;
|
||||
document.getElementById('enhanceSaturate').value = saturate;
|
||||
document.getElementById('enhanceSharp').value = sharp;
|
||||
scheduleEnhancePreview();
|
||||
}
|
||||
|
||||
async function saveEnhance() {
|
||||
if (!enhanceEntryId || !enhanceSourceImg || enhanceSaving) return;
|
||||
const canvas = document.getElementById('enhanceCanvas');
|
||||
enhanceSaving = true;
|
||||
const btn = document.getElementById('enhanceSaveBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Сохранение...';
|
||||
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 || 'Ошибка сохранения');
|
||||
}
|
||||
const savedId = enhanceEntryId;
|
||||
showToast('Фото улучшено и сохранено');
|
||||
closeEnhance();
|
||||
refreshEntryCard(savedId);
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
} finally {
|
||||
enhanceSaving = false;
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Применить';
|
||||
}
|
||||
}
|
||||
|
||||
async function aiCorrect() {
|
||||
if (aiCorrecting) return;
|
||||
const textarea = document.getElementById('editDesc');
|
||||
@@ -730,6 +898,8 @@ document.getElementById('grid').addEventListener('click', e => {
|
||||
if (det) { detachFile(parseInt(det.dataset.detach, 10)); return; }
|
||||
const ed = e.target.closest('[data-edit]');
|
||||
if (ed) { openEdit(parseInt(ed.dataset.edit, 10)); return; }
|
||||
const en = e.target.closest('[data-enhance]');
|
||||
if (en) { openEnhance(parseInt(en.dataset.enhance, 10)); return; }
|
||||
const dl = e.target.closest('[data-del]');
|
||||
if (dl) { delEntry(parseInt(dl.dataset.del, 10)); }
|
||||
});
|
||||
@@ -760,6 +930,14 @@ document.getElementById('editPhotoPreview').addEventListener('click', e => {
|
||||
});
|
||||
|
||||
document.getElementById('aiCorrectBtn').addEventListener('click', aiCorrect);
|
||||
document.getElementById('enhanceCancelBtn').addEventListener('click', closeEnhance);
|
||||
document.getElementById('enhanceAutoBtn').addEventListener('click', autoEnhance);
|
||||
document.getElementById('enhanceSaveBtn').addEventListener('click', saveEnhance);
|
||||
document.getElementById('enhanceModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEnhance(); });
|
||||
document.getElementById('enhanceBrightness').addEventListener('input', scheduleEnhancePreview);
|
||||
document.getElementById('enhanceContrast').addEventListener('input', scheduleEnhancePreview);
|
||||
document.getElementById('enhanceSaturate').addEventListener('input', scheduleEnhancePreview);
|
||||
document.getElementById('enhanceSharp').addEventListener('input', scheduleEnhancePreview);
|
||||
document.getElementById('aiAcceptBtn').addEventListener('click', acceptAiCorrect);
|
||||
document.getElementById('aiRejectBtn').addEventListener('click', closeAiCorrect);
|
||||
document.getElementById('aiCorrectModal').addEventListener('click', e => {
|
||||
|
||||
Reference in New Issue
Block a user