cache_manager.py 18 KB

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