File size: 15,442 Bytes
b044683
 
 
 
 
 
 
 
 
 
 
f8b064f
b044683
 
 
 
 
 
 
 
 
 
f8b064f
f13859d
f8b064f
f13859d
f8b064f
f13859d
 
 
 
 
 
 
 
 
 
f8b064f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b16ad14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1407209
 
 
 
 
b16ad14
1407209
 
b16ad14
 
 
 
 
 
 
1407209
 
f8b064f
b16ad14
 
f8b064f
 
b16ad14
 
 
1407209
b16ad14
 
 
 
 
f8b064f
 
b16ad14
 
f8b064f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b044683
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f8b064f
b044683
 
 
 
 
 
f8b064f
b044683
f8b064f
b044683
 
 
 
 
820155d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b044683
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
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'}`);
});