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: . 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 and end with .`; 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 )/i); if (htmlMatch) return htmlMatch[1].trim(); // If it looks like HTML already, return as-is if (text.trim().startsWith('<') && text.includes(' { 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 `
${c.name || 'Color'}
${c.hex}
`; }).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 = `
Visual Language Guide
${brandName}
${aesthetic || ''}
Primary Typeface
${primaryFont}
Secondary Typeface
${secondaryFont}
${colorBlocks}
`; 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'}`); });