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:
dev
2026-09-17 15:07:43 +03:00
parent 57e48a9d4f
commit 049df61e55
7 changed files with 354 additions and 11 deletions
+11 -2
View File
@@ -21,6 +21,9 @@ const sentText = document.getElementById('sentText');
const sentTimer = document.getElementById('sentTimer');
const SENT_KEY = 'whatido_entry_sent_at';
let entryCooldownMin = 30;
let captureWidth = 640;
let captureHeight = 480;
let captureQuality = 0.92;
let sentTimerId = null;
function unlockForm() {
@@ -156,6 +159,12 @@ if (params.get('nameInput')) {
if (f.footer_right) document.getElementById('footerRight').textContent = f.footer_right;
const c = parseInt(f.spam_interval_min, 10);
if (Number.isFinite(c) && c >= 1) entryCooldownMin = c;
const w = parseInt(f.photo_capture_width, 10);
const h = parseInt(f.photo_capture_height, 10);
if (Number.isFinite(w) && w >= 160 && w <= 4096) captureWidth = w;
if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h;
const q = parseFloat(f.photo_capture_quality);
if (Number.isFinite(q) && q >= 0.5 && q <= 1) captureQuality = q;
} catch (e) { /* ignore */ }
checkPreviousSend();
})();
@@ -175,7 +184,7 @@ function removePhoto() {
async function openCam() {
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } } });
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: captureWidth }, height: { ideal: captureHeight } } });
camVideo.srcObject = stream;
camModal.classList.add('open');
} catch (e) {
@@ -199,7 +208,7 @@ function capture() {
capturedBlob = blob;
showPreview(URL.createObjectURL(blob));
closeCam();
}, 'image/jpeg', 0.85);
}, 'image/jpeg', captureQuality);
}
form.addEventListener('submit', async (e) => {
+178
View File
@@ -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="Удалить">&times;</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="Удалить">&times;</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 => {
+13 -3
View File
@@ -1,4 +1,4 @@
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText'];
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality'];
const SECTION_IDS = ['sec-system', 'sec-antispam', 'sec-share', 'sec-form', 'sec-ai', 'sec-backup', 'sec-bans'];
const SPAM_MAX = 10080;
const BAN_REASONS = {
@@ -81,7 +81,9 @@ function updateDirty() {
function bindDirty() {
DIRTY_FIELDS.forEach(id => {
document.getElementById(id).addEventListener('input', updateDirty);
const el = document.getElementById(id);
el.addEventListener('input', updateDirty);
el.addEventListener('change', updateDirty);
});
}
@@ -151,6 +153,10 @@ async function loadSettings() {
document.getElementById('shareShowNames').checked = s.share_show_student_names !== 'false';
document.getElementById('shareShowPhotos').checked = s.share_show_group_photos !== 'false';
document.getElementById('cookieNoticeText').value = s.cookie_notice_text || '';
const pr = document.getElementById('photoResolution');
if (s.photo_capture_resolution) pr.value = s.photo_capture_resolution;
if (!pr.value) pr.value = '640x480';
document.getElementById('photoQuality').value = s.photo_capture_quality !== undefined && s.photo_capture_quality !== '' ? s.photo_capture_quality : '0.92';
snapshotDirty();
updateSpamUi();
loadAiQueue();
@@ -274,7 +280,7 @@ async function loadAiProfiles() {
<div class="ai-profile-item${data.active === p.id ? ' active' : ''}">
<div class="pi-info">
<div class="pi-name">${esc(p.name)}${data.active === p.id ? ' <span class="pi-badge">по умолчанию</span>' : ''}</div>
<div class="pi-meta">${esc(p.model)} · ${esc(p.base_url)}</div>
<div class="pi-meta">${esc(p.model)} · ${esc(p.base_url)}${p.max_tokens ? ` · max_tokens: ${esc(p.max_tokens)}` : ''}</div>
</div>
<div class="pi-actions">
${p.native ? '' : `<button type="button" class="pi-btn" data-edit-profile="${esc(p.id)}">Изменить</button>`}
@@ -295,6 +301,7 @@ function openAiProfileForm(profile) {
document.getElementById('aiProfileModel').value = profile ? profile.model : '';
document.getElementById('aiProfileBaseUrl').value = profile ? profile.base_url : '';
document.getElementById('aiProfileApiKey').value = '';
document.getElementById('aiProfileMaxTokens').value = profile && profile.max_tokens ? profile.max_tokens : '';
document.getElementById('aiProfileTestStatus').textContent = '';
document.getElementById('aiProfileName').focus();
}
@@ -310,6 +317,7 @@ function aiProfileFormData() {
model: document.getElementById('aiProfileModel').value,
base_url: document.getElementById('aiProfileBaseUrl').value,
api_key: document.getElementById('aiProfileApiKey').value,
max_tokens: document.getElementById('aiProfileMaxTokens').value.trim() === '' ? null : document.getElementById('aiProfileMaxTokens').value,
};
}
@@ -486,6 +494,8 @@ async function saveSettings() {
share_show_student_names: document.getElementById('shareShowNames').checked ? 'true' : 'false',
share_show_group_photos: document.getElementById('shareShowPhotos').checked ? 'true' : 'false',
cookie_notice_text: document.getElementById('cookieNoticeText').value.trim(),
photo_capture_resolution: document.getElementById('photoResolution').value,
photo_capture_quality: String(document.getElementById('photoQuality').value),
} })
});
if (res.ok) {