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.
This commit is contained in:
Jamie Pine
2026-01-29 19:28:42 -08:00
parent ef3c3a7f8c
commit 89f3127c37
15 changed files with 594 additions and 35 deletions
@@ -112,7 +112,6 @@ export function FloatingGenerateBox({
} }
}, [selectedProfileId, profiles, setSelectedProfileId]); }, [selectedProfileId, profiles, setSelectedProfileId]);
// Auto-resize textarea based on content (only when expanded) // Auto-resize textarea based on content (only when expanded)
useEffect(() => { useEffect(() => {
if (!isExpanded) { if (!isExpanded) {
@@ -1,6 +1,7 @@
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react'; import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
DropdownMenu, DropdownMenu,
@@ -12,6 +13,7 @@ import { Textarea } from '@/components/ui/textarea';
import type { StoryItemDetail } from '@/lib/api/types'; import type { StoryItemDetail } from '@/lib/api/types';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { useStoryStore } from '@/stores/storyStore'; import { useStoryStore } from '@/stores/storyStore';
import { useServerStore } from '@/stores/serverStore';
interface StoryChatItemProps { interface StoryChatItemProps {
item: StoryItemDetail; item: StoryItemDetail;
@@ -33,6 +35,10 @@ export function StoryChatItem({
isDragging, isDragging,
}: StoryChatItemProps) { }: StoryChatItemProps) {
const seek = useStoryStore((state) => state.seek); 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 // Check if this item is currently playing based on timecode
const itemStartMs = item.start_time_ms; const itemStartMs = item.start_time_ms;
@@ -72,10 +78,22 @@ export function StoryChatItem({
</button> </button>
)} )}
{/* Voice Icon */} {/* Voice Avatar */}
<div className="shrink-0"> <div className="shrink-0">
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center"> <div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center overflow-hidden">
<Mic className="h-5 w-5 text-muted-foreground" /> {!avatarError ? (
<img
src={avatarUrl}
alt={`${item.profile_name} avatar`}
className={cn(
'h-full w-full object-cover transition-all duration-200',
!isCurrentlyPlaying && 'grayscale'
)}
onError={() => setAvatarError(true)}
/>
) : (
<Mic className="h-5 w-5 text-muted-foreground" />
)}
</div> </div>
</div> </div>
@@ -1,5 +1,6 @@
import { Download, Edit, Mic, Trash2 } from 'lucide-react'; import { Download, Edit, Mic, Trash2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { useServerStore } from '@/stores/serverStore';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -23,15 +24,21 @@ interface ProfileCardProps {
export function ProfileCard({ profile }: ProfileCardProps) { export function ProfileCard({ profile }: ProfileCardProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [avatarError, setAvatarError] = useState(false);
const deleteProfile = useDeleteProfile(); const deleteProfile = useDeleteProfile();
const exportProfile = useExportProfile(); const exportProfile = useExportProfile();
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId); const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen); const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const selectedProfileId = useUIStore((state) => state.selectedProfileId); const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId); const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
const serverUrl = useServerStore((state) => state.serverUrl);
const isSelected = selectedProfileId === profile.id; const isSelected = selectedProfileId === profile.id;
const avatarUrl = profile.avatar_path
? `${serverUrl}/profiles/${profile.id}/avatar`
: null;
const handleSelect = () => { const handleSelect = () => {
setSelectedProfileId(isSelected ? null : profile.id); setSelectedProfileId(isSelected ? null : profile.id);
}; };
@@ -67,8 +74,20 @@ export function ProfileCard({ profile }: ProfileCardProps) {
> >
<CardHeader className="p-3 pb-2"> <CardHeader className="p-3 pb-2">
<CardTitle className="flex items-center gap-1.5 text-base font-medium"> <CardTitle className="flex items-center gap-1.5 text-base font-medium">
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center shrink-0"> <div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden">
<Mic className="h-3.5 w-3.5 text-muted-foreground" /> {avatarUrl && !avatarError ? (
<img
src={avatarUrl}
alt={`${profile.name} avatar`}
className={cn(
'h-full w-full object-cover transition-all duration-200',
!isSelected && 'grayscale'
)}
onError={() => setAvatarError(true)}
/>
) : (
<Mic className="h-3.5 w-3.5 text-muted-foreground" />
)}
</div> </div>
<span className="break-words">{profile.name}</span> <span className="break-words">{profile.name}</span>
</CardTitle> </CardTitle>
@@ -1,6 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Mic, Monitor, Upload, X } from 'lucide-react'; import { Edit2, Mic, Monitor, Upload, X } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import * as z from 'zod'; import * as z from 'zod';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -36,14 +36,17 @@ import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
import { import {
useAddSample, useAddSample,
useCreateProfile, useCreateProfile,
useDeleteAvatar,
useProfile, useProfile,
useUpdateProfile, useUpdateProfile,
useUploadAvatar,
} from '@/lib/hooks/useProfiles'; } from '@/lib/hooks/useProfiles';
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture'; import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
import { useTranscription } from '@/lib/hooks/useTranscription'; import { useTranscription } from '@/lib/hooks/useTranscription';
import { isTauri } from '@/lib/tauri'; import { isTauri } from '@/lib/tauri';
import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio'; import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore'; import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore';
import { useServerStore } from '@/stores/serverStore';
import { AudioSampleRecording } from './AudioSampleRecording'; import { AudioSampleRecording } from './AudioSampleRecording';
import { AudioSampleSystem } from './AudioSampleSystem'; import { AudioSampleSystem } from './AudioSampleSystem';
import { AudioSampleUpload } from './AudioSampleUpload'; import { AudioSampleUpload } from './AudioSampleUpload';
@@ -57,6 +60,7 @@ const baseProfileSchema = z.object({
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]), language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
sampleFile: z.instanceof(File).optional(), sampleFile: z.instanceof(File).optional(),
referenceText: z.string().max(1000).optional(), referenceText: z.string().max(1000).optional(),
avatarFile: z.instanceof(File).optional(),
}); });
const profileSchema = baseProfileSchema.refine( const profileSchema = baseProfileSchema.refine(
@@ -108,13 +112,18 @@ export function ProfileForm() {
const createProfile = useCreateProfile(); const createProfile = useCreateProfile();
const updateProfile = useUpdateProfile(); const updateProfile = useUpdateProfile();
const addSample = useAddSample(); const addSample = useAddSample();
const uploadAvatar = useUploadAvatar();
const deleteAvatar = useDeleteAvatar();
const transcribe = useTranscription(); const transcribe = useTranscription();
const { toast } = useToast(); const { toast } = useToast();
const [sampleMode, setSampleMode] = useState<'upload' | 'record' | 'system'>('record'); const [sampleMode, setSampleMode] = useState<'upload' | 'record' | 'system'>('record');
const [audioDuration, setAudioDuration] = useState<number | null>(null); const [audioDuration, setAudioDuration] = useState<number | null>(null);
const [isValidatingAudio, setIsValidatingAudio] = useState(false); const [isValidatingAudio, setIsValidatingAudio] = useState(false);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const avatarInputRef = useRef<HTMLInputElement>(null);
const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer(); const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer();
const isCreating = !editingProfileId; const isCreating = !editingProfileId;
const serverUrl = useServerStore((state) => state.serverUrl);
const form = useForm<ProfileFormValues>({ const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema), resolver: zodResolver(profileSchema),
@@ -124,10 +133,12 @@ export function ProfileForm() {
language: 'en', language: 'en',
sampleFile: undefined, sampleFile: undefined,
referenceText: '', referenceText: '',
avatarFile: undefined,
}, },
}); });
const selectedFile = form.watch('sampleFile'); const selectedFile = form.watch('sampleFile');
const selectedAvatarFile = form.watch('avatarFile');
// Validate audio duration when file is selected // Validate audio duration when file is selected
useEffect(() => { useEffect(() => {
@@ -244,6 +255,19 @@ export function ProfileForm() {
} }
}, [systemRecordingError, toast]); }, [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 // Restore form state from draft or editing profile
useEffect(() => { useEffect(() => {
if (editingProfile) { if (editingProfile) {
@@ -253,6 +277,7 @@ export function ProfileForm() {
language: editingProfile.language as LanguageCode, language: editingProfile.language as LanguageCode,
sampleFile: undefined, sampleFile: undefined,
referenceText: undefined, referenceText: undefined,
avatarFile: undefined,
}); });
} else if (profileFormDraft && open) { } else if (profileFormDraft && open) {
// Restore from draft when opening in create mode // Restore from draft when opening in create mode
@@ -262,6 +287,7 @@ export function ProfileForm() {
language: profileFormDraft.language as LanguageCode, language: profileFormDraft.language as LanguageCode,
referenceText: profileFormDraft.referenceText, referenceText: profileFormDraft.referenceText,
sampleFile: undefined, sampleFile: undefined,
avatarFile: undefined,
}); });
setSampleMode(profileFormDraft.sampleMode); setSampleMode(profileFormDraft.sampleMode);
// Restore the file if we have it saved // Restore the file if we have it saved
@@ -285,8 +311,10 @@ export function ProfileForm() {
language: 'en', language: 'en',
sampleFile: undefined, sampleFile: undefined,
referenceText: undefined, referenceText: undefined,
avatarFile: undefined,
}); });
setSampleMode('record'); setSampleMode('record');
setAvatarPreview(null);
} }
}, [editingProfile, profileFormDraft, open, form]); }, [editingProfile, profileFormDraft, open, form]);
@@ -330,6 +358,52 @@ export function ProfileForm() {
playPause(file); playPause(file);
} }
function handleAvatarFileChange(e: React.ChangeEvent<HTMLInputElement>) {
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) { async function onSubmit(data: ProfileFormValues) {
try { try {
if (editingProfileId) { if (editingProfileId) {
@@ -342,6 +416,23 @@ export function ProfileForm() {
language: data.language, 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({ toast({
title: 'Voice updated', title: 'Voice updated',
description: `"${data.name}" has been updated successfully.`, description: `"${data.name}" has been updated successfully.`,
@@ -418,6 +509,23 @@ export function ProfileForm() {
file: sampleFile, file: sampleFile,
referenceText: referenceText, 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({ toast({
title: 'Profile created', title: 'Profile created',
description: `"${data.name}" has been created with a sample.`, description: `"${data.name}" has been created with a sample.`,
@@ -670,6 +778,58 @@ export function ProfileForm() {
{/* Right column: Profile info */} {/* Right column: Profile info */}
<div className="space-y-4"> <div className="space-y-4">
{/* Avatar Upload */}
<FormField
control={form.control}
name="avatarFile"
render={() => (
<FormItem>
<FormControl>
<div className="flex justify-center pt-4 pb-2">
<div className="relative group">
<div className="h-24 w-24 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden border-2 border-border">
{avatarPreview ? (
<img
src={avatarPreview}
alt="Avatar preview"
className="h-full w-full object-cover"
/>
) : (
<Mic className="h-10 w-10 text-muted-foreground" />
)}
</div>
<button
type="button"
onClick={() => avatarInputRef.current?.click()}
className="absolute inset-0 rounded-full bg-accent/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer"
>
<Edit2 className="h-6 w-6 text-accent-foreground" />
</button>
{(avatarPreview || editingProfile?.avatar_path) && (
<button
type="button"
onClick={handleRemoveAvatar}
disabled={deleteAvatar.isPending}
className="absolute bottom-0 right-0 h-6 w-6 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center hover:bg-destructive/90 transition-colors shadow-sm"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<input
ref={avatarInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleAvatarFileChange}
className="hidden"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
@@ -188,15 +188,7 @@ export function SampleList({ profileId }: SampleListProps) {
} }
return ( return (
<div className="space-y-4"> <div className="space-y-4 pt-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Audio Samples</h3>
<Button type="button" size="sm" onClick={() => setUploadOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Sample
</Button>
</div>
{samples && samples.length === 0 ? ( {samples && samples.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg"> <div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
<Volume2 className="h-8 w-8 text-muted-foreground/50 mb-2" /> <Volume2 className="h-8 w-8 text-muted-foreground/50 mb-2" />
@@ -304,6 +296,11 @@ export function SampleList({ profileId }: SampleListProps) {
</div> </div>
)} )}
<Button type="button" variant="outline" className="w-full" onClick={() => setUploadOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Sample
</Button>
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} /> <SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
</div> </div>
); );
+26
View File
@@ -165,6 +165,32 @@ class ApiClient {
return response.json(); return response.json();
} }
async uploadAvatar(profileId: string, file: File): Promise<VoiceProfileResponse> {
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<void> {
await this.request<void>(`/profiles/${profileId}/avatar`, {
method: 'DELETE',
});
}
// Generation // Generation
async generateSpeech(data: GenerationRequest): Promise<GenerationResponse> { async generateSpeech(data: GenerationRequest): Promise<GenerationResponse> {
return this.request<GenerationResponse>('/generate', { return this.request<GenerationResponse>('/generate', {
+1
View File
@@ -12,6 +12,7 @@ export interface VoiceProfileResponse {
name: string; name: string;
description?: string; description?: string;
language: string; language: string;
avatar_path?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
+29
View File
@@ -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],
});
},
});
}
+11
View File
@@ -22,6 +22,7 @@ class VoiceProfile(Base):
name = Column(String, unique=True, nullable=False) name = Column(String, unique=True, nullable=False)
description = Column(Text) description = Column(Text)
language = Column(String, default="en") language = Column(String, default="en")
avatar_path = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -277,6 +278,16 @@ def _run_migrations(engine):
conn.commit() conn.commit()
print("Added trim_end_ms column to story_items") 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(): def get_db():
"""Get database session (generator for dependency injection).""" """Get database session (generator for dependency injection)."""
+32 -1
View File
@@ -75,6 +75,16 @@ def export_profile_to_zip(profile_id: str, db: Session) -> bytes:
zip_buffer = io.BytesIO() zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: 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 # Create manifest.json
manifest = { manifest = {
"version": "1.0", "version": "1.0",
@@ -82,7 +92,8 @@ def export_profile_to_zip(profile_id: str, db: Session) -> bytes:
"name": profile.name, "name": profile.name,
"description": profile.description, "description": profile.description,
"language": profile.language, "language": profile.language,
} },
"has_avatar": has_avatar,
} }
zip_file.writestr("manifest.json", json.dumps(manifest, indent=2)) zip_file.writestr("manifest.json", json.dumps(manifest, indent=2))
@@ -173,6 +184,26 @@ async def import_profile_from_zip(file_bytes: bytes, db: Session) -> VoiceProfil
profile_dir = _get_profiles_dir() / profile.id profile_dir = _get_profiles_dir() / profile.id
profile_dir.mkdir(parents=True, exist_ok=True) 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(): for filename, reference_text in samples_data.items():
# Validate filename # Validate filename
if not filename.endswith('.wav'): if not filename.endswith('.wav'):
+55
View File
@@ -296,6 +296,61 @@ async def update_profile_sample(
return 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") @app.get("/profiles/{profile_id}/export")
async def export_profile( async def export_profile(
profile_id: str, profile_id: str,
+1
View File
@@ -20,6 +20,7 @@ class VoiceProfileResponse(BaseModel):
name: str name: str
description: Optional[str] description: Optional[str]
language: str language: str
avatar_path: Optional[str] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
+97
View File
@@ -21,6 +21,7 @@ from .database import (
ProfileSample as DBProfileSample, ProfileSample as DBProfileSample,
) )
from .utils.audio import validate_reference_audio, load_audio, save_audio 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 .tts import get_tts_model
from . import config from . import config
@@ -361,3 +362,99 @@ async def create_voice_prompt_for_profile(
finally: finally:
# Clean up temp file # Clean up temp file
Path(tmp_path).unlink(missing_ok=True) 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
+1
View File
@@ -21,3 +21,4 @@ numpy>=1.24.0
# Utilities # Utilities
python-multipart>=0.0.6 python-multipart>=0.0.6
Pillow>=10.0.0
+114
View File
@@ -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)