cache_manager.py 17 KB

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