Add display simulator, orientation support, and per-image crop settings

- E-paper display simulator: Python port of the C++ Floyd-Steinberg
  dithering (same palette, same coefficients) with side-by-side preview
  in the web UI. Interactive pan/zoom controls with live re-rendering.

- Frame orientation: landscape / portrait_cw / portrait_ccw setting
  controls logical display dimensions (800x480 vs 480x800). Images are
  rotated to match the physical buffer after processing.

- Display modes: zoom (cover+crop) and letterbox (fit with padding),
  configurable globally and per-image. Zoom mode supports pan_x/pan_y
  (0.0-1.0) to control crop position.

- Settings persistence: frame settings, per-image settings, and frame
  state stored as JSON, surviving restarts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 16:32:38 -05:00
parent 4642b90366
commit dd4f8e950d
3 changed files with 621 additions and 62 deletions

142
server/dither.py Normal file
View File

@@ -0,0 +1,142 @@
"""
Floyd-Steinberg dithering for E Ink Spectra 6 (6-color) display.
This is a 1:1 Python port of the C++ dithering in firmware/src/dither.h.
Same palette RGB values, same error diffusion coefficients, same color
distance metric. The output should be pixel-identical to the firmware.
Native panel color codes (what the controller expects over SPI):
0x00 = Black
0x01 = White
0x02 = Yellow
0x03 = Red
0x04 = Orange (not used in Spectra 6, but accepted by controller)
0x05 = Blue
0x06 = Green
Pixel packing: 4bpp, 2 pixels per byte.
High nibble (bits 7-4) = LEFT pixel
Low nibble (bits 3-0) = RIGHT pixel
Scan order: left-to-right, top-to-bottom
Data path on the ESP32:
JPEG → decode to RGB888 → Floyd-Steinberg dither → 4bpp native buffer
→ writeNative(buf, invert=true) → SPI command 0x10 → panel refresh
"""
import numpy as np
from PIL import Image
# Palette: (R, G, B, native_panel_index)
# These RGB values must match firmware/src/dither.h PALETTE[] exactly.
PALETTE = [
( 0, 0, 0, 0), # Black
(255, 255, 255, 1), # White
(200, 30, 30, 3), # Red
( 0, 145, 0, 6), # Green
( 0, 0, 160, 5), # Blue
(230, 210, 0, 2), # Yellow
]
# Extract just the RGB values as a numpy array for vectorized distance calc
PALETTE_RGB = np.array([(r, g, b) for r, g, b, _ in PALETTE], dtype=np.int32)
PALETTE_INDICES = [idx for _, _, _, idx in PALETTE]
# Display RGB values — what the e-paper pigments actually look like.
# Used for rendering the simulator output. These may differ from the palette
# values above (which are what we compare against during dithering).
# For now they're the same, but can be tuned independently.
DISPLAY_RGB = {
0: ( 0, 0, 0), # Black
1: (255, 255, 255), # White
2: (230, 210, 0), # Yellow
3: (200, 30, 30), # Red
5: ( 0, 0, 160), # Blue
6: ( 0, 145, 0), # Green
}
def find_closest_color(r: int, g: int, b: int) -> int:
"""Find the palette index with minimum squared Euclidean distance."""
best_idx = 0
best_dist = float('inf')
for i, (pr, pg, pb, _) in enumerate(PALETTE):
dr = r - pr
dg = g - pg
db = b - pb
dist = dr * dr + dg * dg + db * db
if dist < best_dist:
best_dist = dist
best_idx = i
return best_idx
def dither_floyd_steinberg(img: Image.Image) -> tuple[np.ndarray, Image.Image]:
"""
Apply Floyd-Steinberg dithering to an 800x480 RGB image.
Returns:
native_buffer: numpy array of shape (480, 400) dtype uint8
Each byte = 2 pixels packed as 4bpp native panel indices.
High nibble = left pixel, low nibble = right pixel.
preview_img: PIL Image showing what the e-paper display would look like.
"""
assert img.size == (800, 480), f"Image must be 800x480, got {img.size}"
width, height = 800, 480
# Work with int16 to allow negative error values
pixels = np.array(img, dtype=np.int16)
# Output: what native panel index each pixel gets
result = np.zeros((height, width), dtype=np.uint8)
for y in range(height):
for x in range(width):
r = int(np.clip(pixels[y, x, 0], 0, 255))
g = int(np.clip(pixels[y, x, 1], 0, 255))
b = int(np.clip(pixels[y, x, 2], 0, 255))
ci = find_closest_color(r, g, b)
pr, pg, pb, native_idx = PALETTE[ci]
result[y, x] = native_idx
# Quantization error
er = r - pr
eg = g - pg
eb = b - pb
# Floyd-Steinberg error diffusion (same coefficients as C++)
# Right: 7/16, Bottom-left: 3/16, Bottom: 5/16, Bottom-right: 1/16
if x + 1 < width:
pixels[y, x + 1, 0] += er * 7 // 16
pixels[y, x + 1, 1] += eg * 7 // 16
pixels[y, x + 1, 2] += eb * 7 // 16
if y + 1 < height:
if x - 1 >= 0:
pixels[y + 1, x - 1, 0] += er * 3 // 16
pixels[y + 1, x - 1, 1] += eg * 3 // 16
pixels[y + 1, x - 1, 2] += eb * 3 // 16
pixels[y + 1, x, 0] += er * 5 // 16
pixels[y + 1, x, 1] += eg * 5 // 16
pixels[y + 1, x, 2] += eb * 5 // 16
if x + 1 < width:
pixels[y + 1, x + 1, 0] += er * 1 // 16
pixels[y + 1, x + 1, 1] += eg * 1 // 16
pixels[y + 1, x + 1, 2] += eb * 1 // 16
# Pack into 4bpp buffer (2 pixels per byte, high nibble = left)
native_buffer = np.zeros((height, width // 2), dtype=np.uint8)
for y in range(height):
for x in range(0, width, 2):
left = result[y, x]
right = result[y, x + 1]
native_buffer[y, x // 2] = (left << 4) | right
# Render preview image using display RGB values
preview = np.zeros((height, width, 3), dtype=np.uint8)
for y in range(height):
for x in range(width):
preview[y, x] = DISPLAY_RGB[result[y, x]]
preview_img = Image.fromarray(preview)
return native_buffer, preview_img

View File

@@ -1,2 +1,3 @@
flask==3.1.* flask==3.1.*
numpy>=1.26
pillow==11.* pillow==11.*

View File

@@ -5,14 +5,20 @@ Serves photos for the ESP32 photo frame and provides a web UI for management.
Tracks frame status via heartbeat reports from the ESP32. Tracks frame status via heartbeat reports from the ESP32.
Endpoints: Endpoints:
GET / Web UI — gallery, upload, frame status GET / Web UI — gallery, upload, frame status, settings
GET /photo Random JPEG resized to 800x480 (called by ESP32) GET /photo Random JPEG processed for the display (called by ESP32)
POST /heartbeat ESP32 reports what it displayed POST /heartbeat ESP32 reports what it displayed
GET /api/status Frame status (for Home Assistant REST sensor) GET /api/status Frame status (for Home Assistant REST sensor)
GET /api/photos List all photos as JSON GET /api/photos List all photos as JSON
POST /api/upload Upload new photos POST /api/upload Upload new photos
DELETE /api/photos/<name> Delete a photo DELETE /api/photos/<name> Delete a photo
GET /health Health check 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 argparse
@@ -20,7 +26,6 @@ import io
import json import json
import random import random
import sys import sys
import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -28,34 +33,156 @@ from flask import Flask, Response, jsonify, request, render_template_string
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from PIL import Image from PIL import Image
import numpy as np
from dither import dither_floyd_steinberg
app = Flask(__name__) app = Flask(__name__)
PHOTOS_DIR: Path = Path(".") PHOTOS_DIR: Path = Path(".")
TARGET_WIDTH = 800 PHYSICAL_WIDTH = 800
TARGET_HEIGHT = 480 PHYSICAL_HEIGHT = 480
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".bmp", ".tiff"} SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".bmp", ".tiff"}
MAX_UPLOAD_SIZE = 20 * 1024 * 1024 # 20MB
# Frame state — persisted to disk so it survives restarts DATA_DIR = Path("/data")
STATE_FILE = Path("/data/frame_state.json") STATE_FILE = DATA_DIR / "frame_state.json"
SETTINGS_FILE = DATA_DIR / "settings.json"
IMAGE_SETTINGS_FILE = DATA_DIR / "image_settings.json"
def load_state() -> dict: # --- Persistence helpers ---
def _load_json(path: Path, default):
try: try:
if STATE_FILE.exists(): if path.exists():
return json.loads(STATE_FILE.read_text()) return json.loads(path.read_text())
except Exception: except Exception:
pass pass
return {"last_update": None, "current_photo": None, "ip": None, "updates": 0} return default() if callable(default) else default
def save_state(state: dict): def _save_json(path: Path, data):
try: try:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(state)) path.write_text(json.dumps(data, indent=2))
except Exception as e: except Exception as e:
print(f"Warning: could not save state: {e}", file=sys.stderr) print(f"Warning: could not save {path}: {e}", file=sys.stderr)
frame_state = load_state() # 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]: def get_photo_list() -> list[Path]:
@@ -66,25 +193,6 @@ def get_photo_list() -> list[Path]:
return sorted(photos, key=lambda p: p.name) return sorted(photos, key=lambda p: p.name)
def resize_and_crop(img: Image.Image) -> Image.Image:
target_ratio = TARGET_WIDTH / TARGET_HEIGHT
img_ratio = img.width / img.height
if img_ratio > target_ratio:
new_height = TARGET_HEIGHT
new_width = int(img.width * (TARGET_HEIGHT / img.height))
else:
new_width = TARGET_WIDTH
new_height = int(img.height * (TARGET_WIDTH / img.width))
img = img.resize((new_width, new_height), Image.LANCZOS)
left = (new_width - TARGET_WIDTH) // 2
top = (new_height - TARGET_HEIGHT) // 2
img = img.crop((left, top, left + TARGET_WIDTH, top + TARGET_HEIGHT))
return img
def make_thumbnail(img: Image.Image, size: int = 300) -> bytes: def make_thumbnail(img: Image.Image, size: int = 300) -> bytes:
img.thumbnail((size, size), Image.LANCZOS) img.thumbnail((size, size), Image.LANCZOS)
buf = io.BytesIO() buf = io.BytesIO()
@@ -102,9 +210,9 @@ def random_photo():
chosen = random.choice(photos) chosen = random.choice(photos)
try: try:
img = Image.open(chosen) img = Image.open(chosen).convert("RGB")
img = img.convert("RGB") settings = get_image_settings(chosen.name)
img = resize_and_crop(img) img = process_image(img, settings)
buf = io.BytesIO() buf = io.BytesIO()
img.save(buf, format="JPEG", quality=85) img.save(buf, format="JPEG", quality=85)
@@ -119,7 +227,6 @@ def random_photo():
@app.route("/heartbeat", methods=["POST"]) @app.route("/heartbeat", methods=["POST"])
def heartbeat(): def heartbeat():
"""ESP32 reports after displaying a photo."""
global frame_state global frame_state
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
frame_state["last_update"] = datetime.now(timezone.utc).isoformat() frame_state["last_update"] = datetime.now(timezone.utc).isoformat()
@@ -127,15 +234,57 @@ def heartbeat():
frame_state["ip"] = request.remote_addr frame_state["ip"] = request.remote_addr
frame_state["free_heap"] = data.get("free_heap", None) frame_state["free_heap"] = data.get("free_heap", None)
frame_state["updates"] = frame_state.get("updates", 0) + 1 frame_state["updates"] = frame_state.get("updates", 0) + 1
save_state(frame_state) _save_json(STATE_FILE, frame_state)
return jsonify({"ok": True}) return jsonify({"ok": True})
# --- API endpoints --- # --- 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") @app.route("/api/status")
def api_status(): def api_status():
"""Frame status for Home Assistant REST sensor."""
photos = get_photo_list() photos = get_photo_list()
return jsonify({ return jsonify({
"state": "online" if frame_state.get("last_update") else "waiting", "state": "online" if frame_state.get("last_update") else "waiting",
@@ -144,13 +293,20 @@ def api_status():
"frame_ip": frame_state.get("ip"), "frame_ip": frame_state.get("ip"),
"total_photos": len(photos), "total_photos": len(photos),
"total_updates": frame_state.get("updates", 0), "total_updates": frame_state.get("updates", 0),
"orientation": frame_settings.get("orientation", "landscape"),
}) })
@app.route("/api/photos") @app.route("/api/photos")
def api_photos(): def api_photos():
photos = get_photo_list() photos = get_photo_list()
return jsonify([{"name": p.name, "size": p.stat().st_size} for p in photos]) 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"]) @app.route("/api/upload", methods=["POST"])
@@ -176,6 +332,8 @@ def api_delete(name: str):
if not path.exists(): if not path.exists():
return jsonify({"error": "not found"}), 404 return jsonify({"error": "not found"}), 404
path.unlink() path.unlink()
image_settings.pop(fname, None)
_save_json(IMAGE_SETTINGS_FILE, image_settings)
return jsonify({"deleted": fname}) return jsonify({"deleted": fname})
@@ -186,20 +344,238 @@ def thumbnail(name: str):
if not path.exists(): if not path.exists():
return "", 404 return "", 404
try: try:
size = request.args.get("size", 300, type=int)
size = min(size, 1600)
img = Image.open(path).convert("RGB") img = Image.open(path).convert("RGB")
data = make_thumbnail(img) data = make_thumbnail(img, size)
return Response(data, mimetype="image/jpeg") return Response(data, mimetype="image/jpeg")
except Exception: except Exception:
return "", 500 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("/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")
return render_template_string(SIMULATE_UI, photo_name=fname,
settings=settings, orientation=orientation)
SIMULATE_UI = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>E-Paper Simulator — {{ photo_name }}</title>
<style>
:root { --bg: #1a1a1a; --card: #222; --border: #444; --accent: #6c8; }
* { 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; }
.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);
}
.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;
}
.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; }
.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; }
.pan-controls { display: {{ 'flex' if settings.mode == 'zoom' else 'none' }}; flex-direction: column; gap: 0.6rem; }
.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; }
</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>
<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>
</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>
<a class="back" href="/">&larr; Back to gallery</a>
<script>
const photoName = {{ photo_name | tojson }};
let refreshTimer = null;
function onModeChange() {
const mode = document.getElementById('mode').value;
document.getElementById('pan-controls').style.display = mode === 'zoom' ? 'flex' : 'none';
scheduleRefresh();
}
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 scheduleRefresh() {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(refreshPreview, 300);
}
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');
let url = `/preview/${encodeURIComponent(photoName)}?mode=${mode}`;
if (mode === 'zoom') url += `&pan_x=${panX}&pan_y=${panY}`;
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;
}
function saveSettings() {
const mode = document.getElementById('mode').value;
const body = { mode };
if (mode === 'zoom') {
body.pan_x = parseFloat(document.getElementById('pan_x').value);
body.pan_y = parseFloat(document.getElementById('pan_y').value);
}
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();
});
}
</script>
</body>
</html>
"""
# --- Web UI --- # --- Web UI ---
@app.route("/") @app.route("/")
def index(): def index():
photos = get_photo_list() 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, return render_template_string(WEB_UI, photos=photos, state=frame_state,
photo_count=len(photos)) photo_count=len(photos), orientation=orientation,
default_mode=default_mode)
@app.route("/health") @app.route("/health")
@@ -225,12 +601,17 @@ WEB_UI = """<!DOCTYPE html>
.status-card { .status-card {
background: var(--card); border: 1px solid var(--border); border-radius: 8px; 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; 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 .item { display: flex; flex-direction: column; gap: 0.2rem; }
.status-card .label { font-size: 0.75rem; text-transform: uppercase; color: #888; } .status-card .label { font-size: 0.75rem; text-transform: uppercase; color: #888; }
.status-card .value { font-size: 0.95rem; } .status-card .value { font-size: 0.95rem; }
.status-card .online { color: var(--accent); } .status-card .online { color: var(--accent); }
.status-card .waiting { color: #f80; } .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 { .upload-area {
background: var(--card); border: 2px dashed var(--border); border-radius: 8px; background: var(--card); border: 2px dashed var(--border); border-radius: 8px;
@@ -250,11 +631,15 @@ WEB_UI = """<!DOCTYPE html>
padding: 0.5rem 0.6rem; display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0.6rem; display: flex; justify-content: space-between; align-items: center;
font-size: 0.8rem; font-size: 0.8rem;
} }
.photo-card .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 70%; } .photo-card .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 65%; }
.photo-card .delete { .photo-card .actions { display: flex; gap: 0.3rem; }
background: none; border: none; color: #f55; cursor: pointer; font-size: 0.85rem; .photo-card .btn {
background: none; border: none; cursor: pointer; font-size: 0.85rem;
padding: 0.2rem 0.4rem; border-radius: 4px; 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 .delete:hover { background: rgba(255,85,85,0.15); }
.photo-card.current { border-color: var(--accent); } .photo-card.current { border-color: var(--accent); }
@@ -287,8 +672,19 @@ WEB_UI = """<!DOCTYPE html>
<span class="value">{{ photo_count }}</span> <span class="value">{{ photo_count }}</span>
</div> </div>
<div class="item"> <div class="item">
<span class="label">Total Refreshes</span> <span class="label">Orientation</span>
<span class="value">{{ state.get('updates', 0) }}</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> </div>
@@ -304,7 +700,10 @@ WEB_UI = """<!DOCTYPE html>
<img src="/thumb/{{ p.name }}" loading="lazy" alt="{{ p.name }}"> <img src="/thumb/{{ p.name }}" loading="lazy" alt="{{ p.name }}">
<div class="info"> <div class="info">
<span class="name" title="{{ p.name }}">{{ p.name }}</span> <span class="name" title="{{ p.name }}">{{ p.name }}</span>
<button class="delete" onclick="deletePhoto('{{ p.name }}')" title="Delete">&times;</button> <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>
</div> </div>
{% endfor %} {% endfor %}
@@ -336,12 +735,16 @@ function deletePhoto(name) {
if (!confirm('Delete ' + name + '?')) return; if (!confirm('Delete ' + name + '?')) return;
fetch('/api/photos/' + encodeURIComponent(name), { method: 'DELETE' }) fetch('/api/photos/' + encodeURIComponent(name), { method: 'DELETE' })
.then(r => r.json()) .then(r => r.json())
.then(() => { .then(() => { document.querySelector('[data-name="' + name + '"]')?.remove(); });
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})
});
} }
// Convert UTC timestamps to local time
document.querySelectorAll('[data-utc]').forEach(el => { document.querySelectorAll('[data-utc]').forEach(el => {
const d = new Date(el.dataset.utc); const d = new Date(el.dataset.utc);
el.textContent = d.toLocaleString(); el.textContent = d.toLocaleString();
@@ -359,11 +762,24 @@ if __name__ == "__main__":
args = parser.parse_args() args = parser.parse_args()
PHOTOS_DIR = Path(args.photos_dir) 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(): if not PHOTOS_DIR.exists():
PHOTOS_DIR.mkdir(parents=True, exist_ok=True) 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"Serving photos from: {PHOTOS_DIR.resolve()}")
print(f"Found {len(get_photo_list())} photos") 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}") print(f"Listening on port {args.port}")
app.run(host="0.0.0.0", port=args.port) app.run(host="0.0.0.0", port=args.port)