From 87445e1f3265054fad287eda45b01e80a7977816 Mon Sep 17 00:00:00 2001 From: Dorin Rusu Date: Sun, 16 Aug 2026 13:55:17 +0200 Subject: [PATCH 1/2] Add web preview for plugins Render a plugin's output in the browser without pushing it to the e-ink display. Adds a POST /preview_plugin route that runs the same orientation/resize/enhancement pipeline as the display and returns the result as a base64 PNG, plus a Preview button and modal in the plugin settings UI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/blueprints/plugin.py | 34 +++++++++++++++++++++ src/display/display_manager.py | 25 +++++++++++++--- src/static/styles/main.css | 24 +++++++++++++++ src/templates/plugin.html | 55 ++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 4 deletions(-) diff --git a/src/blueprints/plugin.py b/src/blueprints/plugin.py index b7a80d860..8883a5fae 100644 --- a/src/blueprints/plugin.py +++ b/src/blueprints/plugin.py @@ -4,6 +4,8 @@ from refresh_task import ManualRefresh, PlaylistRefresh import json import os +import io +import base64 import logging logger = logging.getLogger(__name__) @@ -226,6 +228,38 @@ def display_plugin_instance(): return jsonify({"success": True, "message": "Display updated"}), 200 +@plugin_bp.route('/preview_plugin', methods=['POST']) +def preview_plugin(): + """Generate a plugin image and return a preview without pushing it to the display.""" + device_config = current_app.config['DEVICE_CONFIG'] + display_manager = current_app.config['DISPLAY_MANAGER'] + + try: + plugin_settings = parse_form(request.form) + plugin_settings.update(handle_request_files(request.files)) + plugin_id = plugin_settings.pop("plugin_id") + + plugin_config = device_config.get_plugin(plugin_id) + if not plugin_config: + return jsonify({"error": f"Plugin '{plugin_id}' not found"}), 404 + + plugin = get_plugin_instance(plugin_config) + image = plugin.generate_image(plugin_settings, device_config) + + # Apply the same processing pipeline used when rendering to the device + image_settings = plugin_config.get("image_settings", []) + image = display_manager.process_image(image, image_settings) + + # Encode the processed image as a base64 data URL for the browser + buffer = io.BytesIO() + image.convert("RGB").save(buffer, format="PNG") + encoded = base64.b64encode(buffer.getvalue()).decode("utf-8") + except Exception as e: + logger.exception(f"Error in preview_plugin: {str(e)}") + return jsonify({"error": f"An error occurred: {str(e)}"}), 500 + + return jsonify({"success": True, "image": f"data:image/png;base64,{encoded}"}), 200 + @plugin_bp.route('/update_now', methods=['POST']) def update_now(): device_config = current_app.config['DEVICE_CONFIG'] diff --git a/src/display/display_manager.py b/src/display/display_manager.py index 71d9459f3..2ffbd2dd0 100644 --- a/src/display/display_manager.py +++ b/src/display/display_manager.py @@ -54,6 +54,26 @@ def __init__(self, device_config): else: raise ValueError(f"Unsupported display type: {display_type}") + def process_image(self, image, image_settings=[]): + + """ + Applies the same orientation, resize and enhancement pipeline used when + rendering to the device, without pushing the result to any display. + + Args: + image (PIL.Image): The image to be processed. + image_settings (list, optional): List of settings to modify image rendering. + + Returns: + PIL.Image: The processed image as it would appear on the device. + """ + + image = change_orientation(image, self.device_config.get_config("orientation")) + image = resize_image(image, self.device_config.get_resolution(), image_settings) + if self.device_config.get_config("inverted_image"): image = image.rotate(180) + image = apply_image_enhancement(image, self.device_config.get_config("image_settings")) + return image + def display_image(self, image, image_settings=[]): """ @@ -75,10 +95,7 @@ def display_image(self, image, image_settings=[]): image.save(self.device_config.current_image_file) # Resize and adjust orientation - image = change_orientation(image, self.device_config.get_config("orientation")) - image = resize_image(image, self.device_config.get_resolution(), image_settings) - if self.device_config.get_config("inverted_image"): image = image.rotate(180) - image = apply_image_enhancement(image, self.device_config.get_config("image_settings")) + image = self.process_image(image, image_settings) # Pass to the concrete instance to render to the device. self.display.display_image(image, image_settings) \ No newline at end of file diff --git a/src/static/styles/main.css b/src/static/styles/main.css index 0e2f5bc4d..c015b326b 100644 --- a/src/static/styles/main.css +++ b/src/static/styles/main.css @@ -665,6 +665,30 @@ html[data-theme="dark"] .settings-button.dark-mode-toggle::before { transition: color 0.3s ease; } +.preview-hint { + font-size: 0.85rem; + color: var(--text-secondary); + text-align: center; + margin-bottom: 12px; +} + +.preview-image-container { + display: flex; + justify-content: center; + align-items: center; +} + +.preview-image-container img { + max-width: 100%; + height: auto; + border: 1px solid var(--shadow-medium); + border-radius: 4px; +} + +#previewModal .modal-content { + max-width: 700px; +} + .close-button { color: var(--close-btn-color); float: right; diff --git a/src/templates/plugin.html b/src/templates/plugin.html index 9285f0920..306cfac7d 100644 --- a/src/templates/plugin.html +++ b/src/templates/plugin.html @@ -132,6 +132,42 @@ } } + async function handlePreview() { + const loadingIndicator = document.getElementById('loadingIndicator'); + loadingIndicator.style.display = 'block'; + + const form = document.getElementById('settingsForm'); + const formData = new FormData(form); + + // Add uploaded files to the form under its key + Object.keys(uploadedFiles).forEach(key => { + if (uploadedFiles[key].length > 0) { + uploadedFiles[key].forEach(file => { + formData.append(key, file); + }); + } + }); + + try { + const response = await fetch('{{ url_for("plugin.preview_plugin") }}', { + method: 'POST', + body: formData + }); + const result = await response.json(); + if (response.ok) { + document.getElementById('previewImage').src = result.image; + openModal('previewModal'); + } else { + showResponseModal('failure', `Error! ${result.error}`); + } + } catch (error) { + console.error('Error:', error); + alert('An error occurred while generating the preview.'); + } finally { + loadingIndicator.style.display = 'none'; + } + } + function openModal(modal_id) { // Handle refresh settings modal specially with the manager if (modal_id === 'refreshSettingsModal' && editRefreshManager) { @@ -155,6 +191,10 @@ if (event.target === modal) { modal.style.display = 'none'; } + const previewModal = document.getElementById('previewModal'); + if (event.target === previewModal) { + previewModal.style.display = 'none'; + } }; function toggleCollapsible(button) { @@ -404,9 +444,11 @@

