veurone-render / server.js
CodeShamza
feat: add /moodboard endpoint for visual style guide generation
820155d
import express from 'express';
import cors from 'cors';
import puppeteer from 'puppeteer-core';
import { exec } from 'child_process';
import { writeFileSync, readFileSync, mkdirSync, rmSync, existsSync } from 'fs';
import { randomUUID } from 'crypto';
import { promisify } from 'util';
const execAsync = promisify(exec);
const app = express();
app.use(express.json({ limit: '25mb' }));
app.use(cors());
// ── Health Check ──
app.get('/', (_req, res) => {
res.json({ status: 'ok', service: 'Veurone Render Space', timestamp: new Date().toISOString() });
});
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// ── AI Chat Endpoint ──
const SYSTEM_PROMPT = `You are a world-class HTML animation designer following the HyperFrames standard for video composition. You create beautiful, self-contained HTML files using pure HTML/CSS and GSAP.
HYPERFRAMES ANIMATION RULES & BEST PRACTICES:
1. Output ONLY the complete HTML code β€” no explanations, no markdown fences.
2. The HTML must be fully self-contained (inline CSS and JS). Include GSAP via CDN: <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>.
3. Layout Before Animation: Position every element in CSS where it should be at its MOST VISIBLE moment (its end-state). Do not use CSS to hide them.
4. Entrance Animations: Once layout is perfect, add motion using ONLY entrance animations (e.g., \`gsap.from(..., { opacity: 0, y: 50 })\`). Elements must animate into their CSS-defined end-states.
5. NO Exit Animations: NEVER animate elements exiting (offscreen or opacity 0) unless it is the very final fade-to-black scene.
6. Design for 1920Γ—1080 viewport. Use "height: 100vh".
7. Keep animations under 6 seconds total duration. Do NOT use \`repeat: -1\` (infinite loops break the capture engine). Calculate explicit finite repeats if needed.
8. Vary eases across entrance tweens β€” use at least 3 different eases (e.g., 'power3.out', 'expo.out', 'back.out'). Offset the first animation by ~0.2s.
9. Use premium typography (Google Fonts) and vibrant colors (gradients, glass effects, solid + glow for backgrounds to avoid banding).
10. Auto-play the GSAP timeline so the headless browser capture engine can record it immediately.
11. Start your output with <!DOCTYPE html> and end with </html>.`;
app.post('/chat', async (req, res) => {
const { prompt, imageBase64 } = req.body;
if (!prompt || prompt.trim().length < 5) {
return res.status(400).json({ error: 'Prompt is too short.' });
}
const HF_TOKEN = process.env.HF_TOKEN;
if (!HF_TOKEN) {
return res.status(500).json({ error: 'HF_TOKEN not configured on the Space.' });
}
console.log(`[Chat] Prompt: "${prompt.substring(0, 80)}..." | Image: ${imageBase64 ? 'yes' : 'no'}`);
try {
// Build message content
const userContent = [];
if (imageBase64) {
// Ensure proper data URL format
const dataUrl = imageBase64.startsWith('data:')
? imageBase64
: `data:image/jpeg;base64,${imageBase64}`;
userContent.push({ type: 'image_url', image_url: { url: dataUrl } });
}
userContent.push({ type: 'text', text: prompt });
const body = {
model: 'Qwen/Qwen2.5-VL-72B-Instruct',
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userContent },
],
max_tokens: 4096,
temperature: 0.7,
};
// HF auto-routing: automatically picks the best available provider
const API_URL = 'https://router.huggingface.co/v1/chat/completions';
console.log(`[Chat] Calling HF auto-router with Qwen2.5-VL-72B...`);
const apiRes = await fetch(API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${HF_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!apiRes.ok) {
const errText = await apiRes.text();
console.error(`[Chat] VL model failed (${apiRes.status}):`, errText.substring(0, 200));
// Fallback: text-only prompt with Qwen2.5-Coder-32B
if (!imageBase64) {
console.log('[Chat] Trying Qwen2.5-Coder-32B fallback...');
const fbBody = {
model: 'Qwen/Qwen2.5-Coder-32B-Instruct',
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: prompt },
],
max_tokens: 4096,
temperature: 0.7,
};
const fbRes = await fetch(API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${HF_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(fbBody),
});
if (fbRes.ok) {
const fbData = await fbRes.json();
const rawFb = fbData.choices?.[0]?.message?.content || '';
const htmlFb = extractHtml(rawFb);
if (htmlFb && htmlFb.length >= 50) {
console.log(`[Chat] βœ… Success via Coder fallback: ${htmlFb.length} bytes`);
return res.json({ html: htmlFb, model: 'qwen2.5-coder-32b' });
}
}
}
throw new Error(`AI API failed (${apiRes.status}): ${errText.substring(0, 200)}`);
}
const data = await apiRes.json();
const rawContent = data.choices?.[0]?.message?.content || '';
const html = extractHtml(rawContent);
if (!html || html.length < 50) {
return res.status(422).json({
error: 'AI did not generate valid HTML. Try a more specific prompt.',
raw: rawContent.substring(0, 500),
});
}
console.log(`[Chat] βœ… Generated ${html.length} bytes of HTML`);
res.json({ html, model: 'qwen2.5-vl-72b' });
} catch (err) {
console.error('[Chat] ❌ Error:', err.message);
res.status(500).json({ error: `AI generation failed: ${err.message.substring(0, 300)}` });
}
});
/**
* Extract HTML from AI response (handles markdown fences and raw HTML).
*/
function extractHtml(text) {
// Try to extract from markdown code block
const fenceMatch = text.match(/```(?:html)?\s*\n?([\s\S]*?)```/);
if (fenceMatch) return fenceMatch[1].trim();
// Try to find raw HTML (starts with <!DOCTYPE or <html)
const htmlMatch = text.match(/(<!DOCTYPE[\s\S]*<\/html>)/i);
if (htmlMatch) return htmlMatch[1].trim();
// If it looks like HTML already, return as-is
if (text.trim().startsWith('<') && text.includes('</')) {
return text.trim();
}
return text.trim();
}
// ── Render Endpoint ──
app.post('/render', async (req, res) => {
const {
html,
fps = 30,
duration = 6,
width = 1920,
height = 1080,
} = req.body;
if (!html || html.length < 30) {
return res.status(400).json({ error: 'HTML code is too short or empty.' });
}
const jobId = randomUUID();
const workDir = `/tmp/${jobId}`;
const framesDir = `${workDir}/frames`;
const htmlPath = `${workDir}/index.html`;
const outPath = `${workDir}/output.mp4`;
console.log(`[Job ${jobId}] Starting render: ${width}x${height} @ ${fps}fps, ${duration}s`);
let browser;
try {
mkdirSync(framesDir, { recursive: true });
writeFileSync(htmlPath, html);
// Launch headless Chrome
browser = await puppeteer.launch({
executablePath: process.env.CHROME_PATH || '/usr/bin/chromium',
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-web-security',
'--font-render-hinting=none',
],
});
const page = await browser.newPage();
await page.setViewport({ width, height, deviceScaleFactor: 1 });
// Load the HTML
await page.goto(`file://${htmlPath}`, { waitUntil: 'networkidle0', timeout: 15000 });
// Wait for fonts + animations to start
await page.evaluate(() => document.fonts?.ready);
await new Promise(r => setTimeout(r, 1500));
console.log(`[Job ${jobId}] Page loaded, capturing ${fps * duration} frames...`);
// Capture frames
const totalFrames = fps * duration;
const frameDelay = 1000 / fps;
for (let i = 0; i < totalFrames; i++) {
const paddedIndex = String(i).padStart(5, '0');
await page.screenshot({
path: `${framesDir}/frame_${paddedIndex}.png`,
type: 'png',
clip: { x: 0, y: 0, width, height },
});
if (i < totalFrames - 1) {
await new Promise(r => setTimeout(r, frameDelay));
}
// Log progress every 30 frames
if ((i + 1) % 30 === 0) {
console.log(`[Job ${jobId}] Captured frame ${i + 1}/${totalFrames}`);
}
}
await browser.close();
browser = null;
console.log(`[Job ${jobId}] All frames captured. Encoding with FFmpeg...`);
// FFmpeg: compile frames β†’ MP4 (H.264)
const ffmpegCmd = [
'ffmpeg -y',
`-framerate ${fps}`,
`-i ${framesDir}/frame_%05d.png`,
'-c:v libx264',
'-preset fast',
'-crf 20',
'-pix_fmt yuv420p',
`-s ${width}x${height}`,
'-movflags +faststart',
outPath,
].join(' ');
await execAsync(ffmpegCmd, { timeout: 60000 });
if (!existsSync(outPath)) {
throw new Error('FFmpeg did not produce an output file.');
}
const mp4Buffer = readFileSync(outPath);
const videoBase64 = mp4Buffer.toString('base64');
const sizeMB = (mp4Buffer.length / (1024 * 1024)).toFixed(1);
console.log(`[Job ${jobId}] βœ… Done! MP4: ${sizeMB} MB`);
// Cleanup
try { rmSync(workDir, { recursive: true, force: true }); } catch { }
res.json({ videoBase64, format: 'mp4', sizeMB: parseFloat(sizeMB) });
} catch (err) {
console.error(`[Job ${jobId}] ❌ Render failed:`, err.message);
if (browser) {
try { await browser.close(); } catch { }
}
try { rmSync(workDir, { recursive: true, force: true }); } catch { }
res.status(500).json({ error: `Render failed: ${err.message.substring(0, 300)}` });
}
});
// ── Mood Board Generator ──
app.post('/moodboard', async (req, res) => {
const { brandName, colors, aesthetic, fonts } = req.body;
if (!brandName || !colors || colors.length === 0) {
return res.status(400).json({ error: 'brandName and colors are required.' });
}
console.log(`[MoodBoard] Generating for: ${brandName}`);
// Generate a self-contained HTML mood board
const colorBlocks = (colors || []).slice(0, 6).map((c, i) => {
const size = c.role === 'primary' ? '180px' : c.role === 'secondary' ? '140px' : '100px';
return `<div style="width:${size}; height:${size}; background:${c.hex}; border-radius:16px; display:flex; align-items:flex-end; padding:12px; box-shadow: 0 8px 32px ${c.hex}44;">
<div style="color:${contrastColor(c.hex)}; font-size:11px; font-weight:600; opacity:0.9;">
<div>${c.name || 'Color'}</div>
<div style="font-family:monospace; font-size:10px; opacity:0.7;">${c.hex}</div>
</div>
</div>`;
}).join('\n');
const primaryFont = fonts?.primary || 'Inter';
const secondaryFont = fonts?.secondary || 'Space Grotesk';
const primaryColor = colors[0]?.hex || '#6366f1';
const secondaryColor = colors[1]?.hex || '#ec4899';
const moodBoardHtml = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=${encodeURIComponent(primaryFont)}:wght@400;600;700&family=${encodeURIComponent(secondaryFont)}:wght@400;500&display=swap" rel="stylesheet">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { width:1920px; height:1080px; background:#0a0a14; font-family:'${primaryFont}',sans-serif; overflow:hidden; display:flex; }
.left { flex:1; padding:80px; display:flex; flex-direction:column; justify-content:center; }
.right { width:55%; padding:60px; display:flex; flex-wrap:wrap; gap:20px; align-items:center; justify-content:center; align-content:center; }
.label { font-size:13px; text-transform:uppercase; letter-spacing:4px; color:rgba(255,255,255,0.3); margin-bottom:16px; font-family:'${secondaryFont}',sans-serif; }
.brand { font-size:64px; font-weight:700; color:white; line-height:1.1; margin-bottom:24px; }
.accent { background: linear-gradient(135deg, ${primaryColor}, ${secondaryColor}); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
.desc { font-size:18px; color:rgba(255,255,255,0.5); line-height:1.6; max-width:480px; font-family:'${secondaryFont}',sans-serif; }
.divider { width:60px; height:3px; background:linear-gradient(90deg,${primaryColor},${secondaryColor}); border-radius:2px; margin:32px 0; }
.fonts { margin-top:40px; }
.font-label { font-size:11px; color:rgba(255,255,255,0.25); text-transform:uppercase; letter-spacing:2px; margin-bottom:8px; }
.font-name { font-size:20px; color:rgba(255,255,255,0.7); margin-bottom:20px; }
.glow { position:absolute; width:400px; height:400px; border-radius:50%; filter:blur(120px); opacity:0.15; }
.glow1 { top:-100px; right:200px; background:${primaryColor}; }
.glow2 { bottom:-50px; left:300px; background:${secondaryColor}; }
</style>
</head>
<body>
<div class="glow glow1"></div>
<div class="glow glow2"></div>
<div class="left">
<div class="label">Visual Language Guide</div>
<div class="brand"><span class="accent">${brandName}</span></div>
<div class="divider"></div>
<div class="desc">${aesthetic || ''}</div>
<div class="fonts">
<div class="font-label">Primary Typeface</div>
<div class="font-name" style="font-family:'${primaryFont}',sans-serif; font-weight:700;">${primaryFont}</div>
<div class="font-label">Secondary Typeface</div>
<div class="font-name" style="font-family:'${secondaryFont}',sans-serif;">${secondaryFont}</div>
</div>
</div>
<div class="right">
${colorBlocks}
</div>
</body>
</html>`;
let browser;
try {
browser = await puppeteer.launch({
executablePath: process.env.CHROME_PATH || '/usr/bin/chromium',
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
await page.setContent(moodBoardHtml, { waitUntil: 'networkidle0', timeout: 15000 });
await new Promise(r => setTimeout(r, 1000)); // Let fonts load
const screenshotBuffer = await page.screenshot({ type: 'png', fullPage: false });
await browser.close();
const base64 = screenshotBuffer.toString('base64');
console.log(`[MoodBoard] βœ… Generated ${Math.round(base64.length / 1024)}KB image`);
res.json({ imageBase64: base64 });
} catch (err) {
if (browser) try { await browser.close(); } catch { }
console.error('[MoodBoard] ❌ Error:', err.message);
res.status(500).json({ error: `Mood board generation failed: ${err.message}` });
}
});
function contrastColor(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.5 ? '#000000' : '#ffffff';
}
const PORT = process.env.PORT || 7860;
app.listen(PORT, '0.0.0.0', () => {
console.log(`🎬 Veurone Render Space running on port ${PORT}`);
console.log(` Chrome: ${process.env.CHROME_PATH || '/usr/bin/chromium'}`);
});