mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 09:05:17 -07:00
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:
@@ -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">
|
||||||
|
{!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" />
|
<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">
|
||||||
|
{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" />
|
<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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)."""
|
||||||
|
|||||||
@@ -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'):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ numpy>=1.24.0
|
|||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
python-multipart>=0.0.6
|
python-multipart>=0.0.6
|
||||||
|
Pillow>=10.0.0
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user