fractafrag/services/renderer/server.js
John Lightner c4b8c0fe38 Tracks B+C+D: Auth system, renderer, full frontend shell
Track B — Auth & User System (complete):
- User registration with bcrypt + Turnstile verification
- JWT access/refresh token flow with httpOnly cookie rotation
- Redis refresh token blocklist for logout
- User profile + settings update endpoints (username, email)
- API key generation with bcrypt hashing (ff_key_ prefix)
- BYOK key management with AES-256-GCM encryption at rest
- Free tier rate limiting (5 shaders/month)
- Tier-gated endpoints (Pro/Studio for BYOK, API keys, bounty posting)

Track C — Shader Submission & Renderer (complete):
- GLSL validator: entry point check, banned extensions, infinite loop detection,
  brace balancing, loop bound warnings, code length limits
- Puppeteer/headless Chromium renderer with Shadertoy-compatible uniform injection
  (iTime, iResolution, iMouse), WebGL2 with SwiftShader fallback
- Shader compilation error detection via page title signaling
- Thumbnail capture at t=1s, preview frame at t=duration
- Renderer client service for API→renderer HTTP communication
- Shader submission pipeline: validate GLSL → create record → enqueue render job
- Desire fulfillment linking on shader submit
- Re-validation and re-render on shader code update
- Fork endpoint copies code, tags, metadata, enqueues new render

Track D — Frontend Shell (complete):
- React 18 + Vite + TypeScript + Tailwind CSS + TanStack Query + Zustand
- Dark theme with custom fracta color palette and surface tones
- Responsive layout with sticky navbar, gradient branding
- Auth: Login + Register pages with JWT token management
- API client with automatic 401 refresh interceptor
- ShaderCanvas: Full WebGL2 renderer component with Shadertoy uniforms,
  mouse tracking, ResizeObserver, debounced recompilation, error callbacks
- GLSL Editor: Split pane (code textarea + live preview), 400ms debounced
  preview, metadata panel (description, tags, type), GLSL validation errors,
  shader publish flow, fork-from-existing support
- Feed: Infinite scroll with IntersectionObserver sentinel, dwell time tracking,
  skeleton loading states, empty state with CTA
- Explore: Search + tag filter + sort tabs (trending/new/top), grid layout
- ShaderDetail: Full-screen preview, vote controls, view source toggle, fork button
- Bounties: Desire queue list sorted by heat score, status badges, tip display
- BountyDetail: Single desire view with style hints, fulfill CTA
- Profile: User header with avatar initial, shader grid
- Settings: Account info, API key management (create/revoke/copy), subscription tiers
- Generate: AI generation UI stub with prompt input, style controls, example prompts

76 files, ~5,700 lines of application code.
2026-03-24 20:56:42 -05:00

260 lines
7.9 KiB
JavaScript

/**
* 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 `<!DOCTYPE html>
<html><head><style>*{margin:0;padding:0}canvas{display:block}</style></head>
<body>
<canvas id="c" width="${width}" height="${height}"></canvas>
<script>
const canvas = document.getElementById('c');
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
if (!gl) { document.title = 'ERROR:NO_WEBGL'; throw new Error('No WebGL'); }
const vs = \`#version 300 es
in vec4 a_position;
void main() { gl_Position = a_position; }
\`;
const fsPrefix = \`#version 300 es
precision highp float;
uniform float iTime;
uniform vec3 iResolution;
uniform vec4 iMouse;
out vec4 outColor;
\`;
const fsUser = ${JSON.stringify(glsl)};
// Wrap mainImage if present
let fsBody;
if (fsUser.includes('mainImage')) {
fsBody = fsPrefix + fsUser + \`
void main() {
vec4 col;
mainImage(col, gl_FragCoord.xy);
outColor = col;
}
\`;
} else {
// Assume it already has a main() that writes to outColor or gl_FragColor
fsBody = fsPrefix + fsUser.replace('gl_FragColor', 'outColor');
}
function createShader(type, src) {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
const err = gl.getShaderInfoLog(s);
document.title = 'COMPILE_ERROR:' + err.substring(0, 200);
throw new Error(err);
}
return s;
}
let program;
try {
const vShader = createShader(gl.VERTEX_SHADER, vs);
const fShader = createShader(gl.FRAGMENT_SHADER, fsBody);
program = gl.createProgram();
gl.attachShader(program, vShader);
gl.attachShader(program, fShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const err = gl.getProgramInfoLog(program);
document.title = 'LINK_ERROR:' + err.substring(0, 200);
throw new Error(err);
}
} catch(e) {
throw e;
}
gl.useProgram(program);
// Fullscreen quad
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1,1,-1,-1,1,1,1]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(program, 'a_position');
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
const uTime = gl.getUniformLocation(program, 'iTime');
const uRes = gl.getUniformLocation(program, 'iResolution');
const uMouse = gl.getUniformLocation(program, 'iMouse');
gl.uniform3f(uRes, ${width}.0, ${height}.0, 1.0);
gl.uniform4f(uMouse, 0, 0, 0, 0);
const startTime = performance.now();
let frameCount = 0;
function render() {
const t = (performance.now() - startTime) / 1000.0;
gl.uniform1f(uTime, t);
gl.viewport(0, 0, ${width}, ${height});
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
frameCount++;
// Signal frame count in title for Puppeteer to read
document.title = 'FRAME:' + frameCount + ':TIME:' + t.toFixed(3);
requestAnimationFrame(render);
}
render();
</script></body></html>`;
}
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));
});