diff --git a/webapp/src/components/MyCoaches/CoachAvatarEdit.jsx b/webapp/src/components/MyCoaches/CoachAvatarEdit.jsx index 2f37b48..95b3d28 100644 --- a/webapp/src/components/MyCoaches/CoachAvatarEdit.jsx +++ b/webapp/src/components/MyCoaches/CoachAvatarEdit.jsx @@ -16,13 +16,19 @@ const CoachAvatarEdit = () => { const [generating, setGenerating] = useState(false); const [generatedAvatars, setGeneratedAvatars] = useState([]); const [selectedAvatar, setSelectedAvatar] = useState(null); - + const [selfieFile, setSelfieFile] = useState(null); + const [avatarStyle, setAvatarStyle] = useState(''); + const [failedStyles, setFailedStyles] = useState([]); + + /* + Mirrors AVATAR_STYLES in functions/coach-avatar-generator/avatar-generation.js. + The generator ignores anything not in that list and silently falls back to + every style, so these strings have to match it exactly — including + "Disney Charactor", which is misspelled in the PhotoMaker model itself. + */ const avatarStyles = [ - { value: 'realistic', label: 'Realistic Photo' }, - { value: 'professional', label: 'Professional Headshot' }, - { value: 'cartoon', label: 'Cartoon Style' }, - { value: 'fitness', label: 'Fitness Coach' }, - { value: 'athletic', label: 'Athletic Portrait' } + 'Realistic', 'Cinematic', 'Disney Charactor', 'Fantasy art', 'Enhance', + 'Comic book', 'Line art', 'Digital Art', 'Neonpunk', 'Photographic', 'Lowpoly', ]; useEffect(() => { @@ -41,9 +47,11 @@ const CoachAvatarEdit = () => { if (error) throw error; setCoach(coachData); - - // Set up default prompt based on coach data - setAvatarPrompt(`A ${coachData.primary_response_style || 'motivational'} fitness coach`); + /* + No default prompt. It is optional steering on top of the style's own + prompt now, and the old default ("A ... fitness coach") both assumed the + fitness-era product and overrode the style the creator had picked. + */ } catch (error) { console.error('Error fetching coach:', error); toast.error('Failed to load coach data'); @@ -106,37 +114,70 @@ const CoachAvatarEdit = () => { } }; + /** Reads a File into the bare base64 the generator's JSON path expects. */ + const readAsBase64 = (file) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result).replace(/^data:[^;]+;base64,/, '')); + reader.onerror = () => reject(reader.error ?? new Error('Could not read the image')); + reader.readAsDataURL(file); + }); + + /* + Generation is PhotoMaker, which builds a likeness from a photo — it cannot + invent a face from a description alone. So a selfie is required, and the + prompt only steers the result. This used to POST {prompt, style, count} to + `/motivational-images` and expect {images: []}; that endpoint is a Cloud + Scheduler background job with no HTTP contract at all, so the button could + never have worked. See #22. + */ const generateAvatars = async () => { - if (!avatarPrompt.trim()) { - toast.error('Please enter a description for your avatar'); + if (!selfieFile) { + toast.error('Choose a photo to generate from'); return; } try { setGenerating(true); - - const response = await fetch(`${import.meta.env.VITE_API_URL}/motivational-images`, { + setFailedStyles([]); + + // Same override the coach-builder flow honours; without it this component + // would break in any deploy that points the generator at its own URL. + const generatorUrl = + import.meta.env.VITE_COACH_AVATAR_GENERATOR_URL || + `${import.meta.env.VITE_API_URL}/coach-avatar-generator`; + + const response = await fetch(generatorUrl, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - prompt: avatarPrompt, - style: 'portrait', - count: 4 // Generate 4 options - }) + coachId, + selfie_base64: await readAsBase64(selfieFile), + selfie_mime: selfieFile.type || 'image/jpeg', + // Omitted means "every style", which is what the generator does. + ...(avatarStyle ? { style: avatarStyle } : {}), + ...(avatarPrompt.trim() ? { prompt: avatarPrompt.trim() } : {}), + }), }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { - throw new Error('Failed to generate avatars'); + // The generator explains itself; passing that through beats a generic toast. + throw new Error(data.error || `Avatar generation failed (${response.status})`); } - const data = await response.json(); - setGeneratedAvatars(data.images || []); - toast.success('Avatars generated successfully!'); + setGeneratedAvatars(data.avatars || []); + setFailedStyles(data.failedStyles || []); + + if ((data.avatars || []).length === 0) { + toast.error('No avatars came back. Try a clearer, front-facing photo.'); + } else { + toast.success(data.message || `Generated ${data.avatars.length} avatars`); + } } catch (error) { console.error('Error generating avatars:', error); - toast.error('Failed to generate avatars'); + toast.error(error.message || 'Failed to generate avatars'); } finally { setGenerating(false); } @@ -304,26 +345,61 @@ const CoachAvatarEdit = () => { {generateMethod === 'generate' && (
+ Generation works from a photo — it restyles a real face rather than + inventing one, so a clear front-facing picture gives the best result. +
+Using {selfieFile.name}
+ )} +