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:
+162
-41
@@ -422,6 +422,18 @@ let enhanceEntryId = null;
|
||||
let enhanceSourceImg = null;
|
||||
let enhanceTimer = null;
|
||||
let enhanceSaving = false;
|
||||
let enhanceEngine = 'auto';
|
||||
let enhanceEngineLoaded = false;
|
||||
|
||||
async function loadEnhanceEngine() {
|
||||
if (enhanceEngineLoaded) return;
|
||||
enhanceEngineLoaded = true;
|
||||
try {
|
||||
const r = await fetch(`${API}/api/public-settings`);
|
||||
const f = await r.json();
|
||||
if (['auto', 'server', 'client'].includes(f.photo_enhance_engine)) enhanceEngine = f.photo_enhance_engine;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function openEnhance(id) {
|
||||
const e = currentEntries.find(x => x.id === id);
|
||||
@@ -463,41 +475,76 @@ function closeEnhance() {
|
||||
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');
|
||||
function medianFilterPass(ctx, w, h) {
|
||||
const data = ctx.getImageData(0, 0, w, h);
|
||||
const src = data.data;
|
||||
const out = new Uint8ClampedArray(src);
|
||||
const row = w * 4;
|
||||
const win = new Array(9);
|
||||
for (let y = 1; y < h - 1; y++) {
|
||||
for (let x = 1; x < w - 1; x++) {
|
||||
const i = y * row + x * 4;
|
||||
for (let ch = 0; ch < 3; ch++) {
|
||||
win[0] = src[i - row - 4 + ch]; win[1] = src[i - row + ch]; win[2] = src[i - row + 4 + ch];
|
||||
win[3] = src[i - 4 + ch]; win[4] = src[i + ch]; win[5] = src[i + 4 + ch];
|
||||
win[6] = src[i + row - 4 + ch]; win[7] = src[i + row + ch]; win[8] = src[i + row + 4 + ch];
|
||||
win.sort((a, b) => a - b);
|
||||
out[i + ch] = win[4];
|
||||
}
|
||||
}
|
||||
}
|
||||
data.data.set(out);
|
||||
ctx.putImageData(data, 0, 0);
|
||||
}
|
||||
|
||||
function sharpenPass(ctx, w, h, sharp) {
|
||||
const data = ctx.getImageData(0, 0, w, h);
|
||||
const src = data.data;
|
||||
const out = new Uint8ClampedArray(src);
|
||||
const row = w * 4;
|
||||
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 * row + 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 - row + ch] - a * src[i + row + ch];
|
||||
out[i + ch] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
data.data.set(out);
|
||||
ctx.putImageData(data, 0, 0);
|
||||
}
|
||||
|
||||
function drawEnhanced(ctx, img, w, h) {
|
||||
const b = document.getElementById('enhanceBrightness').value;
|
||||
const c = document.getElementById('enhanceContrast').value;
|
||||
const s = document.getElementById('enhanceSaturate').value;
|
||||
const dn = parseInt(document.getElementById('enhanceDenoise').value, 10);
|
||||
const sp = parseInt(document.getElementById('enhanceSharp').value, 10);
|
||||
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);
|
||||
if (dn > 0) {
|
||||
medianFilterPass(ctx, w, h);
|
||||
if (dn > 70) medianFilterPass(ctx, w, h);
|
||||
}
|
||||
if (sp > 0) sharpenPass(ctx, w, h, sp);
|
||||
}
|
||||
|
||||
function renderEnhancePreview() {
|
||||
if (!enhanceSourceImg) return;
|
||||
const img = enhanceSourceImg;
|
||||
const scale = Math.min(1, 1600 / img.naturalWidth);
|
||||
const w = Math.max(1, Math.round(img.naturalWidth * scale));
|
||||
const h = Math.max(1, Math.round(img.naturalHeight * scale));
|
||||
const canvas = document.getElementById('enhanceCanvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
drawEnhanced(ctx, img, w, h);
|
||||
canvas.style.display = 'block';
|
||||
document.getElementById('enhanceLoading').style.display = 'none';
|
||||
}
|
||||
@@ -506,11 +553,46 @@ 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('enhanceDenoiseVal').textContent = document.getElementById('enhanceDenoise').value + '%';
|
||||
document.getElementById('enhanceSharpVal').textContent = document.getElementById('enhanceSharp').value + '%';
|
||||
if (enhanceTimer) clearTimeout(enhanceTimer);
|
||||
enhanceTimer = setTimeout(renderEnhancePreview, 120);
|
||||
}
|
||||
|
||||
function enhanceParams() {
|
||||
return {
|
||||
brightness: parseInt(document.getElementById('enhanceBrightness').value, 10),
|
||||
contrast: parseInt(document.getElementById('enhanceContrast').value, 10),
|
||||
saturate: parseInt(document.getElementById('enhanceSaturate').value, 10),
|
||||
denoise: parseInt(document.getElementById('enhanceDenoise').value, 10),
|
||||
sharp: parseInt(document.getElementById('enhanceSharp').value, 10),
|
||||
};
|
||||
}
|
||||
|
||||
function estimateNoise(img) {
|
||||
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;
|
||||
let sum = 0;
|
||||
let n = 0;
|
||||
for (let y = 0; y < ah - 1; y++) {
|
||||
for (let x = 0; x < aw - 1; x++) {
|
||||
const i = (y * aw + x) * 4;
|
||||
const l = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
|
||||
const lr = 0.299 * d[i + 4] + 0.587 * d[i + 5] + 0.114 * d[i + 6];
|
||||
const lb = 0.299 * d[i + aw * 4] + 0.587 * d[i + aw * 4 + 1] + 0.114 * d[i + aw * 4 + 2];
|
||||
sum += Math.abs(l - lr) + Math.abs(l - lb);
|
||||
n += 2;
|
||||
}
|
||||
}
|
||||
return n ? sum / n : 0;
|
||||
}
|
||||
|
||||
function autoEnhance() {
|
||||
if (!enhanceSourceImg) return;
|
||||
const img = enhanceSourceImg;
|
||||
@@ -546,35 +628,73 @@ function autoEnhance() {
|
||||
}
|
||||
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 noise = estimateNoise(img);
|
||||
let denoise = 0;
|
||||
let sharp = 30;
|
||||
if (noise > 6) {
|
||||
denoise = Math.min(50, Math.round(noise * 6));
|
||||
sharp = 15;
|
||||
}
|
||||
const saturate = 106;
|
||||
const sharp = 30;
|
||||
document.getElementById('enhanceBrightness').value = brightness;
|
||||
document.getElementById('enhanceContrast').value = contrast;
|
||||
document.getElementById('enhanceSaturate').value = saturate;
|
||||
document.getElementById('enhanceDenoise').value = denoise;
|
||||
document.getElementById('enhanceSharp').value = sharp;
|
||||
scheduleEnhancePreview();
|
||||
}
|
||||
|
||||
async function saveEnhance() {
|
||||
if (!enhanceEntryId || !enhanceSourceImg || enhanceSaving) return;
|
||||
const canvas = document.getElementById('enhanceCanvas');
|
||||
enhanceSaving = true;
|
||||
await loadEnhanceEngine();
|
||||
const btn = document.getElementById('enhanceSaveBtn');
|
||||
enhanceSaving = true;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Сохранение...';
|
||||
const entryId = enhanceEntryId;
|
||||
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 || 'Ошибка сохранения');
|
||||
let done = false;
|
||||
let engineLabel = 'клиент';
|
||||
if (enhanceEngine !== 'client') {
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 8000);
|
||||
const res = await fetch(`${API}/api/entries/${entryId}/photo/enhance`, {
|
||||
method: 'PUT',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ params: enhanceParams() }),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Ошибка сервера');
|
||||
}
|
||||
engineLabel = 'сервер';
|
||||
done = true;
|
||||
} catch (e) {
|
||||
if (enhanceEngine === 'server') throw e;
|
||||
}
|
||||
}
|
||||
const savedId = enhanceEntryId;
|
||||
showToast('Фото улучшено и сохранено');
|
||||
if (!done) {
|
||||
const img = enhanceSourceImg;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
drawEnhanced(ctx, img, canvas.width, canvas.height);
|
||||
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/${entryId}/photo/enhance`, { method: 'PUT', headers: hdr(), body: fd });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Ошибка сохранения');
|
||||
}
|
||||
}
|
||||
showToast(`Фото улучшено (${engineLabel}) и сохранено`);
|
||||
closeEnhance();
|
||||
refreshEntryCard(savedId);
|
||||
refreshEntryCard(entryId);
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
} finally {
|
||||
@@ -937,6 +1057,7 @@ document.getElementById('enhanceModal').addEventListener('click', e => { if (e.t
|
||||
document.getElementById('enhanceBrightness').addEventListener('input', scheduleEnhancePreview);
|
||||
document.getElementById('enhanceContrast').addEventListener('input', scheduleEnhancePreview);
|
||||
document.getElementById('enhanceSaturate').addEventListener('input', scheduleEnhancePreview);
|
||||
document.getElementById('enhanceDenoise').addEventListener('input', scheduleEnhancePreview);
|
||||
document.getElementById('enhanceSharp').addEventListener('input', scheduleEnhancePreview);
|
||||
document.getElementById('aiAcceptBtn').addEventListener('click', acceptAiCorrect);
|
||||
document.getElementById('aiRejectBtn').addEventListener('click', closeAiCorrect);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality'];
|
||||
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine'];
|
||||
const SECTION_IDS = ['sec-system', 'sec-antispam', 'sec-share', 'sec-form', 'sec-ai', 'sec-backup', 'sec-bans'];
|
||||
const SPAM_MAX = 10080;
|
||||
const BAN_REASONS = {
|
||||
@@ -157,6 +157,9 @@ async function loadSettings() {
|
||||
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';
|
||||
const pe = document.getElementById('photoEngine');
|
||||
pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto';
|
||||
if (!pe.value) pe.value = 'auto';
|
||||
snapshotDirty();
|
||||
updateSpamUi();
|
||||
loadAiQueue();
|
||||
@@ -496,6 +499,7 @@ async function saveSettings() {
|
||||
cookie_notice_text: document.getElementById('cookieNoticeText').value.trim(),
|
||||
photo_capture_resolution: document.getElementById('photoResolution').value,
|
||||
photo_capture_quality: String(document.getElementById('photoQuality').value),
|
||||
photo_enhance_engine: document.getElementById('photoEngine').value,
|
||||
} })
|
||||
});
|
||||
if (res.ok) {
|
||||
|
||||
Reference in New Issue
Block a user