diff --git a/LEDMatrix.wiki b/LEDMatrix.wiki index fbd8d89a..a01c72e1 160000 --- a/LEDMatrix.wiki +++ b/LEDMatrix.wiki @@ -1 +1 @@ -Subproject commit fbd8d89a186e5757d1785737b0ee4c03ad442dbf +Subproject commit a01c72e156b46c08a5ef1c67db79acd73300a6f7 diff --git a/assets/static_images/default.png b/assets/static_images/default.png new file mode 100644 index 00000000..6db47288 Binary files /dev/null and b/assets/static_images/default.png differ diff --git a/config/config.template.json b/config/config.template.json index 7bba122e..a7e30b74 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -72,7 +72,8 @@ "ncaam_basketball_upcoming": 30, "music": 30, "of_the_day": 40, - "news_manager": 60 + "news_manager": 60, + "static_image": 10 }, "use_short_date_format": true }, @@ -130,7 +131,7 @@ "duration_buffer": 0.1 }, "odds_ticker": { - "enabled": false, + "enabled": true, "show_favorite_teams_only": true, "games_per_favorite_team": 1, "max_games_per_league": 5, @@ -233,8 +234,6 @@ "recent_games_to_show": 1, "upcoming_games_to_show": 1, "show_favorite_teams_only": true, - "show_all_live": false, - "show_shots_on_goal": false, "favorite_teams": [ "TB" ], @@ -301,7 +300,6 @@ "recent_games_to_show": 1, "upcoming_games_to_show": 1, "show_favorite_teams_only": true, - "show_all_live": false, "favorite_teams": [ "TB", "DAL" @@ -334,11 +332,9 @@ "recent_games_to_show": 1, "upcoming_games_to_show": 1, "show_favorite_teams_only": true, - "show_all_live": false, "favorite_teams": [ "UGA", - "AUB", - "AP_TOP_25" + "AUB" ], "logo_dir": "assets/sports/ncaa_logos", "show_records": true, @@ -369,8 +365,6 @@ "recent_games_to_show": 1, "upcoming_games_to_show": 1, "show_favorite_teams_only": true, - "show_all_live": false, - "show_series_summary": false, "favorite_teams": [ "UGA", "AUB" @@ -419,8 +413,6 @@ "recent_games_to_show": 1, "upcoming_games_to_show": 1, "show_favorite_teams_only": true, - "show_all_live": false, - "show_shots_on_goal": false, "favorite_teams": [ "RIT" ], @@ -452,8 +444,6 @@ "recent_games_to_show": 1, "upcoming_games_to_show": 1, "show_favorite_teams_only": true, - "show_all_live": false, - "show_series_summary": false, "favorite_teams": [ "TB", "TEX" @@ -611,5 +601,16 @@ 0, 0 ] + }, + "static_image": { + "enabled": false, + "image_path": "assets/static_images/default.png", + "fit_to_display": true, + "preserve_aspect_ratio": true, + "background_color": [ + 0, + 0, + 0 + ] } } diff --git a/src/display_controller.py b/src/display_controller.py index 9a55df8e..151499b6 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -34,6 +34,7 @@ from src.ncaam_hockey_managers import NCAAMHockeyLiveManager, NCAAMHockeyRecentM from src.youtube_display import YouTubeDisplay from src.calendar_manager import CalendarManager from src.text_display import TextDisplay +from src.static_image_manager import StaticImageManager from src.music_manager import MusicManager from src.of_the_day_manager import OfTheDayManager from src.news_manager import NewsManager @@ -66,10 +67,12 @@ class DisplayController: self.calendar = CalendarManager(self.display_manager, self.config) if self.config.get('calendar', {}).get('enabled', False) else None self.youtube = YouTubeDisplay(self.display_manager, self.config) if self.config.get('youtube', {}).get('enabled', False) else None self.text_display = TextDisplay(self.display_manager, self.config) if self.config.get('text_display', {}).get('enabled', False) else None + self.static_image = StaticImageManager(self.display_manager, self.config) if self.config.get('static_image', {}).get('enabled', False) else None self.of_the_day = OfTheDayManager(self.display_manager, self.config) if self.config.get('of_the_day', {}).get('enabled', False) else None self.news_manager = NewsManager(self.config, self.display_manager, self.config_manager) if self.config.get('news_manager', {}).get('enabled', False) else None logger.info(f"Calendar Manager initialized: {'Object' if self.calendar else 'None'}") logger.info(f"Text Display initialized: {'Object' if self.text_display else 'None'}") + logger.info(f"Static Image Manager initialized: {'Object' if self.static_image else 'None'}") logger.info(f"OfTheDay Manager initialized: {'Object' if self.of_the_day else 'None'}") logger.info(f"News Manager initialized: {'Object' if self.news_manager else 'None'}") logger.info("Display modes initialized in %.3f seconds", time.time() - init_time) @@ -282,6 +285,7 @@ class DisplayController: if self.calendar: self.available_modes.append('calendar') if self.youtube: self.available_modes.append('youtube') if self.text_display: self.available_modes.append('text_display') + if self.static_image: self.available_modes.append('static_image') if self.of_the_day: self.available_modes.append('of_the_day') if self.news_manager: self.available_modes.append('news_manager') if self.music_manager: @@ -600,6 +604,7 @@ class DisplayController: if self.calendar: self.calendar.update(time.time()) if self.youtube: self.youtube.update() if self.text_display: self.text_display.update() + if self.static_image: self.static_image.update() if self.of_the_day: self.of_the_day.update(time.time()) else: # Not scrolling, perform all updates normally @@ -610,6 +615,7 @@ class DisplayController: if self.calendar: self.calendar.update(time.time()) if self.youtube: self.youtube.update() if self.text_display: self.text_display.update() + if self.static_image: self.static_image.update() if self.of_the_day: self.of_the_day.update(time.time()) # Update sports managers for leaderboard data @@ -1208,6 +1214,8 @@ class DisplayController: manager_to_display = self.youtube elif self.current_display_mode == 'text_display' and self.text_display: manager_to_display = self.text_display + elif self.current_display_mode == 'static_image' and self.static_image: + manager_to_display = self.static_image elif self.current_display_mode == 'of_the_day' and self.of_the_day: manager_to_display = self.of_the_day elif self.current_display_mode == 'news_manager' and self.news_manager: @@ -1321,6 +1329,8 @@ class DisplayController: manager_to_display.display(force_clear=self.force_clear) elif self.current_display_mode == 'text_display': manager_to_display.display() # Assumes internal clearing + elif self.current_display_mode == 'static_image': + manager_to_display.display(force_clear=self.force_clear) elif self.current_display_mode == 'of_the_day': manager_to_display.display(force_clear=self.force_clear) elif self.current_display_mode == 'news_manager': diff --git a/src/static_image_manager.py b/src/static_image_manager.py new file mode 100644 index 00000000..f18d9999 --- /dev/null +++ b/src/static_image_manager.py @@ -0,0 +1,211 @@ +import logging +import os +import time +from typing import Optional, Tuple +from PIL import Image, ImageOps +import json + +from .display_manager import DisplayManager + +logger = logging.getLogger(__name__) + +class StaticImageManager: + """ + Manager for displaying static images on the LED matrix. + Supports image scaling, transparency, and configurable display duration. + """ + + def __init__(self, display_manager: DisplayManager, config: dict): + self.display_manager = display_manager + self.config = config.get('static_image', {}) + + # Configuration + self.enabled = self.config.get('enabled', False) + self.image_path = self.config.get('image_path', '') + # Get display duration from main display_durations block + self.display_duration = config.get('display', {}).get('display_durations', {}).get('static_image', 10) + self.fit_to_display = self.config.get('fit_to_display', True) # Auto-fit to display dimensions + self.preserve_aspect_ratio = self.config.get('preserve_aspect_ratio', True) + self.background_color = tuple(self.config.get('background_color', [0, 0, 0])) + + # State + self.current_image = None + self.image_loaded = False + self.last_update_time = 0 + + # Load initial image if enabled + if self.enabled and self.image_path: + self._load_image() + + def _load_image(self) -> bool: + """ + Load and process the image for display. + Returns True if successful, False otherwise. + """ + if not self.image_path or not os.path.exists(self.image_path): + logger.warning(f"[Static Image] Image file not found: {self.image_path}") + return False + + try: + # Load the image + img = Image.open(self.image_path) + + # Convert to RGBA to handle transparency + if img.mode != 'RGBA': + img = img.convert('RGBA') + + # Get display dimensions + display_width = self.display_manager.matrix.width + display_height = self.display_manager.matrix.height + + # Calculate target size - always fit to display while preserving aspect ratio + target_size = self._calculate_fit_size(img.size, (display_width, display_height)) + + # Resize image + if self.preserve_aspect_ratio: + img = img.resize(target_size, Image.Resampling.LANCZOS) + else: + img = img.resize((display_width, display_height), Image.Resampling.LANCZOS) + + # Create display-sized canvas with background color + canvas = Image.new('RGB', (display_width, display_height), self.background_color) + + # Calculate position to center the image + paste_x = (display_width - img.width) // 2 + paste_y = (display_height - img.height) // 2 + + # Handle transparency by compositing + if img.mode == 'RGBA': + # Create a temporary image with the background color + temp_canvas = Image.new('RGB', (display_width, display_height), self.background_color) + temp_canvas.paste(img, (paste_x, paste_y), img) + canvas = temp_canvas + else: + canvas.paste(img, (paste_x, paste_y)) + + self.current_image = canvas + self.image_loaded = True + self.last_update_time = time.time() + + logger.info(f"[Static Image] Successfully loaded and processed image: {self.image_path}") + logger.info(f"[Static Image] Original size: {Image.open(self.image_path).size}, " + f"Display size: {target_size}, Position: ({paste_x}, {paste_y})") + + return True + + except Exception as e: + logger.error(f"[Static Image] Error loading image {self.image_path}: {e}") + self.image_loaded = False + return False + + def _calculate_fit_size(self, image_size: Tuple[int, int], display_size: Tuple[int, int]) -> Tuple[int, int]: + """ + Calculate the size to fit an image within display bounds while preserving aspect ratio. + """ + img_width, img_height = image_size + display_width, display_height = display_size + + # Calculate scaling factor to fit within display + scale_x = display_width / img_width + scale_y = display_height / img_height + scale = min(scale_x, scale_y) + + return (int(img_width * scale), int(img_height * scale)) + + def update(self): + """ + Update method - no continuous updates needed for static images. + """ + pass + + def display(self, force_clear: bool = False): + """ + Display the static image on the LED matrix. + """ + if not self.enabled or not self.image_loaded or not self.current_image: + if self.enabled: + logger.warning("[Static Image] Manager enabled but no image loaded") + return + + # Clear display if requested + if force_clear: + self.display_manager.clear() + + # Set the image on the display manager + self.display_manager.image = self.current_image.copy() + + # Update the display + self.display_manager.update_display() + + logger.debug(f"[Static Image] Displayed image: {self.image_path}") + + def set_image_path(self, image_path: str) -> bool: + """ + Set a new image path and load it. + Returns True if successful, False otherwise. + """ + self.image_path = image_path + return self._load_image() + + def set_fit_to_display(self, fit_to_display: bool): + """ + Set the fit to display option and reload the image. + """ + self.fit_to_display = fit_to_display + if self.image_path: + self._load_image() + logger.info(f"[Static Image] Fit to display set to: {self.fit_to_display}") + + def set_display_duration(self, duration: int): + """ + Set the display duration in seconds. + """ + self.display_duration = max(1, duration) # Minimum 1 second + logger.info(f"[Static Image] Display duration set to: {self.display_duration} seconds") + + def set_background_color(self, color: Tuple[int, int, int]): + """ + Set the background color and reload the image. + """ + self.background_color = color + if self.image_path: + self._load_image() + logger.info(f"[Static Image] Background color set to: {self.background_color}") + + def get_image_info(self) -> dict: + """ + Get information about the currently loaded image. + """ + if not self.image_loaded or not self.current_image: + return {"loaded": False} + + return { + "loaded": True, + "path": self.image_path, + "display_size": self.current_image.size, + "fit_to_display": self.fit_to_display, + "display_duration": self.display_duration, + "background_color": self.background_color + } + + def reload_image(self) -> bool: + """ + Reload the current image. + """ + if not self.image_path: + logger.warning("[Static Image] No image path set for reload") + return False + + return self._load_image() + + def is_enabled(self) -> bool: + """ + Check if the manager is enabled. + """ + return self.enabled + + def get_display_duration(self) -> int: + """ + Get the display duration in seconds. + """ + return self.display_duration diff --git a/templates/index_v2.html b/templates/index_v2.html index d3dc3c5a..c67a6be1 100644 --- a/templates/index_v2.html +++ b/templates/index_v2.html @@ -875,6 +875,9 @@ + @@ -1637,6 +1640,37 @@ + +
+
+
+

Static Image Display

+
+ +
+
+
+
+
+ +
+ + +
+ +
+
+
+
+
+
+
+
+ +
+
+
+
@@ -3114,6 +3148,55 @@ }); })(); + // Static Image form submit + (function augmentStaticImageForm(){ + const form = document.getElementById('static_image-form'); + const initColor = (input) => { + try { const rgb = JSON.parse(input.dataset.rgb || '[255,255,255]'); input.value = rgbToHex(rgb); } catch {} + }; + initColor(document.getElementById('static_image_background_color')); + + form.addEventListener('submit', async function(e){ + e.preventDefault(); + const payload = { + static_image: { + enabled: document.getElementById('static_image_enabled').checked, + image_path: document.getElementById('static_image_path').value, + fit_to_display: document.getElementById('static_image_fit_to_display').checked, + preserve_aspect_ratio: document.getElementById('static_image_preserve_aspect_ratio').checked, + background_color: hexToRgbArray(document.getElementById('static_image_background_color').value) + } + }; + await saveConfigJson(payload); + }); + })(); + + // Image upload handler + function handleImageUpload(input) { + const file = input.files[0]; + if (file) { + const formData = new FormData(); + formData.append('image', file); + + fetch('/upload_image', { + method: 'POST', + body: formData + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + document.getElementById('static_image_path').value = data.path; + showNotification('Image uploaded successfully!', 'success'); + } else { + showNotification('Failed to upload image: ' + data.error, 'error'); + } + }) + .catch(error => { + showNotification('Error uploading image: ' + error, 'error'); + }); + } + } + // YouTube form submit (function augmentYouTubeForm(){ const form = document.getElementById('youtube-form'); diff --git a/test/test_odds_fix.py b/test/test_odds_fix.py new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/test/test_odds_fix.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/test_odds_fix_simple.py b/test/test_odds_fix_simple.py new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/test/test_odds_fix_simple.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test_static_image.py b/test_static_image.py new file mode 100644 index 00000000..f1a77784 --- /dev/null +++ b/test_static_image.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +""" +Test script for the static image manager. +This script tests the static image manager functionality without requiring the full LED matrix hardware. +""" + +import sys +import os +import logging +from PIL import Image + +# Add the src directory to the path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +from src.static_image_manager import StaticImageManager +from src.display_manager import DisplayManager + +# Configure logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +class MockDisplayManager: + """Mock display manager for testing without hardware.""" + + def __init__(self): + self.matrix = type('Matrix', (), {'width': 64, 'height': 32})() + self.image = Image.new("RGB", (self.matrix.width, self.matrix.height)) + self.draw = None + + def clear(self): + """Clear the display.""" + self.image = Image.new("RGB", (self.matrix.width, self.matrix.height)) + logger.info("Display cleared") + + def update_display(self): + """Update the display (mock).""" + logger.info("Display updated") + +def test_static_image_manager(): + """Test the static image manager functionality.""" + logger.info("Starting static image manager test...") + + # Create mock display manager + display_manager = MockDisplayManager() + + # Test configuration + config = { + 'static_image': { + 'enabled': True, + 'image_path': 'assets/static_images/default.png', + 'display_duration': 10, + 'zoom_scale': 1.0, + 'preserve_aspect_ratio': True, + 'background_color': [0, 0, 0] + } + } + + try: + # Initialize the static image manager + logger.info("Initializing static image manager...") + manager = StaticImageManager(display_manager, config) + + # Test basic functionality + logger.info(f"Manager enabled: {manager.is_enabled()}") + logger.info(f"Display duration: {manager.get_display_duration()}") + + # Test image loading + if manager.image_loaded: + logger.info("Image loaded successfully") + image_info = manager.get_image_info() + logger.info(f"Image info: {image_info}") + else: + logger.warning("Image not loaded") + + # Test display + logger.info("Testing display...") + manager.display() + + # Test configuration changes + logger.info("Testing configuration changes...") + manager.set_zoom_scale(1.5) + manager.set_display_duration(15) + manager.set_background_color((255, 0, 0)) + + # Test with a different image path (if it exists) + test_image_path = 'assets/static_images/test.png' + if os.path.exists(test_image_path): + logger.info(f"Testing with image: {test_image_path}") + manager.set_image_path(test_image_path) + + logger.info("Static image manager test completed successfully!") + return True + + except Exception as e: + logger.error(f"Test failed with error: {e}") + return False + +if __name__ == '__main__': + success = test_static_image_manager() + sys.exit(0 if success else 1) diff --git a/test_static_image_simple.py b/test_static_image_simple.py new file mode 100644 index 00000000..9e4d5878 --- /dev/null +++ b/test_static_image_simple.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +""" +Simple test script for the static image manager. +This script tests the image processing functionality without requiring the full LED matrix hardware. +""" + +import sys +import os +import logging +from PIL import Image + +# Configure logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +def test_image_processing(): + """Test image processing functionality.""" + logger.info("Testing image processing...") + + # Test image path + image_path = 'assets/static_images/default.png' + + if not os.path.exists(image_path): + logger.error(f"Test image not found: {image_path}") + return False + + try: + # Load the image + img = Image.open(image_path) + logger.info(f"Original image size: {img.size}") + + # Test different zoom scales + display_size = (64, 32) + + for zoom_scale in [0.5, 1.0, 1.5, 2.0]: + logger.info(f"Testing zoom scale: {zoom_scale}") + + # Calculate target size + if zoom_scale == 1.0: + # Fit to display while preserving aspect ratio + scale_x = display_size[0] / img.size[0] + scale_y = display_size[1] / img.size[1] + scale = min(scale_x, scale_y) + target_size = (int(img.size[0] * scale), int(img.size[1] * scale)) + else: + # Apply zoom scale + target_size = (int(img.size[0] * zoom_scale), int(img.size[1] * zoom_scale)) + + logger.info(f"Target size: {target_size}") + + # Resize image + resized_img = img.resize(target_size, Image.Resampling.LANCZOS) + + # Create display canvas + canvas = Image.new('RGB', display_size, (0, 0, 0)) + + # Center the image + paste_x = max(0, (display_size[0] - resized_img.width) // 2) + paste_y = max(0, (display_size[1] - resized_img.height) // 2) + + # Handle transparency + if resized_img.mode == 'RGBA': + temp_canvas = Image.new('RGB', display_size, (0, 0, 0)) + temp_canvas.paste(resized_img, (paste_x, paste_y), resized_img) + canvas = temp_canvas + else: + canvas.paste(resized_img, (paste_x, paste_y)) + + logger.info(f"Final canvas size: {canvas.size}") + logger.info(f"Image position: ({paste_x}, {paste_y})") + + # Save test output + output_path = f'test_output_zoom_{zoom_scale}.png' + canvas.save(output_path) + logger.info(f"Test output saved: {output_path}") + + logger.info("Image processing test completed successfully!") + return True + + except Exception as e: + logger.error(f"Test failed with error: {e}") + return False + +def test_config_loading(): + """Test configuration loading.""" + logger.info("Testing configuration loading...") + + # Test configuration + config = { + 'static_image': { + 'enabled': True, + 'image_path': 'assets/static_images/default.png', + 'display_duration': 10, + 'zoom_scale': 1.0, + 'preserve_aspect_ratio': True, + 'background_color': [0, 0, 0] + } + } + + try: + # Test configuration parsing + static_config = config.get('static_image', {}) + enabled = static_config.get('enabled', False) + image_path = static_config.get('image_path', '') + display_duration = static_config.get('display_duration', 10) + zoom_scale = static_config.get('zoom_scale', 1.0) + preserve_aspect_ratio = static_config.get('preserve_aspect_ratio', True) + background_color = tuple(static_config.get('background_color', [0, 0, 0])) + + logger.info(f"Configuration loaded:") + logger.info(f" Enabled: {enabled}") + logger.info(f" Image path: {image_path}") + logger.info(f" Display duration: {display_duration}") + logger.info(f" Zoom scale: {zoom_scale}") + logger.info(f" Preserve aspect ratio: {preserve_aspect_ratio}") + logger.info(f" Background color: {background_color}") + + logger.info("Configuration loading test completed successfully!") + return True + + except Exception as e: + logger.error(f"Configuration test failed with error: {e}") + return False + +if __name__ == '__main__': + logger.info("Starting static image manager simple test...") + + success1 = test_config_loading() + success2 = test_image_processing() + + if success1 and success2: + logger.info("All tests completed successfully!") + sys.exit(0) + else: + logger.error("Some tests failed!") + sys.exit(1) diff --git a/web_interface_v2.py b/web_interface_v2.py index edc1304b..9e8cfebf 100644 --- a/web_interface_v2.py +++ b/web_interface_v2.py @@ -20,7 +20,9 @@ from src.odds_ticker_manager import OddsTickerManager from src.calendar_manager import CalendarManager from src.youtube_display import YouTubeDisplay from src.text_display import TextDisplay +from src.static_image_manager import StaticImageManager from src.news_manager import NewsManager +from werkzeug.utils import secure_filename from src.nhl_managers import NHLLiveManager, NHLRecentManager, NHLUpcomingManager from src.nba_managers import NBALiveManager, NBARecentManager, NBAUpcomingManager from src.mlb_manager import MLBLiveManager, MLBRecentManager, MLBUpcomingManager @@ -421,6 +423,10 @@ class OnDemandRunner: mgr = TextDisplay(display_manager, cfg) self._force_enable(mgr) return mgr, lambda fc=False: mgr.display(), lambda: getattr(mgr, 'update', lambda: None)(), 5.0 + if mode == 'static_image': + mgr = StaticImageManager(display_manager, cfg) + self._force_enable(mgr) + return mgr, lambda fc=False: mgr.display(force_clear=fc), lambda: mgr.update(), float(cfg.get('display', {}).get('display_durations', {}).get('static_image', 10)) if mode == 'of_the_day': from src.of_the_day_manager import OfTheDayManager # local import to avoid circulars mgr = OfTheDayManager(display_manager, cfg) @@ -1594,6 +1600,42 @@ def get_current_display(): except Exception as e: return jsonify({'status': 'error', 'message': str(e), 'image': None}), 500 +@app.route('/upload_image', methods=['POST']) +def upload_image(): + """Upload an image for static image display.""" + try: + if 'image' not in request.files: + return jsonify({'success': False, 'error': 'No image file provided'}) + + file = request.files['image'] + if file.filename == '': + return jsonify({'success': False, 'error': 'No image file selected'}) + + if file: + # Secure the filename + filename = secure_filename(file.filename) + # Ensure we have a valid extension + if not filename.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp')): + return jsonify({'success': False, 'error': 'Invalid file type. Only image files are allowed.'}) + + # Create the static images directory if it doesn't exist + static_images_dir = os.path.join(os.path.dirname(__file__), 'assets', 'static_images') + os.makedirs(static_images_dir, exist_ok=True) + + # Save the file + file_path = os.path.join(static_images_dir, filename) + file.save(file_path) + + # Return the relative path for the config + relative_path = f"assets/static_images/{filename}" + + logger.info(f"Image uploaded successfully: {relative_path}") + return jsonify({'success': True, 'path': relative_path}) + + except Exception as e: + logger.error(f"Error uploading image: {e}") + return jsonify({'success': False, 'error': str(e)}) + @app.route('/api/editor/layouts', methods=['GET']) def get_custom_layouts(): """Return saved custom layouts for the editor if available."""