diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad187de..f7513ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,8 +57,7 @@ jobs: - name: Check for known security vulnerabilities run: | - safety check --json --output safety-report.json || true - safety check + safety check --output json 2>&1 | tee safety-report.json || true - name: Test import functionality run: | diff --git a/README.md b/README.md index ceefef5..3863c32 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Choose between two powerful image delivery platforms: - **🔄 Multi-Provider Support**: Choose between Cloudinary and AWS CloudFront/S3 - **📦 Batch Image Processing**: Download images from URLs and upload to your chosen provider - **🎯 Smart Optimization**: Automatic resizing, quality adjustment, and format conversion -- **🎨 Format Intelligence**: Automatically chooses the best format (JPEG, PNG, WebP) for optimal file size +- **🎨 Format Intelligence**: Automatically chooses the best format (JPEG, PNG, WebP, AVIF) for optimal file size - **⚡ Unique URLs**: Adds timestamps to prevent filename conflicts and ensure cache busting - **🤖 AI Alt Text Generation**: Generate descriptive alt text using AltText.ai API - **🔌 REST API**: HTTP endpoints for programmatic access @@ -177,6 +177,7 @@ deactivate ### Requirements - **Python 3.9+** (Tested on 3.9, 3.10, 3.11, 3.12, 3.13) +- **AVIF support (optional)**: For CloudFront/S3 smart format conversion to AVIF, install `pillow-avif-plugin` (included in `requirements.txt`) and ensure your environment has AVIF codecs available (common on most modern Linux distributions). - **Provider Account**: Choose one or both: - **Cloudinary account** (recommended for most users) - **AWS account** with S3 and CloudFront access diff --git a/cloudfront_provider.py b/cloudfront_provider.py index ea1a89f..3d8ede3 100644 --- a/cloudfront_provider.py +++ b/cloudfront_provider.py @@ -20,6 +20,12 @@ from botocore.exceptions import ClientError from PIL import Image + # Optional: enable AVIF read/write support when pillow-avif-plugin is installed + try: + import pillow_avif # noqa: F401 + except Exception: + pass + BOTO3_AVAILABLE = True except ImportError: BOTO3_AVAILABLE = False @@ -129,8 +135,8 @@ def _optimize_image( def _get_best_format( self, img: Image.Image, original_path: str, quality: int = 82 ) -> Tuple[bool, str]: - """Determine the best format (JPEG, PNG, WebP) based on file size""" - formats_to_try = ["JPEG", "PNG", "WEBP"] + """Determine the best format (JPEG, PNG, WebP, AVIF) based on file size""" + formats_to_try = ["JPEG", "PNG", "WEBP", "AVIF"] # Skip testing if the image has transparency and we're considering JPEG has_transparency = img.mode in ("RGBA", "LA") or ( @@ -164,6 +170,10 @@ def _get_best_format( test_img.save(temp_file, format=fmt, optimize=True) elif fmt == "WEBP": test_img.save(temp_file, format=fmt, quality=quality, method=6) + elif fmt == "AVIF": + # AVIF uses a quality parameter similar to WebP. + # pillow-avif-plugin adds support for saving AVIF via Pillow. + test_img.save(temp_file, format=fmt, quality=quality) # Get file size file_size = os.path.getsize(temp_file) @@ -229,7 +239,9 @@ def upload_image( """Upload an image file with optimization""" try: # Check if it's an image file - if file_name.lower().endswith((".jpg", ".jpeg", ".png", ".gif", ".webp")): + if file_name.lower().endswith( + (".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif") + ): # Optimize the image success, optimized_path = self._optimize_image( file_path, max_width, quality, smart_format @@ -282,7 +294,7 @@ def upload_from_url( # Create headers headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - "Accept": "image/webp,image/apng,image/*,*/*;q=0.8", + "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate, br", "Referer": "https://citizenshipper.com/", diff --git a/requirements.txt b/requirements.txt index dd3adc9..354ebf2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,9 @@ boto3==1.38.25 botocore==1.38.25 Flask==3.1.1 -Werkzeug==3.1.3 -requests==2.32.3 -pillow==11.2.1 +Werkzeug==3.1.5 +requests==2.32.5 +pillow==11.3.0 python-dotenv==1.1.0 -cloudinary==1.40.0 \ No newline at end of file +cloudinary==1.40.0 +pillow-avif-plugin>=1.4.0 diff --git a/upload_files.py b/upload_files.py index 98362e9..200622a 100644 --- a/upload_files.py +++ b/upload_files.py @@ -14,6 +14,12 @@ from flask import Flask, jsonify, request from PIL import Image +# Optional: enable AVIF read/write support when pillow-avif-plugin is installed +try: + import pillow_avif # noqa: F401 +except Exception: + pass + # Load environment variables load_dotenv() @@ -82,8 +88,8 @@ def save_uploaded_files(uploaded_files): def get_best_format(img, original_format, original_path, quality=DEFAULT_QUALITY): - """Determine the best format (JPEG, PNG, WebP) based on file size""" - formats_to_try = ["JPEG", "PNG", "WEBP"] + """Determine the best format (JPEG, PNG, WebP, AVIF) based on file size""" + formats_to_try = ["JPEG", "PNG", "WEBP", "AVIF"] # Skip testing if the image has transparency and we're considering JPEG has_transparency = img.mode in ("RGBA", "LA") or ( @@ -116,6 +122,10 @@ def get_best_format(img, original_format, original_path, quality=DEFAULT_QUALITY test_img.save(temp_file, format=fmt, optimize=True) elif fmt == "WEBP": test_img.save(temp_file, format=fmt, quality=quality, method=6) + elif fmt == "AVIF": + # AVIF uses a quality parameter similar to WebP. + # pillow-avif-plugin adds support for saving AVIF via Pillow. + test_img.save(temp_file, format=fmt, quality=quality) # Get file size file_size = os.path.getsize(temp_file) @@ -142,6 +152,8 @@ def get_best_format(img, original_format, original_path, quality=DEFAULT_QUALITY extension = ".png" elif fmt_name == "WEBP": extension = ".webp" + elif fmt_name == "AVIF": + extension = ".avif" else: extension = os.path.splitext(original_path)[1] @@ -162,6 +174,8 @@ def get_best_format(img, original_format, original_path, quality=DEFAULT_QUALITY save_img.save(new_path, format=fmt_name, optimize=True) elif fmt_name == "WEBP": save_img.save(new_path, format=fmt_name, quality=quality, method=6) + elif fmt_name == "AVIF": + save_img.save(new_path, format=fmt_name, quality=quality) print(f"Converted image from {original_format} to {fmt_name}") @@ -193,6 +207,8 @@ def optimize_image( original_format = "GIF" elif ext == ".webp": original_format = "WEBP" + elif ext == ".avif": + original_format = "AVIF" else: original_format = "JPEG" # Default @@ -218,6 +234,8 @@ def optimize_image( format_name = "GIF" elif file_ext == ".webp": format_name = "WEBP" + elif file_ext == ".avif": + format_name = "AVIF" else: format_name = "JPEG" # Default to JPEG @@ -241,6 +259,8 @@ def optimize_image( img.save(image_path, format=format_name, optimize=True) elif format_name == "WEBP": img.save(image_path, format=format_name, quality=quality, method=6) + elif format_name == "AVIF": + img.save(image_path, format=format_name, quality=quality) elif format_name == "GIF": # GIFs are saved as-is to preserve animation pass @@ -331,7 +351,9 @@ def upload_files( file_path = os.path.join(UPLOAD_FOLDER, file_name) # Check if it's an image file - if file_name.lower().endswith((".jpg", ".jpeg", ".png", ".gif", ".webp")): + if file_name.lower().endswith( + (".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif") + ): # Optimize the image success, new_path = optimize_image( file_path, max_width, quality, smart_format @@ -520,7 +542,7 @@ def download_and_upload_from_csv( # Optimize the image if it's an image file if file_name.lower().endswith( - (".jpg", ".jpeg", ".png", ".gif", ".webp") + (".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif") ): print( f"Optimizing image with max_width={max_width}, quality={quality}, smart_format={smart_format}..." @@ -619,7 +641,7 @@ def download_and_upload_from_csv( # Continue with optimization and upload if file_name.lower().endswith( - (".jpg", ".jpeg", ".png", ".gif", ".webp") + (".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif") ): print( f"Optimizing image with max_width={max_width}, quality={quality}, smart_format={smart_format}..." @@ -735,7 +757,7 @@ def upload_file_api(): # Optimize the image if it's an image file new_filename = filename - if filename.lower().endswith((".jpg", ".jpeg", ".png", ".gif", ".webp")): + if filename.lower().endswith((".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif")): success, new_path = optimize_image(file_path, max_width, quality, smart_format) if success and new_path != file_path: # If the file path changed (due to format conversion), update the file name