virtual-tryon/new.txt

675 lines
19 KiB
Text
Raw Permalink Normal View History

2025-10-30 22:44:47 +08:00
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');
});