1 """

2 Async loading mixin for non-blocking image loading and PDF generation.

3 """

4

5 import os

6 from pathlib import Path

7 from typing import TYPE_CHECKING, Optional, cast

8 import logging

9

10 from PyQt6.QtCore import QObject

11 from PyQt6.QtWidgets import QProgressDialog

12

13 from pyPhotoAlbum.async_backend import AsyncImageLoader, AsyncPDFGenerator, ImageCache, LoadPriority

14

15 if TYPE_CHECKING:

16 from PyQt6.QtWidgets import QMainWindow

17

18 logger = logging.getLogger(__name__)

19

20

21 class AsyncLoadingMixin:

22 # Type hints for expected attributes from mixing class

23 _pdf_progress_dialog: Optional[QProgressDialog]

24

25 def update(self) -> None: # type: ignore[empty-body]

26 """Expected from QWidget"""

27 ...

28

29 def window(self) -> "QMainWindow": # type: ignore[empty-body]

30 """Expected from QWidget"""

31 ...

32

33 """

34 Mixin to add async loading capabilities to GLWidget.

35

36 Provides non-blocking image loading and PDF generation with

37 progressive updates and shared caching.

38 """

39

40 def _init_async_loading(self):

41 """Initialize async loading components."""

42 logger.info("Initializing async loading system...")

43

44 # Create shared image cache (512MB)

45 self.image_cache = ImageCache(max_memory_mb=512)

46

47 # Create async image loader

48 self.async_image_loader = AsyncImageLoader(cache=self.image_cache, max_workers=4)

49 self.async_image_loader.image_loaded.connect(self._on_image_loaded)

50 self.async_image_loader.load_failed.connect(self._on_image_load_failed)

51 self.async_image_loader.start()

52

53 # Create async PDF generator

54 self.async_pdf_generator = AsyncPDFGenerator(image_cache=self.image_cache, max_workers=2)

55 self.async_pdf_generator.progress_updated.connect(self._on_pdf_progress)

56 self.async_pdf_generator.export_complete.connect(self._on_pdf_complete)

57 self.async_pdf_generator.export_failed.connect(self._on_pdf_failed)

58 self.async_pdf_generator.start()

59

60 logger.info("Async loading system initialized")

61

62 def _cleanup_async_loading(self):

63 """Cleanup async loading components."""

64 logger.info("Cleaning up async loading system...")

65

66 if hasattr(self, "async_image_loader"):

67 self.async_image_loader.stop()

68

69 if hasattr(self, "async_pdf_generator"):

70 self.async_pdf_generator.stop()

71

72 if hasattr(self, "image_cache"):

73 self.image_cache.clear()

74

75 logger.info("Async loading system cleaned up")

76

77 def _on_image_loaded(self, path: Path, image, user_data):

78 """

79 Handle image loaded callback.

80

81 Args:

82 path: Path to loaded image

83 image: Loaded PIL Image

84 user_data: User data (ImageData element)

85 """

86 logger.debug(f"Image loaded callback: {path}")

87

88 if user_data and hasattr(user_data, "_on_async_image_loaded"):

89 user_data._on_async_image_loaded(image)

90

91 # Trigger re-render to show newly loaded image

92 self.update()

93

94 def _on_image_load_failed(self, path: Path, error_msg: str, user_data):

95 """

96 Handle image load failure.

97

98 Args:

99 path: Path that failed to load

100 error_msg: Error message

101 user_data: User data (ImageData element)

102 """

103 logger.warning(f"Image load failed: {path} - {error_msg}")

104

105 if user_data and hasattr(user_data, "_on_async_image_load_failed"):

106 user_data._on_async_image_load_failed(error_msg)

107

108 def _on_pdf_progress(self, current: int, total: int, message: str):

109 """

110 Handle PDF export progress updates.

111

112 Args:

113 current: Current progress (pages completed)

114 total: Total pages

115 message: Progress message

116 """

117 logger.debug(f"PDF progress: {current}/{total} - {message}")

118

119 # Update progress dialog if it exists

120 # Use local reference to avoid race condition

121 dialog = getattr(self, "_pdf_progress_dialog", None)

122 if dialog is not None:

123 dialog.setValue(current)

124 dialog.setLabelText(message)

125

126 def _on_pdf_complete(self, success: bool, warnings: list):

127 """

128 Handle PDF export completion.

129

130 Args:

131 success: Whether export succeeded

132 warnings: List of warning messages

133 """

