Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 16 additions & 4 deletions cloudfront_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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/",
Expand Down
9 changes: 5 additions & 4 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
cloudinary==1.40.0
pillow-avif-plugin>=1.4.0
34 changes: 28 additions & 6 deletions upload_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand All @@ -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]

Expand All @@ -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}")

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}..."
Expand Down Expand Up @@ -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}..."
Expand Down Expand Up @@ -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
Expand Down
Loading