33import io
44import logging
55
6+ from abc import ABC , abstractmethod
67from concurrent .futures import ThreadPoolExecutor , as_completed
78from dataclasses import dataclass
89from pathlib import Path
9- from typing import Iterable , List , Sequence
10+ from typing import List
1011
1112import pillow_heif # type: ignore
1213
@@ -36,17 +37,37 @@ class _OptimizationResult:
3637 optimized_size : int
3738
3839
39- class ImageOptimizationInsight (Insight [ImageOptimizationInsightResult ]):
40- """Analyse image optimisation opportunities in iOS apps ."""
40+ class BaseImageOptimizationInsight (Insight [ImageOptimizationInsightResult ], ABC ):
41+ """Base class for image optimization insights with shared analysis logic ."""
4142
4243 OPTIMIZABLE_FORMATS = {"png" , "jpg" , "jpeg" , "heif" , "heic" }
4344 MIN_SAVINGS_THRESHOLD = 4096
4445 TARGET_JPEG_QUALITY = 85
4546 TARGET_HEIC_QUALITY = 85
4647 _MAX_WORKERS = 4
4748
49+ @abstractmethod
50+ def _find_images (self , input : InsightsInput ) -> List [FileInfo ]:
51+ """Find and return list of images to analyze. Should include deduplication if needed."""
52+ pass
53+
54+ def _preprocess_image (self , img : Image .Image , file_info : FileInfo ) -> tuple [Image .Image , int , int ]:
55+ """Preprocess image before optimization analysis.
56+
57+ Args:
58+ img: The loaded PIL Image
59+ file_info: File metadata
60+
61+ Returns:
62+ Tuple of (processed_image, baseline_size, baseline_savings):
63+ - processed_image: The image to analyze for optimization
64+ - baseline_size: Size of the processed image before optimization
65+ - baseline_savings: Savings from preprocessing alone (original - baseline)
66+ """
67+ return img , file_info .size , 0
68+
4869 def generate (self , input : InsightsInput ) -> ImageOptimizationInsightResult | None : # noqa: D401
49- files = list ( self ._iter_optimizable_files (input . file_analysis . files ) )
70+ files = self ._find_images (input )
5071 if not files :
5172 return None
5273
@@ -58,9 +79,8 @@ def generate(self, input: InsightsInput) -> ImageOptimizationInsightResult | Non
5879 result = future .result ()
5980 if result and result .potential_savings >= self .MIN_SAVINGS_THRESHOLD :
6081 results .append (result )
61- except Exception as exc : # pragma: no cover
62- file_info = future_to_file [future ]
63- logger .error ("Failed to analyse %s: %s" , file_info .path , exc )
82+ except Exception : # pragma: no cover
83+ logger .exception ("Failed to analyze image in thread pool" )
6484
6585 if not results :
6686 return None
@@ -77,49 +97,50 @@ def _analyze_image_optimization(
7797 self ,
7898 file_info : FileInfo ,
7999 ) -> OptimizableImageFile | None :
80- minify_savings = 0
81- conversion_savings = 0
82- minified_size : int | None = None
83- heic_size : int | None = None
84-
85- full_path = file_info .full_path
86- file_size = file_info .size
87- file_type = file_info .file_type
88- display_path = file_info .path
89-
90- if full_path is None :
91- logger .info ("Skipping %s because it has no full path" , display_path )
100+ if file_info .full_path is None :
101+ logger .info ("Skipping %s because it has no full path" , file_info .path )
92102 return None
93103
94104 try :
95- with Image .open (full_path ) as img :
105+ with Image .open (file_info . full_path ) as img :
96106 img .load () # type: ignore
97- fmt = (img .format or file_type ).lower ()
107+
108+ processed_img , baseline_size , baseline_savings = self ._preprocess_image (img , file_info )
109+
110+ minify_savings = 0
111+ conversion_savings = 0
112+ minified_size : int | None = None
113+ heic_size : int | None = None
114+
115+ fmt = (processed_img .format or file_info .file_type ).lower ()
98116
99117 if fmt in {"png" , "jpg" , "jpeg" }:
100- if res := self ._check_minification (img , file_size , fmt ):
118+ if res := self ._check_minification (processed_img , baseline_size , fmt ):
101119 minify_savings , minified_size = res .savings , res .optimized_size
102- if res := self ._check_heic_conversion (img , file_size ):
120+ if res := self ._check_heic_conversion (processed_img , baseline_size ):
103121 conversion_savings , heic_size = res .savings , res .optimized_size
104122 elif fmt in {"heif" , "heic" }:
105- if res := self ._check_heic_minification (img , file_size ):
123+ if res := self ._check_heic_minification (processed_img , baseline_size ):
106124 minify_savings , minified_size = res .savings , res .optimized_size
107- except Exception as exc :
108- logger .error ("Failed to process %s: %s" , display_path , exc )
109- return None
110125
111- if max (minify_savings , conversion_savings ) < self .MIN_SAVINGS_THRESHOLD :
126+ total_minify = baseline_savings + minify_savings
127+ total_conversion = baseline_savings + conversion_savings
128+
129+ if max (total_minify , total_conversion ) < self .MIN_SAVINGS_THRESHOLD :
130+ return None
131+
132+ return OptimizableImageFile (
133+ file_path = file_info .path ,
134+ current_size = file_info .size ,
135+ minify_savings = total_minify ,
136+ minified_size = minified_size ,
137+ conversion_savings = total_conversion ,
138+ heic_size = heic_size ,
139+ )
140+ except Exception :
141+ logger .exception ("Failed to open or process image file" )
112142 return None
113143
114- return OptimizableImageFile (
115- file_path = display_path ,
116- current_size = file_size ,
117- minify_savings = minify_savings ,
118- minified_size = minified_size ,
119- conversion_savings = conversion_savings ,
120- heic_size = heic_size ,
121- )
122-
123144 def _check_minification (self , img : Image .Image , file_size : int , fmt : str ) -> _OptimizationResult | None :
124145 try :
125146 with io .BytesIO () as buf :
@@ -134,8 +155,8 @@ def _check_minification(self, img: Image.Image, file_size: int, fmt: str) -> _Op
134155 img .save (buf , format = "JPEG" , quality = self .TARGET_JPEG_QUALITY , ** save_params )
135156 new_size = buf .tell ()
136157 return _OptimizationResult (file_size - new_size , new_size ) if new_size < file_size else None
137- except Exception as exc :
138- logger .error ( "Minification check failed: %s" , exc )
158+ except Exception :
159+ logger .exception ( "Image minification optimization failed" )
139160 return None
140161
141162 def _check_heic_conversion (self , img : Image .Image , file_size : int ) -> _OptimizationResult | None :
@@ -144,8 +165,8 @@ def _check_heic_conversion(self, img: Image.Image, file_size: int) -> _Optimizat
144165 img .save (buf , format = "HEIF" , quality = self .TARGET_HEIC_QUALITY )
145166 new_size = buf .tell ()
146167 return _OptimizationResult (file_size - new_size , new_size ) if new_size < file_size else None
147- except Exception as exc :
148- logger .error ( " HEIC conversion check failed: %s" , exc )
168+ except Exception :
169+ logger .exception ( "Image HEIC conversion optimization failed" )
149170 return None
150171
151172 def _check_heic_minification (self , img : Image .Image , file_size : int ) -> _OptimizationResult | None :
@@ -154,16 +175,22 @@ def _check_heic_minification(self, img: Image.Image, file_size: int) -> _Optimiz
154175 img .save (buf , format = "HEIF" , quality = self .TARGET_HEIC_QUALITY )
155176 new_size = buf .tell ()
156177 return _OptimizationResult (file_size - new_size , new_size ) if new_size < file_size else None
157- except Exception as exc :
158- logger .error ("HEIC minification check failed: %s" , exc )
178+ except Exception :
179+ logger .exception ("HEIC image minification failed" )
159180 return None
160181
161- def _iter_optimizable_files (self , files : Sequence [FileInfo ]) -> Iterable [FileInfo ]:
162- for fi in files :
182+
183+ class ImageOptimizationInsight (BaseImageOptimizationInsight ):
184+ """Analyse image optimisation opportunities in iOS apps."""
185+
186+ def _find_images (self , input : InsightsInput ) -> List [FileInfo ]:
187+ images : List [FileInfo ] = []
188+ for fi in input .file_analysis .files :
163189 if fi .file_type == "car" :
164- yield from (c for c in fi .children if self ._is_optimizable_image_file (c ))
190+ images . extend (c for c in fi .children if self ._is_optimizable_image_file (c ))
165191 elif self ._is_optimizable_image_file (fi ):
166- yield fi
192+ images .append (fi )
193+ return images
167194
168195 def _is_optimizable_image_file (self , file_info : FileInfo ) -> bool :
169196 if file_info .file_type .lower () not in self .OPTIMIZABLE_FORMATS :
0 commit comments