134 logger.info(f"PDF export complete: success={success}, warnings={len(warnings)}")

135

136 # Close progress dialog

137 if hasattr(self, "_pdf_progress_dialog") and self._pdf_progress_dialog:

138 self._pdf_progress_dialog.close()

139 self._pdf_progress_dialog = None

140

141 # Show completion message

142 main_window = self.window()

143 if hasattr(main_window, "show_status"):

144 if success:

145 if warnings:

146 main_window.show_status(f"PDF exported successfully with {len(warnings)} warnings", 5000)

147 else:

148 main_window.show_status("PDF exported successfully", 3000)

149 else:

150 main_window.show_status("PDF export failed", 5000)

151

152 def _on_pdf_failed(self, error_msg: str):

153 """

154 Handle PDF export failure.

155

156 Args:

157 error_msg: Error message

158 """

159 logger.error(f"PDF export failed: {error_msg}")

160

161 # Close progress dialog

162 if hasattr(self, "_pdf_progress_dialog") and self._pdf_progress_dialog:

163 self._pdf_progress_dialog.close()

164 self._pdf_progress_dialog = None

165

166 # Show error message

167 main_window = self.window()

168 if hasattr(main_window, "show_status"):

169 main_window.show_status(f"PDF export failed: {error_msg}", 5000)

170

171 def request_image_load(self, image_data, priority: LoadPriority = LoadPriority.NORMAL):

172 """

173 Request async load for an ImageData element.

174

175 Args:

176 image_data: ImageData element to load

177 priority: Load priority level

178 """

179 if not hasattr(self, "async_image_loader"):

180 logger.warning("Async image loader not initialized")

181 return

182

183 if not image_data.image_path:

184 return

185

186 # Security: only load images from the assets folder

187 if not image_data.image_path.startswith("assets/"):

188 logger.warning(f"Skipping path not in assets folder (needs healing): {image_data.image_path}")

189 return

190

191 # Use ImageData's path resolution (delegates to project layer)

192 image_full_path = image_data.resolve_image_path()

193 if not image_full_path:

194 logger.warning(f"Image not found (needs healing): {image_data.image_path}")

195 return

196

197 # Calculate target size (max 2048px like original)

198 target_size = (2048, 2048) # Will be downsampled if larger

199

200 # Request load

201 self.async_image_loader.request_load(

202 Path(image_full_path),

203 priority=priority,

204 target_size=target_size,

205 user_data=image_data, # Pass element for callback

206 )

207

208 def export_pdf_async(self, project, output_path: str, export_dpi: int = 300):

209 """

210 Export PDF asynchronously without blocking UI.

211

212 Args:

213 project: Project to export

214 output_path: Output PDF file path

215 export_dpi: Export DPI (default 300)

216 """

217 if not hasattr(self, "async_pdf_generator"):

218 logger.warning("Async PDF generator not initialized")

219 return False

220

221 # Create progress dialog

222 from PyQt6.QtWidgets import QProgressDialog

223 from PyQt6.QtCore import Qt

224

225 total_pages = sum(1 if page.is_cover else (2 if page.is_double_spread else 1) for page in project.pages)

226

227 from PyQt6.QtWidgets import QWidget

228

229 self._pdf_progress_dialog = QProgressDialog("Exporting to PDF...", "Cancel", 0, total_pages, cast(QWidget, self))

230 self._pdf_progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)

231 self._pdf_progress_dialog.setWindowTitle("PDF Export")

232 self._pdf_progress_dialog.canceled.connect(self._on_pdf_cancel)

233 self._pdf_progress_dialog.show()

234

235 # Start async export

236 return self.async_pdf_generator.export_pdf(project, output_path, export_dpi)

237

238 def _on_pdf_cancel(self):

239 """Handle PDF export cancellation."""

240 logger.info("User requested PDF export cancellation")

241

242 if hasattr(self, "async_pdf_generator"):

243 self.async_pdf_generator.cancel_export()

244

245 def get_async_stats(self) -> dict:

246 """Get async loading system statistics."""

247 stats = {}

248

249 if hasattr(self, "async_image_loader"):

250 stats["image_loader"] = self.async_image_loader.get_stats()

251

252 if hasattr(self, "async_pdf_generator"):

253 stats["pdf_generator"] = self.async_pdf_generator.get_stats()

254

255 return stats