Compare commits

..

No commits in common. "950aa6bee9daa7098324530bab8d43bb9542ec36" and "7d8d2d42f85ad9f71fc6058f94efc94193d3dfa5" have entirely different histories.

8 changed files with 141 additions and 761 deletions

View File

@ -354,16 +354,12 @@ class AsyncImageLoader(QObject):
if img.mode != 'RGBA': if img.mode != 'RGBA':
img = img.convert('RGBA') img = img.convert('RGBA')
# Downsample if target size specified (preserving aspect ratio) # Downsample if target size specified
if target_size: if target_size:
max_size = target_size[0] # Assume square target (2048, 2048) current_size = img.size
if img.width > max_size or img.height > max_size: if current_size[0] > target_size[0] or current_size[1] > target_size[1]:
# Calculate scale to fit within max_size while preserving aspect ratio img = img.resize(target_size, Image.Resampling.LANCZOS)
scale = min(max_size / img.width, max_size / img.height) logger.debug(f"Downsampled {path}: {current_size} -> {target_size}")
new_width = int(img.width * scale)
new_height = int(img.height * scale)
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
logger.debug(f"Downsampled {path}: {img.size} -> ({new_width}, {new_height})")
return img return img

View File

@ -1,251 +0,0 @@
"""
Async project loader for pyPhotoAlbum
Loads projects asynchronously with progress updates to prevent UI freezing.
"""
import os
import json
import zipfile
import tempfile
from typing import Optional, Tuple
from pathlib import Path
from PyQt6.QtCore import QThread, pyqtSignal
from pyPhotoAlbum.project import Project
from pyPhotoAlbum.models import ImageData, set_asset_resolution_context
from pyPhotoAlbum.version_manager import (
CURRENT_DATA_VERSION,
check_version_compatibility,
VersionCompatibility,
DataMigration
)
class AsyncProjectLoader(QThread):
"""
Async worker thread for loading projects from ZIP files.
Signals:
progress_updated(int, int, str): Emitted with (current, total, message)
load_complete(Project): Emitted when loading succeeds
load_failed(str): Emitted when loading fails with error message
"""
progress_updated = pyqtSignal(int, int, str) # current, total, message
load_complete = pyqtSignal(object) # Project instance
load_failed = pyqtSignal(str) # error message
def __init__(self, zip_path: str, extract_to: Optional[str] = None):
super().__init__()
self.zip_path = zip_path
self.extract_to = extract_to
self._cancelled = False
def cancel(self):
"""Cancel the loading operation"""
self._cancelled = True
def run(self):
"""Run the async loading operation"""
try:
if not os.path.exists(self.zip_path):
self.load_failed.emit(f"ZIP file not found: {self.zip_path}")
return
if self._cancelled:
return
# Progress: Starting
self.progress_updated.emit(0, 100, "Preparing to load...")
# Track if we created a temp directory
temp_dir_obj = None
# Determine extraction directory
if self.extract_to is None:
zip_basename = os.path.splitext(os.path.basename(self.zip_path))[0]
temp_dir_obj = tempfile.TemporaryDirectory(prefix=f"pyPhotoAlbum_{zip_basename}_")
extract_to = temp_dir_obj.name
else:
os.makedirs(self.extract_to, exist_ok=True)
extract_to = self.extract_to
if self._cancelled:
return
# Progress: Extracting ZIP
self.progress_updated.emit(10, 100, "Extracting project files...")
# Extract ZIP contents with progress
with zipfile.ZipFile(self.zip_path, 'r') as zipf:
file_list = zipf.namelist()
total_files = len(file_list)
for i, filename in enumerate(file_list):
if self._cancelled:
return
zipf.extract(filename, extract_to)
# Update progress every 10 files or on last file
if i % 10 == 0 or i == total_files - 1:
progress = 10 + int((i / total_files) * 30) # 10-40%
self.progress_updated.emit(
progress, 100,
f"Extracting files... ({i + 1}/{total_files})"
)
if self._cancelled:
return
# Progress: Loading project data
self.progress_updated.emit(45, 100, "Loading project data...")
# Load project.json
project_json_path = os.path.join(extract_to, 'project.json')
if not os.path.exists(project_json_path):
self.load_failed.emit("Invalid project file: project.json not found")
return
with open(project_json_path, 'r') as f:
project_data = json.load(f)
if self._cancelled:
return
# Progress: Checking version
self.progress_updated.emit(55, 100, "Checking version compatibility...")
# Check version compatibility
file_version = project_data.get('data_version', project_data.get('serialization_version', '1.0'))
is_compatible, error_msg = check_version_compatibility(file_version, self.zip_path)
if not is_compatible:
self.load_failed.emit(error_msg)
return
# Apply migrations if needed
if VersionCompatibility.needs_migration(file_version):
self.progress_updated.emit(60, 100, f"Migrating from version {file_version}...")
try:
project_data = DataMigration.migrate(project_data, file_version, CURRENT_DATA_VERSION)
except Exception as e:
self.load_failed.emit(f"Migration failed: {str(e)}")
return
if self._cancelled:
return
# Progress: Creating project
self.progress_updated.emit(70, 100, "Creating project...")
# Create new project
project_name = project_data.get('name', 'Untitled Project')
project = Project(name=project_name, folder_path=extract_to)
# Deserialize project data
project.deserialize(project_data)
# Update folder path to extraction location
project.folder_path = extract_to
project.asset_manager.project_folder = extract_to
project.asset_manager.assets_folder = os.path.join(extract_to, "assets")
# Attach temporary directory to project (if we created one)
if temp_dir_obj is not None:
project._temp_dir = temp_dir_obj
if self._cancelled:
return
# Progress: Normalizing paths
self.progress_updated.emit(85, 100, "Normalizing asset paths...")
# Normalize asset paths
self._normalize_asset_paths(project, extract_to)
# Progress: Setting up asset resolution
self.progress_updated.emit(95, 100, "Setting up asset resolution...")
# Set asset resolution context
zip_directory = os.path.dirname(os.path.abspath(self.zip_path))
set_asset_resolution_context(extract_to, additional_search_paths=[zip_directory])
if self._cancelled:
return
# Progress: Complete
self.progress_updated.emit(100, 100, "Loading complete!")
# Emit success
self.load_complete.emit(project)
except Exception as e:
error_msg = f"Error loading project: {str(e)}"
self.load_failed.emit(error_msg)
def _normalize_asset_paths(self, project: Project, project_folder: str):
"""
Normalize asset paths in a loaded project to be relative to the project folder.
"""
normalized_count = 0
for page in project.pages:
for element in page.layout.elements:
if isinstance(element, ImageData) and element.image_path:
original_path = element.image_path
# Skip if already a simple relative path
if not os.path.isabs(original_path) and not original_path.startswith('./projects/'):
continue
# Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg"
if '/assets/' in original_path:
parts = original_path.split('/assets/')
if len(parts) == 2:
new_path = os.path.join('assets', parts[1])
element.image_path = new_path
normalized_count += 1
continue
# Pattern 2: Absolute path - try to make it relative
if os.path.isabs(original_path):
try:
new_path = os.path.relpath(original_path, project_folder)
element.image_path = new_path
normalized_count += 1
except ValueError:
pass
if normalized_count > 0:
print(f"Normalized {normalized_count} asset paths")
def load_from_zip_async(zip_path: str, extract_to: Optional[str] = None,
progress_callback=None, complete_callback=None,
error_callback=None) -> AsyncProjectLoader:
"""
Load a project from a ZIP file asynchronously.
Args:
zip_path: Path to the ZIP file to load
extract_to: Optional directory to extract to. If None, uses a temporary directory.
progress_callback: Optional callback(current, total, message) for progress updates
complete_callback: Optional callback(project) when loading completes
error_callback: Optional callback(error_msg) when loading fails
Returns:
AsyncProjectLoader instance (already started)
"""
loader = AsyncProjectLoader(zip_path, extract_to)
if progress_callback:
loader.progress_updated.connect(progress_callback)
if complete_callback:
loader.load_complete.connect(complete_callback)
if error_callback:
loader.load_failed.connect(error_callback)
loader.start()
return loader

