/** * Fractafrag Renderer — Headless Chromium shader render service. * * Accepts GLSL code via POST /render, renders in an isolated browser context, * captures a thumbnail (JPEG) and a short preview video (WebM frames → GIF/WebM). * * For M1: captures a still thumbnail at t=1s. Video preview is a future enhancement. */ import express from 'express'; import puppeteer from 'puppeteer-core'; import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; const app = express(); app.use(express.json({ limit: '1mb' })); const PORT = 3100; const OUTPUT_DIR = process.env.OUTPUT_DIR || '/renders'; const MAX_DURATION = parseInt(process.env.MAX_RENDER_DURATION || '8', 10); const CHROMIUM_PATH = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium'; // Ensure output directory exists if (!existsSync(OUTPUT_DIR)) { mkdirSync(OUTPUT_DIR, { recursive: true }); } /** * Generate the HTML page that hosts the shader for rendering. * Shadertoy-compatible uniform injection. */ function buildShaderHTML(glsl, width, height) { return `
`; } let browser = null; async function getBrowser() { if (!browser || !browser.isConnected()) { browser = await puppeteer.launch({ executablePath: CHROMIUM_PATH, headless: 'new', args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu-sandbox', '--use-gl=swiftshader', // Software GL for headless '--enable-webgl', '--no-first-run', '--disable-extensions', '--max-gum-memory-mb=256', ], }); } return browser; } // Health check app.get('/health', async (req, res) => { try { const b = await getBrowser(); res.json({ status: 'ok', service: 'renderer', browserConnected: b.isConnected() }); } catch (e) { res.status(500).json({ status: 'error', error: e.message }); } }); // Render endpoint app.post('/render', async (req, res) => { const { glsl, shader_id, duration = 3, width = 640, height = 360, fps = 30 } = req.body; if (!glsl) { return res.status(400).json({ error: 'Missing glsl field' }); } const renderId = shader_id || randomUUID(); const renderDir = join(OUTPUT_DIR, renderId); mkdirSync(renderDir, { recursive: true }); const startMs = Date.now(); let page = null; try { const b = await getBrowser(); page = await b.newPage(); await page.setViewport({ width, height, deviceScaleFactor: 1 }); const html = buildShaderHTML(glsl, width, height); // Set content and wait for first paint await page.setContent(html, { waitUntil: 'domcontentloaded' }); // Wait for shader to compile (check title for errors) await page.waitForFunction( () => document.title.startsWith('FRAME:') || document.title.startsWith('COMPILE_ERROR:') || document.title.startsWith('LINK_ERROR:') || document.title.startsWith('ERROR:'), { timeout: 10000 } ); const title = await page.title(); if (title.startsWith('COMPILE_ERROR:') || title.startsWith('LINK_ERROR:') || title.startsWith('ERROR:')) { const errorMsg = title.split(':').slice(1).join(':'); return res.status(422).json({ error: `Shader compilation failed: ${errorMsg}` }); } // Let it render for the specified duration to reach a visually interesting state const captureDelay = Math.min(duration, MAX_DURATION) * 1000; // Wait at least 1 second, capture at t=1s for thumbnail await new Promise(r => setTimeout(r, Math.min(captureDelay, 1500))); // Capture thumbnail const thumbPath = join(renderDir, 'thumb.jpg'); await page.screenshot({ path: thumbPath, type: 'jpeg', quality: 85 }); // Capture a second frame later for variety (preview frame) if (captureDelay > 1500) { await new Promise(r => setTimeout(r, captureDelay - 1500)); } const previewPath = join(renderDir, 'preview.jpg'); await page.screenshot({ path: previewPath, type: 'jpeg', quality: 85 }); const durationMs = Date.now() - startMs; res.json({ thumbnail_url: `/renders/${renderId}/thumb.jpg`, preview_url: `/renders/${renderId}/preview.jpg`, duration_ms: durationMs, error: null, }); } catch (e) { const elapsed = Date.now() - startMs; if (elapsed > MAX_DURATION * 1000) { return res.status(408).json({ error: `Render timed out after ${MAX_DURATION}s` }); } res.status(500).json({ error: `Render failed: ${e.message}` }); } finally { if (page) { try { await page.close(); } catch (_) {} } } }); // Graceful shutdown process.on('SIGTERM', async () => { console.log('Shutting down renderer...'); if (browser) await browser.close(); process.exit(0); }); app.listen(PORT, '0.0.0.0', () => { console.log(`Renderer service listening on :${PORT}`); console.log(`Output dir: ${OUTPUT_DIR}`); console.log(`Max render duration: ${MAX_DURATION}s`); console.log(`Chromium: ${CHROMIUM_PATH}`); // Pre-launch browser getBrowser().then(() => console.log('Chromium ready')).catch(e => console.error('Browser launch failed:', e.message)); });