From e48ac50ea19156c2036ec9df628d2d93b9f62857 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Wed, 22 Jul 2026 08:45:42 -0400 Subject: [PATCH] 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. --- server/README.md | 24 +++-- server/app/image_pipeline.py | 131 ++++++++++++++++++------- server/app/migration.py | 11 +++ server/app/models.py | 7 ++ server/app/routers/api_frames.py | 65 +++++++++++- server/app/routers/common.py | 16 ++- server/app/static/frame_config.js | 42 ++++++-- server/app/static/theme.css | 29 ++++++ server/app/templates/frame_config.html | 35 ++++++- 9 files changed, 303 insertions(+), 57 deletions(-) diff --git a/server/README.md b/server/README.md index 2fd56f4..3f3a633 100644 --- a/server/README.md +++ b/server/README.md @@ -147,16 +147,26 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`, - `GET .../albums` -- the owner's Immich albums. - `POST .../config` -- **partial** update: only provided fields change (`name`, `album_id` -- resets queue/history on change --, `order`, - `refresh_interval_s`, `smart_crop_faces`, `queue_target_len`, - `orientation` (composed logically then rotated server-side; the - on-device manage overlay still renders native, a known limitation), - `quiet_hours_*` + `timezone` (a pure server-side decision shaping - what `refresh_interval_s` gets handed to the device), - `firmware_update_repo_url`, `firmware_auto_update`, + `refresh_interval_s`, `display_mode` (`crop_fill`/`crop_faces`/ + `stretch_fill`/`letterbox`, see `image_pipeline.DISPLAY_MODES`), + `queue_target_len`, `orientation` (composed logically then rotated + server-side; the on-device manage overlay still renders native, a + known limitation), `quiet_hours_*` + `timezone` (a pure server-side + decision shaping what `refresh_interval_s` gets handed to the + device), `firmware_update_repo_url`, `firmware_auto_update`, `battery_alert_threshold_pct` -- percent, or `-1`/blank to disable --, `palette` -- exactly 6 `#rrggbb` values in black/white/yellow/red/ blue/green order --, `palette_reset` -- `true` clears back to the - default palette). + default palette --, `color_boost`/`contrast_boost` -- PIL + `ImageEnhance` factors, 0-2, 1 = unchanged --, `dither_strength` -- + 0-1, blends toward a flat/undithered quantization before running + Floyd-Steinberg, so 0 = no dithering texture and 1 = full strength). +- `GET .../preview/original`, `GET .../preview/rendered` -- the + before/after comparison on the Configuration tab: the current + photo's Immich preview untouched (JPEG), and that same photo run + through this frame's actual saved rendering pipeline (PNG, upright + logical orientation, not packed device bytes) -- reflects saved + settings, not unsaved slider positions. - `POST .../take-control` -- always succeeds for a linked user. - `GET .../stats`, `GET .../battery-log`, `GET .../thumbnail/{asset_id}`. - `POST .../firmware` (manual .bin upload, esp_app_desc_t-validated), diff --git a/server/app/image_pipeline.py b/server/app/image_pipeline.py index f7044aa..4d49d6d 100644 --- a/server/app/image_pipeline.py +++ b/server/app/image_pipeline.py @@ -2,7 +2,9 @@ from __future__ import annotations -from PIL import Image, ImageOps +import io + +from PIL import Image, ImageEnhance, ImageOps EPD_WIDTH = 800 EPD_HEIGHT = 480 @@ -208,55 +210,59 @@ def _placement_transform( return scale_x, scale_y, -left * scale_x, -top * scale_y -def render_frame(source: Image.Image, faces: list[dict] | None = None, - orientation: str = "landscape", palette_rgb: list | None = None, - display_mode: str = DEFAULT_DISPLAY_MODE) -> bytes: - """Fits `source` to the panel's resolution, quantizes it to the 6-color - palette with Floyd-Steinberg dithering, and packs 2 pixels/byte the way - epd7in3e.c expects. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes. - - `display_mode` (see DISPLAY_MODES) picks how the photo's aspect ratio - is reconciled with the panel's: crop_fill (center-crop to fill, - excess trimmed), crop_faces (as crop_fill, but shifts the crop to - keep `faces` on screen -- falls back to crop_fill if none), stretch_fill - (fills exactly, aspect ratio not preserved), letterbox (whole photo - visible, letterboxed with LETTERBOX_BG where it doesn't fill). - - `orientation` (see ORIENTATION_TRANSPOSE) composes the photo for how - the frame physically hangs, then rotates into native panel space -- - the output byte layout is identical either way. - - `palette_rgb` overrides DEFAULT_PALETTE_RGB (a frame's tuned colors, - see Frame.palette_rgb) -- None uses the default. - """ +def _compose(source: Image.Image, faces: list[dict] | None, orientation: str, display_mode: str) -> Image.Image: + """Crop/resize/letterbox `source` per display_mode -- returns an RGB + image at logical_render_size(orientation), before enhancement or + quantization. See render_frame for what each display_mode does.""" logical_w, logical_h = logical_render_size(orientation) fitted = ImageOps.exif_transpose(source.convert("RGB")) if display_mode == "stretch_fill": - fitted = fitted.resize((logical_w, logical_h), Image.LANCZOS) - elif display_mode == "letterbox": + return fitted.resize((logical_w, logical_h), Image.LANCZOS) + if display_mode == "letterbox": scale = min(logical_w / fitted.width, logical_h / fitted.height) new_w, new_h = max(1, round(fitted.width * scale)), max(1, round(fitted.height * scale)) resized = fitted.resize((new_w, new_h), Image.LANCZOS) canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG) canvas.paste(resized, ((logical_w - new_w) // 2, (logical_h - new_h) // 2)) - fitted = canvas - elif display_mode == "crop_faces" and faces: + return canvas + if display_mode == "crop_faces" and faces: box = _face_aware_crop_box(fitted.width, fitted.height, logical_w, logical_h, faces) - fitted = fitted.crop(box).resize((logical_w, logical_h), Image.LANCZOS) - else: # crop_fill, or crop_faces with no faces detected - fitted = ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS) - - return _quantize_and_pack(fitted, orientation, palette_rgb) + return fitted.crop(box).resize((logical_w, logical_h), Image.LANCZOS) + return ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS) # crop_fill, or crop_faces w/ no faces -def _quantize_and_pack(logical_img: Image.Image, orientation: str, palette_rgb: list | None = None) -> bytes: - """The shared back half of rendering: 6-color Floyd-Steinberg - quantization, rotation into native panel space, and 2-pixels/byte - packing. Takes an RGB image already composed at logical_render_size() - for the orientation.""" +def _enhance(img: Image.Image, color_boost: float, contrast_boost: float) -> Image.Image: + if color_boost != 1.0: + img = ImageEnhance.Color(img).enhance(color_boost) + if contrast_boost != 1.0: + img = ImageEnhance.Contrast(img).enhance(contrast_boost) + return img + + +def _quantize(img: Image.Image, palette_rgb: list | None, dither_strength: float) -> Image.Image: + """RGB -> palette-quantized P-mode image, same size/orientation as + `img` (no rotation here). dither_strength blends `img` toward its own + flat (undithered) quantization before running Floyd-Steinberg on the + blend: at 0 there's zero quantization error left to diffuse (so the + result IS the flat quantization, no dithering texture at all); at 1 + it's `img` unchanged (full-strength dithering, this project's + original always-on behavior); values between give a smooth continuum + of dithering intensity rather than an on/off toggle.""" palette_image = _build_palette_image(palette_rgb or DEFAULT_PALETTE_RGB) - quantized = logical_img.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG) + if dither_strength >= 1.0: + return img.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG) + if dither_strength <= 0.0: + return img.quantize(palette=palette_image, dither=Image.Dither.NONE) + flat = img.quantize(palette=palette_image, dither=Image.Dither.NONE).convert("RGB") + blended = Image.blend(flat, img, dither_strength) + return blended.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG) + + +def _transpose_and_pack(quantized: Image.Image, orientation: str) -> bytes: + """Rotates a logical-space quantized image into native panel space + and packs it 2 pixels/byte the way epd7in3e.c expects. Always + returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes.""" transpose = ORIENTATION_TRANSPOSE.get(orientation) if transpose is not None: quantized = quantized.transpose(transpose) @@ -274,6 +280,54 @@ def _quantize_and_pack(logical_img: Image.Image, orientation: str, palette_rgb: return bytes(out) +def render_frame(source: Image.Image, faces: list[dict] | None = None, + orientation: str = "landscape", palette_rgb: list | None = None, + display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0, + contrast_boost: float = 1.0, dither_strength: float = 1.0) -> bytes: + """Fits `source` to the panel's resolution, applies color/contrast + enhancement, quantizes it to the 6-color palette, and packs 2 + pixels/byte the way epd7in3e.c expects. Always returns exactly + EPD_WIDTH*EPD_HEIGHT/2 bytes. + + `display_mode` (see DISPLAY_MODES) picks how the photo's aspect ratio + is reconciled with the panel's: crop_fill (center-crop to fill, + excess trimmed), crop_faces (as crop_fill, but shifts the crop to + keep `faces` on screen -- falls back to crop_fill if none), stretch_fill + (fills exactly, aspect ratio not preserved), letterbox (whole photo + visible, letterboxed with LETTERBOX_BG where it doesn't fill). + + `color_boost`/`contrast_boost` are PIL ImageEnhance factors (1.0 = + unchanged, matching PIL's own convention); `dither_strength` is + 0.0-1.0 (see _quantize). + + `orientation` (see ORIENTATION_TRANSPOSE) composes the photo for how + the frame physically hangs, then rotates into native panel space -- + the output byte layout is identical either way. + + `palette_rgb` overrides DEFAULT_PALETTE_RGB (a frame's tuned colors, + see Frame.palette_rgb) -- None uses the default. + """ + fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost) + quantized = _quantize(fitted, palette_rgb, dither_strength) + return _transpose_and_pack(quantized, orientation) + + +def render_preview_png(source: Image.Image, faces: list[dict] | None = None, + orientation: str = "landscape", palette_rgb: list | None = None, + display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0, + contrast_boost: float = 1.0, dither_strength: float = 1.0) -> bytes: + """Identical composition/enhancement/quantization pipeline as + render_frame, but returned as a normal browser-viewable PNG in + logical (upright, as-the-frame-actually-hangs) orientation rather + than packed native-panel bytes and rotation -- what the web UI's + "how it will look on the frame" preview shows.""" + fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost) + quantized = _quantize(fitted, palette_rgb, dither_strength) + buf = io.BytesIO() + quantized.convert("RGB").save(buf, format="PNG") + return buf.getvalue() + + def render_placeholder(lines: list[str], qr_url: str | None = None, orientation: str = "landscape", palette_rgb: list | None = None) -> bytes: """A readable full-panel message (plus an optional QR code) in the @@ -320,4 +374,5 @@ def render_placeholder(lines: list[str], qr_url: str | None = None, if qr_img: img.paste(qr_img, ((logical_w - qr_img.width) // 2, y + 14)) - return _quantize_and_pack(img, orientation, palette_rgb) + quantized = _quantize(img, palette_rgb, dither_strength=1.0) + return _transpose_and_pack(quantized, orientation) diff --git a/server/app/migration.py b/server/app/migration.py index 02aa089..17a130f 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -70,12 +70,23 @@ def _migration_5(conn) -> None: conn.execute(text("ALTER TABLE frames DROP COLUMN smart_crop_faces")) +def _migration_6(conn) -> None: + """Advanced configuration: color/contrast enhancement + dithering + strength (image_pipeline.render_frame). Defaults (1.0/1.0/1.0) + reproduce the exact previous rendering -- no behavior change until a + frame's Configuration tab adjusts one.""" + conn.execute(text("ALTER TABLE frames ADD COLUMN color_boost REAL NOT NULL DEFAULT 1.0")) + conn.execute(text("ALTER TABLE frames ADD COLUMN contrast_boost REAL NOT NULL DEFAULT 1.0")) + conn.execute(text("ALTER TABLE frames ADD COLUMN dither_strength REAL NOT NULL DEFAULT 1.0")) + + MIGRATIONS = [ (1, _migration_1), (2, _migration_2), (3, _migration_3), (4, _migration_4), (5, _migration_5), + (6, _migration_6), ] diff --git a/server/app/models.py b/server/app/models.py index 75113be..a60bc16 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -131,6 +131,13 @@ class Frame(Base): # DEFAULT_PALETTE_RGB for this frame's actual panel. NULL = use the # default -- most frames never touch this. palette_rgb: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) + # Advanced configuration: PIL ImageEnhance factors, 1.0 = unchanged + # (see image_pipeline.render_frame). + color_boost: Mapped[float] = mapped_column(Float, default=1.0) + contrast_boost: Mapped[float] = mapped_column(Float, default=1.0) + # 0.0-1.0, see image_pipeline._quantize -- 1.0 matches this project's + # original always-on full-strength Floyd-Steinberg dithering. + dither_strength: Mapped[float] = mapped_column(Float, default=1.0) # -- state -- current_asset_id: Mapped[str] = mapped_column(String, default="") diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py index c3c46fa..e4c5844 100644 --- a/server/app/routers/api_frames.py +++ b/server/app/routers/api_frames.py @@ -28,12 +28,19 @@ from sqlalchemy.orm import Session from .. import gitea_releases, photo_queue, quiet_hours from ..auth import require_frame_control, require_frame_view, require_user_api from ..db import frame_locked, get_db -from ..image_pipeline import DEFAULT_DISPLAY_MODE, DISPLAY_MODES, PALETTE_LABELS, hex_to_rgb +from ..image_pipeline import ( + DEFAULT_DISPLAY_MODE, + DISPLAY_MODES, + PALETTE_LABELS, + hex_to_rgb, + render_preview_png, +) from ..firmware import firmware_path, parse_app_version from ..models import BatteryLog, Frame from .common import ( OVERDUE_FACTOR, battery_estimate_s, + fetch_source_and_faces, immich_client_for, immich_creds, list_assets, @@ -82,6 +89,9 @@ def api_config_save( battery_alert_threshold_pct: int | None = Form(None), palette: list[str] | None = Form(None), palette_reset: bool | None = Form(None), + color_boost: float | None = Form(None), + contrast_boost: float | None = Form(None), + dither_strength: float | None = Form(None), frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db), ): @@ -136,6 +146,12 @@ def api_config_save( if any(rgb is None for rgb in parsed): raise HTTPException(400, "Palette colors must be #rrggbb hex values") cfg.palette_rgb = [list(rgb) for rgb in parsed] + if color_boost is not None: + cfg.color_boost = max(0.0, min(2.0, color_boost)) + if contrast_boost is not None: + cfg.contrast_boost = max(0.0, min(2.0, contrast_boost)) + if dither_strength is not None: + cfg.dither_strength = max(0.0, min(1.0, dither_strength)) cfg.stats_config_saves += 1 return {"status": "saved"} @@ -316,6 +332,53 @@ def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)): return Response(content=content, media_type=content_type) +def _current_asset_id(frame: Frame, db: Session) -> str: + """Same idempotent get_current() dance /api/frames/{id}/queue uses -- + picks a current photo if none is set yet, otherwise just reads it, + never advances early.""" + require_configured(frame) + client = immich_client_for(frame) + assets = list_assets(client, frame) + with frame_locked(db, frame.id) as cfg: + photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg)) + asset_id = cfg.current_asset_id + if not asset_id: + raise HTTPException(404, "No current photo") + return asset_id + + +@router.get("/api/frames/{frame_id}/preview/original") +def api_preview_original(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): + """The Immich preview image behind the currently-displayed photo, + unprocessed -- the "now displaying" side of the Configuration tab's + before/after comparison.""" + asset_id = _current_asset_id(frame, db) + client = immich_client_for(frame) + try: + jpeg_bytes = client.download_asset_preview(asset_id) + except httpx.HTTPError as e: + raise HTTPException(502, f"Could not download asset from Immich: {e}") from e + return Response(content=jpeg_bytes, media_type="image/jpeg") + + +@router.get("/api/frames/{frame_id}/preview/rendered") +def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): + """The same photo run through this frame's actual saved rendering + pipeline (display mode, palette, color/contrast/dithering) and + exported as a PNG -- the "how it will look on the frame" side of the + comparison. Not a live preview of unsaved slider values; reflects + whatever's currently saved.""" + asset_id = _current_asset_id(frame, db) + client = immich_client_for(frame) + source, faces = fetch_source_and_faces(client, frame, asset_id) + png = render_preview_png( + source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb, + display_mode=frame.display_mode, color_boost=frame.color_boost, + contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength, + ) + return Response(content=png, media_type="image/png") + + @router.post("/api/frames/{frame_id}/firmware") def api_firmware_upload( file: UploadFile = File(...), diff --git a/server/app/routers/common.py b/server/app/routers/common.py index d4cd94a..1fe5de4 100644 --- a/server/app/routers/common.py +++ b/server/app/routers/common.py @@ -69,7 +69,11 @@ def list_assets(client: ImmichClient, frame: Frame) -> list[dict]: return assets -def render_asset(client: ImmichClient, frame: Frame, asset_id: str) -> bytes: +def fetch_source_and_faces(client: ImmichClient, frame: Frame, asset_id: str) -> tuple[Image.Image, list[dict] | None]: + """The shared first half of rendering: download the Immich preview + and (only if display_mode needs it) its detected faces. Used by both + render_asset (device-facing) and the web UI's rendered-preview + endpoint (routers/api_frames.py) so they can't drift apart.""" try: jpeg_bytes = client.download_asset_preview(asset_id) except httpx.HTTPError as e: @@ -84,9 +88,15 @@ def render_asset(client: ImmichClient, frame: Frame, asset_id: str) -> bytes: # all -- just fall back to a plain center-crop this cycle. logger.warning("Could not fetch faces for asset %s: %s", asset_id, e) - source = Image.open(io.BytesIO(jpeg_bytes)) + return Image.open(io.BytesIO(jpeg_bytes)), faces + + +def render_asset(client: ImmichClient, frame: Frame, asset_id: str) -> bytes: + source, faces = fetch_source_and_faces(client, frame, asset_id) return render_frame(source, faces=faces, orientation=frame.orientation, - palette_rgb=frame.palette_rgb, display_mode=frame.display_mode) + palette_rgb=frame.palette_rgb, display_mode=frame.display_mode, + color_boost=frame.color_boost, contrast_boost=frame.contrast_boost, + dither_strength=frame.dither_strength) def battery_estimate_s(frame: Frame) -> int | None: diff --git a/server/app/static/frame_config.js b/server/app/static/frame_config.js index 031c1d5..f29d128 100644 --- a/server/app/static/frame_config.js +++ b/server/app/static/frame_config.js @@ -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 () => { diff --git a/server/app/static/theme.css b/server/app/static/theme.css index e337bae..4210dbe 100644 --- a/server/app/static/theme.css +++ b/server/app/static/theme.css @@ -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); diff --git a/server/app/templates/frame_config.html b/server/app/templates/frame_config.html index 955e061..66a9a2f 100644 --- a/server/app/templates/frame_config.html +++ b/server/app/templates/frame_config.html @@ -156,9 +156,42 @@ - + +

Image adjustments

+ + + +

1.00 is unchanged for color/ + contrast. Dithering strength trades noise texture for smoother + gradients as it goes down; 0 is a flat, un-dithered quantization. + Use the preview below to compare.

+ + + +
+

Preview

+

The current photo, and exactly how it renders on the + panel with this frame's saved settings above.

+
+
+

Now displaying

+ Original photo +
+
+

How it will look on the frame

+ Rendered preview +
+
+ +