Color/contrast/dithering sliders + before/after render preview

Advanced configuration gains three sliders (PIL ImageEnhance factors
for color/contrast, 0-2, 1=unchanged; a 0-1 dithering strength) applied
to every photo this frame renders. Confirmed the parameter conventions
against a similar project (jwchen119/EPF: ImageEnhance.Color/Contrast,
1.0 baseline) before implementing; dithering strength isn't natively
exposed by PIL's quantize(), so it's implemented by blending the source
toward its own flat/undithered quantization before running Floyd-
Steinberg on the blend -- at 0 there's no quantization error left to
diffuse (exactly the flat result), at 1 it's the original unmodified
behavior, with a smooth continuum between rather than dithering being
an on/off toggle.

image_pipeline.py split into composition (_compose), enhancement
(_enhance), quantization (_quantize), and transpose+pack stages so
render_frame (device bytes) and the new render_preview_png (a normal
viewable PNG, upright logical orientation) share the same pipeline
instead of duplicating it. Named-face overlay label math (face_labels.py)
was already routed through the shared _placement_transform, so it
needed no changes for the new params.

Also added the requested before/after comparison: the Configuration
tab's new Preview card shows the current photo's untouched Immich
preview next to that same photo run through the frame's actual saved
rendering pipeline (two new GET endpoints, /preview/original and
/preview/rendered) -- immediate visual feedback for tuning the palette
and these new sliders. "Refresh preview" re-fetches after saving.

Schema migration v6 adds color_boost/contrast_boost/dither_strength,
defaulting to 1.0/1.0/1.0 -- reproduces the exact previous rendering
until a frame's Configuration tab changes one.

Verified against the live-shaped test database: the migration, sliders
persisting and clamping out-of-range input, both preview endpoints
(real JPEG passthrough / real PNG at correct logical size+orientation),
confirmed dither_strength=0 actually changes the rendered bytes vs.
default, and the standing legacy-device curl suite.
This commit is contained in:
2026-07-22 08:46:22 -04:00
parent 83c59af1dd
commit e48ac50ea1
9 changed files with 303 additions and 57 deletions
+35 -7
View File
@@ -119,7 +119,19 @@ for (let i = 0; i < palettePickerCount; i++) {
[f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(i)));
}
async function savePalette(body) {
// Sliders: live numeric readout next to each, no save until the button
// below is clicked.
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
const input = document.getElementById(id);
const readout = document.getElementById(`${id}_value`);
input.addEventListener('input', () => { readout.textContent = Number(input.value).toFixed(2); });
});
async function savePalette(extra) {
const body = new URLSearchParams(extra || {});
for (const input of paletteHexInputs()) {
body.append('palette', input.value);
}
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
@@ -128,17 +140,18 @@ async function savePalette(body) {
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
loadPreview();
} catch (e) {
showStatus(false, e.message);
}
}
document.getElementById('palette-save').addEventListener('click', () => {
const body = new URLSearchParams();
for (const input of paletteHexInputs()) {
body.append('palette', input.value);
}
savePalette(body);
savePalette({
color_boost: document.getElementById('color_boost').value,
contrast_boost: document.getElementById('contrast_boost').value,
dither_strength: document.getElementById('dither_strength').value,
});
});
document.getElementById('palette-reset').addEventListener('click', () => {
@@ -147,9 +160,24 @@ document.getElementById('palette-reset').addEventListener('click', () => {
inputs[i].value = hex;
syncPaletteFromHex(i);
});
savePalette(new URLSearchParams({ palette_reset: 'true' }));
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
document.getElementById(id).value = '1';
document.getElementById(`${id}_value`).textContent = '1.00';
});
savePalette({ palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' });
});
// ---- Preview: current photo vs. how it renders with saved settings ----
function loadPreview() {
const bust = Date.now(); // avoid a stale cached image after settings change
document.getElementById('preview-original').src = `${window.FRAME_API}/preview/original?_=${bust}`;
document.getElementById('preview-rendered').src = `${window.FRAME_API}/preview/rendered?_=${bust}`;
}
document.getElementById('preview-refresh').addEventListener('click', loadPreview);
loadPreview();
// ---- Battery alerts card ----
document.getElementById('battery-alert-save').addEventListener('click', async () => {
+29
View File
@@ -197,6 +197,35 @@ details.card .sub { margin-top: 8px; }
width: 58px;
}
.slider-value {
float: right;
font-weight: 400;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
input[type="range"] {
width: 100%;
margin-top: 8px;
accent-color: var(--accent);
padding: 0;
background: none;
}
.preview-compare {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 14px;
margin-top: 14px;
}
.preview-img {
width: 100%;
display: block;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-alt);
min-height: 100px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);