Replace simulator sliders with WYSIWYG crop tool

The simulator now shows the full source image with a draggable crop
window overlay. Drag the frame over the photo to position the crop —
dimmed regions show what gets cut. The dithered e-paper preview renders
live in the side panel.

Mode toggle: "Fill" (zoom with draggable pan) vs "Fit" (letterbox).
Save persists per-image settings used when the ESP32 fetches the photo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 16:38:55 -05:00
parent dd4f8e950d
commit 75e4d0b786

View File

@@ -393,6 +393,24 @@ def preview_random():
return preview_photo(chosen.name) return preview_photo(chosen.name)
@app.route("/api/photos/<name>/geometry")
def photo_geometry(name: str):
"""Return source image dimensions and the computed crop/fit geometry."""
fname = secure_filename(name)
path = PHOTOS_DIR / fname
if not path.exists():
return jsonify({"error": "not found"}), 404
img = Image.open(path)
lw, lh = get_logical_dimensions()
return jsonify({
"source_width": img.width,
"source_height": img.height,
"logical_width": lw,
"logical_height": lh,
"logical_aspect": lw / lh,
})
@app.route("/simulate/<name>") @app.route("/simulate/<name>")
def simulate_page(name: str): def simulate_page(name: str):
fname = secure_filename(name) fname = secure_filename(name)
@@ -401,8 +419,10 @@ def simulate_page(name: str):
return "", 404 return "", 404
settings = get_image_settings(fname) settings = get_image_settings(fname)
orientation = frame_settings.get("orientation", "landscape") orientation = frame_settings.get("orientation", "landscape")
lw, lh = get_logical_dimensions()
return render_template_string(SIMULATE_UI, photo_name=fname, return render_template_string(SIMULATE_UI, photo_name=fname,
settings=settings, orientation=orientation) settings=settings, orientation=orientation,
logical_w=lw, logical_h=lh)
SIMULATE_UI = """<!DOCTYPE html> SIMULATE_UI = """<!DOCTYPE html>
@@ -410,156 +430,284 @@ SIMULATE_UI = """<!DOCTYPE html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>E-Paper Simulator — {{ photo_name }}</title> <title>{{ photo_name }} — Frame Preview</title>
<style> <style>
:root { --bg: #1a1a1a; --card: #222; --border: #444; --accent: #6c8; } :root { --bg: #111; --card: #1a1a1a; --border: #333; --accent: #6c8; --dim: rgba(0,0,0,0.55); }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: #eee; font-family: -apple-system, system-ui, sans-serif; body { background: var(--bg); color: #eee; font-family: -apple-system, system-ui, sans-serif; }
display: flex; flex-direction: column; align-items: center; min-height: 100vh; padding: 2rem; }
h2 { font-size: 1.1rem; margin-bottom: 1rem; font-weight: 400; color: #999; }
.frame { .top-bar {
background: #e8e4d9; border-radius: 4px; padding: 12px; display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1.25rem;
box-shadow: 0 4px 24px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(0,0,0,0.1); background: var(--card); border-bottom: 1px solid var(--border);
} }
.frame img { display: block; image-rendering: auto; } .top-bar a { color: var(--accent); text-decoration: none; font-size: 0.9rem; }
.frame.landscape img { width: 800px; max-width: 90vw; height: auto; } .top-bar .title { font-size: 0.95rem; color: #aaa; flex: 1; }
.frame.portrait { } .mode-toggle { display: flex; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; }
.frame.portrait img { height: 800px; max-height: 70vh; width: auto; } .mode-toggle button {
background: #222; color: #aaa; border: none; padding: 0.35rem 0.9rem;
.controls { font-size: 0.8rem; cursor: pointer; transition: all 0.15s;
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
padding: 1.25rem; margin-top: 1.5rem; width: 100%; max-width: 700px;
} }
.controls h3 { font-size: 0.85rem; color: #888; text-transform: uppercase; .mode-toggle button.active { background: var(--accent); color: #111; }
letter-spacing: 0.05em; margin-bottom: 0.75rem; } .mode-toggle button:not(:last-child) { border-right: 1px solid var(--border); }
.control-row { display: flex; align-items: center; gap: 1rem; margin-bottom: 0.6rem; flex-wrap: wrap; } .save-btn {
.control-row label { font-size: 0.85rem; min-width: 80px; color: #aaa; } background: var(--accent); color: #111; border: none; padding: 0.35rem 0.9rem;
.control-row select, .control-row input[type=range] { flex: 1; max-width: 300px; } border-radius: 4px; font-size: 0.8rem; cursor: pointer; font-weight: 600;
select { background: #333; color: #eee; border: 1px solid var(--border); border-radius: 4px; }
padding: 0.3rem 0.5rem; font-size: 0.85rem; } .save-btn:hover { opacity: 0.9; }
input[type=range] { accent-color: var(--accent); } .save-btn.saved { background: #555; color: #aaa; }
.range-value { font-size: 0.8rem; color: #888; min-width: 2.5rem; }
.btn-row { display: flex; gap: 0.75rem; margin-top: 1rem; } .workspace { display: flex; height: calc(100vh - 50px); }
.btn { padding: 0.4rem 1rem; border-radius: 4px; border: 1px solid var(--border);
background: #333; color: #eee; cursor: pointer; font-size: 0.85rem; }
.btn:hover { background: #444; }
.btn.primary { background: var(--accent); color: #111; border-color: var(--accent); }
.btn.primary:hover { opacity: 0.9; }
.btn.reset { color: #f80; border-color: #f80; }
.pan-controls { display: {{ 'flex' if settings.mode == 'zoom' else 'none' }}; flex-direction: column; gap: 0.6rem; } /* Left: crop tool */
.crop-panel {
flex: 1; display: flex; align-items: center; justify-content: center;
overflow: hidden; position: relative; background: #0a0a0a;
}
.crop-container {
position: relative; display: inline-block; user-select: none; -webkit-user-select: none;
}
.crop-container img {
display: block; max-width: 100%; max-height: calc(100vh - 50px);
}
/* Dim overlay — four rects around the crop window */
.dim { position: absolute; background: var(--dim); pointer-events: none; transition: all 0.05s; }
/* Crop window border */
.crop-window {
position: absolute; border: 2px solid rgba(255,255,255,0.8);
box-shadow: 0 0 0 1px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(0,0,0,0.2);
cursor: grab; transition: box-shadow 0.15s;
}
.crop-window:active { cursor: grabbing; box-shadow: 0 0 0 2px var(--accent), inset 0 0 0 1px rgba(0,0,0,0.2); }
.crop-window.letterbox {
border-style: dashed; border-color: rgba(255,255,255,0.4); cursor: default;
}
.back { margin-top: 1.5rem; color: var(--accent); text-decoration: none; font-size: 0.9rem; } /* Right: dithered preview */
.back:hover { text-decoration: underline; } .preview-panel {
.loading { color: #888; font-size: 0.8rem; margin-left: 0.5rem; display: none; } width: 420px; min-width: 320px; background: var(--card);
border-left: 1px solid var(--border);
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 1.5rem; gap: 1rem;
}
.preview-label { font-size: 0.7rem; text-transform: uppercase; color: #666; letter-spacing: 0.08em; }
.epaper-frame {
background: #e8e4d9; border-radius: 3px; padding: 8px;
box-shadow: 0 2px 16px rgba(0,0,0,0.4), inset 0 0 0 1px rgba(0,0,0,0.08);
}
.epaper-frame img { display: block; width: 100%; height: auto; }
.rendering { color: #666; font-size: 0.8rem; }
@media (max-width: 800px) {
.workspace { flex-direction: column; height: auto; }
.crop-panel { min-height: 50vh; }
.preview-panel { width: 100%; border-left: none; border-top: 1px solid var(--border); }
}
</style> </style>
</head> </head>
<body> <body>
<h2>E-Paper Display Simulator</h2>
<div class="frame {{ 'portrait' if orientation.startswith('portrait') else 'landscape' }}"> <div class="top-bar">
<img id="preview" src="/preview/{{ photo_name }}" alt="E-paper preview"> <a href="/">&larr; Gallery</a>
<span class="title">{{ photo_name }}</span>
<div class="mode-toggle">
<button id="btn-zoom" onclick="setMode('zoom')">Fill</button>
<button id="btn-letterbox" onclick="setMode('letterbox')">Fit</button>
</div> </div>
<button class="save-btn" id="save-btn" onclick="save()">Save</button>
</div>
<div class="controls"> <div class="workspace">
<h3>Display Settings</h3> <div class="crop-panel" id="crop-panel">
<div class="crop-container" id="crop-container">
<div class="control-row"> <img id="source-img" src="/thumb/{{ photo_name }}?size=1600" draggable="false">
<label>Mode</label> <div class="dim" id="dim-top"></div>
<select id="mode" onchange="onModeChange()"> <div class="dim" id="dim-bottom"></div>
<option value="zoom" {{ 'selected' if settings.mode == 'zoom' else '' }}>Zoom (fill frame, crop overflow)</option> <div class="dim" id="dim-left"></div>
<option value="letterbox" {{ 'selected' if settings.mode == 'letterbox' else '' }}>Letterbox (show entire image)</option> <div class="dim" id="dim-right"></div>
</select> <div class="crop-window" id="crop-window"></div>
</div>
<div class="pan-controls" id="pan-controls">
<div class="control-row">
<label>Pan X</label>
<input type="range" id="pan_x" min="0" max="1" step="0.01"
value="{{ settings.pan_x }}" oninput="onPanChange()">
<span class="range-value" id="pan_x_val">{{ '%.0f' % (settings.pan_x * 100) }}%</span>
</div>
<div class="control-row">
<label>Pan Y</label>
<input type="range" id="pan_y" min="0" max="1" step="0.01"
value="{{ settings.pan_y }}" oninput="onPanChange()">
<span class="range-value" id="pan_y_val">{{ '%.0f' % (settings.pan_y * 100) }}%</span>
</div>
</div>
<div class="btn-row">
<button class="btn primary" onclick="saveSettings()">Save</button>
<button class="btn reset" onclick="resetSettings()">Reset to default</button>
<span class="loading" id="loading">Rendering...</span>
</div> </div>
</div> </div>
<a class="back" href="/">&larr; Back to gallery</a> <div class="preview-panel">
<span class="preview-label">E-Paper Preview</span>
<div class="epaper-frame">
<img id="preview-img" src="/preview/{{ photo_name }}">
</div>
<span class="rendering" id="rendering" style="visibility:hidden">Rendering...</span>
</div>
</div>
<script> <script>
const photoName = {{ photo_name | tojson }}; const photoName = {{ photo_name | tojson }};
let refreshTimer = null; const logicalW = {{ logical_w }};
const logicalH = {{ logical_h }};
const displayAspect = logicalW / logicalH;
function onModeChange() { let mode = {{ settings.mode | tojson }};
const mode = document.getElementById('mode').value; let panX = {{ settings.pan_x }};
document.getElementById('pan-controls').style.display = mode === 'zoom' ? 'flex' : 'none'; let panY = {{ settings.pan_y }};
scheduleRefresh(); let dirty = false;
// DOM refs
const sourceImg = document.getElementById('source-img');
const cropWindow = document.getElementById('crop-window');
const container = document.getElementById('crop-container');
const previewImg = document.getElementById('preview-img');
const renderingLabel = document.getElementById('rendering');
const saveBtn = document.getElementById('save-btn');
const dims = {
top: document.getElementById('dim-top'),
bottom: document.getElementById('dim-bottom'),
left: document.getElementById('dim-left'),
right: document.getElementById('dim-right'),
};
// Wait for image to load to get dimensions
sourceImg.onload = () => { updateMode(); layoutCrop(); };
if (sourceImg.complete) { updateMode(); layoutCrop(); }
function updateMode() {
document.getElementById('btn-zoom').classList.toggle('active', mode === 'zoom');
document.getElementById('btn-letterbox').classList.toggle('active', mode === 'letterbox');
cropWindow.classList.toggle('letterbox', mode === 'letterbox');
} }
function onPanChange() { function setMode(m) {
document.getElementById('pan_x_val').textContent = Math.round(document.getElementById('pan_x').value * 100) + '%'; mode = m;
document.getElementById('pan_y_val').textContent = Math.round(document.getElementById('pan_y').value * 100) + '%'; dirty = true;
scheduleRefresh(); updateMode();
layoutCrop();
schedulePreview();
} }
function scheduleRefresh() { function layoutCrop() {
clearTimeout(refreshTimer); const imgW = sourceImg.clientWidth;
refreshTimer = setTimeout(refreshPreview, 300); const imgH = sourceImg.clientHeight;
if (!imgW || !imgH) return;
const imgAspect = imgW / imgH;
let cropW, cropH;
if (mode === 'letterbox') {
// In letterbox mode, the entire image is shown — crop window = full image
cropW = imgW;
cropH = imgH;
panX = 0; panY = 0;
} else {
// Zoom: crop window has display aspect ratio, sized to fit inside image
if (displayAspect > imgAspect) {
// Display is wider than image — crop window fills image width
cropW = imgW;
cropH = imgW / displayAspect;
} else {
// Display is taller than image — crop window fills image height
cropH = imgH;
cropW = imgH * displayAspect;
}
}
const maxLeft = imgW - cropW;
const maxTop = imgH - cropH;
const left = maxLeft * panX;
const top = maxTop * panY;
cropWindow.style.width = cropW + 'px';
cropWindow.style.height = cropH + 'px';
cropWindow.style.left = left + 'px';
cropWindow.style.top = top + 'px';
// Update dim overlays
dims.top.style.cssText = `left:0;top:0;width:${imgW}px;height:${top}px`;
dims.bottom.style.cssText = `left:0;top:${top+cropH}px;width:${imgW}px;height:${imgH-top-cropH}px`;
dims.left.style.cssText = `left:0;top:${top}px;width:${left}px;height:${cropH}px`;
dims.right.style.cssText = `left:${left+cropW}px;top:${top}px;width:${imgW-left-cropW}px;height:${cropH}px`;
}
// --- Drag to pan ---
let dragging = false;
let dragStartX, dragStartY, dragStartPanX, dragStartPanY;
cropWindow.addEventListener('pointerdown', (e) => {
if (mode === 'letterbox') return;
e.preventDefault();
dragging = true;
cropWindow.setPointerCapture(e.pointerId);
dragStartX = e.clientX;
dragStartY = e.clientY;
dragStartPanX = panX;
dragStartPanY = panY;
});
window.addEventListener('pointermove', (e) => {
if (!dragging) return;
const imgW = sourceImg.clientWidth;
const imgH = sourceImg.clientHeight;
const imgAspect = imgW / imgH;
let cropW, cropH;
if (displayAspect > imgAspect) {
cropW = imgW; cropH = imgW / displayAspect;
} else {
cropH = imgH; cropW = imgH * displayAspect;
}
const maxLeft = imgW - cropW;
const maxTop = imgH - cropH;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
panX = Math.max(0, Math.min(1, dragStartPanX + (maxLeft > 0 ? dx / maxLeft : 0)));
panY = Math.max(0, Math.min(1, dragStartPanY + (maxTop > 0 ? dy / maxTop : 0)));
dirty = true;
layoutCrop();
});
window.addEventListener('pointerup', () => {
if (dragging) {
dragging = false;
schedulePreview();
}
});
// --- Preview rendering ---
let previewTimer = null;
function schedulePreview() {
clearTimeout(previewTimer);
previewTimer = setTimeout(refreshPreview, 400);
} }
function refreshPreview() { function refreshPreview() {
const mode = document.getElementById('mode').value; renderingLabel.style.visibility = 'visible';
const panX = document.getElementById('pan_x').value;
const panY = document.getElementById('pan_y').value;
const img = document.getElementById('preview');
const loading = document.getElementById('loading');
let url = `/preview/${encodeURIComponent(photoName)}?mode=${mode}`; let url = `/preview/${encodeURIComponent(photoName)}?mode=${mode}`;
if (mode === 'zoom') url += `&pan_x=${panX}&pan_y=${panY}`; if (mode === 'zoom') url += `&pan_x=${panX.toFixed(3)}&pan_y=${panY.toFixed(3)}`;
loading.style.display = 'inline'; const img = new window.Image();
const newImg = new window.Image(); img.onload = () => { previewImg.src = img.src; renderingLabel.style.visibility = 'hidden'; };
newImg.onload = () => { img.src = newImg.src; loading.style.display = 'none'; }; img.onerror = () => { renderingLabel.style.visibility = 'hidden'; };
newImg.onerror = () => { loading.style.display = 'none'; }; img.src = url;
newImg.src = url;
} }
function saveSettings() { function save() {
const mode = document.getElementById('mode').value;
const body = { mode }; const body = { mode };
if (mode === 'zoom') { if (mode === 'zoom') {
body.pan_x = parseFloat(document.getElementById('pan_x').value); body.pan_x = parseFloat(panX.toFixed(3));
body.pan_y = parseFloat(document.getElementById('pan_y').value); body.pan_y = parseFloat(panY.toFixed(3));
} }
fetch(`/api/photos/${encodeURIComponent(photoName)}/settings`, { fetch(`/api/photos/${encodeURIComponent(photoName)}/settings`, {
method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)
}).then(r => r.json()).then(() => { /* saved */ }); }).then(r => r.json()).then(() => {
dirty = false;
saveBtn.textContent = 'Saved';
saveBtn.classList.add('saved');
setTimeout(() => { saveBtn.textContent = 'Save'; saveBtn.classList.remove('saved'); }, 1500);
});
} }
function resetSettings() { // Relayout on resize
fetch(`/api/photos/${encodeURIComponent(photoName)}/settings`, { method: 'DELETE' }) window.addEventListener('resize', layoutCrop);
.then(r => r.json())
.then(s => {
document.getElementById('mode').value = s.mode;
document.getElementById('pan_x').value = s.pan_x;
document.getElementById('pan_y').value = s.pan_y;
onModeChange();
onPanChange();
refreshPreview();
});
}
</script> </script>
</body> </body>
</html> </html>