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.
260 lines
7.9 KiB
JavaScript
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));
|
|
});
|