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 { cn } from '../lib/utils'; import { useAuthStore } from '../store/authStore'; import { api } from '../lib/api'; import { Canvas } from '@react-three/fiber'; import { OrbitControls, Stage } from '@react-three/drei'; import { GLTFLoader } from 'three-stdlib'; import * as THREE from 'three'; interface ImageTo3DModalProps { isOpen: boolean; onClose: () => void; 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); useEffect(() => { const loader = new GLTFLoader(); loader.load(url, (gltf) => { setModel(gltf.scene); }); }, [url]); if (!model) return
; return ( ); }; export const ImageTo3DModal: React.FC = ({ isOpen, onClose, onPushToApp }) => { const { profile, setProfile } = 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); const [imageFile, setImageFile] = useState(null); const [imagePreview, setImagePreview] = useState(null); // Meshy specific state const [meshyModelType, setMeshyModelType] = useState('standard'); const [meshyAiModel, setMeshyAiModel] = useState('latest'); const [meshyShouldTexture, setMeshyShouldTexture] = useState(true); const [meshyEnablePbr, setMeshyEnablePbr] = useState(false); const [meshyHdTexture, setMeshyHdTexture] = useState(false); const [meshyTexturePrompt, setMeshyTexturePrompt] = useState('clear text'); const [meshyTextureImageUrl, setMeshyTextureImageUrl] = useState(''); const [meshyEnhanceImage, setMeshyEnhanceImage] = useState(true); const [meshyShouldRemesh, setMeshyShouldRemesh] = useState(false); const [meshyTopology, setMeshyTopology] = useState('triangle'); const [meshyTargetPolycount, setMeshyTargetPolycount] = useState(30000); const [isGenerating, setIsGenerating] = useState(false); const [progressMessage, setProgressMessage] = useState(''); const [generatedModelUrl, setGeneratedModelUrl] = useState(null); const [error, setError] = useState(null); 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); const savedKey = localStorage.getItem('FAL_API_KEY'); if (savedKey) setApiKey(savedKey); const savedTripoKey = localStorage.getItem('TRIPO_API_KEY'); if (savedTripoKey) setTripoApiKey(savedTripoKey); const savedMeshyKey = localStorage.getItem('MESHY_API_KEY'); if (savedMeshyKey) setMeshyApiKey(savedMeshyKey); const savedModel = localStorage.getItem('FAL_MODEL'); if (savedModel) setSelectedModel(savedModel); }, []); const handleImageUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { setImageFile(file); const url = URL.createObjectURL(file); setImagePreview(url); setGeneratedModelUrl(null); setError(null); } }; // 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(); reader.onload = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(file); }); }; const generate3DModel = async () => { if (!imageFile) { setError("Please upload an image first."); return; } if (!profile || profile.api_credits <= 0) { 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) { setError("Meshy API Key is required. Please set it in Tools > API Settings."); return; } const deductCredit = async () => { if (profile) { try { const { api_credits } = await api.deductCredit(); setProfile({ ...profile, api_credits }); } catch (e) { console.error("Credit deduction failed", e); } } }; try { setIsGenerating(true); setError(null); setProgressMessage("Uploading image..."); if (apiProvider === 'fal') { fal.config({ credentials: apiKey }); const dataUrl = await fileToDataUrl(imageFile); setProgressMessage("Generating 3D Model... This might take up to a minute."); let inputPayload: any = { image_url: dataUrl }; if (selectedModel.includes('tripo')) { inputPayload = { image_url: dataUrl }; } 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 (modelUrl) { setGeneratedModelUrl(modelUrl); setProgressMessage("Done!"); deductCredit(); } else { setError("Failed to extract 3D model from response."); } } 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', 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; 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) throw new Error("Meshy API: Task creation failed."); 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; const progress = pollData.progress || 0; 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}`); } } } } catch (err: any) { console.error(err); setError(err.message || "An error occurred during generation."); } finally { setIsGenerating(false); } }; if (!isOpen) return null; return (
{/* Header */}
Image to 3D Generation
{/* Content Area */}
{/* Left Column: Upload */}
{apiProvider === 'fal' && ( )}

Input Image

Front View
{apiProvider === 'meshy' && (

Meshy Settings

{meshyShouldTexture && (
setMeshyTexturePrompt(e.target.value)} maxLength={600} placeholder="Texture prompt (optional)..." className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 text-xs text-zinc-300" /> setMeshyTextureImageUrl(e.target.value)} placeholder="Texture Image URL (optional)..." className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 text-xs text-zinc-300" />
)}
{meshyShouldRemesh && meshyModelType !== 'lowpoly' && (
setMeshyTargetPolycount(parseInt(e.target.value) || 30000)} className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 text-xs text-zinc-300" />
)}
)} {error && (

{error}

)}
{/* Right Column: Output */}

3D Preview

{generatedModelUrl ? ( <>
) : isGenerating ? (

{progressMessage}

) : (

Your 3D model will appear here

)}
{generatedModelUrl && ( )}
); };