diff --git a/backend/server.js b/backend/server.js index c961dbc..6fb0937 100644 --- a/backend/server.js +++ b/backend/server.js @@ -647,6 +647,77 @@ CRITICAL INSTRUCTIONS: } }); +// Edit Layer (Gemini Flash Image) +app.post('/api/sparkplug/edit-layer', authenticate, async (req, res) => { + const { prompt, imageBase64, mimeType } = req.body; + if (!prompt || !imageBase64) return res.status(400).json({ error: 'Missing prompt or image' }); + + try { + const keys = await getKeysFromProfile(req.user.id); + const apiKey = keys.GEMINI_API_KEY || keys.NANO_BANANA_API_KEY; + if (!apiKey) return res.status(400).json({ error: 'Gemini API key missing' }); + + const baseUrl = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image:generateContent'; + const base64Data = imageBase64.replace(/^data:image\/\w+;base64,/, ""); + + const payload = { + contents: [ + { + parts: [ + { text: `You are an expert texture editor. Edit the following image based on this prompt: "${prompt}". \nCRITICAL INSTRUCTIONS:\n1. Output MUST be an image with the exact same aspect ratio as the input.\n2. Do NOT change the layout or mapping of the texture.\n3. Increase the resolution and quality of the visual elements as requested.` }, + { + inlineData: { + mimeType: mimeType || "image/png", + data: base64Data + } + } + ] + } + ], + generationConfig: { + responseModalities: ["IMAGE"] + } + }; + + const response = await axios.post(`${baseUrl}?key=${apiKey}`, payload, { + headers: { 'Content-Type': 'application/json', 'Accept-Encoding': 'gzip,deflate,compress' } + }); + + let resultBase64 = null; + let resultMime = 'image/jpeg'; + + const candidate = response.data?.candidates?.[0]; + if (candidate?.content?.parts) { + for (const part of candidate.content.parts) { + if (part.inlineData && part.inlineData.data) { + resultBase64 = part.inlineData.data; + resultMime = part.inlineData.mimeType || 'image/jpeg'; + break; + } + } + } + + if (!resultBase64) { + throw new Error(`Could not extract base64 image data from Gemini response`); + } + + const buffer = Buffer.from(resultBase64, 'base64'); + const ext = resultMime === 'image/png' ? 'png' : 'jpg'; + const filename = `layer-edit-${req.user.id}-${Date.now()}.${ext}`; + const filepath = path.join(uploadsDir, filename); + fs.writeFileSync(filepath, buffer); + + res.json({ imageUrl: \`/uploads/\${filename}\` }); + } catch (err) { + let errorMsg = err.message; + if (err.response && err.response.data) { + errorMsg = typeof err.response.data === 'object' ? JSON.stringify(err.response.data) : err.response.data; + } + console.error("Gemini Edit Layer Error:", errorMsg); + res.status(500).json({ error: errorMsg }); + } +}); + // 3. Generate 3D (Meshy) app.post('/api/sparkplug/generate-3d', authenticate, async (req, res) => { const { imageUrls } = req.body; diff --git a/src/components/UVEditor.tsx b/src/components/UVEditor.tsx index cbb1723..4e3072a 100644 --- a/src/components/UVEditor.tsx +++ b/src/components/UVEditor.tsx @@ -4,6 +4,9 @@ import { Canvas } from '@react-three/fiber'; import { OrbitControls, Environment } from '@react-three/drei'; import * as THREE from 'three'; import { ModelViewer } from './ModelViewer'; +import { editUVLayer } from '../lib/api/apiUtils'; +import { useAuthStore } from '../store/authStore'; +import { Loader2 } from 'lucide-react'; interface Layer { id: string; @@ -45,6 +48,72 @@ export function UVEditor({ onClose, onSave, targetObject, loadedModel, sceneObje const fileInputRef = useRef(null); const dragRef = useRef<{ handle: Handle; startX: number; startY: number; layer: Layer } | null>(null); + const token = useAuthStore(state => state.token); + const [aiPrompt, setAiPrompt] = useState(""); + const [isEditingLayer, setIsEditingLayer] = useState(false); + const [aiPreviewUrl, setAiPreviewUrl] = useState(null); + const [originalLayerUrl, setOriginalLayerUrl] = useState(null); + + const handleAiEdit = async (regenerate = false) => { + if (!selectedId || !aiPrompt.trim()) return; + const layer = layers.find(l => l.id === selectedId); + if (!layer || !layer.img) return; + + setIsEditingLayer(true); + try { + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = layer.img.width; + tempCanvas.height = layer.img.height; + const ctx = tempCanvas.getContext('2d'); + if (ctx) { + ctx.drawImage(layer.img, 0, 0); + const base64 = tempCanvas.toDataURL('image/png'); + + const newUrl = await editUVLayer(aiPrompt, base64, token || ''); + + if (!originalLayerUrl && !regenerate) { + setOriginalLayerUrl(layer.url); + } + + setAiPreviewUrl(newUrl); + + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + setLayers(ls => ls.map(l => l.id === selectedId ? { ...l, url: newUrl, img } : l)); + setIsEditingLayer(false); + }; + img.src = newUrl; + return; + } + } catch (e: any) { + alert("AI Edit failed: " + e.message); + } + setIsEditingLayer(false); + }; + + const handleApproveEdit = () => { + setAiPreviewUrl(null); + setOriginalLayerUrl(null); + setAiPrompt(""); + }; + + const handleRejectEdit = () => { + if (selectedId && originalLayerUrl) { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + setLayers(ls => ls.map(l => l.id === selectedId ? { ...l, url: originalLayerUrl, img } : l)); + setAiPreviewUrl(null); + setOriginalLayerUrl(null); + }; + img.src = originalLayerUrl; + } else { + setAiPreviewUrl(null); + setOriginalLayerUrl(null); + } + }; + useEffect(() => { const saved = targetObject?.materialProps?.uvLayers; if (saved?.length) { @@ -127,298 +196,6 @@ export function UVEditor({ onClose, onSave, targetObject, loadedModel, sceneObje console.error("Canvas render error (likely CORS taint):", e); return ""; } - }, [canvasW, canvasH]); - - useEffect(() => { - setPreviewUrl(renderCanvas(layers, bgTransparent, bgColor)); - }, [layers, bgTransparent, bgColor]); - - const handleFileUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; if (!file) return; - const reader = new FileReader(); - reader.onload = ev => { - const url = ev.target?.result as string; - const img = new Image(); - img.onload = () => { - const nl: Layer = { id: `l-${Date.now()}`, url, img, x: canvasW / 2, y: canvasH / 2, scaleX: 1, scaleY: 1, rotation: 0, cropL: 0, cropT: 0, cropR: 0, cropB: 0, visible: true }; - setLayers(p => [...p, nl]); - setSelectedId(nl.id); - }; - img.src = url; - }; - reader.readAsDataURL(file); - e.target.value = ''; - }; - - const updateLayer = (id: string, u: Partial) => setLayers(p => p.map(l => l.id === id ? { ...l, ...u } : l)); - const selectedLayer = layers.find(l => l.id === selectedId) ?? null; - - const getSF = () => { - const r = containerRef.current?.getBoundingClientRect(); - return r ? canvasW / r.width : 1; - }; - - const onHandleDown = (e: React.MouseEvent, handle: Handle, layer: Layer) => { - e.stopPropagation(); - setSelectedId(layer.id); - dragRef.current = { handle, startX: e.clientX, startY: e.clientY, layer: { ...layer } }; - e.preventDefault(); - }; - - // Snap helper: snap value to nearest multiple of snapSize - const snap = (v: number, snapSize: number) => Math.round(v / snapSize) * snapSize; - - const onMouseMove = (e: React.MouseEvent) => { - const d = dragRef.current; - if (!d) return; - const sf = getSF(); - const dx = (e.clientX - d.startX) * sf; - const dy = (e.clientY - d.startY) * sf; - const isShift = e.shiftKey; - const SNAP_PX = 16; // grid snap in canvas units - - if (d.handle === 'move') { - let nx = d.layer.x + dx; - let ny = d.layer.y + dy; - // Clamp to canvas bounds with soft margin - nx = Math.max(0, Math.min(canvasW, nx)); - ny = Math.max(0, Math.min(canvasH, ny)); - // Hold Shift to snap to grid - if (isShift) { nx = snap(nx, SNAP_PX); ny = snap(ny, SNAP_PX); } - // Center snapping: snap near canvas center - if (Math.abs(nx - canvasW / 2) < SNAP_PX) nx = canvasW / 2; - if (Math.abs(ny - canvasH / 2) < SNAP_PX) ny = canvasH / 2; - updateLayer(d.layer.id, { x: nx, y: ny }); - } else if (d.handle === 'rotate') { - let rot = d.layer.rotation + dx / 2; - // Snap rotation to 15° increments when Shift held - if (isShift) rot = snap(rot, 15); - updateLayer(d.layer.id, { rotation: rot }); - } else if (d.handle.startsWith('scale')) { - if (isShift) { - // Shift held: stretch X and Y independently - const sx = Math.max(0.05, d.layer.scaleX + dx / 200); - const sy = Math.max(0.05, d.layer.scaleY + dy / 200); - updateLayer(d.layer.id, { scaleX: sx, scaleY: sy }); - } else { - // No shift: uniform scale driven by the larger axis - const delta = (Math.abs(dx) > Math.abs(dy) ? dx : dy) / 200; - const sx = Math.max(0.05, d.layer.scaleX + delta); - const sy = Math.max(0.05, d.layer.scaleY + delta); - updateLayer(d.layer.id, { scaleX: sx, scaleY: sy }); - } - } - }; - - const onMouseUp = () => { dragRef.current = null; }; - - const handleSave = () => { - if (previewUrl) { - const serialized = layers.map(({ img, ...rest }) => rest); - onSave(previewUrl, mappingType, serialized, bgColor, bgTransparent); - } - onClose(); - }; - - const PreviewModel = () => { - const mockObjs = useMemo(() => { - if (!sceneObjects) return []; - return sceneObjects.map(obj => obj.id === targetObject?.id - ? { ...obj, materialProps: { ...obj.materialProps, map: previewUrl, mappingType } } - : obj - ); - }, [sceneObjects, previewUrl, mappingType]); - const cloned = useMemo(() => loadedModel?.clone() ?? null, [loadedModel]); - if (!targetObject) return ; - if (targetObject.type === 'cube') { - const tex = previewUrl ? (() => { const t = new THREE.TextureLoader().load(previewUrl); t.colorSpace = THREE.SRGBColorSpace; t.flipY = false; return t; })() : null; - return ; - } - return ; - }; - - // Bounding box overlay rendered on canvas div - const BoundingBox = ({ layer }: { layer: Layer }) => { - const iw = (layer.img?.width ?? 200) * (1 - layer.cropL - layer.cropR); - const ih = (layer.img?.height ?? 200) * (1 - layer.cropT - layer.cropB); - const rect = containerRef.current?.getBoundingClientRect(); - if (!rect) return null; - - const w = (iw * layer.scaleX / canvasW) * 100; - const h = (ih * layer.scaleY / canvasH) * 100; - const lx = (layer.x / canvasW) * 100; - const ly = (layer.y / canvasH) * 100; - - const cornerStyle = (cx: string, cy: string, cursor: string): React.CSSProperties => ({ - position: 'absolute', left: cx, top: cy, width: 10, height: 10, - background: '#ffffff', border: '2px solid #3b82f6', borderRadius: 2, - transform: 'translate(-50%,-50%)', cursor, zIndex: 10, touchAction: 'none' - }); - const edgeStyle = (cx: string, cy: string, cursor: string): React.CSSProperties => ({ - position: 'absolute', left: cx, top: cy, width: 8, height: 8, - background: '#3b82f6', borderRadius: '50%', - transform: 'translate(-50%,-50%)', cursor, zIndex: 10 - }); - - return ( -
- {/* Corners - scale */} - {(['NW','NE','SW','SE'] as const).map(c => ( -
onHandleDown(e, `scale${c}` as Handle, layer)} - /> - ))} - {/* Edge midpoints - scale */} - {[ - { id: 'N', cx: '50%', cy: '0%', cur: 'ns-resize' }, - { id: 'S', cx: '50%', cy: '100%', cur: 'ns-resize' }, - { id: 'E', cx: '100%', cy: '50%', cur: 'ew-resize' }, - { id: 'W', cx: '0%', cy: '50%', cur: 'ew-resize' }, - ].map(({ id, cx, cy, cur }) => ( -
onHandleDown(e, `scale${id}` as Handle, layer)} /> - ))} - {/* Rotate handle - top center above box */} -
onHandleDown(e, 'rotate', layer)} title="Rotate" /> - {/* Line from box to rotate handle */} -
-
- ); - }; - - return ( -
- {/* Top Bar */} -
-
- -
- UV Map -
-
- - {!bgTransparent && ( -
- -
- {/* Left sidebar */} -
-
- - -
-

Layers

- {layers.length === 0 && ( -
Upload an image to start
- )} - {[...layers].reverse().map((layer, i) => ( -
setSelectedId(layer.id)} - className={`flex items-center gap-2 p-2 rounded-lg border cursor-pointer transition-all ${selectedId === layer.id ? 'border-blue-500 bg-blue-500/10' : 'border-zinc-800 hover:border-zinc-600 bg-zinc-800/20'} ${!layer.visible ? 'opacity-40' : ''}`}> - - Layer {layers.length - i} - - -
- ))} - - {/* Layer props */} - {selectedLayer && ( -
-

Transform

-
- {(['x', 'y'] as const).map(ax => ( -
-

{ax}

- updateLayer(selectedLayer.id, { [ax]: +e.target.value })} - className="w-full bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-[11px] text-blue-400 font-mono outline-none focus:border-blue-500" /> -
- ))} -
-
- {(['scaleX', 'scaleY'] as const).map(ax => ( -
-

{ax === 'scaleX' ? 'Scl X' : 'Scl Y'}

- updateLayer(selectedLayer.id, { [ax]: Math.max(0.01, +e.target.value) })} - className="w-full bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-[11px] text-blue-400 font-mono outline-none focus:border-blue-500" /> -
- ))} -
-
-
- Rotation{Math.round(selectedLayer.rotation)}° -
- updateLayer(selectedLayer.id, { rotation: +e.target.value })} - className="w-full h-1 bg-zinc-700 rounded appearance-none cursor-pointer accent-blue-500" /> -
- -
-

Crop

- {(['cropL', 'cropT', 'cropR', 'cropB'] as const).map(s => ( -
- {s.slice(4)} - updateLayer(selectedLayer.id, { [s]: +e.target.value })} - className="flex-1 h-1 bg-zinc-700 rounded appearance-none cursor-pointer accent-orange-500" /> - {Math.round(selectedLayer[s] * 100)}% -
- ))} -
- )} -
-
- - {/* Canvas area */} -
( throw new Error("Polling timeout exceeded."); } + +export async function editUVLayer(prompt: string, imageBase64: string, token: string): Promise { + // Extract mime type if present in the data URI + let mimeType = 'image/png'; + if (imageBase64.startsWith('data:')) { + const match = imageBase64.match(/^data:([^;]+);/); + if (match) { + mimeType = match[1]; + } + } + + const response = await fetch('/api/sparkplug/edit-layer', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ prompt, imageBase64, mimeType }) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + if (data.error) throw new Error(data.error); + + return data.imageUrl; +}