Files
titan3d_react_node_sqllite/src/components/ImageTo3DModal.tsx
T
2026-10-05 15:14:21 +05:30

501 lines
26 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
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';
import { editUVLayer } from '../lib/api/apiUtils';
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 ModelPreview = ({ url }: { url: string }) => {
const [model, setModel] = useState<THREE.Group | null>(null);
useEffect(() => {
const loader = new GLTFLoader();
loader.load(url, (gltf) => {
setModel(gltf.scene);
});
}, [url]);
if (!model) return <div className="absolute inset-0 flex items-center justify-center"><Loader2 className="w-8 h-8 animate-spin text-zinc-500" /></div>;
return (
<Canvas shadows={{ type: THREE.PCFShadowMap }} camera={{ position: [0, 1.5, 3], fov: 45 }}>
<Stage environment="studio" intensity={0.5}>
<primitive object={model} />
</Stage>
<OrbitControls autoRotate />
</Canvas>
);
};
export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose, onPushToApp }) => {
const { profile, setProfile } = useAuthStore();
const [meshyApiKey, setMeshyApiKey] = useState('');
// Image states
const [imageFile, setImageFile] = useState<File | 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');
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<string | null>(null);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
api.getUserKeys().then(keys => {
if (keys.MESHY_API_KEY) setMeshyApiKey(keys.MESHY_API_KEY);
}).catch(err => console.error("Failed to load user keys", err));
}
}, [isOpen]);
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setImageFile(file);
const url = URL.createObjectURL(file);
setOriginalImagePreview(url);
setGeneratedModelUrl(null);
setError(null);
// Auto clean on upload
handleCleanImage(file);
}
};
const handleCleanImage = async (fileToClean: File) => {
setIsCleaningImage(true);
setCleanedImageFile(null);
setCleanedImagePreview(null);
try {
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
// Scale down if too large to fit in 1MB limit safely
let { width, height } = img;
const maxSize = 1024;
if (width > maxSize || height > maxSize) {
const ratio = Math.min(maxSize / width, maxSize / height);
width = Math.round(width * ratio);
height = Math.round(height * ratio);
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx?.drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL('image/webp', 0.8));
};
img.onerror = reject;
img.src = e.target?.result as string;
};
reader.onerror = reject;
reader.readAsDataURL(fileToClean);
});
const token = localStorage.getItem('auth_token') || '';
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" });
setCleanedImageFile(newFile);
setCleanedImagePreview(newUrl);
} catch (err: any) {
setError("Failed to clean image: " + err.message);
// Fallback to original
setCleanedImageFile(fileToClean);
setCleanedImagePreview(URL.createObjectURL(fileToClean));
} finally {
setIsCleaningImage(false);
}
};
const fileToDataUrl = (file: File): Promise<string> => {
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 () => {
const targetFile = cleanedImageFile || imageFile;
if (!targetFile) {
setError("Please upload an image first.");
return;
}
if (!profile || profile.api_credits <= 0) {
setError("Not enough AI Credits. Please contact an administrator.");
return;
}
if (!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);
const dataUrl = await fileToDataUrl(targetFile);
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;
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(`/api/proxy/glb?url=${encodeURIComponent(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) {
console.error(err);
setError(err.message || "An error occurred during generation.");
} finally {
setIsGenerating(false);
}
};
if (!isOpen) return null;
return (
<AnimatePresence>
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<motion.div
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-5xl bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden flex flex-col h-[700px]"
>
{/* Header */}
<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={20} />
<span className="text-base font-bold text-zinc-200">Meshy 3D Generation</span>
</div>
<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-8 h-full">
{/* Left Column: Upload & Clean */}
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<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>
{!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} />
<div className="flex flex-col items-center text-zinc-500 gap-3 pointer-events-none p-6 text-center">
<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-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 ? (
<>
<img src={originalImagePreview} alt="Original Grayscale" className="absolute inset-0 w-full h-full object-contain p-2 opacity-30 grayscale blur-sm" />
<div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-300 gap-3 bg-black/40 z-10">
<Loader2 size={28} className="animate-spin text-blue-500" />
<span className="text-sm font-medium">AI Cleaning Image...</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>
)}
<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-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.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>
</select>
</div>
</div>
<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-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-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 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-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-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-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.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-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 overflow-hidden">
{isGenerating ? (
<>
{cleanedImagePreview || originalImagePreview ? (
<img src={(cleanedImagePreview || originalImagePreview) as string} alt="Generating Base" className="absolute inset-0 w-full h-full object-contain p-8 opacity-20 grayscale blur-md scale-110" />
) : null}
<div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-300 bg-black/60 z-20">
<Loader2 size={36} className="animate-spin text-blue-500 mb-4" />
<p className="text-sm font-medium text-zinc-200">{progressMessage}</p>
</div>
</>
) : generatedModelUrl ? (
<ModelPreview url={generatedModelUrl} />
) : (
<div className="flex flex-col items-center justify-center text-zinc-600 p-6 text-center">
<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 || 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>
</div>
</motion.div>
</div>
</AnimatePresence>
);
};