diff --git a/src/components/ImageTo3DModal.tsx b/src/components/ImageTo3DModal.tsx index b1e296e..9fa2d11 100644 --- a/src/components/ImageTo3DModal.tsx +++ b/src/components/ImageTo3DModal.tsx @@ -1,7 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { X, Upload, Settings as SettingsIcon, Image as ImageIcon, Box, Download, AlertCircle, Loader2, Check } from 'lucide-react'; -import { fal } from '@fal-ai/client'; +import { X, Upload, Settings as SettingsIcon, Box, AlertCircle, Loader2, RefreshCw, Check } from 'lucide-react'; import { cn } from '../lib/utils'; import { useAuthStore } from '../store/authStore'; import { api } from '../lib/api'; @@ -17,13 +16,6 @@ interface ImageTo3DModalProps { onPushToApp: (modelUrl: string) => void; } -const MODELS = [ - { id: 'fal-ai/hunyuan3d/v2/image-to-3d', name: 'Hunyuan3D-2 (Latest)' }, - { id: 'fal-ai/hunyuan3d/v1', name: 'Hunyuan3D-1' }, - { id: 'fal-ai/meshy/v6/image-to-3d', name: 'Meshy v6' }, - { id: 'fal-ai/tripo3d', name: 'Tripo3D' } -]; - const ModelPreview = ({ url }: { url: string }) => { const [model, setModel] = useState(null); @@ -48,13 +40,15 @@ const ModelPreview = ({ url }: { url: string }) => { export const ImageTo3DModal: React.FC = ({ isOpen, onClose, onPushToApp }) => { const { profile, setProfile, token } = useAuthStore(); - const [apiProvider, setApiProvider] = useState<'fal' | 'tripo' | 'meshy'>('fal'); - const [apiKey, setApiKey] = useState(''); - const [tripoApiKey, setTripoApiKey] = useState(''); const [meshyApiKey, setMeshyApiKey] = useState(''); - const [selectedModel, setSelectedModel] = useState(MODELS[0].id); + + // Image states const [imageFile, setImageFile] = useState(null); - const [imagePreview, setImagePreview] = useState(null); + const [originalImagePreview, setOriginalImagePreview] = useState(null); + + const [cleanedImageFile, setCleanedImageFile] = useState(null); + const [cleanedImagePreview, setCleanedImagePreview] = useState(null); + const [isCleaningImage, setIsCleaningImage] = useState(false); // Meshy specific state const [meshyModelType, setMeshyModelType] = useState('standard'); @@ -73,59 +67,14 @@ export const ImageTo3DModal: React.FC = ({ isOpen, onClose, const [progressMessage, setProgressMessage] = useState(''); const [generatedModelUrl, setGeneratedModelUrl] = useState(null); const [error, setError] = useState(null); - const [isCleaningImage, setIsCleaningImage] = useState(false); const fileInputRef = useRef(null); - const [isValidatingKey, setIsValidatingKey] = useState(false); - const [keyValidationStatus, setKeyValidationStatus] = useState<'idle' | 'valid' | 'invalid' | 'error'>('idle'); useEffect(() => { - setKeyValidationStatus('idle'); - }, [apiKey, tripoApiKey, meshyApiKey, apiProvider]); - - const validateKey = async () => { - setIsValidatingKey(true); - setKeyValidationStatus('idle'); - try { - if (apiProvider === 'fal') { - const res = await fetch('https://fal.run/', { - headers: { Authorization: `Key ${apiKey}` } - }); - if (res.status === 401 || res.status === 403) setKeyValidationStatus('invalid'); - else setKeyValidationStatus('valid'); - } else if (apiProvider === 'tripo') { - const res = await fetch('https://api.tripo3d.ai/v2/openapi/task', { - headers: { Authorization: `Bearer ${tripoApiKey}` } - }); - if (res.status === 401 || res.status === 403) setKeyValidationStatus('invalid'); - else setKeyValidationStatus('valid'); - } else if (apiProvider === 'meshy') { - const res = await fetch('https://api.meshy.ai/openapi/v1/image-to-3d', { - headers: { Authorization: `Bearer ${meshyApiKey}` } - }); - if (res.status === 401 || res.status === 403) setKeyValidationStatus('invalid'); - else setKeyValidationStatus('valid'); - } - } catch (e) { - setKeyValidationStatus('error'); - } finally { - setIsValidatingKey(false); - } - }; - - useEffect(() => { - const savedProvider = localStorage.getItem('API_PROVIDER') as 'fal' | 'tripo' | 'meshy' | null; - if (savedProvider) setApiProvider(savedProvider); - if (isOpen) { api.getUserKeys().then(keys => { - if (keys.FAL_API_KEY) setApiKey(keys.FAL_API_KEY); - if (keys.TRIPO_API_KEY) setTripoApiKey(keys.TRIPO_API_KEY); if (keys.MESHY_API_KEY) setMeshyApiKey(keys.MESHY_API_KEY); }).catch(err => console.error("Failed to load user keys", err)); } - - const savedModel = localStorage.getItem('FAL_MODEL'); - if (savedModel) setSelectedModel(savedModel); }, [isOpen]); const handleImageUpload = (e: React.ChangeEvent) => { @@ -133,36 +82,39 @@ export const ImageTo3DModal: React.FC = ({ isOpen, onClose, if (file) { setImageFile(file); const url = URL.createObjectURL(file); - setImagePreview(url); + setOriginalImagePreview(url); setGeneratedModelUrl(null); setError(null); + + // Auto clean on upload + handleCleanImage(file); } }; - const handleCleanImage = async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (!imageFile || !token) return; - + const handleCleanImage = async (fileToClean: File) => { setIsCleaningImage(true); + setCleanedImageFile(null); + setCleanedImagePreview(null); try { - const base64 = await fileToDataUrl(imageFile); - const newUrl = await editUVLayer("Remove all text, labels, and logos from this object, leaving a clean plain surface of the exact same shape.", base64, token); + const base64 = await fileToDataUrl(fileToClean); + const newUrl = await editUVLayer("Remove the background and make it solid transparent or white. Also carefully remove all text, labels, logos, and stickers, keeping the product as a perfectly clean, plain, unbranded object.", base64, token); const res = await fetch(newUrl); const blob = await res.blob(); const newFile = new File([blob], "cleaned-image.png", { type: "image/png" }); - setImageFile(newFile); - setImagePreview(newUrl); + setCleanedImageFile(newFile); + setCleanedImagePreview(newUrl); } catch (err: any) { - alert("Failed to clean image: " + err.message); + setError("Failed to clean image: " + err.message); + // Fallback to original + setCleanedImageFile(fileToClean); + setCleanedImagePreview(URL.createObjectURL(fileToClean)); } finally { setIsCleaningImage(false); } }; - // Utility to convert file to data URL for Fal API if needed const fileToDataUrl = (file: File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -173,7 +125,8 @@ export const ImageTo3DModal: React.FC = ({ isOpen, onClose, }; const generate3DModel = async () => { - if (!imageFile) { + const targetFile = cleanedImageFile || imageFile; + if (!targetFile) { setError("Please upload an image first."); return; } @@ -181,15 +134,7 @@ export const ImageTo3DModal: React.FC = ({ isOpen, onClose, setError("Not enough AI Credits. Please contact an administrator."); return; } - if (apiProvider === 'fal' && !apiKey) { - setError("Fal API Key is required. Please set it in Tools > API Settings."); - return; - } - if (apiProvider === 'tripo' && !tripoApiKey) { - setError("Tripo API Key is required. Please set it in Tools > API Settings."); - return; - } - if (apiProvider === 'meshy' && !meshyApiKey) { + if (!meshyApiKey) { setError("Meshy API Key is required. Please set it in Tools > API Settings."); return; } @@ -208,195 +153,81 @@ export const ImageTo3DModal: React.FC = ({ isOpen, onClose, try { setIsGenerating(true); setError(null); - setProgressMessage("Uploading image..."); + + const dataUrl = await fileToDataUrl(targetFile); + setProgressMessage("Generating 3D Model with Meshy... This might take up to a minute."); - if (apiProvider === 'fal') { - fal.config({ credentials: apiKey }); - const dataUrl = await fileToDataUrl(imageFile); - setProgressMessage("Generating 3D Model... This might take up to a minute."); + let payload: any = { + image_url: dataUrl, + model_type: meshyModelType, + should_texture: meshyShouldTexture, + should_enhance_image: meshyEnhanceImage, + target_formats: ['glb'] + }; - let inputPayload: any = { image_url: dataUrl }; - if (selectedModel.includes('tripo')) { - inputPayload = { image_url: dataUrl }; + if (meshyModelType !== 'lowpoly') { + payload.ai_model = meshyAiModel === 'latest' ? 'meshy-6' : meshyAiModel; + payload.should_remesh = meshyShouldRemesh; + if (meshyShouldRemesh) { + payload.topology = meshyTopology; + payload.target_polycount = meshyTargetPolycount; } + } - const result = await fal.subscribe(selectedModel, { - input: inputPayload, - logs: true, - onQueueUpdate: (update) => { - if (update.status === "IN_PROGRESS") { - update.logs?.map((log) => setProgressMessage(log.message)); - } - }, - }); - - console.log("Fal API Result:", result); - - let modelUrl = null; - if (result.data) { - if (result.data.model_3d && result.data.model_3d.url) { - modelUrl = result.data.model_3d.url; // Hunyuan v2 - } else if (result.data.video && result.data.video.url && result.data.video.url.endsWith('.glb')) { - modelUrl = result.data.video.url; - } else if (result.data.mesh_url) { - modelUrl = result.data.mesh_url; - } else if (result.data.model_url) { - modelUrl = result.data.model_url; - } + if (meshyShouldTexture) { + payload.enable_pbr = meshyEnablePbr; + if (meshyAiModel !== 'meshy-5') { + payload.hd_texture = meshyHdTexture; } - - if (modelUrl) { - setGeneratedModelUrl(modelUrl); - setProgressMessage("Done!"); - deductCredit(); - } else { - setError("Failed to extract 3D model from response."); + if (meshyTextureImageUrl) { + payload.texture_image_url = meshyTextureImageUrl; + } else if (meshyTexturePrompt) { + payload.texture_prompt = meshyTexturePrompt; } - } else if (apiProvider === 'tripo') { - // TRIPO NATIVE API - const formData = new FormData(); - formData.append('file', imageFile); - - const uploadRes = await fetch('https://api.tripo3d.ai/v2/openapi/upload', { - method: 'POST', + } + + const taskRes = await fetch('https://api.meshy.ai/openapi/v1/image-to-3d', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${meshyApiKey}` + }, + body: JSON.stringify(payload) + }); + + if (!taskRes.ok) { + const errData = await taskRes.text(); + console.error("Meshy task creation error payload:", errData); + throw new Error(`Meshy API: Task creation failed. ${errData}`); + } + const taskData = await taskRes.json(); + const taskId = taskData.result; + + let isDone = false; + while (!isDone) { + await new Promise(r => setTimeout(r, 2000)); + const pollRes = await fetch(`https://api.meshy.ai/openapi/v1/image-to-3d/${taskId}`, { headers: { - 'Authorization': `Bearer ${tripoApiKey}` - }, - body: formData - }); - - if (!uploadRes.ok) throw new Error("Tripo API: Upload failed."); - const uploadData = await uploadRes.json(); - if (uploadData.code !== 0) throw new Error(`Tripo API Upload Error: ${uploadData.message || 'Unknown'}`); - - const imageToken = uploadData.data.image_token; - setProgressMessage("Generating 3D Model with Tripo3D... This might take up to a minute."); - - const taskRes = await fetch('https://api.tripo3d.ai/v2/openapi/task', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${tripoApiKey}` - }, - body: JSON.stringify({ - type: "image_to_model", - file: { - type: imageFile.type.includes('png') ? 'png' : 'jpg', - file_token: imageToken - } - }) - }); - - if (!taskRes.ok) throw new Error("Tripo API: Task creation failed."); - const taskData = await taskRes.json(); - if (taskData.code !== 0) throw new Error(`Tripo API Task Error: ${taskData.message || 'Unknown'}`); - - const taskId = taskData.data.task_id; - - let isDone = false; - while (!isDone) { - await new Promise(r => setTimeout(r, 2000)); - const pollRes = await fetch(`https://api.tripo3d.ai/v2/openapi/task/${taskId}`, { - headers: { - 'Authorization': `Bearer ${tripoApiKey}` - } - }); - if (!pollRes.ok) throw new Error("Tripo API: Polling failed."); - const pollData = await pollRes.json(); - if (pollData.code !== 0) throw new Error(`Tripo API Poll Error: ${pollData.message}`); - - const status = pollData.data.status; - const progress = pollData.data.progress || 0; - setProgressMessage(`Generating... ${progress}%`); - - if (status === 'success') { - const modelUrl = pollData.data.result.model.url || (pollData.data.result.pbr && pollData.data.result.pbr.model && pollData.data.result.pbr.model.url); - if (!modelUrl) throw new Error("Tripo API: Model URL not found in result."); - setGeneratedModelUrl(modelUrl); - setProgressMessage("Done!"); - isDone = true; - deductCredit(); - } else if (status === 'failed' || status === 'cancelled') { - throw new Error(`Tripo API Task ${status}`); - } - } - } else if (apiProvider === 'meshy') { - // MESHY NATIVE API - const dataUrl = await fileToDataUrl(imageFile); - setProgressMessage("Generating 3D Model with Meshy... This might take up to a minute."); - - let payload: any = { - image_url: dataUrl, - model_type: meshyModelType, - should_texture: meshyShouldTexture, - should_enhance_image: meshyEnhanceImage, - target_formats: ['glb'] - }; - - if (meshyModelType !== 'lowpoly') { - payload.ai_model = meshyAiModel === 'latest' ? 'meshy-6' : meshyAiModel; - payload.should_remesh = meshyShouldRemesh; - if (meshyShouldRemesh) { - payload.topology = meshyTopology; - payload.target_polycount = meshyTargetPolycount; - } - } - - if (meshyShouldTexture) { - payload.enable_pbr = meshyEnablePbr; - if (meshyAiModel !== 'meshy-5') { - payload.hd_texture = meshyHdTexture; - } - if (meshyTextureImageUrl) { - payload.texture_image_url = meshyTextureImageUrl; - } else if (meshyTexturePrompt) { - payload.texture_prompt = meshyTexturePrompt; - } - } - - const taskRes = await fetch('https://api.meshy.ai/openapi/v1/image-to-3d', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', 'Authorization': `Bearer ${meshyApiKey}` - }, - body: JSON.stringify(payload) - }); - - if (!taskRes.ok) { - const errData = await taskRes.text(); - console.error("Meshy task creation error payload:", errData); - throw new Error(`Meshy API: Task creation failed. ${errData}`); - } - const taskData = await taskRes.json(); - const taskId = taskData.result; - - let isDone = false; - while (!isDone) { - await new Promise(r => setTimeout(r, 2000)); - const pollRes = await fetch(`https://api.meshy.ai/openapi/v1/image-to-3d/${taskId}`, { - headers: { - 'Authorization': `Bearer ${meshyApiKey}` - } - }); - if (!pollRes.ok) throw new Error("Meshy API: Polling failed."); - const pollData = await pollRes.json(); - - const status = (pollData.status || '').toUpperCase(); - const progress = pollData.progress || 0; - console.log("Meshy poll:", pollData); // Debug log - setProgressMessage(`Generating... ${progress}%`); - - if (status === 'SUCCEEDED') { - const modelUrl = pollData.model_urls?.glb; - if (!modelUrl) throw new Error("Meshy API: Model URL not found in result."); - setGeneratedModelUrl(modelUrl); - setProgressMessage("Done!"); - isDone = true; - deductCredit(); - } else if (status === 'FAILED' || status === 'EXPIRED') { - throw new Error(`Meshy API Task ${status}: ${pollData.task_error?.message || 'Unknown error'}`); } + }); + if (!pollRes.ok) throw new Error("Meshy API: Polling failed."); + const pollData = await pollRes.json(); + + const status = (pollData.status || '').toUpperCase(); + const progress = pollData.progress || 0; + console.log("Meshy poll:", pollData); // Debug log + setProgressMessage(`Generating... ${progress}%`); + + if (status === 'SUCCEEDED') { + const modelUrl = pollData.model_urls?.glb; + if (!modelUrl) throw new Error("Meshy API: Model URL not found in result."); + setGeneratedModelUrl(modelUrl); + setProgressMessage("Done!"); + isDone = true; + deductCredit(); + } else if (status === 'FAILED' || status === 'EXPIRED') { + throw new Error(`Meshy API Task ${status}: ${pollData.task_error?.message || 'Unknown error'}`); } } } catch (err: any) { @@ -416,242 +247,222 @@ export const ImageTo3DModal: React.FC = ({ isOpen, onClose, initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} - className="w-full max-w-4xl bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden flex flex-col h-[600px]" + className="w-full max-w-5xl bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden flex flex-col h-[700px]" > {/* Header */} -
+
- - Image to 3D Generation + + Meshy 3D Generation
-
{/* Content Area */}
-
- {/* Left Column: Upload */} +
+ {/* Left Column: Upload & Clean */}
-
- -
- - {apiProvider === 'fal' && ( - - )} -
-
-
-

Input Image

+

Input Image (Auto Cleaned)

Front View
-
+ + {/* Footer actions */} +
+
+
+ Meshy Generation: 200 AI Credits +
+ +
+ + + {generatedModelUrl ? ( + + ) : ( + + )} +