Files
eink-photo-frame/server/server.py
Thomas Hallock 75e4d0b786 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>
2026-03-27 16:38:55 -05:00

934 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
E-Ink Photo Frame Server
Serves photos for the ESP32 photo frame and provides a web UI for management.
Tracks frame status via heartbeat reports from the ESP32.
Endpoints:
GET / Web UI — gallery, upload, frame status, settings
GET /photo Random JPEG processed for the display (called by ESP32)
POST /heartbeat ESP32 reports what it displayed
GET /api/status Frame status (for Home Assistant REST sensor)
GET /api/photos List all photos as JSON
POST /api/upload Upload new photos
DELETE /api/photos/<name> Delete a photo
GET /api/settings Get frame settings
PUT /api/settings Update frame settings
GET /api/photos/<name>/settings Get per-image settings
PUT /api/photos/<name>/settings Update per-image settings
GET /preview/<name> Dithered e-paper preview image
GET /simulate/<name> Full simulator page with controls
GET /health Health check
"""
import argparse
import io
import json
import random
import sys
from datetime import datetime, timezone
from pathlib import Path
from flask import Flask, Response, jsonify, request, render_template_string
from werkzeug.utils import secure_filename
from PIL import Image
import numpy as np
from dither import dither_floyd_steinberg
app = Flask(__name__)
PHOTOS_DIR: Path = Path(".")
PHYSICAL_WIDTH = 800
PHYSICAL_HEIGHT = 480
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".bmp", ".tiff"}
DATA_DIR = Path("/data")
STATE_FILE = DATA_DIR / "frame_state.json"
SETTINGS_FILE = DATA_DIR / "settings.json"
IMAGE_SETTINGS_FILE = DATA_DIR / "image_settings.json"
# --- Persistence helpers ---
def _load_json(path: Path, default):
try:
if path.exists():
return json.loads(path.read_text())
except Exception:
pass
return default() if callable(default) else default
def _save_json(path: Path, data):
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2))
except Exception as e:
print(f"Warning: could not save {path}: {e}", file=sys.stderr)
# Frame state (runtime, updated by heartbeats)
frame_state = _load_json(STATE_FILE, lambda: {
"last_update": None, "current_photo": None, "ip": None, "updates": 0
})
# Frame settings (user-configured)
DEFAULT_SETTINGS = {
"orientation": "landscape", # landscape | portrait_cw | portrait_ccw
"default_mode": "zoom", # zoom | letterbox
"letterbox_color": [255, 255, 255], # white — looks like e-paper background
}
frame_settings = _load_json(SETTINGS_FILE, lambda: dict(DEFAULT_SETTINGS))
# Per-image settings: { "filename.jpg": { "mode": "zoom", "pan_x": 0.5, "pan_y": 0.3 }, ... }
image_settings: dict = _load_json(IMAGE_SETTINGS_FILE, dict)
def get_image_settings(name: str) -> dict:
"""Get effective settings for an image (per-image overrides merged with defaults)."""
defaults = {"mode": None, "pan_x": 0.5, "pan_y": 0.5}
per_image = image_settings.get(name, {})
merged = {**defaults, **per_image}
if merged["mode"] is None:
merged["mode"] = frame_settings.get("default_mode", "zoom")
return merged
# --- Image processing ---
def get_logical_dimensions() -> tuple[int, int]:
"""Get the logical display dimensions based on frame orientation."""
orientation = frame_settings.get("orientation", "landscape")
if orientation == "landscape":
return PHYSICAL_WIDTH, PHYSICAL_HEIGHT # 800x480
else:
return PHYSICAL_HEIGHT, PHYSICAL_WIDTH # 480x800
def process_image(img: Image.Image, settings: dict) -> Image.Image:
"""
Resize and transform an image for the display.
Takes frame orientation into account:
- Determines logical dimensions (800x480 or 480x800)
- Applies zoom+pan or letterbox based on settings
- Rotates to match physical 800x480 buffer if portrait
Returns an 800x480 image ready for dithering / JPEG encoding.
"""
logical_w, logical_h = get_logical_dimensions()
mode = settings.get("mode", "zoom")
pan_x = settings.get("pan_x", 0.5)
pan_y = settings.get("pan_y", 0.5)
if mode == "zoom":
img = _zoom_and_pan(img, logical_w, logical_h, pan_x, pan_y)
else:
img = _letterbox(img, logical_w, logical_h)
# Rotate to match physical buffer if portrait
orientation = frame_settings.get("orientation", "landscape")
if orientation == "portrait_cw":
img = img.rotate(-90, expand=True)
elif orientation == "portrait_ccw":
img = img.rotate(90, expand=True)
return img
def _zoom_and_pan(img: Image.Image, tw: int, th: int,
pan_x: float, pan_y: float) -> Image.Image:
"""Scale to cover target, then crop at the specified pan position."""
target_ratio = tw / th
img_ratio = img.width / img.height
if img_ratio > target_ratio:
# Image is wider — fit height, crop width
new_height = th
new_width = int(img.width * (th / img.height))
else:
# Image is taller — fit width, crop height
new_width = tw
new_height = int(img.height * (tw / img.width))
img = img.resize((new_width, new_height), Image.LANCZOS)
# Pan-aware crop: pan_x/pan_y are 0.01.0, controlling crop position
max_left = new_width - tw
max_top = new_height - th
left = int(max_left * pan_x)
top = int(max_top * pan_y)
return img.crop((left, top, left + tw, top + th))
def _letterbox(img: Image.Image, tw: int, th: int) -> Image.Image:
"""Scale to fit inside target, pad with letterbox color."""
lb_color = tuple(frame_settings.get("letterbox_color", [255, 255, 255]))
target_ratio = tw / th
img_ratio = img.width / img.height
if img_ratio > target_ratio:
# Image is wider — fit width
new_width = tw
new_height = int(img.height * (tw / img.width))
else:
# Image is taller — fit height
new_height = th
new_width = int(img.width * (th / img.height))
img = img.resize((new_width, new_height), Image.LANCZOS)
canvas = Image.new("RGB", (tw, th), lb_color)
paste_x = (tw - new_width) // 2
paste_y = (th - new_height) // 2
canvas.paste(img, (paste_x, paste_y))
return canvas
def get_photo_list() -> list[Path]:
photos = []
for f in PHOTOS_DIR.rglob("*"):
if f.suffix.lower() in SUPPORTED_EXTENSIONS and f.is_file():
photos.append(f)
return sorted(photos, key=lambda p: p.name)
def make_thumbnail(img: Image.Image, size: int = 300) -> bytes:
img.thumbnail((size, size), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=75)
return buf.getvalue()
# --- ESP32 endpoints ---
@app.route("/photo")
def random_photo():
photos = get_photo_list()
if not photos:
return jsonify({"error": "No photos found"}), 404
chosen = random.choice(photos)
try:
img = Image.open(chosen).convert("RGB")
settings = get_image_settings(chosen.name)
img = process_image(img, settings)
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=85)
buf.seek(0)
return Response(buf.getvalue(), mimetype="image/jpeg",
headers={"X-Photo-Name": chosen.name})
except Exception as e:
print(f"Error processing {chosen}: {e}", file=sys.stderr)
return jsonify({"error": str(e)}), 500
@app.route("/heartbeat", methods=["POST"])
def heartbeat():
global frame_state
data = request.get_json(silent=True) or {}
frame_state["last_update"] = datetime.now(timezone.utc).isoformat()
frame_state["current_photo"] = data.get("photo", None)
frame_state["ip"] = request.remote_addr
frame_state["free_heap"] = data.get("free_heap", None)
frame_state["updates"] = frame_state.get("updates", 0) + 1
_save_json(STATE_FILE, frame_state)
return jsonify({"ok": True})
# --- Settings API ---
@app.route("/api/settings", methods=["GET"])
def api_get_settings():
return jsonify(frame_settings)
@app.route("/api/settings", methods=["PUT"])
def api_put_settings():
global frame_settings
data = request.get_json()
allowed_keys = {"orientation", "default_mode", "letterbox_color"}
for k, v in data.items():
if k in allowed_keys:
frame_settings[k] = v
_save_json(SETTINGS_FILE, frame_settings)
return jsonify(frame_settings)
@app.route("/api/photos/<name>/settings", methods=["GET"])
def api_get_image_settings(name: str):
fname = secure_filename(name)
return jsonify(get_image_settings(fname))
@app.route("/api/photos/<name>/settings", methods=["PUT"])
def api_put_image_settings(name: str):
fname = secure_filename(name)
data = request.get_json()
allowed_keys = {"mode", "pan_x", "pan_y"}
current = image_settings.get(fname, {})
for k, v in data.items():
if k in allowed_keys:
current[k] = v
image_settings[fname] = current
_save_json(IMAGE_SETTINGS_FILE, image_settings)
return jsonify(get_image_settings(fname))
@app.route("/api/photos/<name>/settings", methods=["DELETE"])
def api_delete_image_settings(name: str):
fname = secure_filename(name)
image_settings.pop(fname, None)
_save_json(IMAGE_SETTINGS_FILE, image_settings)
return jsonify(get_image_settings(fname))
# --- Other API endpoints ---
@app.route("/api/status")
def api_status():
photos = get_photo_list()
return jsonify({
"state": "online" if frame_state.get("last_update") else "waiting",
"last_update": frame_state.get("last_update"),
"current_photo": frame_state.get("current_photo"),
"frame_ip": frame_state.get("ip"),
"total_photos": len(photos),
"total_updates": frame_state.get("updates", 0),
"orientation": frame_settings.get("orientation", "landscape"),
})
@app.route("/api/photos")
def api_photos():
photos = get_photo_list()
result = []
for p in photos:
entry = {"name": p.name, "size": p.stat().st_size}
if p.name in image_settings:
entry["settings"] = image_settings[p.name]
result.append(entry)
return jsonify(result)
@app.route("/api/upload", methods=["POST"])
def api_upload():
uploaded = []
for f in request.files.getlist("photos"):
if not f.filename:
continue
fname = secure_filename(f.filename)
ext = Path(fname).suffix.lower()
if ext not in SUPPORTED_EXTENSIONS:
continue
dest = PHOTOS_DIR / fname
f.save(str(dest))
uploaded.append(fname)
return jsonify({"uploaded": uploaded, "count": len(uploaded)})
@app.route("/api/photos/<name>", methods=["DELETE"])
def api_delete(name: str):
fname = secure_filename(name)
path = PHOTOS_DIR / fname
if not path.exists():
return jsonify({"error": "not found"}), 404
path.unlink()
image_settings.pop(fname, None)
_save_json(IMAGE_SETTINGS_FILE, image_settings)
return jsonify({"deleted": fname})
@app.route("/thumb/<name>")
def thumbnail(name: str):
fname = secure_filename(name)
path = PHOTOS_DIR / fname
if not path.exists():
return "", 404
try:
size = request.args.get("size", 300, type=int)
size = min(size, 1600)
img = Image.open(path).convert("RGB")
data = make_thumbnail(img, size)
return Response(data, mimetype="image/jpeg")
except Exception:
return "", 500
# --- Simulator / Preview ---
@app.route("/preview/<name>")
def preview_photo(name: str):
fname = secure_filename(name)
path = PHOTOS_DIR / fname
if not path.exists():
return jsonify({"error": "not found"}), 404
# Allow query param overrides for interactive simulator
settings = get_image_settings(fname)
if "mode" in request.args:
settings["mode"] = request.args["mode"]
if "pan_x" in request.args:
settings["pan_x"] = float(request.args["pan_x"])
if "pan_y" in request.args:
settings["pan_y"] = float(request.args["pan_y"])
try:
img = Image.open(path).convert("RGB")
img = process_image(img, settings)
_, preview = dither_floyd_steinberg(img)
buf = io.BytesIO()
preview.save(buf, format="PNG")
buf.seek(0)
return Response(buf.getvalue(), mimetype="image/png")
except Exception as e:
print(f"Preview error: {e}", file=sys.stderr)
return jsonify({"error": str(e)}), 500
@app.route("/preview")
def preview_random():
photos = get_photo_list()
if not photos:
return jsonify({"error": "No photos found"}), 404
chosen = random.choice(photos)
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)
path = PHOTOS_DIR / fname
if not path.exists():
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,
logical_w=lw, logical_h=lh)
SIMULATE_UI = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ photo_name }} — Frame Preview</title>
<style>
: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; }
.top-bar {
display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1.25rem;
background: var(--card); border-bottom: 1px solid var(--border);
}
.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;
}
.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; }
.workspace { display: flex; height: calc(100vh - 50px); }
/* 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;
}
/* 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>
<div class="top-bar">
<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>
<button class="save-btn" id="save-btn" onclick="save()">Save</button>
</div>
<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="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>
const photoName = {{ photo_name | tojson }};
const logicalW = {{ logical_w }};
const logicalH = {{ logical_h }};
const displayAspect = logicalW / logicalH;
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 setMode(m) {
mode = m;
dirty = true;
updateMode();
layoutCrop();
schedulePreview();
}
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() {
renderingLabel.style.visibility = 'visible';
let url = `/preview/${encodeURIComponent(photoName)}?mode=${mode}`;
if (mode === 'zoom') url += `&pan_x=${panX.toFixed(3)}&pan_y=${panY.toFixed(3)}`;
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 save() {
const body = { mode };
if (mode === 'zoom') {
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(() => {
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>
"""
# --- Web UI ---
@app.route("/")
def index():
photos = get_photo_list()
orientation = frame_settings.get("orientation", "landscape")
default_mode = frame_settings.get("default_mode", "zoom")
return render_template_string(WEB_UI, photos=photos, state=frame_state,
photo_count=len(photos), orientation=orientation,
default_mode=default_mode)
@app.route("/health")
def health():
photos = get_photo_list()
return jsonify({"status": "ok", "photo_count": len(photos)})
WEB_UI = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Photo Frame</title>
<style>
:root { --bg: #111; --card: #1a1a1a; --text: #eee; --accent: #6c8; --border: #333; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
.container { max-width: 960px; margin: 0 auto; padding: 1.5rem; }
h1 { font-size: 1.5rem; margin-bottom: 1.5rem; }
h1 span { color: var(--accent); }
.status-card {
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
padding: 1rem 1.25rem; margin-bottom: 1.5rem; display: flex; gap: 2rem; flex-wrap: wrap;
align-items: flex-end;
}
.status-card .item { display: flex; flex-direction: column; gap: 0.2rem; }
.status-card .label { font-size: 0.75rem; text-transform: uppercase; color: #888; }
.status-card .value { font-size: 0.95rem; }
.status-card .online { color: var(--accent); }
.status-card .waiting { color: #f80; }
.status-card select {
background: #333; color: #eee; border: 1px solid var(--border); border-radius: 4px;
padding: 0.25rem 0.4rem; font-size: 0.85rem;
}
.upload-area {
background: var(--card); border: 2px dashed var(--border); border-radius: 8px;
padding: 2rem; text-align: center; margin-bottom: 1.5rem; cursor: pointer;
transition: border-color 0.2s;
}
.upload-area:hover, .upload-area.dragover { border-color: var(--accent); }
.upload-area input { display: none; }
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; }
.photo-card {
background: var(--card); border: 1px solid var(--border); border-radius: 6px;
overflow: hidden; position: relative;
}
.photo-card img { width: 100%; aspect-ratio: 5/3; object-fit: cover; display: block; }
.photo-card .info {
padding: 0.5rem 0.6rem; display: flex; justify-content: space-between; align-items: center;
font-size: 0.8rem;
}
.photo-card .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 65%; }
.photo-card .actions { display: flex; gap: 0.3rem; }
.photo-card .btn {
background: none; border: none; cursor: pointer; font-size: 0.85rem;
padding: 0.2rem 0.4rem; border-radius: 4px;
}
.photo-card .simulate { color: #6c8; }
.photo-card .simulate:hover { background: rgba(102,204,136,0.15); }
.photo-card .delete { color: #f55; }
.photo-card .delete:hover { background: rgba(255,85,85,0.15); }
.photo-card.current { border-color: var(--accent); }
.empty { text-align: center; padding: 3rem; color: #666; }
</style>
</head>
<body>
<div class="container">
<h1><span>&#9632;</span> Photo Frame</h1>
<div class="status-card">
<div class="item">
<span class="label">Frame Status</span>
<span class="value {{ 'online' if state.get('last_update') else 'waiting' }}">
{{ 'Online' if state.get('last_update') else 'Waiting for first update' }}
</span>
</div>
{% if state.get('last_update') %}
<div class="item">
<span class="label">Last Update</span>
<span class="value" data-utc="{{ state.last_update }}">{{ state.last_update[:19] }}</span>
</div>
<div class="item">
<span class="label">Showing</span>
<span class="value">{{ state.get('current_photo', 'Unknown') }}</span>
</div>
{% endif %}
<div class="item">
<span class="label">Photos</span>
<span class="value">{{ photo_count }}</span>
</div>
<div class="item">
<span class="label">Orientation</span>
<select onchange="updateSetting('orientation', this.value)">
<option value="landscape" {{ 'selected' if orientation == 'landscape' else '' }}>Landscape</option>
<option value="portrait_cw" {{ 'selected' if orientation == 'portrait_cw' else '' }}>Portrait (CW)</option>
<option value="portrait_ccw" {{ 'selected' if orientation == 'portrait_ccw' else '' }}>Portrait (CCW)</option>
</select>
</div>
<div class="item">
<span class="label">Default Mode</span>
<select onchange="updateSetting('default_mode', this.value)">
<option value="zoom" {{ 'selected' if default_mode == 'zoom' else '' }}>Zoom</option>
<option value="letterbox" {{ 'selected' if default_mode == 'letterbox' else '' }}>Letterbox</option>
</select>
</div>
</div>
<div class="upload-area" id="upload-area" onclick="document.getElementById('file-input').click()">
<input type="file" id="file-input" multiple accept="image/*">
<p>Drop photos here or click to upload</p>
</div>
{% if photos %}
<div class="gallery">
{% for p in photos %}
<div class="photo-card {{ 'current' if state.get('current_photo') == p.name else '' }}" data-name="{{ p.name }}">
<img src="/thumb/{{ p.name }}" loading="lazy" alt="{{ p.name }}">
<div class="info">
<span class="name" title="{{ p.name }}">{{ p.name }}</span>
<div class="actions">
<a class="btn simulate" href="/simulate/{{ p.name }}" title="Preview on e-paper">&#9632;</a>
<button class="btn delete" onclick="deletePhoto('{{ p.name }}')" title="Delete">&times;</button>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty">No photos yet. Upload some!</div>
{% endif %}
</div>
<script>
const area = document.getElementById('upload-area');
const input = document.getElementById('file-input');
['dragenter','dragover'].forEach(e => area.addEventListener(e, ev => { ev.preventDefault(); area.classList.add('dragover'); }));
['dragleave','drop'].forEach(e => area.addEventListener(e, ev => { ev.preventDefault(); area.classList.remove('dragover'); }));
area.addEventListener('drop', ev => { uploadFiles(ev.dataTransfer.files); });
input.addEventListener('change', () => { uploadFiles(input.files); });
function uploadFiles(files) {
const fd = new FormData();
for (const f of files) fd.append('photos', f);
fetch('/api/upload', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => { if (d.count > 0) location.reload(); });
}
function deletePhoto(name) {
if (!confirm('Delete ' + name + '?')) return;
fetch('/api/photos/' + encodeURIComponent(name), { method: 'DELETE' })
.then(r => r.json())
.then(() => { document.querySelector('[data-name="' + name + '"]')?.remove(); });
}
function updateSetting(key, value) {
fetch('/api/settings', {
method: 'PUT', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({[key]: value})
});
}
document.querySelectorAll('[data-utc]').forEach(el => {
const d = new Date(el.dataset.utc);
el.textContent = d.toLocaleString();
});
</script>
</body>
</html>
"""
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="E-Ink Photo Frame Server")
parser.add_argument("--port", type=int, default=8473)
parser.add_argument("--photos-dir", type=str, default="./photos")
args = parser.parse_args()
PHOTOS_DIR = Path(args.photos_dir)
DATA_DIR = Path(args.photos_dir) / ".data"
STATE_FILE = DATA_DIR / "frame_state.json"
SETTINGS_FILE = DATA_DIR / "settings.json"
IMAGE_SETTINGS_FILE = DATA_DIR / "image_settings.json"
if not PHOTOS_DIR.exists():
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
# Reload with correct paths
frame_state = _load_json(STATE_FILE, lambda: {
"last_update": None, "current_photo": None, "ip": None, "updates": 0
})
frame_settings = _load_json(SETTINGS_FILE, lambda: dict(DEFAULT_SETTINGS))
image_settings = _load_json(IMAGE_SETTINGS_FILE, dict)
print(f"Serving photos from: {PHOTOS_DIR.resolve()}")
print(f"Found {len(get_photo_list())} photos")
print(f"Orientation: {frame_settings['orientation']}, Default mode: {frame_settings['default_mode']}")
print(f"Listening on port {args.port}")
app.run(host="0.0.0.0", port=args.port)