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.
208 lines
11 KiB
HTML
208 lines
11 KiB
HTML
{% extends "app_base.html" %}
|
|
|
|
{% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %}
|
|
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
|
|
|
|
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
|
|
|
{% block content %}
|
|
<div id="control-banner" class="control-banner" style="display: none;">
|
|
<span id="control-holder"></span>
|
|
<button type="button" id="take-control" class="btn-inline">Take control</button>
|
|
</div>
|
|
|
|
<div class="layout">
|
|
<div class="main-col">
|
|
<section class="card">
|
|
<h2 class="card-title">Display settings</h2>
|
|
<form id="config-form">
|
|
<label>Frame name
|
|
<input type="text" id="frame_name" maxlength="64" value="{{ frame.name }}">
|
|
</label>
|
|
<label>Order
|
|
<select id="order">
|
|
<option value="sequential" {% if frame.order == "sequential" %}selected{% endif %}>Sequential</option>
|
|
<option value="shuffle" {% if frame.order == "shuffle" %}selected{% endif %}>Shuffle</option>
|
|
</select>
|
|
</label>
|
|
<label>Orientation
|
|
<select id="orientation">
|
|
<option value="landscape" {% if frame.orientation == "landscape" %}selected{% endif %}>Landscape</option>
|
|
<option value="portrait" {% if frame.orientation == "portrait" %}selected{% endif %}>Portrait</option>
|
|
<option value="landscape_flipped" {% if frame.orientation == "landscape_flipped" %}selected{% endif %}>Landscape (flipped)</option>
|
|
<option value="portrait_flipped" {% if frame.orientation == "portrait_flipped" %}selected{% endif %}>Portrait (flipped)</option>
|
|
</select>
|
|
</label>
|
|
<label>Refresh interval (minutes)
|
|
<input type="number" id="refresh_interval_minutes" min="1" max="1440"
|
|
value="{{ (frame.refresh_interval_s // 60) or 60 }}" required>
|
|
</label>
|
|
<label>Display mode
|
|
<select id="display_mode">
|
|
{% for mode, label in display_mode_labels.items() %}
|
|
<option value="{{ mode }}" {% if frame.display_mode == mode %}selected{% endif %}>{{ label }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</label>
|
|
<p class="sub" style="margin-top: 8px;">How a photo's aspect ratio
|
|
is reconciled with the panel's: <strong>Crop to fill</strong>
|
|
trims the excess; <strong>Crop to faces</strong> does the same
|
|
but shifts the crop to keep people on screen; <strong>Stretch to
|
|
fill</strong> fills the panel exactly without cropping (photos
|
|
not matching the panel's aspect ratio look stretched);
|
|
<strong>Shrink to fit</strong> shows the whole photo, letterboxed
|
|
if needed.</p>
|
|
<div class="checkbox-row">
|
|
<input type="checkbox" id="quiet_hours_enabled" {% if frame.quiet_hours_enabled %}checked{% endif %}>
|
|
<label for="quiet_hours_enabled">Quiet hours (don't wake overnight)</label>
|
|
</div>
|
|
<label>Quiet hours start
|
|
<input type="time" id="quiet_hours_start" value="{{ frame.quiet_hours_start }}">
|
|
</label>
|
|
<label>Quiet hours end
|
|
<input type="time" id="quiet_hours_end" value="{{ frame.quiet_hours_end }}">
|
|
</label>
|
|
<label>Timezone
|
|
<select id="timezone">
|
|
{% for tz in timezones %}
|
|
<option value="{{ tz }}" {% if tz == frame.timezone %}selected{% endif %}>{{ tz }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</label>
|
|
<p class="sub" style="margin-top: 8px;">Quiet hours times are
|
|
interpreted in this timezone. The device may still wake once right
|
|
at the start of quiet hours -- it can't know ahead of time -- but
|
|
goes right back to sleep until they end.</p>
|
|
<button type="submit">Save</button>
|
|
</form>
|
|
</section>
|
|
</div>
|
|
|
|
<div class="side-col">
|
|
<section class="card">
|
|
<h2 class="card-title">Firmware update</h2>
|
|
<p class="sub" id="firmware-board">
|
|
{% if frame.device_board_variant %}Detected board: {{ frame.device_board_variant }}
|
|
{% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %}
|
|
</p>
|
|
<p class="sub" id="firmware-available">
|
|
{% if frame.firmware_available_version %}Uploaded: v{{ frame.firmware_available_version }} -- the frame
|
|
updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %}
|
|
</p>
|
|
<input type="file" id="firmware-file" accept=".bin">
|
|
<button type="button" class="secondary" id="firmware-upload">Upload firmware</button>
|
|
|
|
<div id="firmware-repo-display" style="margin-top: 16px; {% if not frame.firmware_update_repo_url %}display: none;{% endif %}">
|
|
<p class="sub">Gitea repo: <code id="firmware-repo-text">{{ frame.firmware_update_repo_url }}</code>
|
|
<button type="button" class="secondary btn-inline" id="firmware-repo-edit-btn">Edit</button>
|
|
</p>
|
|
</div>
|
|
<label id="firmware-repo-edit" style="{% if frame.firmware_update_repo_url %}display: none;{% endif %}">Gitea repo URL
|
|
<input type="text" id="firmware_update_repo_url" placeholder="https://git.example.com/owner/repo"
|
|
value="{{ frame.firmware_update_repo_url }}">
|
|
</label>
|
|
<div class="checkbox-row">
|
|
<input type="checkbox" id="firmware_auto_update" {% if frame.firmware_auto_update %}checked{% endif %}>
|
|
<label for="firmware_auto_update">Automatically apply updates</label>
|
|
</div>
|
|
<button type="button" class="secondary" id="firmware-settings-save">Save</button>
|
|
<p class="sub" id="firmware-gitea-status" style="display: none; margin-top: 10px;"></p>
|
|
<button type="button" class="secondary" id="firmware-check-now">Check now</button>
|
|
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<h2 class="card-title">Battery alerts</h2>
|
|
<label>Email me when battery drops below (%)
|
|
<input type="number" id="battery_alert_threshold_pct" min="0" max="100"
|
|
value="{% if frame.battery_alert_threshold_pct >= 0 %}{{ frame.battery_alert_threshold_pct }}{% endif %}"
|
|
placeholder="disabled">
|
|
</label>
|
|
<p class="sub" style="margin-top: 8px;">Sent once per discharge cycle
|
|
to the frame owner's email (set in Settings) -- clear the field to
|
|
disable. Needs SMTP configured by an admin.</p>
|
|
<button type="button" class="secondary" id="battery-alert-save">Save</button>
|
|
</section>
|
|
|
|
<details class="card">
|
|
<summary class="card-title">Advanced configuration</summary>
|
|
<p class="sub">Color-quantization values used when dithering photos
|
|
for this panel -- approximations by default, since exact primaries
|
|
aren't published. Tune them by comparing a rendered photo against
|
|
the physical panel; different panel units can vary enough to be
|
|
worth calibrating per frame.</p>
|
|
<div class="palette-table-wrap">
|
|
<table class="palette-table">
|
|
<thead><tr><th></th><th>Color</th><th>Hex</th><th>R</th><th>G</th><th>B</th></tr></thead>
|
|
<tbody>
|
|
{% set current_palette = frame.palette_rgb or default_palette_rgb %}
|
|
{% set current_hex = palette_to_hex(current_palette) %}
|
|
{% for label in palette_labels %}
|
|
<tr>
|
|
<td><span class="palette-swatch-preview" data-index="{{ loop.index0 }}"
|
|
style="background: {{ current_hex[loop.index0] }};"></span></td>
|
|
<td>{{ label }}</td>
|
|
<td><input type="text" class="palette-hex" id="palette_{{ loop.index0 }}" data-index="{{ loop.index0 }}"
|
|
value="{{ current_hex[loop.index0] }}" maxlength="7" pattern="#[0-9a-fA-F]{6}"
|
|
spellcheck="false" autocomplete="off"></td>
|
|
<td><input type="number" class="palette-rgb palette-r" data-index="{{ loop.index0 }}"
|
|
min="0" max="255" value="{{ current_palette[loop.index0][0] }}"></td>
|
|
<td><input type="number" class="palette-rgb palette-g" data-index="{{ loop.index0 }}"
|
|
min="0" max="255" value="{{ current_palette[loop.index0][1] }}"></td>
|
|
<td><input type="number" class="palette-rgb palette-b" data-index="{{ loop.index0 }}"
|
|
min="0" max="255" value="{{ current_palette[loop.index0][2] }}"></td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<h2 class="card-title" style="margin-top: 20px;">Image adjustments</h2>
|
|
<label>Color enhancement <span class="slider-value" id="color_boost_value">{{ "%.2f" | format(frame.color_boost) }}</span>
|
|
<input type="range" id="color_boost" min="0" max="2" step="0.05" value="{{ frame.color_boost }}">
|
|
</label>
|
|
<label>Contrast <span class="slider-value" id="contrast_boost_value">{{ "%.2f" | format(frame.contrast_boost) }}</span>
|
|
<input type="range" id="contrast_boost" min="0" max="2" step="0.05" value="{{ frame.contrast_boost }}">
|
|
</label>
|
|
<label>Dithering strength <span class="slider-value" id="dither_strength_value">{{ "%.2f" | format(frame.dither_strength) }}</span>
|
|
<input type="range" id="dither_strength" min="0" max="1" step="0.05" value="{{ frame.dither_strength }}">
|
|
</label>
|
|
<p class="sub" style="margin-top: 8px;">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.</p>
|
|
|
|
<button type="button" class="secondary" id="palette-save" style="margin-top: 16px;">Save</button>
|
|
<button type="button" class="secondary" id="palette-reset">Reset to defaults</button>
|
|
</details>
|
|
|
|
<section class="card">
|
|
<h2 class="card-title">Preview</h2>
|
|
<p class="sub">The current photo, and exactly how it renders on the
|
|
panel with this frame's saved settings above.</p>
|
|
<div class="preview-compare">
|
|
<div class="preview-pane">
|
|
<p class="sub">Now displaying</p>
|
|
<img class="preview-img" id="preview-original" alt="Original photo">
|
|
</div>
|
|
<div class="preview-pane">
|
|
<p class="sub">How it will look on the frame</p>
|
|
<img class="preview-img" id="preview-rendered" alt="Rendered preview">
|
|
</div>
|
|
</div>
|
|
<button type="button" class="secondary" id="preview-refresh">Refresh preview</button>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="result"></div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
|
|
window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }};
|
|
</script>
|
|
<script src="/static/frame_config.js"></script>
|
|
{% endblock %}
|