Feature/static image manager (#95)

* feat(static-image): Add static image manager with web interface

- Create StaticImageManager class with image scaling and transparency support
- Add configuration options for display duration, zoom scale, and background color
- Integrate with display controller and web interface
- Add image upload functionality to web interface
- Support for various image formats with proper scaling
- Efficient image processing with aspect ratio preservation
- Ready for future scrolling feature implementation

* fix(static-image): Move display duration to main display_durations block

- Remove display_duration from static_image config section
- Update StaticImageManager to read duration from display.display_durations.static_image
- Remove display duration field from web interface form
- Update web interface JavaScript to not include display_duration in payload
- Follows same pattern as all other managers in the project

* feat(static-image): Add fit to display option

- Add fit_to_display checkbox to automatically scale images to fit display
- When enabled, images are scaled to fit display dimensions while preserving aspect ratio
- When disabled, manual zoom_scale control is available
- Update web interface with smart form controls (zoom scale disabled when fit to display is on)
- Prevents stretching or cropping - images are always properly fitted
- Default to fit_to_display=true for better user experience

* refactor(static-image): Remove zoom_scale and simplify to fit_to_display only

- Remove zoom_scale option entirely as it was confusing and redundant
- Simplify image processing to always fit to display dimensions
- Remove zoom_scale field from web interface
- Clean up JavaScript to remove zoom scale logic
- Images are now always properly fitted without stretching or cropping
- Much simpler and more intuitive user experience
This commit is contained in:
Chuck
2025-10-05 10:46:36 -04:00
committed by GitHub
parent a115a1ed6b
commit f3d02e07ea
11 changed files with 600 additions and 15 deletions

Submodule LEDMatrix.wiki updated: fbd8d89a18...a01c72e156

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 B

View File

@@ -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
]
}
}

View File

@@ -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':

211
src/static_image_manager.py Normal file
View File

@@ -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

View File

@@ -875,6 +875,9 @@
<button class="tab-btn" onclick="showTab('text')">
<i class="fas fa-font"></i> Text
</button>
<button class="tab-btn" onclick="showTab('static_image')">
<i class="fas fa-image"></i> Static Image
</button>
<button class="tab-btn" onclick="showTab('features')">
<i class="fas fa-star"></i> Features
</button>
@@ -1637,6 +1640,37 @@
</div>
</div>
<!-- Static Image Tab -->
<div id="static_image" class="tab-content">
<div class="config-section">
<div style="display:flex; justify-content: space-between; align-items:center;">
<h3>Static Image Display</h3>
<div style="display:flex; gap:8px;">
<button type="button" class="btn btn-info" onclick="startOnDemand('static_image')"><i class="fas fa-bolt"></i> On-Demand</button>
</div>
</div>
<form id="static_image-form">
<div class="form-group"><label><input type="checkbox" id="static_image_enabled" {% if safe_config_get(main_config, 'static_image', 'enabled', default=False) %}checked{% endif %}> Enable</label></div>
<div class="form-group">
<label for="static_image_path">Image Path</label>
<div style="display:flex; gap:8px;">
<input type="text" id="static_image_path" class="form-control" value="{{ safe_config_get(main_config, 'static_image', 'image_path', default='assets/static_images/default.png') }}">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('image_upload').click()"><i class="fas fa-upload"></i> Upload</button>
</div>
<input type="file" id="image_upload" accept="image/*" style="display:none" onchange="handleImageUpload(this)">
</div>
<div class="form-row">
<div class="form-group"><label><input type="checkbox" id="static_image_fit_to_display" {% if safe_config_get(main_config, 'static_image', 'fit_to_display', default=True) %}checked{% endif %}> Fit to Display</label></div>
</div>
<div class="form-row">
<div class="form-group"><label><input type="checkbox" id="static_image_preserve_aspect_ratio" {% if safe_config_get(main_config, 'static_image', 'preserve_aspect_ratio', default=True) %}checked{% endif %}> Preserve Aspect Ratio</label></div>
<div class="form-group"><label for="static_image_background_color">Background Color</label><input type="color" id="static_image_background_color" class="form-control" data-rgb='{{ safe_config_get(main_config, 'static_image', 'background_color', default=[0, 0, 0]) | tojson }}'></div>
</div>
<button type="submit" class="btn btn-success">Save Static Image Settings</button>
</form>
</div>
</div>
<!-- Features Tab -->
<div id="features" class="tab-content">
<div class="config-section">
@@ -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');

1
test/test_odds_fix.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

100
test_static_image.py Normal file
View File

@@ -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)

136
test_static_image_simple.py Normal file
View File

@@ -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)

View File

@@ -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."""