const express = require('express'); const app = express(); const cors = require('cors'); require('dotenv').config(); const { createClient } = require('@supabase/supabase-js'); const axios = require('axios'); const { v4: uuidv4 } = require('uuid'); app.use(express.json({ limit: '50mb' })); // ← Add limit here app.use(express.urlencoded({ limit: '50mb', extended: true })); // ← Add this too const port = process.env.PORT || 5235; // Middleware app.use(cors()); app.use(express.json()); // ============================================ // SUPABASE SETUP // ============================================ const supabase = createClient( process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY ); console.log('āœ… Supabase client initialized'); // ============================================ // FASHN.AI SETUP // ============================================ const FASHN_API_KEY = process.env.FASHN_API_KEY; const FASHN_API_URL = 'https://api.fashn.ai/v1'; // In-memory cache const jobsCache = new Map(); // ============================================ // FASHN.AI HELPER FUNCTIONS // ============================================ /** * Convert blob/data URL to public URL - Upload to Supabase Storage */ async function uploadImageToStorage(imageData, userId, fileName) { try { console.log('šŸ“¤ Uploading image to Supabase Storage...'); let fileBuffer; let contentType = 'image/jpeg'; if (imageData.startsWith('data:')) { const matches = imageData.match(/^data:([^;]+);base64,(.+)$/); if (!matches) { throw new Error('Invalid data URL format'); } contentType = matches[1]; const base64Data = matches[2]; fileBuffer = Buffer.from(base64Data, 'base64'); } else if (imageData.startsWith('blob:')) { throw new Error('Blob URLs cannot be uploaded. Please convert to base64 first.'); } else { return imageData; } const filePath = `${userId}/${fileName}_${Date.now()}.jpg`; const { data, error } = await supabase.storage .from('user-uploads') .upload(filePath, fileBuffer, { contentType: contentType, upsert: false }); if (error) { console.error('Supabase storage error:', error); throw error; } const { data: urlData } = supabase.storage .from('user-uploads') .getPublicUrl(filePath); console.log('āœ… Image uploaded:', urlData.publicUrl); return urlData.publicUrl; } catch (error) { console.error('āŒ Upload error:', error); throw new Error(`Failed to upload image: ${error.message}`); } } /** * Apply single garment using FASHN.ai with polling */ async function applyGarmentWithFashn(modelImage, garmentImage) { try { console.log('šŸ“¤ Calling FASHN.ai API...'); console.log('Model image:', modelImage.substring(0, 80) + '...'); console.log('Garment image:', garmentImage.substring(0, 80) + '...'); if (modelImage.startsWith('blob:') || modelImage.startsWith('data:')) { throw new Error('Model image must be a public URL'); } const startTime = Date.now(); // Step 1: Submit job const requestBody = { model_image: modelImage, garment_image: garmentImage, category: 'auto' }; console.log('šŸ“¤ Submitting job to FASHN.ai...'); const submitResponse = await axios.post( `${FASHN_API_URL}/run`, requestBody, { headers: { 'Authorization': `Bearer ${FASHN_API_KEY}`, 'Content-Type': 'application/json' }, timeout: 10000 } ); console.log('šŸ“„ Submission response:', JSON.stringify(submitResponse.data, null, 2)); if (!submitResponse.data || !submitResponse.data.id) { throw new Error('No job ID received from FASHN.ai'); } const jobId = submitResponse.data.id; console.log('āœ… FASHN.ai job created:', jobId); // Step 2: Poll for result console.log('ā³ Polling for result...'); const maxAttempts = 60; const pollInterval = 2000; let attempts = 0; while (attempts < maxAttempts) { await new Promise(resolve => setTimeout(resolve, pollInterval)); attempts++; console.log(`šŸ”„ Checking status (attempt ${attempts}/${maxAttempts})...`); try { const statusResponse = await axios.get( `${FASHN_API_URL}/${jobId}`, { headers: { 'Authorization': `Bearer ${FASHN_API_KEY}` }, timeout: 10000 } ); console.log('šŸ“Š Status response:', JSON.stringify(statusResponse.data, null, 2)); if (statusResponse.data.status === 'completed') { const processingTime = Math.round((Date.now() - startTime) / 1000); let imageUrl = null; if (statusResponse.data.output && Array.isArray(statusResponse.data.output)) { imageUrl = statusResponse.data.output[0]; } else if (statusResponse.data.result) { imageUrl = statusResponse.data.result; } else if (statusResponse.data.image) { imageUrl = statusResponse.data.image; } if (imageUrl) { console.log(`āœ… FASHN.ai completed in ${processingTime}s`); console.log(`šŸ–¼ļø Result URL: ${imageUrl}`); return { imageUrl, processingTime }; } else { console.error('āŒ No image URL in completed response'); console.error('Full response:', JSON.stringify(statusResponse.data, null, 2)); throw new Error('Job completed but no image URL found'); } } if (statusResponse.data.status === 'failed') { throw new Error(`FASHN.ai job failed: ${statusResponse.data.error || 'Unknown error'}`); } console.log('ā³ Status:', statusResponse.data.status || 'processing'); } catch (pollError) { if (pollError.response) { console.log(`āš ļø Poll error - Status: ${pollError.response.status}`); console.log(`āš ļø Response:`, JSON.stringify(pollError.response.data, null, 2)); if (pollError.response.status === 404) { console.log('ā³ Job not ready yet (404)...'); continue; } } else { console.log('āš ļø Poll error:', pollError.message); } // Continue polling on errors continue; } } throw new Error('FASHN.ai processing timeout'); } catch (error) { if (error.response) { console.error('āŒ FASHN.ai API error:'); console.error('Status:', error.response.status); console.error('Data:', JSON.stringify(error.response.data, null, 2)); throw new Error(`FASHN.ai API error (${error.response.status}): ${error.response.data.message || error.response.statusText}`); } else if (error.request) { console.error('āŒ No response from FASHN.ai'); throw new Error('FASHN.ai did not respond'); } else { console.error('āŒ FASHN.ai error:', error.message); throw error; } } } /** * Layer wardrobe items sequentially */ async function layerWardrobeItems(userImage, wardrobeItems, userId) { let currentImage = userImage; const steps = []; console.log(`\nšŸ‘• Layering ${wardrobeItems.length} wardrobe items...`); if (userImage.startsWith('blob:') || userImage.startsWith('data:')) { console.log('šŸ”„ Converting user image to public URL...'); currentImage = await uploadImageToStorage(userImage, userId, 'user_image'); } for (let i = 0; i < wardrobeItems.length; i++) { const item = wardrobeItems[i]; console.log(`\nšŸ“¦ Step ${i + 1}/${wardrobeItems.length}: Adding ${item.name}`); try { const result = await applyGarmentWithFashn(currentImage, item.image_url); currentImage = result.imageUrl; steps.push({ step: i + 1, item: item.name, resultUrl: currentImage, processingTime: result.processingTime }); console.log(`āœ… ${item.name} applied successfully`); } catch (error) { console.error(`āŒ Failed to apply ${item.name}:`, error.message); throw new Error(`Failed to apply ${item.name}: ${error.message}`); } } console.log('\nāœ… All wardrobe items applied!'); return { finalImage: currentImage, steps }; } /** * Process job in background */ async function processJob(jobId) { try { console.log(`\nšŸŽ¬ ========================================`); console.log(`šŸ“‹ Processing job: ${jobId}`); console.log(`šŸŽ¬ ========================================`); const { data: job, error: fetchError } = await supabase .from('generation_jobs') .select('*') .eq('job_id', jobId) .single(); if (fetchError || !job) { throw new Error('Job not found'); } await supabase .from('generation_jobs') .update({ status: 'processing', progress: 10 }) .eq('job_id', jobId); jobsCache.set(jobId, { status: 'processing', progress: 10 }); const userImage = job.user_image; const wardrobeItems = job.products || []; console.log('\nšŸ“¦ Job Details:'); console.log('User image:', userImage ? 'Yes' : 'No'); console.log('Wardrobe items:', wardrobeItems.length); await supabase .from('generation_jobs') .update({ progress: 20 }) .eq('job_id', jobId); jobsCache.set(jobId, { status: 'processing', progress: 20 }); const { finalImage, steps } = await layerWardrobeItems(userImage, wardrobeItems, job.user_id); const progressPerItem = 60 / wardrobeItems.length; for (let i = 0; i < steps.length; i++) { const newProgress = 20 + Math.round((i + 1) * progressPerItem); await supabase .from('generation_jobs') .update({ progress: newProgress }) .eq('job_id', jobId); jobsCache.set(jobId, { status: 'processing', progress: newProgress }); } const totalProcessingTime = steps.reduce((sum, step) => sum + step.processingTime, 0); await supabase .from('generation_jobs') .update({ status: 'completed', progress: 100, generated_image_url: finalImage, processing_time: totalProcessingTime, processing_steps: steps, completed_at: new Date().toISOString() }) .eq('job_id', jobId); jobsCache.set(jobId, { status: 'completed', progress: 100, imageUrl: finalImage }); console.log(`\nāœ… ========================================`); console.log(`āœ… Job ${jobId} COMPLETED!`); console.log(`āœ… ========================================`); console.log(`šŸ–¼ļø Final image: ${finalImage}`); } catch (error) { console.error(`\nāŒ Job ${jobId} FAILED!`); console.error(`āŒ Error:`, error.message); await supabase .from('generation_jobs') .update({ status: 'failed', error: error.message, completed_at: new Date().toISOString() }) .eq('job_id', jobId); jobsCache.set(jobId, { status: 'failed', error: error.message }); } } // ============================================ // API ROUTES // ============================================ app.post('/api/generate', async (req, res) => { try { const { userId, userImage, wardrobeItems, selectedPose, selectedBackground, selectedEffect } = req.body; console.log('\nšŸ†• ========================================'); console.log('šŸ†• NEW GENERATION REQUEST'); console.log('šŸ†• ========================================'); console.log('User:', userId); console.log('Wardrobe items:', wardrobeItems?.length || 0); if (!wardrobeItems || wardrobeItems.length === 0) { return res.status(400).json({ success: false, error: 'At least one wardrobe item required' }); } if (!userImage) { return res.status(400).json({ success: false, error: 'User image required' }); } if (!FASHN_API_KEY) { return res.status(500).json({ success: false, error: 'FASHN_API_KEY not configured' }); } const jobId = uuidv4(); console.log('Job ID:', jobId); const productUrls = wardrobeItems.map(item => item.image_url); const { error: insertError } = await supabase .from('generation_jobs') .insert({ job_id: jobId, user_id: userId || 'guest', user_image: userImage, product_urls: productUrls, products: wardrobeItems, pose_url: selectedPose?.pose_image_url, pose: selectedPose, background_url: selectedBackground?.bg_image_url, background: selectedBackground, effect: selectedEffect?.name || 'natural', effect_data: selectedEffect, status: 'queued', progress: 0, created_at: new Date().toISOString() }); if (insertError) { console.error('Supabase error:', insertError); throw insertError; } console.log('āœ… Job created in database'); jobsCache.set(jobId, { status: 'queued', progress: 0 }); setImmediate(() => { processJob(jobId).catch(err => console.error('Processing error:', err)); }); const estimatedTime = wardrobeItems.length * 15; res.status(201).json({ success: true, jobId, status: 'queued', message: 'Generation job created', wardrobeItemsCount: wardrobeItems.length, estimatedTime: `${estimatedTime}-${estimatedTime + 30} seconds` }); } catch (error) { console.error('āŒ Generate error:', error); res.status(500).json({ success: false, error: error.message }); } }); app.get('/api/job-status/:jobId', async (req, res) => { try { const { jobId } = req.params; const { data: job, error } = await supabase .from('generation_jobs') .select('*') .eq('job_id', jobId) .single(); if (error || !job) { return res.status(404).json({ success: false, error: 'Job not found' }); } jobsCache.set(jobId, { status: job.status, progress: job.progress, imageUrl: job.generated_image_url }); res.json({ success: true, jobId: job.job_id, status: job.status, progress: job.progress, generatedImageUrl: job.generated_image_url, processingSteps: job.processing_steps, error: job.error, createdAt: job.created_at, completedAt: job.completed_at, processingTime: job.processing_time }); } catch (error) { console.error('āŒ Status error:', error); res.status(500).json({ success: false, error: error.message }); } }); app.get('/api/health', async (req, res) => { try { const { error: supabaseError } = await supabase .from('generation_jobs') .select('job_id', { count: 'exact', head: true }) .limit(1); const supabaseStatus = supabaseError ? 'error' : 'connected'; const fashnStatus = FASHN_API_KEY ? 'configured' : 'not_configured'; res.json({ success: true, status: 'healthy', services: { supabase: supabaseStatus, fashn: fashnStatus }, stats: { jobsInCache: jobsCache.size } }); } catch (error) { res.status(503).json({ success: false, error: error.message }); } }); app.get('/api/products', async (req, res) => { try { const { data: products, error } = await supabase .from('products') .select('*'); if (error) { throw error; } res.json({ success: true, products: products || [] }); } catch (error) { console.error('āŒ Products error:', error); res.status(500).json({ success: false, error: error.message }); } }); app.post('/api/addproducts', async (req, res) => { try { const { name, price, description, image_url, category } = req.body; // Basic validation if (!name || !price || !description || !image_url || !category) { return res.status(400).json({ success: false, error: 'All fields (name, price, description, image_url, category) are required' }); } // Insert into Supabase const { data, error } = await supabase .from('products') .insert([ { name, price, description, image_url, category, created_at: new Date().toISOString(), updated_at: new Date().toISOString() } ]) .select('*') .single(); if (error) throw error; res.status(201).json({ success: true, message: 'āœ… Product created successfully', product: data }); } catch (error) { console.error('āŒ Product insert error:', error); res.status(500).json({ success: false, error: error.message }); } }); app.get('/api/backgrounds', async (req, res) => { try { const { data, error } = await supabase .from('backgrounds') .select('*') .order('id', { ascending: true }); if (error) throw error; res.json({ success: true, backgrounds: data || [] }); } catch (err) { console.error('āŒ Backgrounds fetch error:', err); res.status(500).json({ success: false, error: err.message }); } }); app.get('/api/poses', async (req, res) => { try { const { data, error } = await supabase .from('poses') .select('*') .order('id', { ascending: true }); if (error) throw error; res.json({ success: true, poses: data || [] }); } catch (err) { console.error('āŒ Poses fetch error:', err); res.status(500).json({ success: false, error: err.message }); } }); app.get('/api/effects', async (req, res) => { try { const { data, error } = await supabase .from('effects') .select('*') .order('id', { ascending: true }); if (error) throw error; res.json({ success: true, effects: data || [] }); } catch (err) { console.error('āŒ Effects fetch error:', err); res.status(500).json({ success: false, error: err.message }); } }); app.get('/', (req, res) => { res.json({ message: 'Virtual Try-On Server with FASHN.ai', version: '2.0.0', database: 'Supabase', aiProvider: 'FASHN.ai' }); }); app.use((req, res) => { res.status(404).json({ success: false, error: 'Endpoint not found' }); }); app.use((err, req, res, next) => { console.error('āŒ Error:', err); res.status(500).json({ success: false, error: 'Internal server error' }); }); app.listen(port, () => { console.log('\n' + '='.repeat(70)); console.log('šŸš€ Virtual Try-On Server with FASHN.ai - EXACT PRODUCTS VERSION'); console.log('='.repeat(70)); console.log(`šŸ“” Server: http://localhost:${port}`); console.log(`šŸ’¾ Database: Supabase`); console.log(`šŸ¤– AI Provider: FASHN.ai`); console.log(`šŸ“Š Health: http://localhost:${port}/api/health`); console.log('='.repeat(70)); if (!FASHN_API_KEY) { console.log('\nāš ļø WARNING: FASHN_API_KEY not found!'); } else { console.log('\nāœ… FASHN API Key configured'); } console.log('\n'); });