Spaces:
Sleeping
Sleeping
| 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'}`); | |
| }); | |