pyPhotoAlbum/pyPhotoAlbum/mixins/async_loading.py
Duncan Tourolle fae9e5bd2b
Some checks failed
Python CI / test (push) Successful in 1m17s
Lint / lint (push) Successful in 1m32s
Tests / test (3.10) (push) Successful in 1m10s
Tests / test (3.9) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Additional refactoring
2025-11-27 21:57:57 +01:00

249 lines
8.4 KiB
Python

"""
Async loading mixin for non-blocking image loading and PDF generation.
"""
import os
from pathlib import Path
from typing import Optional
import logging
from PyQt6.QtCore import QObject
from pyPhotoAlbum.async_backend import AsyncImageLoader, AsyncPDFGenerator, ImageCache, LoadPriority
logger = logging.getLogger(__name__)
class AsyncLoadingMixin:
"""
Mixin to add async loading capabilities to GLWidget.
Provides non-blocking image loading and PDF generation with
progressive updates and shared caching.
"""
def _init_async_loading(self):
"""Initialize async loading components."""
logger.info("Initializing async loading system...")
# Create shared image cache (512MB)
self.image_cache = ImageCache(max_memory_mb=512)
# Create async image loader
self.async_image_loader = AsyncImageLoader(cache=self.image_cache, max_workers=4)
self.async_image_loader.image_loaded.connect(self._on_image_loaded)
self.async_image_loader.load_failed.connect(self._on_image_load_failed)
self.async_image_loader.start()
# Create async PDF generator
self.async_pdf_generator = AsyncPDFGenerator(image_cache=self.image_cache, max_workers=2)
self.async_pdf_generator.progress_updated.connect(self._on_pdf_progress)
self.async_pdf_generator.export_complete.connect(self._on_pdf_complete)
self.async_pdf_generator.export_failed.connect(self._on_pdf_failed)
self.async_pdf_generator.start()
logger.info("Async loading system initialized")
def _cleanup_async_loading(self):
"""Cleanup async loading components."""
logger.info("Cleaning up async loading system...")
if hasattr(self, 'async_image_loader'):
self.async_image_loader.stop()
if hasattr(self, 'async_pdf_generator'):
self.async_pdf_generator.stop()
if hasattr(self, 'image_cache'):
self.image_cache.clear()
logger.info("Async loading system cleaned up")
def _on_image_loaded(self, path: Path, image, user_data):
"""
Handle image loaded callback.
Args:
path: Path to loaded image
image: Loaded PIL Image
user_data: User data (ImageData element)
"""
logger.debug(f"Image loaded callback: {path}")
if user_data and hasattr(user_data, '_on_async_image_loaded'):
user_data._on_async_image_loaded(image)
# Trigger re-render to show newly loaded image
self.update()
def _on_image_load_failed(self, path: Path, error_msg: str, user_data):
"""
Handle image load failure.
Args:
path: Path that failed to load
error_msg: Error message
user_data: User data (ImageData element)
"""
logger.warning(f"Image load failed: {path} - {error_msg}")
if user_data and hasattr(user_data, '_on_async_image_load_failed'):
user_data._on_async_image_load_failed(error_msg)
def _on_pdf_progress(self, current: int, total: int, message: str):
"""
Handle PDF export progress updates.
Args:
current: Current progress (pages completed)
total: Total pages
message: Progress message
"""
logger.debug(f"PDF progress: {current}/{total} - {message}")
# Update progress dialog if it exists
if hasattr(self, '_pdf_progress_dialog') and self._pdf_progress_dialog:
self._pdf_progress_dialog.setValue(current)
self._pdf_progress_dialog.setLabelText(message)
def _on_pdf_complete(self, success: bool, warnings: list):
"""
Handle PDF export completion.
Args:
success: Whether export succeeded
warnings: List of warning messages
"""
logger.info(f"PDF export complete: success={success}, warnings={len(warnings)}")
# Close progress dialog
if hasattr(self, '_pdf_progress_dialog') and self._pdf_progress_dialog:
self._pdf_progress_dialog.close()
self._pdf_progress_dialog = None
# Show completion message
main_window = self.window()
if hasattr(main_window, 'show_status'):
if success:
if warnings:
main_window.show_status(
f"PDF exported successfully with {len(warnings)} warnings",
5000
)
else:
main_window.show_status("PDF exported successfully", 3000)
else:
main_window.show_status("PDF export failed", 5000)
def _on_pdf_failed(self, error_msg: str):
"""
Handle PDF export failure.
Args:
error_msg: Error message
"""
logger.error(f"PDF export failed: {error_msg}")
# Close progress dialog
if hasattr(self, '_pdf_progress_dialog') and self._pdf_progress_dialog:
self._pdf_progress_dialog.close()
self._pdf_progress_dialog = None
# Show error message
main_window = self.window()
if hasattr(main_window, 'show_status'):
main_window.show_status(f"PDF export failed: {error_msg}", 5000)
def request_image_load(self, image_data, priority: LoadPriority = LoadPriority.NORMAL):
"""
Request async load for an ImageData element.
Args:
image_data: ImageData element to load
priority: Load priority level
"""
if not hasattr(self, 'async_image_loader'):
logger.warning("Async image loader not initialized")
return
if not image_data.image_path:
return
# Security: only load images from the assets folder
if not image_data.image_path.startswith("assets/"):
logger.warning(f"Skipping path not in assets folder (needs healing): {image_data.image_path}")
return
# Use ImageData's path resolution (delegates to project layer)
image_full_path = image_data.resolve_image_path()
if not image_full_path:
logger.warning(f"Image not found (needs healing): {image_data.image_path}")
return
# Calculate target size (max 2048px like original)
target_size = (2048, 2048) # Will be downsampled if larger
# Request load
self.async_image_loader.request_load(
Path(image_full_path),
priority=priority,
target_size=target_size,
user_data=image_data # Pass element for callback
)
def export_pdf_async(self, project, output_path: str, export_dpi: int = 300):
"""
Export PDF asynchronously without blocking UI.
Args:
project: Project to export
output_path: Output PDF file path
export_dpi: Export DPI (default 300)
"""
if not hasattr(self, 'async_pdf_generator'):
logger.warning("Async PDF generator not initialized")
return False
# Create progress dialog
from PyQt6.QtWidgets import QProgressDialog
from PyQt6.QtCore import Qt
total_pages = sum(
1 if page.is_cover else (2 if page.is_double_spread else 1)
for page in project.pages
)
self._pdf_progress_dialog = QProgressDialog(
"Exporting to PDF...",
"Cancel",
0,
total_pages,
self
)
self._pdf_progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
self._pdf_progress_dialog.setWindowTitle("PDF Export")
self._pdf_progress_dialog.canceled.connect(self._on_pdf_cancel)
self._pdf_progress_dialog.show()
# Start async export
return self.async_pdf_generator.export_pdf(project, output_path, export_dpi)
def _on_pdf_cancel(self):
"""Handle PDF export cancellation."""
logger.info("User requested PDF export cancellation")
if hasattr(self, 'async_pdf_generator'):
self.async_pdf_generator.cancel_export()
def get_async_stats(self) -> dict:
"""Get async loading system statistics."""
stats = {}
if hasattr(self, 'async_image_loader'):
stats['image_loader'] = self.async_image_loader.get_stats()
if hasattr(self, 'async_pdf_generator'):
stats['pdf_generator'] = self.async_pdf_generator.get_stats()
return stats