png_cache_manager.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. """PNG Cache Manager for dune-weaver-touch
  2. Converts WebP previews to PNG format for optimal Qt/QML compatibility.
  3. """
  4. import asyncio
  5. import os
  6. import logging
  7. from pathlib import Path
  8. from typing import List, Tuple
  9. try:
  10. from PIL import Image
  11. except ImportError:
  12. Image = None
  13. logger = logging.getLogger(__name__)
  14. class PngCacheManager:
  15. """Manages PNG cache generation from WebP sources for touch interface"""
  16. def __init__(self, cache_dir: Path = None):
  17. # Default to the main cache directory relative to touch app
  18. self.cache_dir = cache_dir or Path("../patterns/cached_images")
  19. self.conversion_stats = {
  20. "total_webp_found": 0,
  21. "png_already_exist": 0,
  22. "converted_successfully": 0,
  23. "conversion_errors": 0
  24. }
  25. async def ensure_png_cache_available(self) -> bool:
  26. """
  27. Ensure PNG previews are available for all WebP files.
  28. Returns True if all conversions completed successfully.
  29. """
  30. if not Image:
  31. logger.error("PIL (Pillow) not available - cannot convert WebP to PNG")
  32. return False
  33. if not self.cache_dir.exists():
  34. logger.info(f"Cache directory {self.cache_dir} does not exist - no conversion needed")
  35. return True
  36. logger.info(f"Starting PNG cache check for directory: {self.cache_dir}")
  37. # Find all WebP files that need PNG conversion
  38. webp_files = await self._find_webp_files_needing_conversion()
  39. if not webp_files:
  40. logger.info("All WebP files already have PNG equivalents")
  41. return True
  42. logger.info(f"Found {len(webp_files)} WebP files needing PNG conversion")
  43. # Convert WebP files to PNG in batches
  44. success = await self._convert_webp_to_png_batch(webp_files)
  45. # Log conversion statistics
  46. self._log_conversion_stats()
  47. return success
  48. async def _find_webp_files_needing_conversion(self) -> List[Path]:
  49. """Find WebP files that don't have corresponding PNG files"""
  50. def _scan_webp():
  51. webp_files = []
  52. for webp_file in self.cache_dir.rglob("*.webp"):
  53. # Check if corresponding PNG exists
  54. png_file = webp_file.with_suffix(".png")
  55. if not png_file.exists():
  56. webp_files.append(webp_file)
  57. else:
  58. self.conversion_stats["png_already_exist"] += 1
  59. self.conversion_stats["total_webp_found"] += 1
  60. return webp_files
  61. return await asyncio.to_thread(_scan_webp)
  62. async def _convert_webp_to_png_batch(self, webp_files: List[Path]) -> bool:
  63. """Convert WebP files to PNG in parallel batches"""
  64. batch_size = 5 # Process 5 files at a time to avoid overwhelming the system
  65. all_success = True
  66. for i in range(0, len(webp_files), batch_size):
  67. batch = webp_files[i:i + batch_size]
  68. batch_tasks = [self._convert_single_webp_to_png(webp_file) for webp_file in batch]
  69. batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True)
  70. # Check results
  71. for webp_file, result in zip(batch, batch_results):
  72. if isinstance(result, Exception):
  73. logger.error(f"Failed to convert {webp_file}: {result}")
  74. self.conversion_stats["conversion_errors"] += 1
  75. all_success = False
  76. elif result:
  77. self.conversion_stats["converted_successfully"] += 1
  78. logger.debug(f"Converted {webp_file} to PNG")
  79. else:
  80. self.conversion_stats["conversion_errors"] += 1
  81. all_success = False
  82. # Log progress
  83. processed = min(i + batch_size, len(webp_files))
  84. logger.info(f"PNG conversion progress: {processed}/{len(webp_files)} files processed")
  85. return all_success
  86. async def _convert_single_webp_to_png(self, webp_file: Path) -> bool:
  87. """Convert a single WebP file to PNG format"""
  88. try:
  89. png_file = webp_file.with_suffix(".png")
  90. def _convert():
  91. # Open WebP image and convert to PNG
  92. with Image.open(webp_file) as img:
  93. # Convert to RGB if necessary (PNG doesn't support some WebP modes)
  94. if img.mode in ('RGBA', 'LA', 'P'):
  95. # Keep transparency for these modes
  96. img.save(png_file, "PNG", optimize=True)
  97. else:
  98. # Convert to RGB for other modes
  99. rgb_img = img.convert('RGB')
  100. rgb_img.save(png_file, "PNG", optimize=True)
  101. # Set file permissions to match the WebP file
  102. try:
  103. webp_stat = webp_file.stat()
  104. os.chmod(png_file, webp_stat.st_mode)
  105. except (OSError, PermissionError):
  106. # Not critical if we can't set permissions
  107. pass
  108. await asyncio.to_thread(_convert)
  109. return True
  110. except Exception as e:
  111. logger.error(f"Failed to convert {webp_file} to PNG: {e}")
  112. return False
  113. def _log_conversion_stats(self):
  114. """Log conversion statistics"""
  115. stats = self.conversion_stats
  116. logger.info("PNG Cache Conversion Statistics:")
  117. logger.info(f" Total WebP files found: {stats['total_webp_found']}")
  118. logger.info(f" PNG files already existed: {stats['png_already_exist']}")
  119. logger.info(f" Files converted successfully: {stats['converted_successfully']}")
  120. logger.info(f" Conversion errors: {stats['conversion_errors']}")
  121. if stats['conversion_errors'] > 0:
  122. logger.warning(f"⚠️ {stats['conversion_errors']} files failed to convert")
  123. else:
  124. logger.info("✅ All WebP to PNG conversions completed successfully")
  125. async def convert_specific_pattern(self, pattern_name: str) -> bool:
  126. """Convert a specific pattern's WebP to PNG if needed"""
  127. if not Image:
  128. return False
  129. # Handle both hierarchical and flat naming conventions
  130. webp_files = []
  131. # Try hierarchical structure first
  132. webp_hierarchical = self.cache_dir / f"{pattern_name}.webp"
  133. if webp_hierarchical.exists():
  134. png_hierarchical = webp_hierarchical.with_suffix(".png")
  135. if not png_hierarchical.exists():
  136. webp_files.append(webp_hierarchical)
  137. # Try flattened structure
  138. pattern_name_flat = pattern_name.replace("/", "_").replace("\\", "_")
  139. webp_flat = self.cache_dir / f"{pattern_name_flat}.webp"
  140. if webp_flat.exists():
  141. png_flat = webp_flat.with_suffix(".png")
  142. if not png_flat.exists():
  143. webp_files.append(webp_flat)
  144. if not webp_files:
  145. return True # No conversion needed
  146. # Convert found WebP files
  147. tasks = [self._convert_single_webp_to_png(webp_file) for webp_file in webp_files]
  148. results = await asyncio.gather(*tasks)
  149. return all(results)
  150. async def ensure_png_cache_startup():
  151. """
  152. Startup function to ensure PNG cache is available.
  153. Call this during application startup.
  154. """
  155. try:
  156. cache_manager = PngCacheManager()
  157. success = await cache_manager.ensure_png_cache_available()
  158. if success:
  159. logger.info("PNG cache startup check completed successfully")
  160. else:
  161. logger.warning("PNG cache startup check completed with some errors")
  162. return success
  163. except Exception as e:
  164. logger.error(f"PNG cache startup check failed: {e}")
  165. return False