View File

@ -1,184 +0,0 @@
"""
Loading progress widget for pyPhotoAlbum
Displays loading progress in the lower-right corner of the window.
"""
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar
from PyQt6.QtCore import Qt, QPropertyAnimation, QEasingCurve, pyqtProperty
from PyQt6.QtGui import QPalette, QColor
class LoadingWidget(QWidget):
"""
A widget that displays loading progress in the lower-right corner.
Features:
- Fade in/out animations
- Progress bar with percentage
- Status message display
- Compact, non-intrusive design
"""
def __init__(self, parent=None):
super().__init__(parent)
# Widget configuration
self.setWindowFlags(Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, False)
self.setFixedSize(280, 80)
# Styling
self.setStyleSheet("""
QWidget {
background-color: rgba(50, 50, 50, 230);
border-radius: 8px;
border: 1px solid rgba(100, 100, 100, 180);
}
QLabel {
color: white;
background-color: transparent;
font-size: 11pt;
}
QProgressBar {
border: 1px solid rgba(80, 80, 80, 180);
border-radius: 4px;
background-color: rgba(30, 30, 30, 200);
text-align: center;
color: white;
font-size: 10pt;
}
QProgressBar::chunk {
background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 rgba(70, 130, 180, 220),
stop:1 rgba(100, 160, 210, 220));
border-radius: 3px;
}
""")
# Layout
layout = QVBoxLayout()
layout.setContentsMargins(12, 10, 12, 10)
layout.setSpacing(8)
# Status label
self._status_label = QLabel("Loading...")
self._status_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
layout.addWidget(self._status_label)
# Progress bar with percentage label
progress_layout = QHBoxLayout()
progress_layout.setSpacing(8)
self._progress_bar = QProgressBar()
self._progress_bar.setMinimum(0)
self._progress_bar.setMaximum(100)
self._progress_bar.setValue(0)
self._progress_bar.setTextVisible(True)
self._progress_bar.setFormat("%p%")
progress_layout.addWidget(self._progress_bar, 1)
layout.addLayout(progress_layout)
self.setLayout(layout)
# Animation for fade in/out
self._opacity = 1.0
self._fade_animation = QPropertyAnimation(self, b"opacity")
self._fade_animation.setDuration(300)
self._fade_animation.setEasingCurve(QEasingCurve.Type.InOutQuad)
# Initially hidden
self.hide()
@pyqtProperty(float)
def opacity(self):
"""Get opacity for animation"""
return self._opacity
@opacity.setter
def opacity(self, value):
"""Set opacity for animation"""
self._opacity = value
self.setWindowOpacity(value)
def show_loading(self, message: str = "Loading..."):
"""
Show the loading widget with a fade-in animation.
Args:
message: Initial status message
"""
self.set_status(message)
self.set_progress(0)
# Position in lower-right corner of parent
self._reposition()
# Fade in
self.show()
self._fade_animation.stop()
self._fade_animation.setStartValue(0.0)
self._fade_animation.setEndValue(1.0)
self._fade_animation.start()
def hide_loading(self):
"""Hide the loading widget with a fade-out animation."""
self._fade_animation.stop()
self._fade_animation.setStartValue(1.0)
self._fade_animation.setEndValue(0.0)
self._fade_animation.finished.connect(self.hide)
self._fade_animation.start()
def set_status(self, message: str):
"""
Update the status message.
Args:
message: Status message to display
"""
self._status_label.setText(message)
def set_progress(self, value: int, maximum: int = 100):
"""
Update the progress bar.
Args:
value: Current progress value
maximum: Maximum progress value (default: 100)
"""
self._progress_bar.setMaximum(maximum)
self._progress_bar.setValue(value)
def set_indeterminate(self, indeterminate: bool = True):
"""
Set the progress bar to indeterminate mode (busy indicator).
Args:
indeterminate: True for indeterminate, False for normal progress
"""
if indeterminate:
self._progress_bar.setMinimum(0)
self._progress_bar.setMaximum(0)
else:
self._progress_bar.setMinimum(0)
self._progress_bar.setMaximum(100)
def _reposition(self):
"""Position the widget in the lower-right corner of the parent."""
if self.parent():
parent_rect = self.parent().rect()
margin = 20
x = parent_rect.width() - self.width() - margin
y = parent_rect.height() - self.height() - margin
self.move(x, y)
def showEvent(self, event):
"""Handle show event to reposition."""
super().showEvent(event)
self._reposition()
def resizeParent(self):
"""Call this when parent is resized to reposition the widget."""
if self.isVisible():
self._reposition()