{{ plugin.display_name }}

{% if plugin_instance %} + {% else %} + {% endif %} @@ -464,5 +506,18 @@

Add to Playlist

+ + + From 72f953aab0fe4b1196235309b372238288fc8f24 Mon Sep 17 00:00:00 2001 From: Dorin Rusu Date: Sun, 16 Aug 2026 14:22:25 +0200 Subject: [PATCH 2/2] Fix preview freezing the app by serializing rendering The preview route rendered the plugin image inline in the single Waitress worker thread, blocking the whole web UI and running a second Chromium screenshot process concurrently with the background refresh thread, which could hang indefinitely. Route previews through the background refresh task (like Update Now) so rendering is serialized, and capture the processed image instead of displaying it. Falls back to inline rendering only when the refresh task is not running (dev mode). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/blueprints/plugin.py | 17 ++++++++---- src/refresh_task.py | 58 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/blueprints/plugin.py b/src/blueprints/plugin.py index 8883a5fae..31c3db877 100644 --- a/src/blueprints/plugin.py +++ b/src/blueprints/plugin.py @@ -233,6 +233,7 @@ def preview_plugin(): """Generate a plugin image and return a preview without pushing it to the display.""" device_config = current_app.config['DEVICE_CONFIG'] display_manager = current_app.config['DISPLAY_MANAGER'] + refresh_task = current_app.config['REFRESH_TASK'] try: plugin_settings = parse_form(request.form) @@ -243,12 +244,18 @@ def preview_plugin(): if not plugin_config: return jsonify({"error": f"Plugin '{plugin_id}' not found"}), 404 - plugin = get_plugin_instance(plugin_config) - image = plugin.generate_image(plugin_settings, device_config) + if refresh_task.running: + # Serialize rendering through the background refresh task to avoid running a + # second browser/screenshot process concurrently, which can hang the display. + image = refresh_task.preview(plugin_id, plugin_settings) + else: + # In development mode the refresh task may not be running, render directly. + plugin = get_plugin_instance(plugin_config) + image = plugin.generate_image(plugin_settings, device_config) + image = display_manager.process_image(image, plugin_config.get("image_settings", [])) - # Apply the same processing pipeline used when rendering to the device - image_settings = plugin_config.get("image_settings", []) - image = display_manager.process_image(image, image_settings) + if image is None: + return jsonify({"error": "Failed to generate preview image"}), 500 # Encode the processed image as a base64 data URL for the browser buffer = io.BytesIO() diff --git a/src/refresh_task.py b/src/refresh_task.py index f554e2adb..13d82ddee 100644 --- a/src/refresh_task.py +++ b/src/refresh_task.py @@ -112,6 +112,16 @@ def _run(self): continue plugin = get_plugin_instance(plugin_config) image = refresh_action.execute(plugin, self.device_config, current_dt) + + if isinstance(refresh_action, PreviewRefresh): + # Preview only: process the image like the display would, + # but do not push it to the device or persist any state. + logger.info("Generating plugin preview (not updating display).") + self.refresh_result["preview_image"] = self.display_manager.process_image( + image, plugin_config.get("image_settings", []) + ) + continue + image_hash = compute_image_hash(image) refresh_info = refresh_action.get_refresh_info() @@ -149,6 +159,28 @@ def manual_update(self, refresh_action): else: logger.warning("Background refresh task is not running, unable to do a manual update") + def preview(self, plugin_id, plugin_settings): + """Generate a plugin image for preview via the background thread without updating the display. + + Rendering is serialized through the single background worker (same as a manual update) to avoid + concurrent browser/screenshot processes, and returns the processed image without pushing it to + the device. + """ + if not self.running: + raise RuntimeError("Background refresh task is not running, unable to generate a preview") + + with self.condition: + self.manual_update_request = PreviewRefresh(plugin_id, plugin_settings) + self.refresh_result = {} + self.refresh_event.clear() + + self.condition.notify_all() # Wake the thread to process the preview request + + self.refresh_event.wait() + if self.refresh_result.get("exception"): + raise self.refresh_result.get("exception") + return self.refresh_result.get("preview_image") + def signal_config_change(self): """Notify the background thread that config has changed (e.g., interval updated).""" if self.running: @@ -219,7 +251,7 @@ def get_plugin_id(self): class ManualRefresh(RefreshAction): """Performs a manual refresh based on a plugin's ID and its associated settings. - + Attributes: plugin_id (str): The ID of the plugin to refresh. plugin_settings (dict): The settings for the manual refresh. @@ -241,6 +273,30 @@ def get_plugin_id(self): """Return the plugin ID associated with this refresh.""" return self.plugin_id +class PreviewRefresh(RefreshAction): + """Generates a plugin image for preview without updating the display. + + Attributes: + plugin_id (str): The ID of the plugin to preview. + plugin_settings (dict): The settings to render the preview with. + """ + + def __init__(self, plugin_id: str, plugin_settings: dict): + self.plugin_id = plugin_id + self.plugin_settings = plugin_settings + + def execute(self, plugin, device_config, current_dt: datetime): + """Generates the plugin image using the stored plugin ID and settings.""" + return plugin.generate_image(self.plugin_settings, device_config) + + def get_refresh_info(self): + """Return refresh metadata as a dictionary.""" + return {"refresh_type": "Preview", "plugin_id": self.plugin_id} + + def get_plugin_id(self): + """Return the plugin ID associated with this refresh.""" + return self.plugin_id + class PlaylistRefresh(RefreshAction): """Performs a refresh using a plugin instance within a playlist context.