-
+
+ {avatarUrl && !avatarError ? (
+

setAvatarError(true)}
+ />
+ ) : (
+
+ )}
{profile.name}
diff --git a/app/src/components/VoiceProfiles/ProfileForm.tsx b/app/src/components/VoiceProfiles/ProfileForm.tsx
index c45dc9f1..4606eadb 100644
--- a/app/src/components/VoiceProfiles/ProfileForm.tsx
+++ b/app/src/components/VoiceProfiles/ProfileForm.tsx
@@ -1,6 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod';
-import { Mic, Monitor, Upload, X } from 'lucide-react';
-import { useEffect, useState } from 'react';
+import { Edit2, Mic, Monitor, Upload, X } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
@@ -36,14 +36,17 @@ import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
import {
useAddSample,
useCreateProfile,
+ useDeleteAvatar,
useProfile,
useUpdateProfile,
+ useUploadAvatar,
} from '@/lib/hooks/useProfiles';
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
import { useTranscription } from '@/lib/hooks/useTranscription';
import { isTauri } from '@/lib/tauri';
import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore';
+import { useServerStore } from '@/stores/serverStore';
import { AudioSampleRecording } from './AudioSampleRecording';
import { AudioSampleSystem } from './AudioSampleSystem';
import { AudioSampleUpload } from './AudioSampleUpload';
@@ -57,6 +60,7 @@ const baseProfileSchema = z.object({
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
sampleFile: z.instanceof(File).optional(),
referenceText: z.string().max(1000).optional(),
+ avatarFile: z.instanceof(File).optional(),
});
const profileSchema = baseProfileSchema.refine(
@@ -108,13 +112,18 @@ export function ProfileForm() {
const createProfile = useCreateProfile();
const updateProfile = useUpdateProfile();
const addSample = useAddSample();
+ const uploadAvatar = useUploadAvatar();
+ const deleteAvatar = useDeleteAvatar();
const transcribe = useTranscription();
const { toast } = useToast();
const [sampleMode, setSampleMode] = useState<'upload' | 'record' | 'system'>('record');
const [audioDuration, setAudioDuration] = useState
(null);
const [isValidatingAudio, setIsValidatingAudio] = useState(false);
+ const [avatarPreview, setAvatarPreview] = useState(null);
+ const avatarInputRef = useRef(null);
const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer();
const isCreating = !editingProfileId;
+ const serverUrl = useServerStore((state) => state.serverUrl);
const form = useForm({
resolver: zodResolver(profileSchema),
@@ -124,10 +133,12 @@ export function ProfileForm() {
language: 'en',
sampleFile: undefined,
referenceText: '',
+ avatarFile: undefined,
},
});
const selectedFile = form.watch('sampleFile');
+ const selectedAvatarFile = form.watch('avatarFile');
// Validate audio duration when file is selected
useEffect(() => {
@@ -244,6 +255,19 @@ export function ProfileForm() {
}
}, [systemRecordingError, toast]);
+ // Handle avatar preview
+ useEffect(() => {
+ if (selectedAvatarFile instanceof File) {
+ const url = URL.createObjectURL(selectedAvatarFile);
+ setAvatarPreview(url);
+ return () => URL.revokeObjectURL(url);
+ } else if (editingProfile?.avatar_path) {
+ setAvatarPreview(`${serverUrl}/profiles/${editingProfile.id}/avatar`);
+ } else {
+ setAvatarPreview(null);
+ }
+ }, [selectedAvatarFile, editingProfile, serverUrl]);
+
// Restore form state from draft or editing profile
useEffect(() => {
if (editingProfile) {
@@ -253,6 +277,7 @@ export function ProfileForm() {
language: editingProfile.language as LanguageCode,
sampleFile: undefined,
referenceText: undefined,
+ avatarFile: undefined,
});
} else if (profileFormDraft && open) {
// Restore from draft when opening in create mode
@@ -262,6 +287,7 @@ export function ProfileForm() {
language: profileFormDraft.language as LanguageCode,
referenceText: profileFormDraft.referenceText,
sampleFile: undefined,
+ avatarFile: undefined,
});
setSampleMode(profileFormDraft.sampleMode);
// Restore the file if we have it saved
@@ -285,8 +311,10 @@ export function ProfileForm() {
language: 'en',
sampleFile: undefined,
referenceText: undefined,
+ avatarFile: undefined,
});
setSampleMode('record');
+ setAvatarPreview(null);
}
}, [editingProfile, profileFormDraft, open, form]);
@@ -330,6 +358,52 @@ export function ProfileForm() {
playPause(file);
}
+ function handleAvatarFileChange(e: React.ChangeEvent) {
+ const file = e.target.files?.[0];
+ if (file) {
+ if (!file.type.startsWith('image/')) {
+ toast({
+ title: 'Invalid file type',
+ description: 'Please select an image file (PNG, JPG, or WebP)',
+ variant: 'destructive',
+ });
+ return;
+ }
+ if (file.size > 5 * 1024 * 1024) {
+ toast({
+ title: 'File too large',
+ description: 'Image must be less than 5MB',
+ variant: 'destructive',
+ });
+ return;
+ }
+ form.setValue('avatarFile', file);
+ }
+ }
+
+ async function handleRemoveAvatar() {
+ if (editingProfileId && editingProfile?.avatar_path) {
+ try {
+ await deleteAvatar.mutateAsync(editingProfileId);
+ toast({
+ title: 'Avatar removed',
+ description: 'Avatar image has been removed successfully.',
+ });
+ } catch (error) {
+ toast({
+ title: 'Failed to remove avatar',
+ description: error instanceof Error ? error.message : 'Unknown error',
+ variant: 'destructive',
+ });
+ }
+ }
+ form.setValue('avatarFile', undefined);
+ setAvatarPreview(null);
+ if (avatarInputRef.current) {
+ avatarInputRef.current.value = '';
+ }
+ }
+
async function onSubmit(data: ProfileFormValues) {
try {
if (editingProfileId) {
@@ -342,6 +416,23 @@ export function ProfileForm() {
language: data.language,
},
});
+
+ // Handle avatar upload/update if file changed
+ if (data.avatarFile) {
+ try {
+ await uploadAvatar.mutateAsync({
+ profileId: editingProfileId,
+ file: data.avatarFile,
+ });
+ } catch (avatarError) {
+ toast({
+ title: 'Avatar upload failed',
+ description: avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
+ variant: 'destructive',
+ });
+ }
+ }
+
toast({
title: 'Voice updated',
description: `"${data.name}" has been updated successfully.`,
@@ -418,6 +509,23 @@ export function ProfileForm() {
file: sampleFile,
referenceText: referenceText,
});
+
+ // Handle avatar upload if provided
+ if (data.avatarFile) {
+ try {
+ await uploadAvatar.mutateAsync({
+ profileId: profile.id,
+ file: data.avatarFile,
+ });
+ } catch (avatarError) {
+ toast({
+ title: 'Avatar upload failed',
+ description: avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
+ variant: 'destructive',
+ });
+ }
+ }
+
toast({
title: 'Profile created',
description: `"${data.name}" has been created with a sample.`,
@@ -670,6 +778,58 @@ export function ProfileForm() {
{/* Right column: Profile info */}
+ {/* Avatar Upload */}
+
(
+
+
+
+
+
+ {avatarPreview ? (
+

+ ) : (
+
+ )}
+
+
+ {(avatarPreview || editingProfile?.avatar_path) && (
+
+ )}
+
+
+
+
+
+
+ )}
+ />
+
-
-
Audio Samples
-
-
-
+
{samples && samples.length === 0 ? (
@@ -304,6 +296,11 @@ export function SampleList({ profileId }: SampleListProps) {
)}
+
+
);
diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts
index 7e319b43..cd87ab89 100644
--- a/app/src/lib/api/client.ts
+++ b/app/src/lib/api/client.ts
@@ -165,6 +165,32 @@ class ApiClient {
return response.json();
}
+ async uploadAvatar(profileId: string, file: File): Promise {
+ const url = `${this.getBaseUrl()}/profiles/${profileId}/avatar`;
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const response = await fetch(url, {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({
+ detail: response.statusText,
+ }));
+ throw new Error(error.detail || `HTTP error! status: ${response.status}`);
+ }
+
+ return response.json();
+ }
+
+ async deleteAvatar(profileId: string): Promise {
+ await this.request(`/profiles/${profileId}/avatar`, {
+ method: 'DELETE',
+ });
+ }
+
// Generation
async generateSpeech(data: GenerationRequest): Promise {
return this.request('/generate', {
diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts
index cdd68058..6da3161a 100644
--- a/app/src/lib/api/types.ts
+++ b/app/src/lib/api/types.ts
@@ -12,6 +12,7 @@ export interface VoiceProfileResponse {
name: string;
description?: string;
language: string;
+ avatar_path?: string;
created_at: string;
updated_at: string;
}
diff --git a/app/src/lib/hooks/useProfiles.ts b/app/src/lib/hooks/useProfiles.ts
index f0415d70..1bb1049a 100644
--- a/app/src/lib/hooks/useProfiles.ts
+++ b/app/src/lib/hooks/useProfiles.ts
@@ -185,3 +185,32 @@ export function useImportProfile() {
},
});
}
+
+export function useUploadAvatar() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ profileId, file }: { profileId: string; file: File }) =>
+ apiClient.uploadAvatar(profileId, file),
+ onSuccess: (_, variables) => {
+ queryClient.invalidateQueries({ queryKey: ['profiles'] });
+ queryClient.invalidateQueries({
+ queryKey: ['profiles', variables.profileId],
+ });
+ },
+ });
+}
+
+export function useDeleteAvatar() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (profileId: string) => apiClient.deleteAvatar(profileId),
+ onSuccess: (_, profileId) => {
+ queryClient.invalidateQueries({ queryKey: ['profiles'] });
+ queryClient.invalidateQueries({
+ queryKey: ['profiles', profileId],
+ });
+ },
+ });
+}
diff --git a/backend/database.py b/backend/database.py
index ff59173e..3b9c51ee 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -17,11 +17,12 @@ Base = declarative_base()
class VoiceProfile(Base):
"""Voice profile database model."""
__tablename__ = "profiles"
-
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, unique=True, nullable=False)
description = Column(Text)
language = Column(String, default="en")
+ avatar_path = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -277,6 +278,16 @@ def _run_migrations(engine):
conn.commit()
print("Added trim_end_ms column to story_items")
+ # Migration: Add avatar_path to profiles table
+ if 'profiles' in inspector.get_table_names():
+ columns = {col['name'] for col in inspector.get_columns('profiles')}
+ if 'avatar_path' not in columns:
+ print("Migrating profiles: adding avatar_path column")
+ with engine.connect() as conn:
+ conn.execute(text("ALTER TABLE profiles ADD COLUMN avatar_path VARCHAR"))
+ conn.commit()
+ print("Added avatar_path column to profiles")
+
def get_db():
"""Get database session (generator for dependency injection)."""
diff --git a/backend/export_import.py b/backend/export_import.py
index b2011ba1..6d705aa4 100644
--- a/backend/export_import.py
+++ b/backend/export_import.py
@@ -75,6 +75,16 @@ def export_profile_to_zip(profile_id: str, db: Session) -> bytes:
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
+ # Check if profile has avatar
+ has_avatar = False
+ if profile.avatar_path:
+ avatar_path = Path(profile.avatar_path)
+ if avatar_path.exists():
+ has_avatar = True
+ # Add avatar to ZIP root with original extension
+ avatar_ext = avatar_path.suffix
+ zip_file.write(avatar_path, f"avatar{avatar_ext}")
+
# Create manifest.json
manifest = {
"version": "1.0",
@@ -82,30 +92,31 @@ def export_profile_to_zip(profile_id: str, db: Session) -> bytes:
"name": profile.name,
"description": profile.description,
"language": profile.language,
- }
+ },
+ "has_avatar": has_avatar,
}
zip_file.writestr("manifest.json", json.dumps(manifest, indent=2))
-
+
# Create samples.json mapping
samples_data = {}
profile_dir = _get_profiles_dir() / profile_id
-
+
for sample in samples:
# Get filename from audio_path (should be {sample_id}.wav)
audio_path = Path(sample.audio_path)
filename = audio_path.name
-
+
# Read audio file
if not audio_path.exists():
raise ValueError(f"Audio file not found: {audio_path}")
-
+
# Add to samples directory in ZIP
zip_path = f"samples/{filename}"
zip_file.write(audio_path, zip_path)
-
+
# Map filename to reference text
samples_data[filename] = sample.reference_text
-
+
zip_file.writestr("samples.json", json.dumps(samples_data, indent=2))
zip_buffer.seek(0)
@@ -168,11 +179,31 @@ async def import_profile_from_zip(file_bytes: bytes, db: Session) -> VoiceProfil
)
profile = await create_profile(profile_create, db)
-
+
# Extract and add samples
profile_dir = _get_profiles_dir() / profile.id
profile_dir.mkdir(parents=True, exist_ok=True)
-
+
+ # Handle avatar if present
+ avatar_files = [f for f in namelist if f.startswith("avatar.")]
+ if avatar_files:
+ try:
+ avatar_file = avatar_files[0]
+ # Extract to temporary file
+ import tempfile
+ with tempfile.NamedTemporaryFile(suffix=Path(avatar_file).suffix, delete=False) as tmp:
+ tmp.write(zip_file.read(avatar_file))
+ tmp_path = tmp.name
+
+ try:
+ from .profiles import upload_avatar
+ await upload_avatar(profile.id, tmp_path, db)
+ finally:
+ Path(tmp_path).unlink(missing_ok=True)
+ except Exception as e:
+ # Avatar import is optional - continue even if it fails
+ pass
+
for filename, reference_text in samples_data.items():
# Validate filename
if not filename.endswith('.wav'):
diff --git a/backend/main.py b/backend/main.py
index c7d7c5ff..ca53f348 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -296,6 +296,61 @@ async def update_profile_sample(
return sample
+@app.post("/profiles/{profile_id}/avatar", response_model=models.VoiceProfileResponse)
+async def upload_profile_avatar(
+ profile_id: str,
+ file: UploadFile = File(...),
+ db: Session = Depends(get_db),
+):
+ """Upload or update avatar image for a profile."""
+ # Save uploaded file to temp location
+ with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename).suffix) as tmp:
+ content = await file.read()
+ tmp.write(content)
+ tmp_path = tmp.name
+
+ try:
+ profile = await profiles.upload_avatar(profile_id, tmp_path, db)
+ return profile
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ finally:
+ # Clean up temp file
+ Path(tmp_path).unlink(missing_ok=True)
+
+
+@app.get("/profiles/{profile_id}/avatar")
+async def get_profile_avatar(
+ profile_id: str,
+ db: Session = Depends(get_db),
+):
+ """Get avatar image for a profile."""
+ profile = await profiles.get_profile(profile_id, db)
+ if not profile:
+ raise HTTPException(status_code=404, detail="Profile not found")
+
+ if not profile.avatar_path:
+ raise HTTPException(status_code=404, detail="No avatar found for this profile")
+
+ avatar_path = Path(profile.avatar_path)
+ if not avatar_path.exists():
+ raise HTTPException(status_code=404, detail="Avatar file not found")
+
+ return FileResponse(avatar_path)
+
+
+@app.delete("/profiles/{profile_id}/avatar")
+async def delete_profile_avatar(
+ profile_id: str,
+ db: Session = Depends(get_db),
+):
+ """Delete avatar image for a profile."""
+ success = await profiles.delete_avatar(profile_id, db)
+ if not success:
+ raise HTTPException(status_code=404, detail="Profile not found or no avatar to delete")
+ return {"message": "Avatar deleted successfully"}
+
+
@app.get("/profiles/{profile_id}/export")
async def export_profile(
profile_id: str,
diff --git a/backend/models.py b/backend/models.py
index 5009c2a3..1951b887 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -20,6 +20,7 @@ class VoiceProfileResponse(BaseModel):
name: str
description: Optional[str]
language: str
+ avatar_path: Optional[str] = None
created_at: datetime
updated_at: datetime
diff --git a/backend/profiles.py b/backend/profiles.py
index 894611aa..81c12876 100644
--- a/backend/profiles.py
+++ b/backend/profiles.py
@@ -21,6 +21,7 @@ from .database import (
ProfileSample as DBProfileSample,
)
from .utils.audio import validate_reference_audio, load_audio, save_audio
+from .utils.images import validate_image, process_avatar
from .tts import get_tts_model
from . import config
@@ -307,23 +308,23 @@ async def create_voice_prompt_for_profile(
) -> dict:
"""
Create a combined voice prompt from all samples in a profile.
-
+
Args:
profile_id: Profile ID
db: Database session
use_cache: Whether to use cached prompts
-
+
Returns:
Voice prompt dictionary
"""
# Get all samples for profile
samples = db.query(DBProfileSample).filter_by(profile_id=profile_id).all()
-
+
if not samples:
raise ValueError(f"No samples found for profile {profile_id}")
-
+
tts_model = get_tts_model()
-
+
if len(samples) == 1:
# Single sample - use directly
sample = samples[0]
@@ -337,19 +338,19 @@ async def create_voice_prompt_for_profile(
# Multiple samples - combine them
audio_paths = [s.audio_path for s in samples]
reference_texts = [s.reference_text for s in samples]
-
+
# Combine audio
combined_audio, combined_text = await tts_model.combine_voice_prompts(
audio_paths,
reference_texts,
)
-
+
# Save combined audio temporarily
import tempfile
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
save_audio(combined_audio, tmp.name, 24000)
tmp_path = tmp.name
-
+
try:
# Create prompt from combined audio
voice_prompt, _ = await tts_model.create_voice_prompt(
@@ -361,3 +362,99 @@ async def create_voice_prompt_for_profile(
finally:
# Clean up temp file
Path(tmp_path).unlink(missing_ok=True)
+
+
+async def upload_avatar(
+ profile_id: str,
+ image_path: str,
+ db: Session,
+) -> VoiceProfileResponse:
+ """
+ Upload and process avatar image for a profile.
+
+ Args:
+ profile_id: Profile ID
+ image_path: Path to uploaded image file
+ db: Database session
+
+ Returns:
+ Updated profile
+ """
+ # Validate profile exists
+ profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
+ if not profile:
+ raise ValueError(f"Profile {profile_id} not found")
+
+ # Validate image
+ is_valid, error_msg = validate_image(image_path)
+ if not is_valid:
+ raise ValueError(error_msg)
+
+ # Delete existing avatar if present
+ if profile.avatar_path:
+ old_avatar = Path(profile.avatar_path)
+ if old_avatar.exists():
+ old_avatar.unlink()
+
+ # Determine file extension from uploaded file
+ from PIL import Image
+ with Image.open(image_path) as img:
+ # Normalize JPEG variants (MPO is multi-picture format from some cameras)
+ img_format = img.format
+ if img_format in ('MPO', 'JPG'):
+ img_format = 'JPEG'
+
+ ext_map = {
+ 'PNG': '.png',
+ 'JPEG': '.jpg',
+ 'WEBP': '.webp'
+ }
+ ext = ext_map.get(img_format, '.png')
+
+ # Save processed image to profile directory
+ profile_dir = _get_profiles_dir() / profile_id
+ profile_dir.mkdir(parents=True, exist_ok=True)
+ output_path = profile_dir / f"avatar{ext}"
+
+ process_avatar(image_path, str(output_path))
+
+ # Update database
+ profile.avatar_path = str(output_path)
+ profile.updated_at = datetime.utcnow()
+
+ db.commit()
+ db.refresh(profile)
+
+ return VoiceProfileResponse.model_validate(profile)
+
+
+async def delete_avatar(
+ profile_id: str,
+ db: Session,
+) -> bool:
+ """
+ Delete avatar image for a profile.
+
+ Args:
+ profile_id: Profile ID
+ db: Database session
+
+ Returns:
+ True if deleted, False if not found or no avatar
+ """
+ profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
+ if not profile or not profile.avatar_path:
+ return False
+
+ # Delete avatar file
+ avatar_path = Path(profile.avatar_path)
+ if avatar_path.exists():
+ avatar_path.unlink()
+
+ # Update database
+ profile.avatar_path = None
+ profile.updated_at = datetime.utcnow()
+
+ db.commit()
+
+ return True
diff --git a/backend/requirements.txt b/backend/requirements.txt
index a5ed251a..e0f6ded5 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -21,3 +21,4 @@ numpy>=1.24.0
# Utilities
python-multipart>=0.0.6
+Pillow>=10.0.0
diff --git a/backend/utils/images.py b/backend/utils/images.py
new file mode 100644
index 00000000..37e3f45f
--- /dev/null
+++ b/backend/utils/images.py
@@ -0,0 +1,114 @@
+"""Image processing utilities for avatar uploads."""
+
+from pathlib import Path
+from typing import Optional, Tuple
+from PIL import Image
+
+# JPEG can be reported as 'JPEG' or 'MPO' (for multi-picture format from some cameras)
+ALLOWED_FORMATS = {'PNG', 'JPEG', 'WEBP', 'MPO', 'JPG'}
+MAX_SIZE = 512
+MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
+
+
+def validate_image(file_path: str) -> Tuple[bool, Optional[str]]:
+ """
+ Validate image format and file size.
+
+ Args:
+ file_path: Path to image file
+
+ Returns:
+ Tuple of (is_valid, error_message)
+ """
+ path = Path(file_path)
+
+ # Check file size
+ if path.stat().st_size > MAX_FILE_SIZE:
+ return False, f"File size exceeds maximum of {MAX_FILE_SIZE // (1024 * 1024)}MB"
+
+ try:
+ with Image.open(file_path) as img:
+ # Verify the image can be loaded
+ img.load()
+
+ # Check format (normalize JPEG variants)
+ img_format = img.format
+ if img_format in ('MPO', 'JPG'):
+ img_format = 'JPEG'
+
+ if img_format not in {'PNG', 'JPEG', 'WEBP'}:
+ return False, f"Invalid format '{img_format}'. Allowed formats: PNG, JPEG, WEBP"
+
+ return True, None
+ except Exception as e:
+ return False, f"Invalid image file: {str(e)}"
+
+
+def process_avatar(input_path: str, output_path: str, max_size: int = MAX_SIZE) -> None:
+ """
+ Process avatar image: resize and optimize.
+
+ Resizes image to fit within max_size x max_size while maintaining aspect ratio.
+
+ Args:
+ input_path: Path to input image
+ output_path: Path to save processed image
+ max_size: Maximum width or height in pixels
+ """
+ with Image.open(input_path) as img:
+ # Handle EXIF orientation for JPEG images
+ try:
+ from PIL import ExifTags
+ for orientation in ExifTags.TAGS.keys():
+ if ExifTags.TAGS[orientation] == 'Orientation':
+ break
+ exif = img._getexif()
+ if exif is not None:
+ orientation_value = exif.get(orientation)
+ if orientation_value == 3:
+ img = img.rotate(180, expand=True)
+ elif orientation_value == 6:
+ img = img.rotate(270, expand=True)
+ elif orientation_value == 8:
+ img = img.rotate(90, expand=True)
+ except (AttributeError, KeyError, IndexError, TypeError):
+ # No EXIF data or orientation tag
+ pass
+
+ # Convert to RGB if necessary (handles RGBA, P, CMYK, etc.)
+ if img.mode not in ('RGB', 'L'):
+ if img.mode == 'RGBA':
+ # Create white background for RGBA images
+ background = Image.new('RGB', img.size, (255, 255, 255))
+ background.paste(img, mask=img.split()[3]) # Use alpha channel as mask
+ img = background
+ elif img.mode == 'CMYK':
+ # Convert CMYK to RGB
+ img = img.convert('RGB')
+ elif img.mode == 'P':
+ # Convert palette mode to RGB
+ img = img.convert('RGB')
+ else:
+ img = img.convert('RGB')
+
+ # Calculate new size maintaining aspect ratio
+ img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
+
+ # Determine output format from extension
+ output_ext = Path(output_path).suffix.lower()
+
+ format_map = {
+ '.png': 'PNG',
+ '.jpeg': 'JPEG',
+ '.jpg': 'JPEG',
+ '.webp': 'WEBP'
+ }
+
+ output_format = format_map.get(output_ext, 'PNG')
+
+ # Save with optimization
+ save_kwargs = {'optimize': True}
+ if output_format == 'JPEG':
+ save_kwargs['quality'] = 90
+
+ img.save(output_path, format=output_format, **save_kwargs)