cache_manager.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. """Image Cache Manager for pre-generating and managing image previews."""
  2. import os
  3. import json
  4. import asyncio
  5. import logging
  6. from pathlib import Path
  7. from modules.core.pattern_manager import list_theta_rho_files, THETA_RHO_DIR, parse_theta_rho_file
  8. logger = logging.getLogger(__name__)
  9. # Global cache progress state
  10. cache_progress = {
  11. "is_running": False,
  12. "total_files": 0,
  13. "processed_files": 0,
  14. "current_file": "",
  15. "stage": "idle", # idle, metadata, images, complete
  16. "error": None
  17. }
  18. # Constants
  19. CACHE_DIR = os.path.join(THETA_RHO_DIR, "cached_images")
  20. METADATA_CACHE_FILE = "metadata_cache.json" # Now in root directory
  21. def ensure_cache_dir():
  22. """Ensure the cache directory exists with proper permissions."""
  23. try:
  24. Path(CACHE_DIR).mkdir(parents=True, exist_ok=True)
  25. # Initialize metadata cache if it doesn't exist
  26. if not os.path.exists(METADATA_CACHE_FILE):
  27. with open(METADATA_CACHE_FILE, 'w') as f:
  28. json.dump({}, f)
  29. try:
  30. os.chmod(METADATA_CACHE_FILE, 0o644) # More conservative permissions
  31. except (OSError, PermissionError) as e:
  32. logger.debug(f"Could not set metadata cache file permissions: {str(e)}")
  33. for root, dirs, files in os.walk(CACHE_DIR):
  34. try:
  35. os.chmod(root, 0o755) # More conservative permissions
  36. for file in files:
  37. file_path = os.path.join(root, file)
  38. try:
  39. os.chmod(file_path, 0o644) # More conservative permissions
  40. except (OSError, PermissionError) as e:
  41. # Log as debug instead of error since this is not critical
  42. logger.debug(f"Could not set permissions for file {file_path}: {str(e)}")
  43. except (OSError, PermissionError) as e:
  44. # Log as debug instead of error since this is not critical
  45. logger.debug(f"Could not set permissions for directory {root}: {str(e)}")
  46. continue
  47. except Exception as e:
  48. logger.error(f"Failed to create cache directory: {str(e)}")
  49. def get_cache_path(pattern_file):
  50. """Get the cache path for a pattern file."""
  51. # Create subdirectories in cache to match the pattern file structure
  52. cache_subpath = os.path.dirname(pattern_file)
  53. if cache_subpath:
  54. # Create the same subdirectory structure in cache (including custom_patterns)
  55. cache_dir = os.path.join(CACHE_DIR, cache_subpath)
  56. else:
  57. # For files in root pattern directory
  58. cache_dir = CACHE_DIR
  59. # Ensure the subdirectory exists
  60. os.makedirs(cache_dir, exist_ok=True)
  61. try:
  62. os.chmod(cache_dir, 0o755) # More conservative permissions
  63. except (OSError, PermissionError) as e:
  64. # Log as debug instead of error since this is not critical
  65. logger.debug(f"Could not set permissions for cache subdirectory {cache_dir}: {str(e)}")
  66. # Use just the filename part for the cache file
  67. filename = os.path.basename(pattern_file)
  68. safe_name = filename.replace('\\', '_')
  69. return os.path.join(cache_dir, f"{safe_name}.webp")
  70. def load_metadata_cache():
  71. """Load the metadata cache from disk."""
  72. try:
  73. if os.path.exists(METADATA_CACHE_FILE):
  74. with open(METADATA_CACHE_FILE, 'r') as f:
  75. return json.load(f)
  76. except Exception as e:
  77. logger.warning(f"Failed to load metadata cache: {str(e)}")
  78. return {}
  79. def save_metadata_cache(cache_data):
  80. """Save the metadata cache to disk."""
  81. try:
  82. ensure_cache_dir()
  83. with open(METADATA_CACHE_FILE, 'w') as f:
  84. json.dump(cache_data, f, indent=2)
  85. except Exception as e:
  86. logger.error(f"Failed to save metadata cache: {str(e)}")
  87. def get_pattern_metadata(pattern_file):
  88. """Get cached metadata for a pattern file."""
  89. cache_data = load_metadata_cache()
  90. # Check if we have cached metadata and if the file hasn't changed
  91. if pattern_file in cache_data:
  92. cached_entry = cache_data[pattern_file]
  93. pattern_path = os.path.join(THETA_RHO_DIR, pattern_file)
  94. try:
  95. file_mtime = os.path.getmtime(pattern_path)
  96. if cached_entry.get('mtime') == file_mtime:
  97. return cached_entry.get('metadata')
  98. except OSError:
  99. pass
  100. return None
  101. def cache_pattern_metadata(pattern_file, first_coord, last_coord, total_coords):
  102. """Cache metadata for a pattern file."""
  103. try:
  104. cache_data = load_metadata_cache()
  105. pattern_path = os.path.join(THETA_RHO_DIR, pattern_file)
  106. file_mtime = os.path.getmtime(pattern_path)
  107. cache_data[pattern_file] = {
  108. 'mtime': file_mtime,
  109. 'metadata': {
  110. 'first_coordinate': first_coord,
  111. 'last_coordinate': last_coord,
  112. 'total_coordinates': total_coords
  113. }
  114. }
  115. save_metadata_cache(cache_data)
  116. logger.debug(f"Cached metadata for {pattern_file}")
  117. except Exception as e:
  118. logger.warning(f"Failed to cache metadata for {pattern_file}: {str(e)}")
  119. def needs_cache(pattern_file):
  120. """Check if a pattern file needs its cache generated."""
  121. # Check if image preview exists
  122. cache_path = get_cache_path(pattern_file)
  123. if not os.path.exists(cache_path):
  124. return True
  125. # Check if metadata cache exists and is valid
  126. metadata = get_pattern_metadata(pattern_file)
  127. if metadata is None:
  128. return True
  129. return False
  130. async def generate_image_preview(pattern_file):
  131. """Generate image preview for a single pattern file."""
  132. from modules.core.preview import generate_preview_image
  133. from modules.core.pattern_manager import parse_theta_rho_file
  134. try:
  135. logger.debug(f"Starting preview generation for {pattern_file}")
  136. # Check if we need to update metadata cache
  137. metadata = get_pattern_metadata(pattern_file)
  138. if metadata is None:
  139. # Parse file to get metadata (this is the only time we need to parse)
  140. logger.debug(f"Parsing {pattern_file} for metadata cache")
  141. pattern_path = os.path.join(THETA_RHO_DIR, pattern_file)
  142. try:
  143. coordinates = await asyncio.to_thread(parse_theta_rho_file, pattern_path)
  144. if coordinates:
  145. first_coord = {"x": coordinates[0][0], "y": coordinates[0][1]}
  146. last_coord = {"x": coordinates[-1][0], "y": coordinates[-1][1]}
  147. total_coords = len(coordinates)
  148. # Cache the metadata for future use
  149. cache_pattern_metadata(pattern_file, first_coord, last_coord, total_coords)
  150. logger.debug(f"Metadata cached for {pattern_file}: {total_coords} coordinates")
  151. else:
  152. logger.warning(f"No coordinates found in {pattern_file}")
  153. except Exception as e:
  154. logger.error(f"Failed to parse {pattern_file} for metadata: {str(e)}")
  155. # Continue with image generation even if metadata fails
  156. # Check if we need to generate the image
  157. cache_path = get_cache_path(pattern_file)
  158. if os.path.exists(cache_path):
  159. logger.debug(f"Skipping image generation for {pattern_file} - already cached")
  160. return True
  161. # Generate the image
  162. logger.debug(f"Generating image preview for {pattern_file}")
  163. image_content = await generate_preview_image(pattern_file)
  164. if not image_content:
  165. logger.error(f"Generated image content is empty for {pattern_file}")
  166. return False
  167. # Ensure cache directory exists
  168. ensure_cache_dir()
  169. with open(cache_path, 'wb') as f:
  170. f.write(image_content)
  171. try:
  172. os.chmod(cache_path, 0o644) # More conservative permissions
  173. except (OSError, PermissionError) as e:
  174. # Log as debug instead of error since this is not critical
  175. logger.debug(f"Could not set cache file permissions for {pattern_file}: {str(e)}")
  176. logger.debug(f"Successfully generated preview for {pattern_file}")
  177. return True
  178. except Exception as e:
  179. logger.error(f"Failed to generate image for {pattern_file}: {str(e)}")
  180. return False
  181. async def generate_all_image_previews():
  182. """Generate image previews for all pattern files with progress tracking."""
  183. global cache_progress
  184. try:
  185. ensure_cache_dir()
  186. pattern_files = [f for f in list_theta_rho_files() if f.endswith('.thr')]
  187. if not pattern_files:
  188. logger.info("No .thr pattern files found. Skipping image preview generation.")
  189. return
  190. patterns_to_cache = [f for f in pattern_files if needs_cache(f)]
  191. total_files = len(patterns_to_cache)
  192. skipped_files = len(pattern_files) - total_files
  193. if total_files == 0:
  194. logger.info(f"All {skipped_files} pattern files already have image previews. Skipping image generation.")
  195. return
  196. # Update progress state
  197. cache_progress.update({
  198. "stage": "images",
  199. "total_files": total_files,
  200. "processed_files": 0,
  201. "current_file": "",
  202. "error": None
  203. })
  204. logger.info(f"Generating image cache for {total_files} uncached .thr patterns ({skipped_files} already cached)...")
  205. batch_size = 5
  206. successful = 0
  207. for i in range(0, total_files, batch_size):
  208. batch = patterns_to_cache[i:i + batch_size]
  209. tasks = [generate_image_preview(file) for file in batch]
  210. results = await asyncio.gather(*tasks)
  211. successful += sum(1 for r in results if r)
  212. # Update progress
  213. cache_progress["processed_files"] = min(i + batch_size, total_files)
  214. if i < total_files:
  215. cache_progress["current_file"] = patterns_to_cache[min(i + batch_size - 1, total_files - 1)]
  216. # Log progress
  217. progress = min(i + batch_size, total_files)
  218. logger.info(f"Image cache generation progress: {progress}/{total_files} files processed")
  219. logger.info(f"Image cache generation completed: {successful}/{total_files} patterns cached successfully, {skipped_files} patterns skipped (already cached)")
  220. except Exception as e:
  221. logger.error(f"Error during image cache generation: {str(e)}")
  222. cache_progress["error"] = str(e)
  223. raise
  224. async def generate_metadata_cache():
  225. """Generate metadata cache for all pattern files with progress tracking."""
  226. global cache_progress
  227. try:
  228. logger.info("Starting metadata cache generation...")
  229. # Get all pattern files using the same function as the rest of the codebase
  230. pattern_files = list_theta_rho_files()
  231. if not pattern_files:
  232. logger.info("No pattern files found. Skipping metadata cache generation.")
  233. return
  234. # Filter out files that already have valid metadata cache
  235. files_to_process = []
  236. for file_name in pattern_files:
  237. if get_pattern_metadata(file_name) is None:
  238. files_to_process.append(file_name)
  239. total_files = len(files_to_process)
  240. skipped_files = len(pattern_files) - total_files
  241. if total_files == 0:
  242. logger.info(f"All {skipped_files} files already have metadata cache. Skipping metadata generation.")
  243. return
  244. # Update progress state
  245. cache_progress.update({
  246. "stage": "metadata",
  247. "total_files": total_files,
  248. "processed_files": 0,
  249. "current_file": "",
  250. "error": None
  251. })
  252. logger.info(f"Generating metadata cache for {total_files} new files ({skipped_files} files already cached)...")
  253. # Process in batches
  254. batch_size = 5
  255. successful = 0
  256. for i in range(0, total_files, batch_size):
  257. batch = files_to_process[i:i + batch_size]
  258. tasks = []
  259. for file_name in batch:
  260. pattern_path = os.path.join(THETA_RHO_DIR, file_name)
  261. try:
  262. # Parse file to get metadata
  263. coordinates = await asyncio.to_thread(parse_theta_rho_file, pattern_path)
  264. if coordinates:
  265. first_coord = {"x": coordinates[0][0], "y": coordinates[0][1]}
  266. last_coord = {"x": coordinates[-1][0], "y": coordinates[-1][1]}
  267. total_coords = len(coordinates)
  268. # Cache the metadata
  269. cache_pattern_metadata(file_name, first_coord, last_coord, total_coords)
  270. successful += 1
  271. logger.debug(f"Generated metadata for {file_name}")
  272. # Update current file being processed
  273. cache_progress["current_file"] = file_name
  274. except Exception as e:
  275. logger.error(f"Failed to generate metadata for {file_name}: {str(e)}")
  276. # Update progress
  277. cache_progress["processed_files"] = min(i + batch_size, total_files)
  278. # Log progress
  279. progress = min(i + batch_size, total_files)
  280. logger.info(f"Metadata cache generation progress: {progress}/{total_files} files processed")
  281. logger.info(f"Metadata cache generation completed: {successful}/{total_files} patterns cached successfully, {skipped_files} patterns skipped (already cached)")
  282. except Exception as e:
  283. logger.error(f"Error during metadata cache generation: {str(e)}")
  284. cache_progress["error"] = str(e)
  285. raise
  286. async def rebuild_cache():
  287. """Rebuild the entire cache for all pattern files."""
  288. logger.info("Starting cache rebuild...")
  289. # Ensure cache directory exists
  290. ensure_cache_dir()
  291. # First generate metadata cache for all files
  292. await generate_metadata_cache()
  293. # Then generate image previews
  294. pattern_files = [f for f in list_theta_rho_files() if f.endswith('.thr')]
  295. total_files = len(pattern_files)
  296. if total_files == 0:
  297. logger.info("No pattern files found to cache")
  298. return
  299. logger.info(f"Generating image previews for {total_files} pattern files...")
  300. # Process in batches
  301. batch_size = 5
  302. successful = 0
  303. for i in range(0, total_files, batch_size):
  304. batch = pattern_files[i:i + batch_size]
  305. tasks = [generate_image_preview(file) for file in batch]
  306. results = await asyncio.gather(*tasks)
  307. successful += sum(1 for r in results if r)
  308. # Log progress
  309. progress = min(i + batch_size, total_files)
  310. logger.info(f"Image preview generation progress: {progress}/{total_files} files processed")
  311. logger.info(f"Cache rebuild completed: {successful}/{total_files} patterns cached successfully")
  312. async def generate_cache_background():
  313. """Run cache generation in the background with progress tracking."""
  314. global cache_progress
  315. try:
  316. cache_progress.update({
  317. "is_running": True,
  318. "stage": "starting",
  319. "total_files": 0,
  320. "processed_files": 0,
  321. "current_file": "",
  322. "error": None
  323. })
  324. # First generate metadata cache
  325. await generate_metadata_cache()
  326. # Then generate image previews
  327. await generate_all_image_previews()
  328. # Mark as complete
  329. cache_progress.update({
  330. "is_running": False,
  331. "stage": "complete",
  332. "current_file": "",
  333. "error": None
  334. })
  335. logger.info("Background cache generation completed successfully")
  336. except Exception as e:
  337. logger.error(f"Background cache generation failed: {str(e)}")
  338. cache_progress.update({
  339. "is_running": False,
  340. "stage": "error",
  341. "error": str(e)
  342. })
  343. raise
  344. def get_cache_progress():
  345. """Get the current cache generation progress."""
  346. global cache_progress
  347. return cache_progress.copy()
  348. def is_cache_generation_needed():
  349. """Check if cache generation is needed."""
  350. pattern_files = [f for f in list_theta_rho_files() if f.endswith('.thr')]
  351. if not pattern_files:
  352. return False
  353. # Check if any files need caching
  354. patterns_to_cache = [f for f in pattern_files if needs_cache(f)]
  355. # Check metadata cache
  356. files_needing_metadata = []
  357. for file_name in pattern_files:
  358. if get_pattern_metadata(file_name) is None:
  359. files_needing_metadata.append(file_name)
  360. return len(patterns_to_cache) > 0 or len(files_needing_metadata) > 0