From 89f3127c3715a3615d2f5ca61545acd053115a5c Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Thu, 29 Jan 2026 19:28:42 -0800 Subject: [PATCH] Implement avatar upload and management for voice profiles - Added functionality to upload, delete, and retrieve avatar images for voice profiles. - Introduced new API endpoints for avatar management, including upload and delete operations. - Enhanced profile forms and components to support avatar image handling, including previews and error handling. - Updated database schema to include avatar_path for profiles and added necessary migrations. - Implemented image validation and processing utilities to ensure proper avatar uploads. --- .../Generation/FloatingGenerateBox.tsx | 1 - .../components/StoriesTab/StoryChatItem.tsx | 24 ++- .../components/VoiceProfiles/ProfileCard.tsx | 23 ++- .../components/VoiceProfiles/ProfileForm.tsx | 164 +++++++++++++++++- .../components/VoiceProfiles/SampleList.tsx | 15 +- app/src/lib/api/client.ts | 26 +++ app/src/lib/api/types.ts | 1 + app/src/lib/hooks/useProfiles.ts | 29 ++++ backend/database.py | 13 +- backend/export_import.py | 49 +++++- backend/main.py | 55 ++++++ backend/models.py | 1 + backend/profiles.py | 113 +++++++++++- backend/requirements.txt | 1 + backend/utils/images.py | 114 ++++++++++++ 15 files changed, 594 insertions(+), 35 deletions(-) create mode 100644 backend/utils/images.py diff --git a/app/src/components/Generation/FloatingGenerateBox.tsx b/app/src/components/Generation/FloatingGenerateBox.tsx index e4607e23..b020a81f 100644 --- a/app/src/components/Generation/FloatingGenerateBox.tsx +++ b/app/src/components/Generation/FloatingGenerateBox.tsx @@ -112,7 +112,6 @@ export function FloatingGenerateBox({ } }, [selectedProfileId, profiles, setSelectedProfileId]); - // Auto-resize textarea based on content (only when expanded) useEffect(() => { if (!isExpanded) { diff --git a/app/src/components/StoriesTab/StoryChatItem.tsx b/app/src/components/StoriesTab/StoryChatItem.tsx index febf8190..19fa2249 100644 --- a/app/src/components/StoriesTab/StoryChatItem.tsx +++ b/app/src/components/StoriesTab/StoryChatItem.tsx @@ -1,6 +1,7 @@ import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react'; +import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -12,6 +13,7 @@ import { Textarea } from '@/components/ui/textarea'; import type { StoryItemDetail } from '@/lib/api/types'; import { cn } from '@/lib/utils/cn'; import { useStoryStore } from '@/stores/storyStore'; +import { useServerStore } from '@/stores/serverStore'; interface StoryChatItemProps { item: StoryItemDetail; @@ -33,6 +35,10 @@ export function StoryChatItem({ isDragging, }: StoryChatItemProps) { const seek = useStoryStore((state) => state.seek); + const serverUrl = useServerStore((state) => state.serverUrl); + const [avatarError, setAvatarError] = useState(false); + + const avatarUrl = `${serverUrl}/profiles/${item.profile_id}/avatar`; // Check if this item is currently playing based on timecode const itemStartMs = item.start_time_ms; @@ -72,10 +78,22 @@ export function StoryChatItem({ )} - {/* Voice Icon */} + {/* Voice Avatar */}
-
- +
+ {!avatarError ? ( + {`${item.profile_name} setAvatarError(true)} + /> + ) : ( + + )}
diff --git a/app/src/components/VoiceProfiles/ProfileCard.tsx b/app/src/components/VoiceProfiles/ProfileCard.tsx index d386599b..ed46259b 100644 --- a/app/src/components/VoiceProfiles/ProfileCard.tsx +++ b/app/src/components/VoiceProfiles/ProfileCard.tsx @@ -1,5 +1,6 @@ import { Download, Edit, Mic, Trash2 } from 'lucide-react'; import { useState } from 'react'; +import { useServerStore } from '@/stores/serverStore'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -23,15 +24,21 @@ interface ProfileCardProps { export function ProfileCard({ profile }: ProfileCardProps) { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [avatarError, setAvatarError] = useState(false); const deleteProfile = useDeleteProfile(); const exportProfile = useExportProfile(); const setEditingProfileId = useUIStore((state) => state.setEditingProfileId); const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen); const selectedProfileId = useUIStore((state) => state.selectedProfileId); const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId); + const serverUrl = useServerStore((state) => state.serverUrl); const isSelected = selectedProfileId === profile.id; + const avatarUrl = profile.avatar_path + ? `${serverUrl}/profiles/${profile.id}/avatar` + : null; + const handleSelect = () => { setSelectedProfileId(isSelected ? null : profile.id); }; @@ -67,8 +74,20 @@ export function ProfileCard({ profile }: ProfileCardProps) { > -
- +
+ {avatarUrl && !avatarError ? ( + {`${profile.name} 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 ? ( + Avatar preview + ) : ( + + )} +
+ + {(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)