Plugin framework for photo source backends: - PhotoProvider ABC with lifecycle hooks, auth flow, cache control - @register_provider decorator + registry for auto-discovery - ProviderManager handles instance lifecycle, config persistence, aggregated photo pool with weighted random selection - ProviderCache: in-memory list cache (per-provider TTL), disk-based thumbnail cache, optional full image cache for remote providers - Per-image settings migrated from bare filenames to composite keys (provider_id:photo_id) with automatic one-time migration + backup Local directory provider included as reference implementation — wraps the existing filesystem logic into the provider interface with upload and delete support. All existing endpoints preserved with composite key routing. ESP32 firmware unchanged — still hits GET /photo, gets a JPEG. New API: /api/providers/* for managing provider instances, auth flows, and cache control. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
913 lines
35 KiB
Python
913 lines
35 KiB
Python
"""
|
|
E-Ink Photo Frame Server
|
|
|
|
Serves photos for the ESP32 photo frame and provides a web UI for management.
|
|
Photos come from pluggable provider backends (local directory, SFTP, cloud, etc).
|
|
Tracks frame status via heartbeat reports from the ESP32.
|
|
"""
|
|
|
|
import argparse
|
|
import io
|
|
import json
|
|
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
|
|
|
|
from dither import dither_floyd_steinberg
|
|
from providers import (
|
|
ProviderManager, available_provider_types, migrate_image_settings,
|
|
)
|
|
# Import providers to trigger registration
|
|
import providers.local # noqa: F401
|
|
|
|
app = Flask(__name__)
|
|
|
|
PHYSICAL_WIDTH = 800
|
|
PHYSICAL_HEIGHT = 480
|
|
|
|
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",
|
|
"default_mode": "zoom",
|
|
"letterbox_color": [255, 255, 255],
|
|
}
|
|
|
|
frame_settings = _load_json(SETTINGS_FILE, lambda: dict(DEFAULT_SETTINGS))
|
|
|
|
# Per-image settings keyed by composite key: "provider_id:photo_id"
|
|
image_settings: dict = _load_json(IMAGE_SETTINGS_FILE, dict)
|
|
|
|
# Provider manager — initialized in main
|
|
provider_manager: ProviderManager = None # type: ignore
|
|
|
|
|
|
def _parse_photo_key(photo_key: str) -> tuple[str, str]:
|
|
"""Parse a composite key into (provider_id, photo_id)."""
|
|
if ":" not in photo_key:
|
|
# Legacy bare filename — assume local-default
|
|
return "local-default", photo_key
|
|
return photo_key.split(":", 1)
|
|
|
|
|
|
def get_image_settings(composite_key: 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(composite_key, {})
|
|
merged = {**defaults, **per_image}
|
|
if merged["mode"] is None:
|
|
merged["mode"] = frame_settings.get("default_mode", "zoom")
|
|
return merged
|
|
|
|
|
|
# --- Image processing (unchanged — operates on PIL Images) ---
|
|
|
|
def get_logical_dimensions() -> tuple[int, int]:
|
|
orientation = frame_settings.get("orientation", "landscape")
|
|
if orientation == "landscape":
|
|
return PHYSICAL_WIDTH, PHYSICAL_HEIGHT
|
|
else:
|
|
return PHYSICAL_HEIGHT, PHYSICAL_WIDTH
|
|
|
|
|
|
def process_image(img: Image.Image, settings: dict) -> Image.Image:
|
|
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)
|
|
|
|
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:
|
|
target_ratio = tw / th
|
|
img_ratio = img.width / img.height
|
|
|
|
if img_ratio > target_ratio:
|
|
new_height = th
|
|
new_width = int(img.width * (th / img.height))
|
|
else:
|
|
new_width = tw
|
|
new_height = int(img.height * (tw / img.width))
|
|
|
|
img = img.resize((new_width, new_height), Image.LANCZOS)
|
|
|
|
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:
|
|
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:
|
|
new_width = tw
|
|
new_height = int(img.height * (tw / img.width))
|
|
else:
|
|
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
|
|
|
|
|
|
# --- ESP32 endpoints ---
|
|
|
|
@app.route("/photo")
|
|
def random_photo():
|
|
ref = provider_manager.pick_random_photo()
|
|
if not ref:
|
|
return jsonify({"error": "No photos found"}), 404
|
|
try:
|
|
img = provider_manager.get_photo_image(ref)
|
|
settings = get_image_settings(ref.composite_key)
|
|
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": ref.composite_key})
|
|
except Exception as e:
|
|
print(f"Error processing {ref.composite_key}: {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})
|
|
|
|
|
|
# --- Frame 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)
|
|
|
|
|
|
# --- Per-image settings API (uses composite keys) ---
|
|
|
|
@app.route("/api/photos/<path:photo_key>/settings", methods=["GET"])
|
|
def api_get_image_settings(photo_key: str):
|
|
return jsonify(get_image_settings(photo_key))
|
|
|
|
@app.route("/api/photos/<path:photo_key>/settings", methods=["PUT"])
|
|
def api_put_image_settings(photo_key: str):
|
|
data = request.get_json()
|
|
allowed_keys = {"mode", "pan_x", "pan_y"}
|
|
current = image_settings.get(photo_key, {})
|
|
for k, v in data.items():
|
|
if k in allowed_keys:
|
|
current[k] = v
|
|
image_settings[photo_key] = current
|
|
_save_json(IMAGE_SETTINGS_FILE, image_settings)
|
|
return jsonify(get_image_settings(photo_key))
|
|
|
|
@app.route("/api/photos/<path:photo_key>/settings", methods=["DELETE"])
|
|
def api_delete_image_settings(photo_key: str):
|
|
image_settings.pop(photo_key, None)
|
|
_save_json(IMAGE_SETTINGS_FILE, image_settings)
|
|
return jsonify(get_image_settings(photo_key))
|
|
|
|
|
|
# --- Provider management API ---
|
|
|
|
@app.route("/api/providers/types")
|
|
def api_provider_types():
|
|
return jsonify(available_provider_types())
|
|
|
|
@app.route("/api/providers", methods=["GET"])
|
|
def api_list_providers():
|
|
result = []
|
|
for iid, cfg in provider_manager.all_configs().items():
|
|
entry = {"id": iid, **cfg}
|
|
provider = provider_manager.get_instance(iid)
|
|
if provider:
|
|
entry["health"] = provider.health_check().__dict__
|
|
else:
|
|
entry["health"] = {"status": "disabled"}
|
|
result.append(entry)
|
|
return jsonify(result)
|
|
|
|
@app.route("/api/providers", methods=["POST"])
|
|
def api_add_provider():
|
|
data = request.get_json()
|
|
iid = data.get("id", "").strip()
|
|
ptype = data.get("type", "")
|
|
config = data.get("config", {})
|
|
if not iid or not ptype:
|
|
return jsonify({"error": "id and type are required"}), 400
|
|
try:
|
|
provider_manager.add_provider(iid, ptype, config)
|
|
return jsonify({"id": iid, "status": "created"})
|
|
except ValueError as e:
|
|
return jsonify({"error": str(e)}), 400
|
|
|
|
@app.route("/api/providers/<provider_id>", methods=["GET"])
|
|
def api_get_provider(provider_id: str):
|
|
configs = provider_manager.all_configs()
|
|
if provider_id not in configs:
|
|
return jsonify({"error": "not found"}), 404
|
|
entry = {"id": provider_id, **configs[provider_id]}
|
|
provider = provider_manager.get_instance(provider_id)
|
|
if provider:
|
|
entry["health"] = provider.health_check().__dict__
|
|
return jsonify(entry)
|
|
|
|
@app.route("/api/providers/<provider_id>", methods=["PUT"])
|
|
def api_update_provider(provider_id: str):
|
|
data = request.get_json()
|
|
try:
|
|
if "config" in data:
|
|
provider_manager.update_provider_config(provider_id, data["config"])
|
|
if "enabled" in data:
|
|
provider_manager.set_enabled(provider_id, data["enabled"])
|
|
return jsonify({"id": provider_id, "status": "updated"})
|
|
except ValueError as e:
|
|
return jsonify({"error": str(e)}), 400
|
|
|
|
@app.route("/api/providers/<provider_id>", methods=["DELETE"])
|
|
def api_delete_provider(provider_id: str):
|
|
provider_manager.remove_provider(provider_id)
|
|
return jsonify({"id": provider_id, "status": "removed"})
|
|
|
|
@app.route("/api/providers/<provider_id>/cache/clear", methods=["POST"])
|
|
def api_clear_provider_cache(provider_id: str):
|
|
provider_manager.cache.clear(provider_id)
|
|
return jsonify({"status": "cleared"})
|
|
|
|
# Auth flow endpoints
|
|
@app.route("/api/providers/<provider_id>/auth/start", methods=["POST"])
|
|
def api_auth_start(provider_id: str):
|
|
provider = provider_manager.get_instance(provider_id)
|
|
if not provider:
|
|
return jsonify({"error": "not found"}), 404
|
|
callback_url = request.json.get("callback_url", "") if request.json else ""
|
|
return jsonify(provider.auth_start(callback_url))
|
|
|
|
@app.route("/api/providers/<provider_id>/auth/callback")
|
|
def api_auth_callback(provider_id: str):
|
|
provider = provider_manager.get_instance(provider_id)
|
|
if not provider:
|
|
return jsonify({"error": "not found"}), 404
|
|
result = provider.auth_callback(dict(request.args))
|
|
if "config" in result:
|
|
provider_manager.save_provider_auth_state(provider_id, result.pop("config"))
|
|
return jsonify(result)
|
|
|
|
@app.route("/api/providers/<provider_id>/auth/code", methods=["POST"])
|
|
def api_auth_code(provider_id: str):
|
|
provider = provider_manager.get_instance(provider_id)
|
|
if not provider:
|
|
return jsonify({"error": "not found"}), 404
|
|
code = request.json.get("code", "") if request.json else ""
|
|
return jsonify(provider.auth_submit_code(code))
|
|
|
|
@app.route("/api/providers/<provider_id>/auth/status")
|
|
def api_auth_status(provider_id: str):
|
|
provider = provider_manager.get_instance(provider_id)
|
|
if not provider:
|
|
return jsonify({"error": "not found"}), 404
|
|
return jsonify(provider.get_auth_state())
|
|
|
|
|
|
# --- Photo API endpoints ---
|
|
|
|
@app.route("/api/status")
|
|
def api_status():
|
|
all_photos = provider_manager.get_all_photos()
|
|
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(all_photos),
|
|
"total_updates": frame_state.get("updates", 0),
|
|
"orientation": frame_settings.get("orientation", "landscape"),
|
|
})
|
|
|
|
|
|
@app.route("/api/photos")
|
|
def api_photos():
|
|
all_photos = provider_manager.get_all_photos()
|
|
result = []
|
|
for ref in all_photos:
|
|
entry = {
|
|
"key": ref.composite_key,
|
|
"name": ref.display_name,
|
|
"provider": ref.provider_id,
|
|
}
|
|
provider = provider_manager.get_instance(ref.provider_id)
|
|
if provider:
|
|
size = provider.get_photo_size(ref.photo_id)
|
|
if size is not None:
|
|
entry["size"] = size
|
|
if ref.composite_key in image_settings:
|
|
entry["settings"] = image_settings[ref.composite_key]
|
|
result.append(entry)
|
|
return jsonify(result)
|
|
|
|
|
|
@app.route("/api/upload", methods=["POST"])
|
|
def api_upload():
|
|
target_id = request.args.get("provider", None)
|
|
|
|
# Find a provider that supports upload
|
|
provider = None
|
|
if target_id:
|
|
provider = provider_manager.get_instance(target_id)
|
|
else:
|
|
for p in provider_manager.all_instances().values():
|
|
if p.supports_upload():
|
|
provider = p
|
|
break
|
|
|
|
if not provider or not provider.supports_upload():
|
|
return jsonify({"error": "No upload-capable provider found"}), 400
|
|
|
|
uploaded = []
|
|
for f in request.files.getlist("photos"):
|
|
if not f.filename:
|
|
continue
|
|
fname = secure_filename(f.filename)
|
|
try:
|
|
ref = provider.upload_photo(fname, f.read())
|
|
uploaded.append(ref.composite_key)
|
|
except Exception as e:
|
|
print(f"Upload error for {fname}: {e}", file=sys.stderr)
|
|
|
|
# Invalidate the provider's photo list cache
|
|
provider_manager.cache.invalidate_list(provider.instance_id)
|
|
return jsonify({"uploaded": uploaded, "count": len(uploaded)})
|
|
|
|
|
|
@app.route("/api/photos/<path:photo_key>", methods=["DELETE"])
|
|
def api_delete(photo_key: str):
|
|
provider_id, photo_id = _parse_photo_key(photo_key)
|
|
provider = provider_manager.get_instance(provider_id)
|
|
if not provider:
|
|
return jsonify({"error": "provider not found"}), 404
|
|
if not provider.supports_delete():
|
|
return jsonify({"error": "provider does not support deletion"}), 400
|
|
try:
|
|
provider.delete_photo(photo_id)
|
|
except FileNotFoundError:
|
|
return jsonify({"error": "not found"}), 404
|
|
image_settings.pop(photo_key, None)
|
|
_save_json(IMAGE_SETTINGS_FILE, image_settings)
|
|
provider_manager.cache.invalidate_list(provider_id)
|
|
return jsonify({"deleted": photo_key})
|
|
|
|
|
|
@app.route("/thumb/<path:photo_key>")
|
|
def thumbnail(photo_key: str):
|
|
ref = provider_manager.find_photo_ref(photo_key)
|
|
if not ref:
|
|
return "", 404
|
|
try:
|
|
size = request.args.get("size", 300, type=int)
|
|
size = min(size, 1600)
|
|
data = provider_manager.get_photo_thumbnail(ref, size)
|
|
return Response(data, mimetype="image/jpeg")
|
|
except Exception:
|
|
return "", 500
|
|
|
|
|
|
# --- Simulator / Preview ---
|
|
|
|
@app.route("/preview/<path:photo_key>")
|
|
def preview_photo(photo_key: str):
|
|
ref = provider_manager.find_photo_ref(photo_key)
|
|
if not ref:
|
|
return jsonify({"error": "not found"}), 404
|
|
|
|
settings = get_image_settings(ref.composite_key)
|
|
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 = provider_manager.get_photo_image(ref)
|
|
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():
|
|
ref = provider_manager.pick_random_photo()
|
|
if not ref:
|
|
return jsonify({"error": "No photos found"}), 404
|
|
return preview_photo(ref.composite_key)
|
|
|
|
|
|
@app.route("/api/photos/<path:photo_key>/geometry")
|
|
def photo_geometry(photo_key: str):
|
|
ref = provider_manager.find_photo_ref(photo_key)
|
|
if not ref:
|
|
return jsonify({"error": "not found"}), 404
|
|
img = provider_manager.get_photo_image(ref)
|
|
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/<path:photo_key>")
|
|
def simulate_page(photo_key: str):
|
|
ref = provider_manager.find_photo_ref(photo_key)
|
|
if not ref:
|
|
return "", 404
|
|
settings = get_image_settings(ref.composite_key)
|
|
orientation = frame_settings.get("orientation", "landscape")
|
|
lw, lh = get_logical_dimensions()
|
|
return render_template_string(SIMULATE_UI, photo_key=ref.composite_key,
|
|
photo_name=ref.display_name,
|
|
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); }
|
|
.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 { position: absolute; background: var(--dim); pointer-events: none; transition: all 0.05s; }
|
|
.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; }
|
|
.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="/">← 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_key }}?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_key }}">
|
|
</div>
|
|
<span class="rendering" id="rendering" style="visibility:hidden">Rendering...</span>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
const photoKey = {{ photo_key | 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;
|
|
const sourceImg = document.getElementById('source-img');
|
|
const cropWindow = document.getElementById('crop-window');
|
|
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'),
|
|
};
|
|
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, imgH = sourceImg.clientHeight;
|
|
if (!imgW || !imgH) return;
|
|
let cropW, cropH;
|
|
if (mode === 'letterbox') { cropW = imgW; cropH = imgH; panX = 0; panY = 0; }
|
|
else {
|
|
const imgAspect = imgW / imgH;
|
|
if (displayAspect > imgAspect) { cropW = imgW; cropH = imgW / displayAspect; }
|
|
else { cropH = imgH; cropW = imgH * displayAspect; }
|
|
}
|
|
const maxLeft = imgW - cropW, maxTop = imgH - cropH;
|
|
const left = maxLeft * panX, top = maxTop * panY;
|
|
cropWindow.style.width = cropW + 'px'; cropWindow.style.height = cropH + 'px';
|
|
cropWindow.style.left = left + 'px'; cropWindow.style.top = top + 'px';
|
|
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`;
|
|
}
|
|
let dragging = false, 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, 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, maxTop = imgH - cropH;
|
|
panX = Math.max(0, Math.min(1, dragStartPanX + (maxLeft > 0 ? (e.clientX - dragStartX) / maxLeft : 0)));
|
|
panY = Math.max(0, Math.min(1, dragStartPanY + (maxTop > 0 ? (e.clientY - dragStartY) / maxTop : 0)));
|
|
dirty = true; layoutCrop();
|
|
});
|
|
window.addEventListener('pointerup', () => { if (dragging) { dragging = false; schedulePreview(); } });
|
|
let previewTimer = null;
|
|
function schedulePreview() { clearTimeout(previewTimer); previewTimer = setTimeout(refreshPreview, 400); }
|
|
function refreshPreview() {
|
|
renderingLabel.style.visibility = 'visible';
|
|
let url = `/preview/${photoKey}?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/${photoKey}/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);
|
|
});
|
|
}
|
|
window.addEventListener('resize', layoutCrop);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
# --- Web UI ---
|
|
|
|
@app.route("/")
|
|
def index():
|
|
all_photos = provider_manager.get_all_photos()
|
|
orientation = frame_settings.get("orientation", "landscape")
|
|
default_mode = frame_settings.get("default_mode", "zoom")
|
|
return render_template_string(WEB_UI, photos=all_photos, state=frame_state,
|
|
photo_count=len(all_photos), orientation=orientation,
|
|
default_mode=default_mode)
|
|
|
|
|
|
@app.route("/health")
|
|
def health():
|
|
all_photos = provider_manager.get_all_photos()
|
|
return jsonify({"status": "ok", "photo_count": len(all_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>■</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.composite_key else '' }}" data-key="{{ p.composite_key }}">
|
|
<img src="/thumb/{{ p.composite_key }}" loading="lazy" alt="{{ p.display_name }}">
|
|
<div class="info">
|
|
<span class="name" title="{{ p.display_name }}">{{ p.display_name }}</span>
|
|
<div class="actions">
|
|
<a class="btn simulate" href="/simulate/{{ p.composite_key }}" title="Preview on e-paper">■</a>
|
|
<button class="btn delete" onclick="deletePhoto('{{ p.composite_key }}')" title="Delete">×</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(key) {
|
|
if (!confirm('Delete this photo?')) return;
|
|
fetch('/api/photos/' + key, { method: 'DELETE' })
|
|
.then(r => r.json())
|
|
.then(() => { document.querySelector('[data-key="' + key + '"]')?.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 = 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 state 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)
|
|
|
|
# Initialize provider manager
|
|
provider_manager = ProviderManager(DATA_DIR, default_photos_dir=str(photos_dir.resolve()))
|
|
provider_manager.load()
|
|
|
|
# Migrate legacy image settings to composite keys
|
|
migrate_image_settings(DATA_DIR)
|
|
|
|
# Reload image settings after migration
|
|
image_settings = _load_json(IMAGE_SETTINGS_FILE, dict)
|
|
|
|
all_photos = provider_manager.get_all_photos()
|
|
print(f"Photos: {len(all_photos)} across {len(provider_manager.all_instances())} provider(s)")
|
|
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)
|