Files
titan3d_react_node_sqllite/backend/server.js
T
AI Bot c4253022db
Build and Deploy / build-and-push (push) Failing after 54s
feat: Add layer-wise texture editing using Gemini AI
2026-09-17 09:56:37 +05:30

816 lines
31 KiB
JavaScript

require('dotenv').config();
const express = require('express');
const cors = require('cors');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const pdfParse = require('pdf-parse');
const { GoogleGenerativeAI } = require('@google/generative-ai');
const axios = require('axios');
const crypto = require('crypto');
const { db, run, get, all } = require('./database');
const { sendMail, sendActivationEmail, sendPasswordResetEmail } = require('./mail');
const app = express();
app.use(cors());
app.use(express.json({ limit: '500mb' }));
app.use(express.urlencoded({ limit: '500mb', extended: true }));
const JWT_SECRET = 'viz-3d-local-secret-key-1234';
// Ensure uploads dir exists
const uploadsDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
app.use('/uploads', express.static(uploadsDir));
// Multer storage
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, uploadsDir);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + '-' + file.originalname);
}
});
const upload = multer({ storage: storage });
// --- AUTH MIDDLEWARE ---
const authenticate = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader) return res.status(401).json({ error: 'No token provided' });
const token = authHeader.split(' ')[1];
jwt.verify(token, JWT_SECRET, (err, decoded) => {
if (err) return res.status(401).json({ error: 'Invalid token' });
req.user = decoded;
next();
});
};
// --- ROUTES ---
// 1. Auth Register
app.post('/api/auth/register', async (req, res) => {
const { email, password } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
try {
const existing = await get('SELECT * FROM profiles WHERE email = ?', [email]);
if (existing) return res.status(400).json({ error: 'User already exists' });
const hash = await bcrypt.hash(password, 10);
const id = uuidv4();
// First user becomes admin and is automatically active
const countRow = await get('SELECT COUNT(*) as count FROM profiles');
const role = parseInt(countRow.count) === 0 ? 'admin' : 'user';
const is_active = parseInt(countRow.count) === 0 ? true : false;
let activation_token = null;
if (!is_active) {
activation_token = crypto.randomBytes(32).toString('hex');
}
await run(
'INSERT INTO profiles (id, email, password_hash, role, api_credits, storage_limit_mb, is_active, activation_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[id, email, hash, role, 10, 500, is_active, activation_token]
);
if (is_active === false) {
// Send activation email
const baseUrl = req.headers.origin || 'http://localhost:5173';
try {
await sendActivationEmail(email, activation_token, baseUrl);
} catch (err) {
console.error('Failed to send activation email', err);
// We still return pending, maybe they can resend later
}
return res.json({ status: 'pending', message: 'Account created. Please check your email for the activation link.' });
}
const token = jwt.sign({ id, email, role }, JWT_SECRET, { expiresIn: '7d' });
const profile = await get('SELECT id, email, role, api_credits, storage_limit_mb, is_active FROM profiles WHERE id = ?', [id]);
res.json({ status: 'active', token, profile });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 1.5. Auth Activate
app.post('/api/auth/activate', async (req, res) => {
const { token } = req.body;
if (!token) return res.status(400).json({ error: 'Activation token required' });
try {
const user = await get('SELECT * FROM profiles WHERE activation_token = ?', [token]);
if (!user) return res.status(400).json({ error: 'Invalid or expired activation token' });
await run('UPDATE profiles SET is_active = true, activation_token = NULL WHERE id = ?', [user.id]);
res.json({ success: true, message: 'Account activated successfully.' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 2. Auth Login
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
try {
const user = await get('SELECT * FROM profiles WHERE email = ?', [email]);
if (!user) return res.status(400).json({ error: 'Invalid credentials' });
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) return res.status(400).json({ error: 'Invalid credentials' });
if (user.is_active === false) {
return res.status(403).json({ error: 'Account pending admin approval. Please contact an administrator.' });
}
const token = jwt.sign({ id: user.id, email: user.email, role: user.role }, JWT_SECRET, { expiresIn: '7d' });
const profile = { id: user.id, email: user.email, role: user.role, api_credits: user.api_credits, storage_limit_mb: user.storage_limit_mb, is_active: user.is_active };
res.json({ token, profile });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 3. Auth Me
app.get('/api/auth/me', authenticate, async (req, res) => {
try {
const profile = await get('SELECT id, email, role, api_credits, storage_limit_mb, is_active FROM profiles WHERE id = ?', [req.user.id]);
res.json({ profile });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 3.5. Get User API Keys
app.get('/api/user/keys', authenticate, async (req, res) => {
try {
const profile = await get('SELECT api_keys FROM profiles WHERE id = ?', [req.user.id]);
let keys = {};
if (profile && profile.api_keys) {
try {
keys = JSON.parse(profile.api_keys);
} catch (e) {
// ignore invalid json
}
}
res.json({ keys });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 3.6. Update User API Keys
app.put('/api/user/keys', authenticate, async (req, res) => {
const { keys } = req.body;
if (!keys || typeof keys !== 'object') {
return res.status(400).json({ error: 'Invalid keys object' });
}
try {
const keysStr = JSON.stringify(keys);
await run('UPDATE profiles SET api_keys = ? WHERE id = ?', [keysStr, req.user.id]);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 4. Get all projects for user
app.get('/api/projects', authenticate, async (req, res) => {
try {
const projects = await all('SELECT id, name, created_at FROM projects WHERE user_id = ?', [req.user.id]);
res.json({ projects });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 5. Get project by ID
app.get('/api/projects/:id', authenticate, async (req, res) => {
try {
const project = await get('SELECT * FROM projects WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
if (!project) return res.status(404).json({ error: 'Project not found' });
if (project.scene_data) {
project.scene_data = JSON.parse(project.scene_data);
}
res.json({ project });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 6. Save (create or update) project
app.post('/api/projects', authenticate, async (req, res) => {
const { id, name, scene_data } = req.body;
const projectId = id || uuidv4();
try {
const existing = await get('SELECT * FROM projects WHERE id = ?', [projectId]);
if (existing) {
if (existing.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
await run('UPDATE projects SET name = ?, scene_data = ? WHERE id = ?', [name, JSON.stringify(scene_data), projectId]);
} else {
await run('INSERT INTO projects (id, user_id, name, scene_data) VALUES (?, ?, ?, ?)', [projectId, req.user.id, name, JSON.stringify(scene_data)]);
}
res.json({ success: true, id: projectId });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.delete('/api/projects/:id', authenticate, async (req, res) => {
const { id } = req.params;
try {
const existing = await get('SELECT * FROM projects WHERE id = ?', [id]);
if (!existing) return res.status(404).json({ error: 'Not found' });
if (existing.user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
await run('DELETE FROM projects WHERE id = ?', [id]);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 7. Get users (Admin only)
app.get('/api/users', authenticate, async (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' });
try {
const users = await all('SELECT id, email, role, api_credits, storage_limit_mb, is_active FROM profiles');
res.json({ users });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 7.1. Add user (Admin only)
app.post('/api/users', authenticate, async (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' });
const { email, role, api_credits, storage_limit_mb } = req.body;
if (!email) return res.status(400).json({ error: 'Email required' });
try {
const existing = await get('SELECT * FROM profiles WHERE email = ?', [email]);
if (existing) return res.status(400).json({ error: 'User already exists' });
// Generate random temp password (they will reset it anyway)
const tempPassword = crypto.randomBytes(16).toString('hex');
const hash = await bcrypt.hash(tempPassword, 10);
const id = uuidv4();
const activation_token = crypto.randomBytes(32).toString('hex');
await run(
'INSERT INTO profiles (id, email, password_hash, role, api_credits, storage_limit_mb, is_active, activation_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[id, email, hash, role || 'user', api_credits || 10, storage_limit_mb || 500, false, activation_token]
);
const baseUrl = req.headers.origin || 'http://localhost:5173';
try {
await sendActivationEmail(email, activation_token, baseUrl);
} catch (err) {
console.error('Failed to send activation email', err);
}
res.json({ success: true, message: 'User created and activation email sent.' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 7.2. Delete user (Admin only)
app.delete('/api/users/:id', authenticate, async (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' });
try {
// Cascade delete projects for the user
await run('DELETE FROM projects WHERE user_id = ?', [req.params.id]);
await run('DELETE FROM profiles WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 8. Update user (Admin only)
app.put('/api/users/:id', authenticate, async (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' });
const { role, api_credits, storage_limit_mb, is_active } = req.body;
try {
await run('UPDATE profiles SET role = ?, api_credits = ?, storage_limit_mb = ?, is_active = ? WHERE id = ?', [role, api_credits, storage_limit_mb, is_active !== undefined ? is_active : true, req.params.id]);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 8.1. Admin trigger reset password for user
app.post('/api/users/:id/reset-password', authenticate, async (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' });
try {
const user = await get('SELECT * FROM profiles WHERE id = ?', [req.params.id]);
if (!user) return res.status(404).json({ error: 'User not found' });
const reset_token = crypto.randomBytes(32).toString('hex');
await run('UPDATE profiles SET reset_token = ? WHERE id = ?', [reset_token, user.id]);
const baseUrl = req.headers.origin || 'http://localhost:5173';
try {
await sendPasswordResetEmail(user.email, reset_token, baseUrl);
} catch (err) {
console.error('Failed to send reset email', err);
}
res.json({ success: true, message: 'Password reset email sent to user.' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 8.2. Forgot Password (User request)
app.post('/api/auth/forgot-password', async (req, res) => {
const { email } = req.body;
if (!email) return res.status(400).json({ error: 'Email required' });
try {
const user = await get('SELECT * FROM profiles WHERE email = ?', [email]);
if (user) {
const reset_token = crypto.randomBytes(32).toString('hex');
await run('UPDATE profiles SET reset_token = ? WHERE id = ?', [reset_token, user.id]);
const baseUrl = req.headers.origin || 'http://localhost:5173';
try {
await sendPasswordResetEmail(user.email, reset_token, baseUrl);
} catch (err) {
console.error('Failed to send reset email', err);
}
}
// Always return success to prevent email enumeration
res.json({ success: true, message: 'If an account exists, a reset link has been sent.' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 8.3. Reset Password (User submission)
app.post('/api/auth/reset-password', async (req, res) => {
const { token, newPassword } = req.body;
if (!token || !newPassword) return res.status(400).json({ error: 'Token and new password required' });
try {
const user = await get('SELECT * FROM profiles WHERE reset_token = ?', [token]);
if (!user) return res.status(400).json({ error: 'Invalid or expired reset token' });
const hash = await bcrypt.hash(newPassword, 10);
await run('UPDATE profiles SET password_hash = ?, reset_token = NULL WHERE id = ?', [hash, user.id]);
res.json({ success: true, message: 'Password has been reset successfully.' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 9. Deduct API credit
app.post('/api/users/deduct-credit', authenticate, async (req, res) => {
try {
const user = await get('SELECT api_credits FROM profiles WHERE id = ?', [req.user.id]);
if (user.api_credits <= 0) return res.status(400).json({ error: 'Not enough credits' });
await run('UPDATE profiles SET api_credits = api_credits - 1 WHERE id = ?', [req.user.id]);
const updated = await get('SELECT api_credits FROM profiles WHERE id = ?', [req.user.id]);
res.json({ api_credits: updated.api_credits });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 9.5 Proxy for downloading GLB files to fix CORS issues in frontend viewers
app.get('/api/proxy/glb', async (req, res) => {
const { url } = req.query;
if (!url) return res.status(400).json({ error: 'URL required' });
try {
const response = await axios.get(url, { responseType: 'stream' });
res.setHeader('Content-Type', 'model/gltf-binary');
// Add CORS headers for the frontend to easily fetch it
res.setHeader('Access-Control-Allow-Origin', '*');
response.data.pipe(res);
} catch (err) {
console.error('Proxy error:', err.message);
res.status(500).json({ error: 'Failed to proxy file' });
}
});
// 10. Upload file
app.post('/api/upload', authenticate, upload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const url = `/uploads/${req.file.filename}`;
res.json({ url });
});
// 10b. Chunked Upload - Receive chunk
app.post('/api/upload/chunk', authenticate, upload.single('file'), (req, res) => {
const { uploadId, chunkIndex, totalChunks } = req.body;
if (!req.file || !uploadId || !chunkIndex || !totalChunks) {
return res.status(400).json({ error: 'Missing chunk data' });
}
const tempDir = path.join(uploadsDir, 'temp', uploadId);
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const chunkPath = path.join(tempDir, chunkIndex);
fs.renameSync(req.file.path, chunkPath);
res.json({ success: true });
});
// 10c. Chunked Upload - Complete
app.post('/api/upload/complete', authenticate, (req, res) => {
const { uploadId, fileName, totalChunks } = req.body;
if (!uploadId || !fileName || !totalChunks) {
return res.status(400).json({ error: 'Missing completion data' });
}
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const finalFileName = uniqueSuffix + '-' + fileName;
const finalPath = path.join(uploadsDir, finalFileName);
const tempDir = path.join(uploadsDir, 'temp', uploadId);
try {
const writeStream = fs.createWriteStream(finalPath);
for (let i = 0; i < totalChunks; i++) {
const chunkPath = path.join(tempDir, i.toString());
if (!fs.existsSync(chunkPath)) {
throw new Error(`Chunk ${i} is missing`);
}
const data = fs.readFileSync(chunkPath);
writeStream.write(data);
}
writeStream.end();
// Clean up temp dir
fs.rmSync(tempDir, { recursive: true, force: true });
const url = `/uploads/${finalFileName}`;
res.json({ url });
} catch (error) {
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
res.status(500).json({ error: error.message });
}
});
// ==========================================
// SPARK PLUG WORKFLOW APIS
// ==========================================
const getKeysFromProfile = async (userId) => {
const profile = await get('SELECT api_keys FROM profiles WHERE id = ?', [userId]);
if (profile && profile.api_keys) {
try {
return JSON.parse(profile.api_keys);
} catch(e) {}
}
return {};
};
// 1. Extract PDF (Gemini)
app.post('/api/sparkplug/extract', authenticate, async (req, res) => {
const { fileUrl } = req.body;
if (!fileUrl) return res.status(400).json({ error: 'No fileUrl provided' });
try {
const keys = await getKeysFromProfile(req.user.id);
const geminiKey = keys.GEMINI_API_KEY;
if (!geminiKey) return res.status(400).json({ error: 'Gemini API key missing' });
// Read PDF from the local file system
// fileUrl is something like '/uploads/1788251962641-55866782-file.pdf'
const fileName = fileUrl.replace('/uploads/', '');
const localFilePath = path.join(uploadsDir, fileName);
if (!fs.existsSync(localFilePath)) {
return res.status(404).json({ error: 'File not found on server' });
}
const dataBuffer = fs.readFileSync(localFilePath);
const base64Pdf = dataBuffer.toString('base64');
// Call Gemini to extract prompt
const genAI = new GoogleGenerativeAI(geminiKey);
const model = genAI.getGenerativeModel({ model: "gemini-3.5-flash-lite" });
const prompt = `
You are an expert product designer. Review the attached product spec PDF (which may include text and sample images).
Your goal is to create a highly detailed, concise visual design prompt for an image generation AI.
CRITICAL: If there is a sample image of the product in the PDF, you MUST carefully analyze it and extract the exact HEX color codes used in the design.
Include these HEX codes, as well as the exact shape, materials, typography style, and label content in your final prompt so the image generator knows exactly how it looks.
Do not include background details.
`;
const result = await model.generateContent([
{
inlineData: {
data: base64Pdf,
mimeType: "application/pdf"
}
},
prompt
]);
const response = await result.response;
const extractedPrompt = response.text();
res.json({ prompt: extractedPrompt });
} catch (err) {
res.status(500).json({ error: err.message });
} finally {
if (fileUrl) {
const fileName = fileUrl.replace('/uploads/', '');
const localFilePath = path.join(uploadsDir, fileName);
if (fs.existsSync(localFilePath)) {
fs.unlinkSync(localFilePath); // cleanup uploaded PDF
}
}
}
});
// 2. Generate Images (Gemini 3.1 Flash Image)
app.post('/api/sparkplug/generate-images', authenticate, async (req, res) => {
const { prompt } = req.body;
if (!prompt) return res.status(400).json({ error: 'No prompt provided' });
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' });
// Use the Gemini 3.1 Flash Image API
const baseUrl = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image:generateContent';
// Intelligently determine needed views using a text model
const genAI = new GoogleGenerativeAI(apiKey);
const textModel = genAI.getGenerativeModel({ model: "gemini-3.5-flash-lite" });
const viewPrompt = `You are a 3D modeling assistant. The user wants to generate orthographic views for a product based on this description: "${prompt}".
If the product is extremely thin and flat (like a sachet, pouch, packet, paper, or card), we only need the front and back views.
Otherwise, for a standard 3D volume (bottle, box, can, etc), we need front, back, left, and right.
Return ONLY a valid JSON array of strings representing the views needed.
Example 1: ["Front view", "Back view"]
Example 2: ["Front view", "Back view", "Left side view", "Right side view"]`;
let views = ['Front view', 'Back view', 'Left side view', 'Right side view'];
try {
const viewResult = await textModel.generateContent(viewPrompt);
const textResponse = viewResult.response.text().trim();
const match = textResponse.match(/\[.*\]/s);
if (match) {
views = JSON.parse(match[0]);
}
} catch (e) {
console.error("Failed to intelligently determine views, falling back to 4 views", e);
}
// Run parallel generation for determined views
const promises = views.map(async (view, index) => {
let backViewSpecifics = "";
if (view.toLowerCase().includes('back')) {
backViewSpecifics = "\n4. CRITICAL: This is the REAR / BACK view. It MUST look completely different from the front. It should show typical back-of-packaging elements like a barcode, ingredients list, usage instructions, and small regulatory text. DO NOT just generate the front logo again.";
}
const fullPrompt = `A clean, isolated 3D render concept art of: ${prompt}.
Focus ONLY on the object itself.
View: ${view}.
CRITICAL INSTRUCTIONS:
1. Pure white background.
2. DO NOT include any infographics, extra text, labels, icons, or background scenery. Just the isolated object.
3. If this object is very thin (like a sachet, pouch, or packet), the 'Left side view' and 'Right side view' must only show the thin edge profile of the object without the main front artwork.
4. CRITICAL: Maintain EXACTLY the same physical dimensions, shape, and aspect ratio across all views. The product's height and width MUST be consistent. Do not change between landscape and portrait orientations.
5. CRITICAL: ONLY generate EXACTLY ONE single product item in the image. Do NOT generate multiple items, groups, arrays, or side-by-side variations.${backViewSpecifics}`;
const payload = {
contents: [
{
parts: [
{ text: fullPrompt }
]
}
],
generationConfig: {
responseModalities: ["IMAGE"]
}
};
const response = await axios.post(`${baseUrl}?key=${apiKey}`, payload, {
headers: { 'Content-Type': 'application/json' }
});
let base64Image = null;
let mimeType = 'image/jpeg';
// Extract from standard Gemini generateContent response
const candidate = response.data?.candidates?.[0];
if (candidate?.content?.parts) {
for (const part of candidate.content.parts) {
if (part.inlineData && part.inlineData.data) {
base64Image = part.inlineData.data;
mimeType = part.inlineData.mimeType || 'image/jpeg';
break;
}
}
}
if (!base64Image) {
throw new Error(`Could not extract base64 image data from Gemini response: ${JSON.stringify(response.data)}`);
}
const buffer = Buffer.from(base64Image, 'base64');
const ext = mimeType === 'image/png' ? 'png' : 'jpg';
const filename = `sparkplug-${req.user.id}-${Date.now()}-${index}.${ext}`;
const filepath = path.join(uploadsDir, filename);
fs.writeFileSync(filepath, buffer);
return `/uploads/${filename}`;
});
const generatedUrls = await Promise.all(promises);
res.json({ images: generatedUrls });
} 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 Image Gen Error:", errorMsg);
res.status(500).json({ error: errorMsg });
}
});
// 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;
if (!imageUrls || imageUrls.length === 0) return res.status(400).json({ error: 'No images provided' });
try {
const keys = await getKeysFromProfile(req.user.id);
const meshyKey = keys.MESHY_API_KEY;
if (!meshyKey) return res.status(400).json({ error: 'Meshy API key missing' });
// Assuming imageUrls are local paths like /uploads/...
// Meshy requires base64 Data URIs if the images are not publicly accessible URLs.
// Our local URLs are not publicly accessible to Meshy's servers! We MUST convert to base64.
const base64Images = imageUrls.map(url => {
// Extract filename from URL
const filename = url.replace('/uploads/', '');
const filepath = path.join(uploadsDir, filename);
const buffer = fs.readFileSync(filepath);
return `data:image/png;base64,${buffer.toString('base64')}`;
});
const response = await axios.post('https://api.meshy.ai/openapi/v1/multi-image-to-3d', {
image_urls: base64Images,
enable_pbr: true,
topology: 'quad',
target_polycount: 50000,
ai_model: 'meshy-6',
hd_texture: true,
model_type: 'standard'
}, {
headers: {
'Authorization': `Bearer ${meshyKey}`,
'Content-Type': 'application/json'
}
});
res.json({ taskId: response.data.result });
} catch (err) {
const errorMsg = err.response?.data?.message || err.message;
res.status(500).json({ error: errorMsg });
}
});
// 4. Check 3D Status (Meshy)
app.get('/api/sparkplug/status-3d/:taskId', authenticate, async (req, res) => {
const { taskId } = req.params;
try {
const keys = await getKeysFromProfile(req.user.id);
const meshyKey = keys.MESHY_API_KEY;
if (!meshyKey) return res.status(400).json({ error: 'Meshy API key missing' });
const response = await axios.get(`https://api.meshy.ai/openapi/v1/multi-image-to-3d/${taskId}`, {
headers: {
'Authorization': `Bearer ${meshyKey}`
}
});
const data = response.data;
// If generation succeeded, intercept and proxy download the model to local storage to bypass CORS
if (data.status === 'SUCCEEDED' && data.model_urls && data.model_urls.glb) {
const glbUrl = data.model_urls.glb;
const localFilename = `${taskId}.glb`;
const localFilepath = path.join(uploadsDir, localFilename);
// Only download if we haven't already
if (!fs.existsSync(localFilepath)) {
try {
const fileRes = await axios.get(glbUrl, { responseType: 'arraybuffer' });
fs.writeFileSync(localFilepath, fileRes.data);
} catch (downloadErr) {
console.error("Failed to proxy download Meshy GLB:", downloadErr);
}
}
if (fs.existsSync(localFilepath)) {
// Overwrite the returned url with our local proxy url
const serverUrl = `${req.protocol}://${req.get('host')}`;
data.model_urls.glb = `${serverUrl}/uploads/${localFilename}`;
}
}
res.json(data);
} catch (err) {
const errorMsg = err.response?.data?.message || err.message;
res.status(500).json({ error: errorMsg });
}
});
const PORT = process.env.PORT || 3005;
app.listen(PORT, () => {
console.log(`Backend server running on port ${PORT}`);
});