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:
368
server/server.py
368
server/server.py
@@ -393,6 +393,24 @@ def preview_random():
|
||||
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>")
|
||||
def simulate_page(name: str):
|
||||
fname = secure_filename(name)
|
||||
@@ -401,8 +419,10 @@ def simulate_page(name: str):
|
||||
return "", 404
|
||||
settings = get_image_settings(fname)
|
||||
orientation = frame_settings.get("orientation", "landscape")
|
||||
lw, lh = get_logical_dimensions()
|
||||
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>
|
||||
@@ -410,156 +430,284 @@ SIMULATE_UI = """<!DOCTYPE html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>E-Paper Simulator — {{ photo_name }}</title>
|
||||
<title>{{ photo_name }} — Frame Preview</title>
|
||||
<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; }
|
||||
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; }
|
||||
body { background: var(--bg); color: #eee; font-family: -apple-system, system-ui, sans-serif; }
|
||||
|
||||
.frame {
|
||||
background: #e8e4d9; border-radius: 4px; padding: 12px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(0,0,0,0.1);
|
||||
.top-bar {
|
||||
display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1.25rem;
|
||||
background: var(--card); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.frame img { display: block; image-rendering: auto; }
|
||||
.frame.landscape img { width: 800px; max-width: 90vw; height: auto; }
|
||||
.frame.portrait { }
|
||||
.frame.portrait img { height: 800px; max-height: 70vh; width: auto; }
|
||||
|
||||
.controls {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 1.25rem; margin-top: 1.5rem; width: 100%; max-width: 700px;
|
||||
.top-bar a { color: var(--accent); text-decoration: none; font-size: 0.9rem; }
|
||||
.top-bar .title { font-size: 0.95rem; color: #aaa; flex: 1; }
|
||||
.mode-toggle { display: flex; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; }
|
||||
.mode-toggle button {
|
||||
background: #222; color: #aaa; border: none; padding: 0.35rem 0.9rem;
|
||||
font-size: 0.8rem; cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.controls h3 { font-size: 0.85rem; color: #888; text-transform: uppercase;
|
||||
letter-spacing: 0.05em; margin-bottom: 0.75rem; }
|
||||
.control-row { display: flex; align-items: center; gap: 1rem; margin-bottom: 0.6rem; flex-wrap: wrap; }
|
||||
.control-row label { font-size: 0.85rem; min-width: 80px; color: #aaa; }
|
||||
.control-row select, .control-row input[type=range] { flex: 1; max-width: 300px; }
|
||||
select { background: #333; color: #eee; border: 1px solid var(--border); border-radius: 4px;
|
||||
padding: 0.3rem 0.5rem; font-size: 0.85rem; }
|
||||
input[type=range] { accent-color: var(--accent); }
|
||||
.range-value { font-size: 0.8rem; color: #888; min-width: 2.5rem; }
|
||||
.mode-toggle button.active { background: var(--accent); color: #111; }
|
||||
.mode-toggle button:not(:last-child) { border-right: 1px solid var(--border); }
|
||||
.save-btn {
|
||||
background: var(--accent); color: #111; border: none; padding: 0.35rem 0.9rem;
|
||||
border-radius: 4px; font-size: 0.8rem; cursor: pointer; font-weight: 600;
|
||||
}
|
||||
.save-btn:hover { opacity: 0.9; }
|
||||
.save-btn.saved { background: #555; color: #aaa; }
|
||||
|
||||
.btn-row { display: flex; gap: 0.75rem; margin-top: 1rem; }
|
||||
.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; }
|
||||
.workspace { display: flex; height: calc(100vh - 50px); }
|
||||
|
||||
.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; }
|
||||
.back:hover { text-decoration: underline; }
|
||||
.loading { color: #888; font-size: 0.8rem; margin-left: 0.5rem; display: none; }
|
||||
/* Right: dithered preview */
|
||||
.preview-panel {
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
<h2>E-Paper Display Simulator</h2>
|
||||
|
||||
<div class="frame {{ 'portrait' if orientation.startswith('portrait') else 'landscape' }}">
|
||||
<img id="preview" src="/preview/{{ photo_name }}" alt="E-paper preview">
|
||||
<div class="top-bar">
|
||||
<a href="/">← 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>
|
||||
<button class="save-btn" id="save-btn" onclick="save()">Save</button>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<h3>Display Settings</h3>
|
||||
|
||||
<div class="control-row">
|
||||
<label>Mode</label>
|
||||
<select id="mode" onchange="onModeChange()">
|
||||
<option value="zoom" {{ 'selected' if settings.mode == 'zoom' else '' }}>Zoom (fill frame, crop overflow)</option>
|
||||
<option value="letterbox" {{ 'selected' if settings.mode == 'letterbox' else '' }}>Letterbox (show entire image)</option>
|
||||
</select>
|
||||
</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 class="workspace">
|
||||
<div class="crop-panel" id="crop-panel">
|
||||
<div class="crop-container" id="crop-container">
|
||||
<img id="source-img" src="/thumb/{{ photo_name }}?size=1600" draggable="false">
|
||||
<div class="dim" id="dim-top"></div>
|
||||
<div class="dim" id="dim-bottom"></div>
|
||||
<div class="dim" id="dim-left"></div>
|
||||
<div class="dim" id="dim-right"></div>
|
||||
<div class="crop-window" id="crop-window"></div>
|
||||
</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 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>
|
||||
|
||||
<a class="back" href="/">← Back to gallery</a>
|
||||
|
||||
<script>
|
||||
const photoName = {{ photo_name | tojson }};
|
||||
let refreshTimer = null;
|
||||
const logicalW = {{ logical_w }};
|
||||
const logicalH = {{ logical_h }};
|
||||
const displayAspect = logicalW / logicalH;
|
||||
|
||||
function onModeChange() {
|
||||
const mode = document.getElementById('mode').value;
|
||||
document.getElementById('pan-controls').style.display = mode === 'zoom' ? 'flex' : 'none';
|
||||
scheduleRefresh();
|
||||
let mode = {{ settings.mode | tojson }};
|
||||
let panX = {{ settings.pan_x }};
|
||||
let panY = {{ settings.pan_y }};
|
||||
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() {
|
||||
document.getElementById('pan_x_val').textContent = Math.round(document.getElementById('pan_x').value * 100) + '%';
|
||||
document.getElementById('pan_y_val').textContent = Math.round(document.getElementById('pan_y').value * 100) + '%';
|
||||
scheduleRefresh();
|
||||
function setMode(m) {
|
||||
mode = m;
|
||||
dirty = true;
|
||||
updateMode();
|
||||
layoutCrop();
|
||||
schedulePreview();
|
||||
}
|
||||
|
||||
function scheduleRefresh() {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = setTimeout(refreshPreview, 300);
|
||||
function layoutCrop() {
|
||||
const imgW = sourceImg.clientWidth;
|
||||
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() {
|
||||
const mode = document.getElementById('mode').value;
|
||||
const panX = document.getElementById('pan_x').value;
|
||||
const panY = document.getElementById('pan_y').value;
|
||||
const img = document.getElementById('preview');
|
||||
const loading = document.getElementById('loading');
|
||||
|
||||
renderingLabel.style.visibility = 'visible';
|
||||
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 newImg = new window.Image();
|
||||
newImg.onload = () => { img.src = newImg.src; loading.style.display = 'none'; };
|
||||
newImg.onerror = () => { loading.style.display = 'none'; };
|
||||
newImg.src = url;
|
||||
const img = new window.Image();
|
||||
img.onload = () => { previewImg.src = img.src; renderingLabel.style.visibility = 'hidden'; };
|
||||
img.onerror = () => { renderingLabel.style.visibility = 'hidden'; };
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
const mode = document.getElementById('mode').value;
|
||||
function save() {
|
||||
const body = { mode };
|
||||
if (mode === 'zoom') {
|
||||
body.pan_x = parseFloat(document.getElementById('pan_x').value);
|
||||
body.pan_y = parseFloat(document.getElementById('pan_y').value);
|
||||
body.pan_x = parseFloat(panX.toFixed(3));
|
||||
body.pan_y = parseFloat(panY.toFixed(3));
|
||||
}
|
||||
fetch(`/api/photos/${encodeURIComponent(photoName)}/settings`, {
|
||||
method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)
|
||||
}).then(r => r.json()).then(() => { /* saved */ });
|
||||
}
|
||||
|
||||
function resetSettings() {
|
||||
fetch(`/api/photos/${encodeURIComponent(photoName)}/settings`, { method: 'DELETE' })
|
||||
.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();
|
||||
}).then(r => r.json()).then(() => {
|
||||
dirty = false;
|
||||
saveBtn.textContent = 'Saved';
|
||||
saveBtn.classList.add('saved');
|
||||
setTimeout(() => { saveBtn.textContent = 'Save'; saveBtn.classList.remove('saved'); }, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
// Relayout on resize
|
||||
window.addEventListener('resize', layoutCrop);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user