virtual-tryon/new.txt
2025-10-30 22:44:47 +08:00

675 lines
No EOL
19 KiB
Text
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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');
});