refactor: simplify ImageTo3DModal, only use Meshy, and auto-clean uploaded images
Build and Deploy / build-and-push (push) Successful in 29s
Build and Deploy / build-and-push (push) Successful in 29s
This commit is contained in:
+167
-356
@@ -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<THREE.Group | null>(null);
|
||||
|
||||
@@ -48,13 +40,15 @@ const ModelPreview = ({ url }: { url: string }) => {
|
||||
|
||||
export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ 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<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [originalImagePreview, setOriginalImagePreview] = useState<string | null>(null);
|
||||
|
||||
const [cleanedImageFile, setCleanedImageFile] = useState<File | null>(null);
|
||||
const [cleanedImagePreview, setCleanedImagePreview] = useState<string | null>(null);
|
||||
const [isCleaningImage, setIsCleaningImage] = useState(false);
|
||||
|
||||
// Meshy specific state
|
||||
const [meshyModelType, setMeshyModelType] = useState('standard');
|
||||
@@ -73,59 +67,14 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
||||
const [progressMessage, setProgressMessage] = useState('');
|
||||
const [generatedModelUrl, setGeneratedModelUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isCleaningImage, setIsCleaningImage] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
@@ -133,36 +82,39 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ 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<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -173,7 +125,8 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ 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<ImageTo3DModalProps> = ({ 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,121 +153,8 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
||||
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);
|
||||
const dataUrl = await fileToDataUrl(targetFile);
|
||||
setProgressMessage("Generating 3D Model with Meshy... This might take up to a minute.");
|
||||
|
||||
let payload: any = {
|
||||
@@ -398,7 +230,6 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
||||
throw new Error(`Meshy API Task ${status}: ${pollData.task_error?.message || 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError(err.message || "An error occurred during generation.");
|
||||
@@ -416,124 +247,94 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ 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 */}
|
||||
<div className="h-12 bg-zinc-800 flex items-center justify-between px-6 border-b border-zinc-700">
|
||||
<div className="h-14 bg-zinc-800 flex items-center justify-between px-6 border-b border-zinc-700">
|
||||
<div className="flex items-center gap-2 text-blue-400">
|
||||
<Box size={18} />
|
||||
<span className="text-sm font-bold text-zinc-200">Image to 3D Generation</span>
|
||||
<Box size={20} />
|
||||
<span className="text-base font-bold text-zinc-200">Meshy 3D Generation</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 hover:bg-zinc-700 rounded transition-colors">
|
||||
<X size={16} className="text-zinc-400" />
|
||||
<button onClick={onClose} className="p-2 hover:bg-zinc-700 rounded transition-colors">
|
||||
<X size={18} className="text-zinc-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-y-auto bg-zinc-900/50 p-6">
|
||||
<div className="grid grid-cols-2 gap-6 h-full">
|
||||
{/* Left Column: Upload */}
|
||||
<div className="grid grid-cols-2 gap-8 h-full">
|
||||
{/* Left Column: Upload & Clean */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Provider & Model</label>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={apiProvider}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value as 'fal' | 'tripo' | 'meshy';
|
||||
setApiProvider(val);
|
||||
localStorage.setItem('API_PROVIDER', val);
|
||||
}}
|
||||
className="flex-1 bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-xs text-zinc-300 focus:outline-none focus:border-blue-500/50"
|
||||
>
|
||||
<option value="fal">Fal.ai</option>
|
||||
<option value="tripo">Tripo3D</option>
|
||||
<option value="meshy">Meshy</option>
|
||||
</select>
|
||||
{apiProvider === 'fal' && (
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => {
|
||||
setSelectedModel(e.target.value);
|
||||
localStorage.setItem('FAL_MODEL', e.target.value);
|
||||
}}
|
||||
className="flex-1 bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-xs text-zinc-300 focus:outline-none focus:border-blue-500/50"
|
||||
>
|
||||
{MODELS.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold text-zinc-200">Input Image</h3>
|
||||
<h3 className="text-sm font-bold text-zinc-200">Input Image (Auto Cleaned)</h3>
|
||||
<span className="text-[10px] text-zinc-500 bg-zinc-800 px-2 py-0.5 rounded-full">Front View</span>
|
||||
</div>
|
||||
|
||||
<label className="flex-1 min-h-[160px] flex flex-col items-center justify-center border-2 border-dashed border-zinc-700 hover:border-zinc-500 rounded-xl bg-zinc-900/50 cursor-pointer transition-colors relative overflow-hidden group">
|
||||
{!originalImagePreview ? (
|
||||
<label className="flex-1 min-h-[220px] flex flex-col items-center justify-center border-2 border-dashed border-zinc-700 hover:border-zinc-500 rounded-xl bg-zinc-900/50 cursor-pointer transition-colors relative overflow-hidden group">
|
||||
<input type="file" ref={fileInputRef} accept="image/png, image/jpeg" className="hidden" onChange={handleImageUpload} />
|
||||
{imagePreview ? (
|
||||
<>
|
||||
<img src={imagePreview} alt="Preview" className="absolute inset-0 w-full h-full object-contain p-2" />
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 flex flex-col items-center justify-center gap-3 transition-opacity">
|
||||
<span className="text-white text-xs font-medium flex items-center gap-2"><Upload size={14}/> Change Image</span>
|
||||
<button
|
||||
onClick={handleCleanImage}
|
||||
disabled={isCleaningImage}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white rounded text-xs font-bold flex items-center gap-2 transition-colors shadow-lg shadow-black/50"
|
||||
>
|
||||
{isCleaningImage ? <Loader2 size={12} className="animate-spin" /> : null}
|
||||
Clean Image (AI)
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setImageFile(null);
|
||||
setImagePreview(null);
|
||||
setGeneratedModelUrl(null);
|
||||
setError(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}}
|
||||
className="absolute top-2 right-2 p-1.5 bg-red-500/80 hover:bg-red-500 text-white rounded-md backdrop-blur shadow-sm opacity-0 group-hover:opacity-100 transition-all z-20"
|
||||
title="Remove image"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center text-zinc-500 gap-3 pointer-events-none p-6 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-zinc-800 flex items-center justify-center">
|
||||
<Upload size={20} className="text-zinc-400" />
|
||||
<div className="w-14 h-14 rounded-full bg-zinc-800 flex items-center justify-center">
|
||||
<Upload size={24} className="text-zinc-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-300 mb-1">Click to upload image</p>
|
||||
<p className="text-xs">PNG, JPG up to 10MB</p>
|
||||
<p className="text-[10px] mt-2 text-zinc-600">Tip: Use an image with a solid or transparent background.</p>
|
||||
<p className="text-base font-medium text-zinc-300 mb-1">Click to upload image</p>
|
||||
<p className="text-sm">PNG, JPG up to 10MB</p>
|
||||
<p className="text-xs mt-3 text-blue-400 font-medium">Image will be automatically cleaned of text/logos.</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
) : (
|
||||
<div className="flex gap-4 h-[220px]">
|
||||
{/* Original Image */}
|
||||
<label className="flex-1 relative border-2 border-dashed border-zinc-700 rounded-xl overflow-hidden group cursor-pointer">
|
||||
<input type="file" ref={fileInputRef} accept="image/png, image/jpeg" className="hidden" onChange={handleImageUpload} />
|
||||
<img src={originalImagePreview} alt="Original" className="w-full h-full object-contain p-2" />
|
||||
<div className="absolute inset-x-0 bottom-0 bg-black/80 text-center text-xs text-zinc-400 py-1.5 border-t border-zinc-800">Original</div>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity">
|
||||
<span className="text-white text-sm font-medium flex items-center gap-2"><Upload size={16}/> Change Image</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Cleaned Image */}
|
||||
<div className="flex-1 relative border-2 border-solid border-zinc-700 rounded-xl overflow-hidden bg-zinc-900/50 flex items-center justify-center group">
|
||||
{isCleaningImage ? (
|
||||
<div className="flex flex-col items-center text-zinc-400 gap-3">
|
||||
<Loader2 size={28} className="animate-spin text-blue-500" />
|
||||
<span className="text-sm font-medium">AI Cleaning...</span>
|
||||
</div>
|
||||
) : cleanedImagePreview ? (
|
||||
<>
|
||||
<img src={cleanedImagePreview} alt="Cleaned" className="w-full h-full object-contain p-2" />
|
||||
<div className="absolute inset-x-0 bottom-0 bg-blue-900/80 text-center text-xs text-blue-200 py-1.5 font-bold border-t border-blue-800">Cleaned Result</div>
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex flex-col items-center justify-center gap-3 transition-opacity">
|
||||
<button onClick={(e) => { e.preventDefault(); handleCleanImage(imageFile!); }} className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-bold shadow-lg flex items-center gap-2 transition-colors">
|
||||
<RefreshCw size={14} /> Try Again
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-zinc-600 text-sm">Failed to clean.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{apiProvider === 'meshy' && (
|
||||
<div className="flex flex-col gap-3 mt-2 bg-zinc-900 p-3 rounded-xl border border-zinc-800">
|
||||
<h4 className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider flex items-center gap-1"><SettingsIcon size={12}/> Meshy Settings</h4>
|
||||
<div className="flex flex-col gap-3 mt-4 bg-zinc-900 p-4 rounded-xl border border-zinc-800">
|
||||
<h4 className="text-xs font-bold text-zinc-400 uppercase tracking-wider flex items-center gap-1.5"><SettingsIcon size={14}/> Meshy Config</h4>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] text-zinc-500 font-bold">Model Type</label>
|
||||
<select value={meshyModelType} onChange={e => setMeshyModelType(e.target.value)} className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 text-xs text-zinc-300">
|
||||
<div className="grid grid-cols-2 gap-4 mt-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-zinc-500 font-bold">Model Type</label>
|
||||
<select value={meshyModelType} onChange={e => setMeshyModelType(e.target.value)} className="bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-300">
|
||||
<option value="standard">Standard</option>
|
||||
<option value="lowpoly">Low Poly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] text-zinc-500 font-bold">AI Model</label>
|
||||
<select value={meshyAiModel} onChange={e => setMeshyAiModel(e.target.value)} disabled={meshyModelType === 'lowpoly'} className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 text-xs text-zinc-300 disabled:opacity-50">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-zinc-500 font-bold">AI Model</label>
|
||||
<select value={meshyAiModel} onChange={e => setMeshyAiModel(e.target.value)} disabled={meshyModelType === 'lowpoly'} className="bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-300 disabled:opacity-50">
|
||||
<option value="latest">Latest (Meshy 6)</option>
|
||||
<option value="meshy-6">Meshy 6</option>
|
||||
<option value="meshy-5">Meshy 5</option>
|
||||
@@ -541,117 +342,127 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-zinc-300 cursor-pointer">
|
||||
<input type="checkbox" checked={meshyShouldTexture} onChange={e => setMeshyShouldTexture(e.target.checked)} className="rounded border-zinc-700 bg-zinc-900 text-blue-500 cursor-pointer" />
|
||||
<div className="flex items-center gap-5 mt-2">
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-300 cursor-pointer">
|
||||
<input type="checkbox" checked={meshyShouldTexture} onChange={e => setMeshyShouldTexture(e.target.checked)} className="rounded border-zinc-700 bg-zinc-900 w-4 h-4 text-blue-500 cursor-pointer" />
|
||||
Texture
|
||||
</label>
|
||||
<label className={cn("flex items-center gap-1.5 text-xs text-zinc-300 cursor-pointer", !meshyShouldTexture && "opacity-50 pointer-events-none")}>
|
||||
<input type="checkbox" checked={meshyEnablePbr} onChange={e => setMeshyEnablePbr(e.target.checked)} disabled={!meshyShouldTexture} className="rounded border-zinc-700 bg-zinc-900 text-blue-500 cursor-pointer" />
|
||||
<label className={cn("flex items-center gap-2 text-sm text-zinc-300 cursor-pointer", !meshyShouldTexture && "opacity-50 pointer-events-none")}>
|
||||
<input type="checkbox" checked={meshyEnablePbr} onChange={e => setMeshyEnablePbr(e.target.checked)} disabled={!meshyShouldTexture} className="rounded border-zinc-700 bg-zinc-900 w-4 h-4 text-blue-500 cursor-pointer" />
|
||||
PBR
|
||||
</label>
|
||||
<label className={cn("flex items-center gap-1.5 text-xs text-zinc-300 cursor-pointer", (!meshyShouldTexture || meshyAiModel === 'meshy-5') && "opacity-50 pointer-events-none")}>
|
||||
<input type="checkbox" checked={meshyHdTexture} onChange={e => setMeshyHdTexture(e.target.checked)} disabled={!meshyShouldTexture || meshyAiModel === 'meshy-5'} className="rounded border-zinc-700 bg-zinc-900 text-blue-500 cursor-pointer" />
|
||||
<label className={cn("flex items-center gap-2 text-sm text-zinc-300 cursor-pointer", (!meshyShouldTexture || meshyAiModel === 'meshy-5') && "opacity-50 pointer-events-none")}>
|
||||
<input type="checkbox" checked={meshyHdTexture} onChange={e => setMeshyHdTexture(e.target.checked)} disabled={!meshyShouldTexture || meshyAiModel === 'meshy-5'} className="rounded border-zinc-700 bg-zinc-900 w-4 h-4 text-blue-500 cursor-pointer" />
|
||||
4K HD
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{meshyShouldTexture && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<input type="text" value={meshyTexturePrompt} onChange={e => 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" />
|
||||
<input type="text" value={meshyTextureImageUrl} onChange={e => 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" />
|
||||
<div className="flex flex-col gap-2 mt-1">
|
||||
<input type="text" value={meshyTexturePrompt} onChange={e => setMeshyTexturePrompt(e.target.value)} maxLength={600} placeholder="Texture prompt (optional)..." className="bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-300" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<label className={cn("flex items-center gap-1.5 text-xs text-zinc-300 cursor-pointer", meshyModelType === 'lowpoly' && "opacity-50 pointer-events-none")}>
|
||||
<input type="checkbox" checked={meshyShouldRemesh} onChange={e => setMeshyShouldRemesh(e.target.checked)} disabled={meshyModelType === 'lowpoly'} className="rounded border-zinc-700 bg-zinc-900 text-blue-500 cursor-pointer" />
|
||||
<div className="flex items-center gap-5 mt-2">
|
||||
<label className={cn("flex items-center gap-2 text-sm text-zinc-300 cursor-pointer", meshyModelType === 'lowpoly' && "opacity-50 pointer-events-none")}>
|
||||
<input type="checkbox" checked={meshyShouldRemesh} onChange={e => setMeshyShouldRemesh(e.target.checked)} disabled={meshyModelType === 'lowpoly'} className="rounded border-zinc-700 bg-zinc-900 w-4 h-4 text-blue-500 cursor-pointer" />
|
||||
Remesh
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-zinc-300 cursor-pointer" title="Keep enabled to prevent flat text/logos from becoming embossed geometry">
|
||||
<input type="checkbox" checked={meshyEnhanceImage} onChange={e => setMeshyEnhanceImage(e.target.checked)} className="rounded border-zinc-700 bg-zinc-900 text-blue-500 cursor-pointer" />
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-300 cursor-pointer" title="Keep enabled to prevent flat text/logos from becoming embossed geometry">
|
||||
<input type="checkbox" checked={meshyEnhanceImage} onChange={e => setMeshyEnhanceImage(e.target.checked)} className="rounded border-zinc-700 bg-zinc-900 w-4 h-4 text-blue-500 cursor-pointer" />
|
||||
Enhance Image
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{meshyShouldRemesh && meshyModelType !== 'lowpoly' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] text-zinc-500 font-bold">Topology</label>
|
||||
<select value={meshyTopology} onChange={e => setMeshyTopology(e.target.value)} className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 text-xs text-zinc-300">
|
||||
<div className="grid grid-cols-2 gap-4 mt-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-zinc-500 font-bold">Topology</label>
|
||||
<select value={meshyTopology} onChange={e => setMeshyTopology(e.target.value)} className="bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-300">
|
||||
<option value="triangle">Triangle</option>
|
||||
<option value="quad">Quad</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] text-zinc-500 font-bold">Polycount</label>
|
||||
<input type="number" min={100} max={300000} step={1000} value={meshyTargetPolycount} onChange={e => 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" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-zinc-500 font-bold">Polycount</label>
|
||||
<input type="number" min={100} max={300000} step={1000} value={meshyTargetPolycount} onChange={e => setMeshyTargetPolycount(parseInt(e.target.value) || 30000)} className="bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-300" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex gap-2 items-start text-red-400 text-xs">
|
||||
<AlertCircle size={14} className="shrink-0 mt-0.5" />
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex gap-2 items-start text-red-400 text-sm mt-auto">
|
||||
<AlertCircle size={16} className="shrink-0 mt-0.5" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column: 3D Preview */}
|
||||
<div className="flex flex-col h-full bg-zinc-950 rounded-xl overflow-hidden border border-zinc-800 relative">
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<h3 className="text-sm font-bold text-zinc-200">3D Preview</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative flex items-center justify-center">
|
||||
{generatedModelUrl ? (
|
||||
<ModelPreview url={generatedModelUrl} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-zinc-600 p-6 text-center">
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 size={32} className="animate-spin text-blue-500 mb-4" />
|
||||
<p className="text-sm font-medium text-zinc-300">{progressMessage}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Box size={32} className="mb-3 opacity-50" />
|
||||
<p className="text-sm">Your 3D model will appear here</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="h-16 bg-zinc-900 border-t border-zinc-800 px-6 flex items-center justify-between shrink-0">
|
||||
<div className="text-xs font-medium px-3 py-1.5 bg-blue-500/10 text-blue-400 rounded-full border border-blue-500/20 flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />
|
||||
Meshy Generation: 200 AI Credits
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-lg text-sm font-bold transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
{generatedModelUrl ? (
|
||||
<button
|
||||
onClick={() => onPushToApp(generatedModelUrl)}
|
||||
className="px-6 py-2 bg-green-600 hover:bg-green-500 text-white rounded-lg text-sm font-bold shadow-lg shadow-green-900/20 transition-all flex items-center gap-2"
|
||||
>
|
||||
<Check size={16} />
|
||||
Use Model
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={generate3DModel}
|
||||
disabled={!imageFile || isGenerating}
|
||||
className="w-full py-3 bg-blue-600 hover:bg-blue-500 disabled:bg-zinc-800 disabled:text-zinc-500 text-white rounded-xl text-sm font-bold shadow-lg shadow-blue-900/20 transition-all flex items-center justify-center gap-2"
|
||||
disabled={!imageFile || isGenerating || isCleaningImage}
|
||||
className="px-8 py-2 bg-blue-600 hover:bg-blue-500 disabled:bg-zinc-800 disabled:text-zinc-500 text-white rounded-lg text-sm font-bold shadow-lg shadow-blue-900/20 transition-all flex items-center gap-2"
|
||||
>
|
||||
{isGenerating ? <Loader2 size={16} className="animate-spin" /> : <Box size={16} />}
|
||||
{isGenerating ? 'Generating...' : 'Generate 3D Model'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Output */}
|
||||
<div className="flex flex-col gap-4 border-l border-zinc-800 pl-6">
|
||||
<h3 className="text-sm font-bold text-zinc-200">3D Preview</h3>
|
||||
<div className="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl relative overflow-hidden flex flex-col items-center justify-center">
|
||||
{generatedModelUrl ? (
|
||||
<>
|
||||
<div className="absolute inset-0">
|
||||
<ModelPreview url={`/api/proxy/glb?url=${encodeURIComponent(generatedModelUrl)}`} />
|
||||
</div>
|
||||
<div className="absolute top-3 right-3 flex gap-2 z-10">
|
||||
<a href={generatedModelUrl} download="model.glb" target="_blank" className="p-2 bg-zinc-900/80 hover:bg-zinc-800 backdrop-blur rounded-lg border border-zinc-700 transition-colors text-zinc-300">
|
||||
<Download size={16} />
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
) : isGenerating ? (
|
||||
<div className="flex flex-col items-center gap-4 text-zinc-400">
|
||||
<Loader2 size={32} className="animate-spin text-blue-500" />
|
||||
<p className="text-xs font-medium animate-pulse">{progressMessage}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-zinc-600 flex flex-col items-center gap-2">
|
||||
<Box size={32} className="opacity-20" />
|
||||
<p className="text-xs">Your 3D model will appear here</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{generatedModelUrl && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const proxyUrl = `/api/proxy/glb?url=${encodeURIComponent(generatedModelUrl)}`;
|
||||
onPushToApp(proxyUrl);
|
||||
onClose();
|
||||
}}
|
||||
className="w-full py-3 bg-green-600 hover:bg-green-500 text-white rounded-xl text-sm font-bold shadow-lg shadow-green-900/20 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
Push to Application Scene
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user