Build and push server image / build-and-push (push) Failing after 10s
Two features, both toggleable/settable from the web config UI:
Refresh interval: new GET /frame/config returns
{"refresh_interval_s": ...} as plain JSON. Reuses the endpoint the frame
already needs to hit for a reachability check each wake cycle (previously
/health) rather than adding a third round trip, and always returns 200
with current settings regardless of Immich-configured state so it stays
valid as a pure reachability signal. Clamped to [60, 86400] seconds in
POST /api/config.
Face-aware cropping: GET /api/faces?id={assetId} on Immich already
returns real per-photo face bounding boxes from its own People-feature
ML -- confirmed against a live instance, boxes scaled to the asset's
native resolution. No face detection built or bundled here at all, just
an API call plus rectangle math. image_pipeline.render_frame() gains an
optional `faces` param: when present, computes the largest crop window
matching the panel's aspect ratio that fits in the source image, centered
on the union of all face boxes' centroid (scaled into the downloaded
preview's actual resolution) instead of the image's geometric center,
clamped to stay within bounds. No faces (or the smart_crop_faces config
toggle off) falls straight back to the existing ImageOps.fit() center-crop
-- zero behavior change in that case. A faces-lookup failure logs and
degrades to center-crop rather than failing the whole request.
Verified: unit tests for the crop-box math (horizontal shift toward an
off-center face, edge clamping), a full mock-Immich end-to-end pass
(extended to serve /faces) confirming the toggle changes output and the
response is still exactly 192,000 bytes, and a live comparison against a
real 4-face photo on the user's Immich instance (crop top shifted from
528px to 246px toward the detected faces).
123 lines
4.9 KiB
HTML
123 lines
4.9 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>ESPresso Frame</title>
|
|
<style>
|
|
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 40px auto; padding: 0 16px; color: #222; }
|
|
h1 { font-size: 20px; }
|
|
p.sub { color: #666; font-size: 14px; margin-top: -8px; }
|
|
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; }
|
|
input, select { width: 100%; padding: 8px; box-sizing: border-box; margin-top: 4px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; }
|
|
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
|
|
.checkbox-row input { width: auto; margin-top: 0; }
|
|
.checkbox-row label { margin-top: 0; font-weight: normal; }
|
|
button { margin-top: 20px; padding: 10px 16px; border: none; border-radius: 4px; background: #2563eb; color: white; cursor: pointer; font-size: 14px; }
|
|
button:hover { background: #1d4ed8; }
|
|
button.secondary { background: #6b7280; margin-right: 8px; }
|
|
.status { margin-top: 16px; padding: 10px; border-radius: 4px; font-size: 14px; }
|
|
.status.ok { background: #dcfce7; color: #166534; }
|
|
.status.err { background: #fee2e2; color: #991b1b; }
|
|
code { background: #f3f4f6; padding: 2px 5px; border-radius: 3px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>ESPresso Frame</h1>
|
|
<p class="sub">Point your frame's "Tools Server" field at this server's <code>host:port</code>.</p>
|
|
|
|
<form id="config-form">
|
|
<label>Immich URL
|
|
<input type="text" id="immich_url" value="{{ cfg.immich_url }}" placeholder="http://192.168.1.10:2283" required>
|
|
</label>
|
|
<label>Immich API Key
|
|
<input type="password" id="immich_api_key" value="{{ cfg.immich_api_key }}" required>
|
|
</label>
|
|
<label>Album
|
|
<select id="album_id">
|
|
{% if cfg.album_id %}<option value="{{ cfg.album_id }}" selected>(current selection -- reload to rename)</option>{% endif %}
|
|
</select>
|
|
</label>
|
|
<label>Order
|
|
<select id="order">
|
|
<option value="sequential" {% if cfg.order == "sequential" %}selected{% endif %}>Sequential</option>
|
|
<option value="shuffle" {% if cfg.order == "shuffle" %}selected{% endif %}>Shuffle</option>
|
|
</select>
|
|
</label>
|
|
<label>Refresh interval (minutes)
|
|
<input type="number" id="refresh_interval_minutes" min="1" max="1440"
|
|
value="{{ (cfg.refresh_interval_s // 60) or 60 }}" required>
|
|
</label>
|
|
<div class="checkbox-row">
|
|
<input type="checkbox" id="smart_crop_faces" {% if cfg.smart_crop_faces %}checked{% endif %}>
|
|
<label for="smart_crop_faces">Center faces in crop</label>
|
|
</div>
|
|
<button type="button" class="secondary" id="load-albums">Load Albums</button>
|
|
<button type="submit">Save</button>
|
|
</form>
|
|
<div id="result"></div>
|
|
|
|
<script>
|
|
const resultEl = document.getElementById('result');
|
|
|
|
function showStatus(ok, message) {
|
|
resultEl.innerHTML = `<div class="status ${ok ? 'ok' : 'err'}">${message}</div>`;
|
|
}
|
|
|
|
async function saveConfig() {
|
|
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
|
|
const body = new URLSearchParams({
|
|
immich_url: document.getElementById('immich_url').value,
|
|
immich_api_key: document.getElementById('immich_api_key').value,
|
|
album_id: document.getElementById('album_id').value || '',
|
|
order: document.getElementById('order').value,
|
|
refresh_interval_s: String(minutes * 60),
|
|
smart_crop_faces: String(document.getElementById('smart_crop_faces').checked),
|
|
});
|
|
const resp = await fetch('/api/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body,
|
|
});
|
|
if (!resp.ok) {
|
|
throw new Error(await resp.text());
|
|
}
|
|
}
|
|
|
|
document.getElementById('load-albums').addEventListener('click', async () => {
|
|
try {
|
|
await saveConfig();
|
|
|
|
const resp = await fetch('/api/albums');
|
|
if (!resp.ok) {
|
|
throw new Error(await resp.text());
|
|
}
|
|
const albums = await resp.json();
|
|
|
|
const select = document.getElementById('album_id');
|
|
select.innerHTML = '';
|
|
for (const a of albums) {
|
|
const opt = document.createElement('option');
|
|
opt.value = a.id;
|
|
opt.textContent = `${a.name} (${a.count})`;
|
|
select.appendChild(opt);
|
|
}
|
|
showStatus(true, `Loaded ${albums.length} album(s) -- pick one and click Save.`);
|
|
} catch (e) {
|
|
showStatus(false, e.message);
|
|
}
|
|
});
|
|
|
|
document.getElementById('config-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
try {
|
|
await saveConfig();
|
|
showStatus(true, 'Saved.');
|
|
} catch (e) {
|
|
showStatus(false, e.message);
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|