View File

@ -131,27 +131,21 @@ class MainWindow(
"""Register keyboard shortcuts from decorated methods""" """Register keyboard shortcuts from decorated methods"""
from PyQt6.QtGui import QShortcut, QKeySequence from PyQt6.QtGui import QShortcut, QKeySequence
from pyPhotoAlbum.ribbon_builder import get_keyboard_shortcuts from pyPhotoAlbum.ribbon_builder import get_keyboard_shortcuts
shortcuts = get_keyboard_shortcuts(self.__class__) shortcuts = get_keyboard_shortcuts(self.__class__)
for shortcut_str, method_name in shortcuts.items(): for shortcut_str, method_name in shortcuts.items():
if hasattr(self, method_name): if hasattr(self, method_name):
shortcut = QShortcut(QKeySequence(shortcut_str), self) shortcut = QShortcut(QKeySequence(shortcut_str), self)
method = getattr(self, method_name) method = getattr(self, method_name)
shortcut.activated.connect(method) shortcut.activated.connect(method)
print(f"Registered shortcut: {shortcut_str} -> {method_name}") print(f"Registered shortcut: {shortcut_str} -> {method_name}")
# Register additional Ctrl+Shift+Z shortcut for redo # Register additional Ctrl+Shift+Z shortcut for redo
if hasattr(self, 'redo'): if hasattr(self, 'redo'):
redo_shortcut = QShortcut(QKeySequence("Ctrl+Shift+Z"), self) redo_shortcut = QShortcut(QKeySequence("Ctrl+Shift+Z"), self)
redo_shortcut.activated.connect(self.redo) redo_shortcut.activated.connect(self.redo)
print("Registered shortcut: Ctrl+Shift+Z -> redo") print("Registered shortcut: Ctrl+Shift+Z -> redo")
def resizeEvent(self, event):
"""Handle window resize to reposition loading widget"""
super().resizeEvent(event)
if hasattr(self, '_loading_widget'):
self._loading_widget.resizeParent()
def _add_sample_page(self): def _add_sample_page(self):
"""Add a sample page with some elements for demonstration""" """Add a sample page with some elements for demonstration"""

View File

@ -2,19 +2,9 @@
File operations mixin for pyPhotoAlbum File operations mixin for pyPhotoAlbum
""" """
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import QFileDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QRadioButton, QButtonGroup
QFileDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QRadioButton,
QButtonGroup, QLineEdit, QTextEdit
)
from pyPhotoAlbum.decorators import ribbon_action, numerical_input from pyPhotoAlbum.decorators import ribbon_action, numerical_input
from pyPhotoAlbum.project import Project, Page from pyPhotoAlbum.project import Project
from pyPhotoAlbum.async_project_loader import AsyncProjectLoader
from pyPhotoAlbum.loading_widget import LoadingWidget
from pyPhotoAlbum.project_serializer import save_to_zip
from pyPhotoAlbum.models import set_asset_resolution_context
from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
class FileOperationsMixin: class FileOperationsMixin:
@ -29,6 +19,8 @@ class FileOperationsMixin:
) )
def new_project(self): def new_project(self):
"""Create a new project with initial setup dialog""" """Create a new project with initial setup dialog"""
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QLineEdit
# Create new project setup dialog # Create new project setup dialog
dialog = QDialog(self) dialog = QDialog(self)
dialog.setWindowTitle("New Project Setup") dialog.setWindowTitle("New Project Setup")
@ -161,6 +153,7 @@ class FileOperationsMixin:
self.project.export_dpi = export_dpi self.project.export_dpi = export_dpi
# Set asset resolution context # Set asset resolution context
from pyPhotoAlbum.models import set_asset_resolution_context
set_asset_resolution_context(self.project.folder_path) set_asset_resolution_context(self.project.folder_path)
# Update view # Update view
@ -180,71 +173,36 @@ class FileOperationsMixin:
shortcut="Ctrl+O" shortcut="Ctrl+O"
) )
def open_project(self): def open_project(self):
"""Open an existing project with async loading and progress bar""" """Open an existing project"""
from pyPhotoAlbum.project_serializer import load_from_zip
file_path, _ = QFileDialog.getOpenFileName( file_path, _ = QFileDialog.getOpenFileName(
self, self,
"Open Project", "Open Project",
"", "",
"pyPhotoAlbum Projects (*.ppz);;All Files (*)" "pyPhotoAlbum Projects (*.ppz);;All Files (*)"
) )
if file_path: if file_path:
print(f"Opening project: {file_path}") print(f"Opening project: {file_path}")
# Cleanup old project if it exists
if hasattr(self, 'project') and self.project:
self.project.cleanup()
# Create loading widget if not exists # Load project from ZIP
if not hasattr(self, '_loading_widget'): project, error = load_from_zip(file_path)
self._loading_widget = LoadingWidget(self)
# Show loading widget if project:
self._loading_widget.show_loading("Opening project...") self.project = project
self.current_page_index = 0 # Reset to first page
# Create and configure async loader self.update_view()
self._project_loader = AsyncProjectLoader(file_path) self.show_status(f"Project opened: {file_path}")
print(f"Successfully loaded project: {project.name}")
# Connect signals else:
self._project_loader.progress_updated.connect(self._on_load_progress) error_msg = f"Failed to open project: {error}"
self._project_loader.load_complete.connect(self._on_load_complete) self.show_status(error_msg)
self._project_loader.load_failed.connect(self._on_load_failed) print(error_msg)
# Start async loading
self._project_loader.start()
def _on_load_progress(self, current: int, total: int, message: str):
"""Handle loading progress updates"""
if hasattr(self, '_loading_widget'):
self._loading_widget.set_progress(current, total)
self._loading_widget.set_status(message)
def _on_load_complete(self, project):
"""Handle successful project load"""
# Cleanup old project if it exists
if hasattr(self, 'project') and self.project:
self.project.cleanup()
# Set new project
self.project = project
self.gl_widget.current_page_index = 0 # Reset to first page
# Hide loading widget
if hasattr(self, '_loading_widget'):
self._loading_widget.hide_loading()
# Update view (this will trigger progressive image loading)
self.update_view()
self.show_status(f"Project opened: {project.name}")
print(f"Successfully loaded project: {project.name}")
def _on_load_failed(self, error_msg: str):
"""Handle project load failure"""
# Hide loading widget
if hasattr(self, '_loading_widget'):
self._loading_widget.hide_loading()
error_msg = f"Failed to open project: {error_msg}"
self.show_status(error_msg)
self.show_error("Load Failed", error_msg)
print(error_msg)
@ribbon_action( @ribbon_action(
label="Save", label="Save",
@ -255,6 +213,8 @@ class FileOperationsMixin:
) )
def save_project(self): def save_project(self):
"""Save the current project""" """Save the current project"""
from pyPhotoAlbum.project_serializer import save_to_zip
file_path, _ = QFileDialog.getSaveFileName( file_path, _ = QFileDialog.getSaveFileName(
self, self,
"Save Project", "Save Project",
@ -284,6 +244,8 @@ class FileOperationsMixin:
) )
def heal_assets(self): def heal_assets(self):
"""Open the asset healing dialog to reconnect missing images""" """Open the asset healing dialog to reconnect missing images"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
dialog = AssetHealDialog(self.project, self) dialog = AssetHealDialog(self.project, self)
dialog.exec() dialog.exec()
@ -567,6 +529,9 @@ class FileOperationsMixin:
) )
def show_about(self): def show_about(self):
"""Show about dialog with version information""" """Show about dialog with version information"""
from PyQt6.QtWidgets import QTextEdit
from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION
dialog = QDialog(self) dialog = QDialog(self)
dialog.setWindowTitle("About pyPhotoAlbum") dialog.setWindowTitle("About pyPhotoAlbum")
dialog.setMinimumWidth(600) dialog.setMinimumWidth(600)

View File

@ -6,7 +6,6 @@ from abc import ABC, abstractmethod
from typing import Tuple, Optional, Dict, Any, List from typing import Tuple, Optional, Dict, Any, List
import json import json
import os import os
from PIL import Image
# Global configuration for asset path resolution # Global configuration for asset path resolution
_asset_search_paths: List[str] = [] _asset_search_paths: List[str] = []
@ -58,55 +57,15 @@ class BaseLayoutElement(ABC):
class ImageData(BaseLayoutElement): class ImageData(BaseLayoutElement):
"""Class to store image data and properties""" """Class to store image data and properties"""
def __init__(self, image_path: str = "", crop_info: Optional[Tuple] = None, def __init__(self, image_path: str = "", crop_info: Optional[Tuple] = None, **kwargs):
image_dimensions: Optional[Tuple[int, int]] = None, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.image_path = image_path self.image_path = image_path
self.crop_info = crop_info or (0, 0, 1, 1) # Default: no crop self.crop_info = crop_info or (0, 0, 1, 1) # Default: no crop
# Metadata: Store image dimensions for aspect ratio calculations before full load
# This allows correct rendering even while async loading is in progress
self.image_dimensions = image_dimensions # (width, height) or None
# If dimensions not provided and we have a path, try to extract them quickly
if not self.image_dimensions and self.image_path:
self._extract_dimensions_metadata()
# Async loading state # Async loading state
self._async_loading = False self._async_loading = False
self._async_load_requested = False self._async_load_requested = False
def _extract_dimensions_metadata(self):
"""
Extract image dimensions without loading the full image.
Uses PIL's lazy loading to just read the header.
"""
try:
# Resolve path
image_path = self.image_path
if not os.path.isabs(self.image_path):
project_folder, search_paths = get_asset_search_paths()
if project_folder and os.path.exists(os.path.join(project_folder, self.image_path)):
image_path = os.path.join(project_folder, self.image_path)
if os.path.exists(image_path):
# Use PIL to just read dimensions (fast, doesn't load pixel data)
with Image.open(image_path) as img:
width, height = img.width, img.height
# Apply same downsampling logic as the old sync code (max 2048px)
max_size = 2048
if width > max_size or height > max_size:
scale = min(max_size / width, max_size / height)
width = int(width * scale)
height = int(height * scale)
self.image_dimensions = (width, height)
print(f"ImageData: Extracted dimensions {self.image_dimensions} for {self.image_path}")
except Exception as e:
print(f"ImageData: Could not extract dimensions for {self.image_path}: {e}")
self.image_dimensions = None
def render(self): def render(self):
"""Render the image using OpenGL""" """Render the image using OpenGL"""
from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_QUADS, GL_LINE_LOOP, from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_QUADS, GL_LINE_LOOP,
@ -167,71 +126,97 @@ class ImageData(BaseLayoutElement):
print(f"ImageData: Could not resolve path: {self.image_path}") print(f"ImageData: Could not resolve path: {self.image_path}")
print(f" Tried paths: {possible_paths[:3]}") # Print first 3 to avoid clutter print(f" Tried paths: {possible_paths[:3]}") # Print first 3 to avoid clutter
# NOTE: Async loading is now handled by page_layout.py calling request_image_load() if image_full_path and os.path.exists(image_full_path):
# This sync path should only be reached if async loading is not available # Check if we already have a cached texture
# The actual image will be loaded in the background and the texture created if not hasattr(self, '_texture_id') or not hasattr(self, '_texture_path') or self._texture_path != image_full_path:
# via _on_async_image_loaded() callback when ready try:
# Load image
# Use cached texture if available img = Image.open(image_full_path)
if hasattr(self, '_texture_id') and self._texture_id: img = img.convert('RGBA')
texture_id = self._texture_id
# Scale down large images for performance (max 2048px on longest side)
# Get image dimensions (from loaded texture or metadata) max_size = 2048
if hasattr(self, '_img_width') and hasattr(self, '_img_height'): if img.width > max_size or img.height > max_size:
img_width, img_height = self._img_width, self._img_height scale = min(max_size / img.width, max_size / img.height)
elif self.image_dimensions: new_width = int(img.width * scale)
img_width, img_height = self.image_dimensions new_height = int(img.height * scale)
else: img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
# No dimensions available, render without aspect ratio correction
img_width, img_height = int(w), int(h) img_data = img.tobytes()
# Get crop info # Delete old texture if it exists
crop_x_min, crop_y_min, crop_x_max, crop_y_max = self.crop_info if hasattr(self, '_texture_id') and self._texture_id:
glDeleteTextures([self._texture_id])
# Calculate aspect ratios for center crop
img_aspect = img_width / img_height # Create texture
target_aspect = w / h texture_id = glGenTextures(1)
glBindTexture(GL_TEXTURE_2D, texture_id)
# Calculate texture coordinates for center crop glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
if img_aspect > target_aspect: glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
# Image is wider - crop horizontally glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, img.width, img.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data)
scale = target_aspect / img_aspect
tx_offset = (1.0 - scale) / 2.0 # Cache texture ID and path
tx_min_base = tx_offset self._texture_id = texture_id
tx_max_base = 1.0 - tx_offset self._texture_path = image_full_path
ty_min_base = 0.0 self._img_width = img.width
ty_max_base = 1.0 self._img_height = img.height
else:
# Image is taller - crop vertically except Exception as e:
scale = img_aspect / target_aspect print(f"Error loading image {image_full_path}: {e}")
ty_offset = (1.0 - scale) / 2.0 print(f" Original path: {self.image_path}")
tx_min_base = 0.0 self._texture_id = None
tx_max_base = 1.0
ty_min_base = ty_offset # Use cached texture
ty_max_base = 1.0 - ty_offset if hasattr(self, '_texture_id') and self._texture_id:
texture_id = self._texture_id
# Apply additional crop from crop_info (for spanning elements)
tx_range = tx_max_base - tx_min_base # Get crop info
ty_range = ty_max_base - ty_min_base crop_x_min, crop_y_min, crop_x_max, crop_y_max = self.crop_info
tx_min = tx_min_base + crop_x_min * tx_range # Calculate aspect ratios for center crop
tx_max = tx_min_base + crop_x_max * tx_range img_aspect = self._img_width / self._img_height
ty_min = ty_min_base + crop_y_min * ty_range target_aspect = w / h
ty_max = ty_min_base + crop_y_max * ty_range
# Calculate texture coordinates for center crop
# Enable texturing and draw with crop if img_aspect > target_aspect:
glEnable(GL_TEXTURE_2D) # Image is wider - crop horizontally
glBindTexture(GL_TEXTURE_2D, texture_id) scale = target_aspect / img_aspect
glColor4f(1.0, 1.0, 1.0, 1.0) # White color to show texture as-is tx_offset = (1.0 - scale) / 2.0
tx_min_base = tx_offset
glBegin(GL_QUADS) tx_max_base = 1.0 - tx_offset
glTexCoord2f(tx_min, ty_min); glVertex2f(x, y) ty_min_base = 0.0
glTexCoord2f(tx_max, ty_min); glVertex2f(x + w, y) ty_max_base = 1.0
glTexCoord2f(tx_max, ty_max); glVertex2f(x + w, y + h) else:
glTexCoord2f(tx_min, ty_max); glVertex2f(x, y + h) # Image is taller - crop vertically
glEnd() scale = img_aspect / target_aspect
ty_offset = (1.0 - scale) / 2.0
glDisable(GL_TEXTURE_2D) tx_min_base = 0.0
tx_max_base = 1.0
ty_min_base = ty_offset
ty_max_base = 1.0 - ty_offset
# Apply additional crop from crop_info (for spanning elements)
tx_range = tx_max_base - tx_min_base
ty_range = ty_max_base - ty_min_base
tx_min = tx_min_base + crop_x_min * tx_range
tx_max = tx_min_base + crop_x_max * tx_range
ty_min = ty_min_base + crop_y_min * ty_range
ty_max = ty_min_base + crop_y_max * ty_range
# Enable texturing and draw with crop
glEnable(GL_TEXTURE_2D)
glBindTexture(GL_TEXTURE_2D, texture_id)
glColor4f(1.0, 1.0, 1.0, 1.0) # White color to show texture as-is
glBegin(GL_QUADS)
glTexCoord2f(tx_min, ty_min); glVertex2f(x, y)
glTexCoord2f(tx_max, ty_min); glVertex2f(x + w, y)
glTexCoord2f(tx_max, ty_max); glVertex2f(x + w, y + h)
glTexCoord2f(tx_min, ty_max); glVertex2f(x, y + h)
glEnd()
glDisable(GL_TEXTURE_2D)
# If no image or loading failed, draw placeholder # If no image or loading failed, draw placeholder
if not texture_id: if not texture_id:
@ -258,7 +243,7 @@ class ImageData(BaseLayoutElement):
def serialize(self) -> Dict[str, Any]: def serialize(self) -> Dict[str, Any]:
"""Serialize image data to dictionary""" """Serialize image data to dictionary"""
data = { return {
"type": "image", "type": "image",
"position": self.position, "position": self.position,
"size": self.size, "size": self.size,
@ -267,10 +252,6 @@ class ImageData(BaseLayoutElement):
"image_path": self.image_path, "image_path": self.image_path,
"crop_info": self.crop_info "crop_info": self.crop_info
} }
# Include image dimensions metadata if available
if self.image_dimensions:
data["image_dimensions"] = self.image_dimensions
return data
def deserialize(self, data: Dict[str, Any]): def deserialize(self, data: Dict[str, Any]):
"""Deserialize from dictionary""" """Deserialize from dictionary"""
@ -280,10 +261,6 @@ class ImageData(BaseLayoutElement):
self.z_index = data.get("z_index", 0) self.z_index = data.get("z_index", 0)
self.image_path = data.get("image_path", "") self.image_path = data.get("image_path", "")
self.crop_info = tuple(data.get("crop_info", (0, 0, 1, 1))) self.crop_info = tuple(data.get("crop_info", (0, 0, 1, 1)))
# Load image dimensions metadata if available
self.image_dimensions = data.get("image_dimensions", None)
if self.image_dimensions:
self.image_dimensions = tuple(self.image_dimensions)
def _on_async_image_loaded(self, pil_image): def _on_async_image_loaded(self, pil_image):
""" """
@ -318,10 +295,6 @@ class ImageData(BaseLayoutElement):
self._img_height = pil_image.height self._img_height = pil_image.height
self._async_loading = False self._async_loading = False
# Update metadata for future renders
if not self.image_dimensions:
self.image_dimensions = (pil_image.width, pil_image.height)
print(f"ImageData: Async loaded texture for {self.image_path}") print(f"ImageData: Async loaded texture for {self.image_path}")
except Exception as e: except Exception as e:

View File

@ -491,11 +491,14 @@ class PDFExporter:
current_width, current_height = cropped_img.size current_width, current_height = cropped_img.size
if current_width > target_width_px or current_height > target_height_px: if current_width > target_width_px or current_height > target_height_px:
# Use LANCZOS resampling for high quality downsampling # Use LANCZOS resampling for high quality downsampling
cropped_img = cropped_img.resize((target_width_px, target_height_px), cropped_img = cropped_img.resize((target_width_px, target_height_px),
Image.Resampling.LANCZOS) Image.Resampling.LANCZOS)
# Note: Rotation is applied at the canvas level (below), not here # Apply rotation if needed
# to avoid double-rotation issues if image_element.rotation != 0:
# Rotate around center
cropped_img = cropped_img.rotate(-image_element.rotation, expand=True,
fillcolor=(255, 255, 255, 0))
# Save state for transformations # Save state for transformations
c.saveState() c.saveState()
@ -506,7 +509,7 @@ class PDFExporter:
center_x = x_pt + width_pt / 2 center_x = x_pt + width_pt / 2
center_y = y_pt + height_pt / 2 center_y = y_pt + height_pt / 2
c.translate(center_x, center_y) c.translate(center_x, center_y)
c.rotate(-image_element.rotation) c.rotate(image_element.rotation)
c.translate(-width_pt / 2, -height_pt / 2) c.translate(-width_pt / 2, -height_pt / 2)
# Draw at origin after transformation # Draw at origin after transformation
c.drawImage(ImageReader(cropped_img), 0, 0, width_pt, height_pt, c.drawImage(ImageReader(cropped_img), 0, 0, width_pt, height_pt,

View File

@ -1,116 +0,0 @@
#!/usr/bin/env python3
"""
Test script for the loading widget functionality
"""
import sys
import time
from pathlib import Path
from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton, QVBoxLayout, QWidget
from PyQt6.QtCore import QTimer
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent))
from pyPhotoAlbum.loading_widget import LoadingWidget
class TestWindow(QMainWindow):
"""Test window for loading widget"""
def __init__(self):
super().__init__()
self.setWindowTitle("Loading Widget Test")
self.resize(800, 600)
# Central widget
central = QWidget()
layout = QVBoxLayout()
# Test buttons
btn1 = QPushButton("Show Loading (Determinate)")
btn1.clicked.connect(self.test_determinate)
layout.addWidget(btn1)
btn2 = QPushButton("Show Loading (Indeterminate)")
btn2.clicked.connect(self.test_indeterminate)
layout.addWidget(btn2)
btn3 = QPushButton("Simulate File Loading")
btn3.clicked.connect(self.simulate_file_loading)
layout.addWidget(btn3)
central.setLayout(layout)
self.setCentralWidget(central)
# Create loading widget
self.loading_widget = LoadingWidget(self)
# Timer for progress simulation
self.timer = QTimer()
self.timer.timeout.connect(self.update_progress)
self.progress = 0
def test_determinate(self):
"""Test determinate progress"""
self.loading_widget.show_loading("Loading...")
self.loading_widget.set_indeterminate(False)
self.loading_widget.set_progress(50, 100)
# Auto hide after 3 seconds
QTimer.singleShot(3000, self.loading_widget.hide_loading)
def test_indeterminate(self):
"""Test indeterminate progress"""
self.loading_widget.show_loading("Processing...")
self.loading_widget.set_indeterminate(True)
# Auto hide after 3 seconds
QTimer.singleShot(3000, self.loading_widget.hide_loading)
def simulate_file_loading(self):
"""Simulate file loading with progress"""
self.progress = 0
self.loading_widget.show_loading("Extracting files...")
self.loading_widget.set_indeterminate(False)
self.timer.start(100) # Update every 100ms
def update_progress(self):
"""Update progress during simulation"""
self.progress += 5
if self.progress <= 40:
self.loading_widget.set_status(f"Extracting files... ({self.progress}%)")
elif self.progress <= 70:
self.loading_widget.set_status("Loading project data...")
elif self.progress <= 95:
self.loading_widget.set_status("Normalizing asset paths...")
else:
self.loading_widget.set_status("Loading complete!")
self.loading_widget.set_progress(self.progress, 100)
if self.progress >= 100:
self.timer.stop()
QTimer.singleShot(500, self.loading_widget.hide_loading)
def resizeEvent(self, event):
"""Handle resize"""
super().resizeEvent(event)
self.loading_widget.resizeParent()
def main():
"""Run test"""
app = QApplication(sys.argv)
window = TestWindow()
window.show()
print("Loading widget test window opened")
print("Click buttons to test different loading states")
sys.exit(app.exec())
if __name__ == "__main__":
main()