diff --git a/services/api/app/routers/shaders.py b/services/api/app/routers/shaders.py
index 46ca25b..75d3ea7 100644
--- a/services/api/app/routers/shaders.py
+++ b/services/api/app/routers/shaders.py
@@ -1,14 +1,16 @@
"""Shaders router — CRUD, submit, fork, search."""
from uuid import UUID
+from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select, func, or_
+from sqlalchemy import select, func
from app.database import get_db
from app.models import User, Shader
from app.schemas import ShaderCreate, ShaderUpdate, ShaderPublic
from app.middleware.auth import get_current_user, get_optional_user
+from app.services.glsl_validator import validate_glsl
router = APIRouter()
@@ -69,10 +71,33 @@ async def create_shader(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
- # TODO: Turnstile verification for submit
- # TODO: Rate limit check (free tier: 5/month)
- # TODO: GLSL validation via glslang
- # TODO: Enqueue render job
+ # Rate limit: free tier gets 5 submissions/month
+ if user.subscription_tier == "free":
+ month_start = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
+ count_result = await db.execute(
+ select(func.count()).select_from(Shader).where(
+ Shader.author_id == user.id,
+ Shader.created_at >= month_start,
+ )
+ )
+ monthly_count = count_result.scalar()
+ if monthly_count >= 5:
+ raise HTTPException(
+ status_code=429,
+ detail="Free tier: 5 shader submissions per month. Upgrade to Pro for unlimited."
+ )
+
+ # Validate GLSL
+ validation = validate_glsl(body.glsl_code, body.shader_type)
+ if not validation.valid:
+ raise HTTPException(
+ status_code=422,
+ detail={
+ "message": "GLSL validation failed",
+ "errors": validation.errors,
+ "warnings": validation.warnings,
+ }
+ )
shader = Shader(
author_id=user.id,
@@ -87,6 +112,27 @@ async def create_shader(
)
db.add(shader)
await db.flush()
+
+ # Enqueue render job
+ from app.worker import celery_app
+ try:
+ celery_app.send_task("render_shader", args=[str(shader.id)])
+ except Exception:
+ # If Celery isn't available (dev without worker), mark as ready
+ # with no thumbnail — the frontend can still render live
+ shader.render_status = "ready"
+
+ # If this shader fulfills a desire, link them
+ if body.fulfills_desire_id:
+ from app.models import Desire
+ desire = (await db.execute(
+ select(Desire).where(Desire.id == body.fulfills_desire_id, Desire.status == "open")
+ )).scalar_one_or_none()
+ if desire:
+ desire.status = "fulfilled"
+ desire.fulfilled_by_shader = shader.id
+ desire.fulfilled_at = datetime.now(timezone.utc)
+
return shader
@@ -104,9 +150,32 @@ async def update_shader(
if shader.author_id != user.id and user.role != "admin":
raise HTTPException(status_code=403, detail="Not the shader owner")
- for field, value in body.model_dump(exclude_unset=True).items():
+ updates = body.model_dump(exclude_unset=True)
+
+ # Re-validate GLSL if code changed
+ if "glsl_code" in updates:
+ validation = validate_glsl(updates["glsl_code"], shader.shader_type)
+ if not validation.valid:
+ raise HTTPException(
+ status_code=422,
+ detail={
+ "message": "GLSL validation failed",
+ "errors": validation.errors,
+ "warnings": validation.warnings,
+ }
+ )
+ # Re-render if code changed
+ shader.render_status = "pending"
+ from app.worker import celery_app
+ try:
+ celery_app.send_task("render_shader", args=[str(shader.id)])
+ except Exception:
+ shader.render_status = "ready"
+
+ for field, value in updates.items():
setattr(shader, field, value)
+ shader.updated_at = datetime.now(timezone.utc)
return shader
@@ -147,8 +216,17 @@ async def fork_shader(
tags=original.tags,
shader_type=original.shader_type,
forked_from=original.id,
+ style_metadata=original.style_metadata,
render_status="pending",
)
db.add(forked)
await db.flush()
+
+ # Enqueue render for the fork
+ from app.worker import celery_app
+ try:
+ celery_app.send_task("render_shader", args=[str(forked.id)])
+ except Exception:
+ forked.render_status = "ready"
+
return forked
diff --git a/services/api/app/routers/users.py b/services/api/app/routers/users.py
index 3ae256b..b43498d 100644
--- a/services/api/app/routers/users.py
+++ b/services/api/app/routers/users.py
@@ -6,8 +6,9 @@ from sqlalchemy import select
from app.database import get_db
from app.models import User
-from app.schemas import UserPublic, UserMe
-from app.middleware.auth import get_current_user
+from app.schemas import UserPublic, UserMe, UserUpdate, ByokKeysUpdate
+from app.middleware.auth import get_current_user, require_tier
+from app.services.byok import encrypt_key, get_stored_providers
router = APIRouter()
@@ -28,14 +29,59 @@ async def get_me(user: User = Depends(get_current_user)):
@router.put("/me", response_model=UserMe)
async def update_me(
+ body: UserUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
- """Update user settings. (Expanded in Track B)"""
- # TODO: Accept settings updates (username, email, etc.)
+ updates = body.model_dump(exclude_unset=True)
+ if not updates:
+ return user
+
+ # Check uniqueness for username/email changes
+ if "username" in updates and updates["username"] != user.username:
+ existing = await db.execute(
+ select(User).where(User.username == updates["username"])
+ )
+ if existing.scalar_one_or_none():
+ raise HTTPException(status_code=409, detail="Username already taken")
+
+ if "email" in updates and updates["email"] != user.email:
+ existing = await db.execute(
+ select(User).where(User.email == updates["email"])
+ )
+ if existing.scalar_one_or_none():
+ raise HTTPException(status_code=409, detail="Email already taken")
+
+ for field, value in updates.items():
+ setattr(user, field, value)
+
return user
+@router.put("/me/ai-keys")
+async def update_byok_keys(
+ body: ByokKeysUpdate,
+ db: AsyncSession = Depends(get_db),
+ user: User = Depends(require_tier("pro", "studio")),
+):
+ """Store encrypted BYOK API keys for AI providers."""
+ from app.services.byok import save_user_keys
+
+ await save_user_keys(db, user, body)
+ providers = await get_stored_providers(db, user)
+ return {"status": "ok", "configured_providers": providers}
+
+
+@router.get("/me/ai-keys")
+async def get_byok_keys(
+ db: AsyncSession = Depends(get_db),
+ user: User = Depends(require_tier("pro", "studio")),
+):
+ """List which providers have BYOK keys configured (never returns actual keys)."""
+ providers = await get_stored_providers(db, user)
+ return {"configured_providers": providers}
+
+
# ── Creator Economy Stubs (501) ─────────────────────────────
@router.get("/dashboard")
diff --git a/services/api/app/schemas/schemas.py b/services/api/app/schemas/schemas.py
index 628f9f3..e2de2bb 100644
--- a/services/api/app/schemas/schemas.py
+++ b/services/api/app/schemas/schemas.py
@@ -47,6 +47,18 @@ class UserMe(UserPublic):
last_active_at: Optional[datetime] = None
+class UserUpdate(BaseModel):
+ username: Optional[str] = Field(None, min_length=3, max_length=30, pattern=r"^[a-zA-Z0-9_-]+$")
+ email: Optional[EmailStr] = None
+
+
+class ByokKeysUpdate(BaseModel):
+ """Bring Your Own Key — encrypted API keys for AI providers."""
+ anthropic_key: Optional[str] = Field(None, description="Anthropic API key")
+ openai_key: Optional[str] = Field(None, description="OpenAI API key")
+ ollama_endpoint: Optional[str] = Field(None, description="Ollama endpoint URL")
+
+
# ════════════════════════════════════════════════════════════
# SHADERS
# ════════════════════════════════════════════════════════════
diff --git a/services/api/app/services/byok.py b/services/api/app/services/byok.py
new file mode 100644
index 0000000..ca1e13a
--- /dev/null
+++ b/services/api/app/services/byok.py
@@ -0,0 +1,105 @@
+"""BYOK (Bring Your Own Key) encryption service.
+
+Encrypts user API keys at rest using AES-256-GCM with a key derived from
+the user's ID + the server master key. Keys are only decrypted in the
+worker context when a generation job runs.
+"""
+
+import os
+import base64
+import hashlib
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, delete
+
+from app.config import get_settings
+
+
+# Keys stored as JSON in user metadata — simple approach for now.
+# Could be a separate table if key management gets complex.
+
+PROVIDERS = ("anthropic", "openai", "ollama")
+
+
+def _derive_key(user_id: str) -> bytes:
+ """Derive a per-user AES-256 key from master key + user ID."""
+ settings = get_settings()
+ master = settings.byok_master_key.encode()
+ return hashlib.pbkdf2_hmac("sha256", master, user_id.encode(), 100_000)
+
+
+def encrypt_key(user_id: str, plaintext: str) -> str:
+ """Encrypt an API key. Returns base64-encoded nonce+ciphertext."""
+ key = _derive_key(user_id)
+ aesgcm = AESGCM(key)
+ nonce = os.urandom(12)
+ ct = aesgcm.encrypt(nonce, plaintext.encode(), None)
+ return base64.b64encode(nonce + ct).decode()
+
+
+def decrypt_key(user_id: str, encrypted: str) -> str:
+ """Decrypt an API key from base64-encoded nonce+ciphertext."""
+ key = _derive_key(user_id)
+ raw = base64.b64decode(encrypted)
+ nonce = raw[:12]
+ ct = raw[12:]
+ aesgcm = AESGCM(key)
+ return aesgcm.decrypt(nonce, ct, None).decode()
+
+
+async def save_user_keys(db: AsyncSession, user, body) -> None:
+ """Save encrypted BYOK keys to the user's metadata.
+
+ Stores as a JSONB field on the user record. Each provider key is
+ individually encrypted so compromising one doesn't expose others.
+ """
+ from app.models import User
+
+ existing_meta = user.style_metadata if hasattr(user, 'byok_keys') else {}
+ # We store byok data in a dedicated pattern — not in style_metadata
+ # For now using a simple approach: store in a known Redis key
+ # In production this should be a separate encrypted column or table
+
+ from app.redis import get_redis
+ redis = await get_redis()
+ user_id_str = str(user.id)
+
+ if body.anthropic_key is not None:
+ if body.anthropic_key == "":
+ await redis.hdel(f"byok:{user_id_str}", "anthropic")
+ else:
+ encrypted = encrypt_key(user_id_str, body.anthropic_key)
+ await redis.hset(f"byok:{user_id_str}", "anthropic", encrypted)
+
+ if body.openai_key is not None:
+ if body.openai_key == "":
+ await redis.hdel(f"byok:{user_id_str}", "openai")
+ else:
+ encrypted = encrypt_key(user_id_str, body.openai_key)
+ await redis.hset(f"byok:{user_id_str}", "openai", encrypted)
+
+ if body.ollama_endpoint is not None:
+ if body.ollama_endpoint == "":
+ await redis.hdel(f"byok:{user_id_str}", "ollama")
+ else:
+ encrypted = encrypt_key(user_id_str, body.ollama_endpoint)
+ await redis.hset(f"byok:{user_id_str}", "ollama", encrypted)
+
+
+async def get_stored_providers(db: AsyncSession, user) -> list[str]:
+ """Return list of provider names that have BYOK keys configured."""
+ from app.redis import get_redis
+ redis = await get_redis()
+ user_id_str = str(user.id)
+ keys = await redis.hkeys(f"byok:{user_id_str}")
+ return [k for k in keys if k in PROVIDERS]
+
+
+async def get_decrypted_key(user_id: str, provider: str) -> str | None:
+ """Decrypt and return a user's BYOK key for a provider. Worker-context only."""
+ from app.redis import get_redis
+ redis = await get_redis()
+ encrypted = await redis.hget(f"byok:{user_id}", provider)
+ if not encrypted:
+ return None
+ return decrypt_key(user_id, encrypted)
diff --git a/services/api/app/services/glsl_validator.py b/services/api/app/services/glsl_validator.py
new file mode 100644
index 0000000..0e96d59
--- /dev/null
+++ b/services/api/app/services/glsl_validator.py
@@ -0,0 +1,123 @@
+"""GLSL Validator — validates shader code before rendering.
+
+Uses basic static analysis. In production, this would shell out to
+glslangValidator for full Khronos reference compilation. For now,
+performs structural checks that catch the most common issues.
+"""
+
+import re
+from dataclasses import dataclass
+
+
+@dataclass
+class ValidationResult:
+ valid: bool
+ errors: list[str]
+ warnings: list[str]
+
+
+# Extensions that are banned (GPU-specific, compute shaders, etc.)
+BANNED_EXTENSIONS = {
+ "GL_ARB_compute_shader",
+ "GL_NV_gpu_shader5",
+ "GL_NV_shader_atomic_float",
+ "GL_NV_shader_atomic_int64",
+ "GL_EXT_shader_image_load_store",
+}
+
+# Patterns that suggest infinite loops or excessive iteration
+DANGEROUS_PATTERNS = [
+ (r"for\s*\(\s*;\s*;\s*\)", "Infinite for-loop detected"),
+ (r"while\s*\(\s*true\s*\)", "Infinite while-loop detected"),
+ (r"while\s*\(\s*1\s*\)", "Infinite while-loop detected"),
+]
+
+# Maximum allowed loop iterations (heuristic check)
+MAX_LOOP_ITERATIONS = 1024
+
+
+def validate_glsl(code: str, shader_type: str = "2d") -> ValidationResult:
+ """
+ Validate GLSL fragment shader code.
+
+ Checks:
+ 1. Required entry point exists (mainImage or main)
+ 2. Output writes to fragColor
+ 3. No banned extensions
+ 4. No obvious infinite loops
+ 5. Reasonable code length
+ """
+ errors = []
+ warnings = []
+
+ # Basic sanity
+ if not code or len(code.strip()) < 20:
+ errors.append("Shader code is too short to be valid")
+ return ValidationResult(valid=False, errors=errors, warnings=warnings)
+
+ if len(code) > 100_000:
+ errors.append("Shader code exceeds 100KB limit")
+ return ValidationResult(valid=False, errors=errors, warnings=warnings)
+
+ # Must have mainImage entry point (Shadertoy format)
+ has_main_image = bool(re.search(
+ r"void\s+mainImage\s*\(\s*out\s+vec4\s+\w+\s*,\s*in\s+vec2\s+\w+\s*\)",
+ code
+ ))
+ has_main = bool(re.search(r"void\s+main\s*\(\s*\)", code))
+
+ if not has_main_image and not has_main:
+ errors.append(
+ "Missing entry point: expected 'void mainImage(out vec4 fragColor, in vec2 fragCoord)' "
+ "or 'void main()'"
+ )
+
+ # Check for fragColor output
+ if has_main_image and "fragColor" not in code and "fragcolour" not in code.lower():
+ # The output param could be named anything, but fragColor is conventional
+ # Only warn if mainImage exists and the first param name isn't used
+ main_match = re.search(
+ r"void\s+mainImage\s*\(\s*out\s+vec4\s+(\w+)",
+ code
+ )
+ if main_match:
+ out_name = main_match.group(1)
+ if out_name not in code.split("mainImage")[1]:
+ warnings.append(f"Output parameter '{out_name}' may not be written to")
+
+ # Check for banned extensions
+ for ext in BANNED_EXTENSIONS:
+ if ext in code:
+ errors.append(f"Banned extension: {ext}")
+
+ # Check for dangerous patterns
+ for pattern, message in DANGEROUS_PATTERNS:
+ if re.search(pattern, code):
+ errors.append(message)
+
+ # Check for unreasonably large loop bounds
+ for match in re.finditer(r"for\s*\([^;]*;\s*\w+\s*<\s*(\d+)", code):
+ bound = int(match.group(1))
+ if bound > MAX_LOOP_ITERATIONS:
+ warnings.append(
+ f"Loop with {bound} iterations may be too expensive for real-time rendering "
+ f"(recommended max: {MAX_LOOP_ITERATIONS})"
+ )
+
+ # Check #extension directives
+ for match in re.finditer(r"#extension\s+(\w+)", code):
+ ext_name = match.group(1)
+ if ext_name in BANNED_EXTENSIONS:
+ errors.append(f"Banned extension directive: #extension {ext_name}")
+
+ # Balanced braces check
+ open_braces = code.count("{")
+ close_braces = code.count("}")
+ if open_braces != close_braces:
+ errors.append(f"Unbalanced braces: {open_braces} opening vs {close_braces} closing")
+
+ return ValidationResult(
+ valid=len(errors) == 0,
+ errors=errors,
+ warnings=warnings,
+ )
diff --git a/services/api/app/services/renderer_client.py b/services/api/app/services/renderer_client.py
new file mode 100644
index 0000000..90ef411
--- /dev/null
+++ b/services/api/app/services/renderer_client.py
@@ -0,0 +1,73 @@
+"""Renderer client — communicates with the headless Chromium renderer service."""
+
+import httpx
+from dataclasses import dataclass
+from typing import Optional
+
+from app.config import get_settings
+
+
+@dataclass
+class RenderResult:
+ success: bool
+ thumbnail_url: Optional[str] = None
+ preview_url: Optional[str] = None
+ duration_ms: Optional[int] = None
+ error: Optional[str] = None
+
+
+async def render_shader(
+ glsl_code: str,
+ shader_id: str,
+ duration: int = 5,
+ width: int = 640,
+ height: int = 360,
+ fps: int = 30,
+) -> RenderResult:
+ """
+ Submit GLSL code to the renderer service for thumbnail + preview generation.
+
+ Args:
+ glsl_code: Complete GLSL fragment shader
+ shader_id: UUID for organizing output files
+ duration: Seconds to render
+ width: Output width in pixels
+ height: Output height in pixels
+ fps: Frames per second for video preview
+ """
+ settings = get_settings()
+
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ resp = await client.post(
+ f"{settings.renderer_url}/render",
+ json={
+ "glsl": glsl_code,
+ "shader_id": shader_id,
+ "duration": duration,
+ "width": width,
+ "height": height,
+ "fps": fps,
+ },
+ )
+
+ if resp.status_code == 200:
+ data = resp.json()
+ return RenderResult(
+ success=True,
+ thumbnail_url=data.get("thumbnail_url"),
+ preview_url=data.get("preview_url"),
+ duration_ms=data.get("duration_ms"),
+ )
+ else:
+ data = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
+ return RenderResult(
+ success=False,
+ error=data.get("error", f"Renderer returned status {resp.status_code}"),
+ )
+ except httpx.TimeoutException:
+ return RenderResult(success=False, error="Renderer timed out after 30s")
+ except httpx.ConnectError:
+ return RenderResult(success=False, error="Could not connect to renderer service")
+ except Exception as e:
+ return RenderResult(success=False, error=f"Renderer error: {str(e)}")
diff --git a/services/api/pyproject.toml b/services/api/pyproject.toml
index b6e761d..3c7f562 100644
--- a/services/api/pyproject.toml
+++ b/services/api/pyproject.toml
@@ -13,11 +13,13 @@ dependencies = [
"alembic>=1.14.0",
"pydantic>=2.10.0",
"pydantic-settings>=2.7.0",
+ "email-validator>=2.2.0",
"pgvector>=0.3.6",
"redis>=5.2.0",
"celery[redis]>=5.4.0",
"passlib[bcrypt]>=1.7.4",
"python-jose[cryptography]>=3.3.0",
+ "cryptography>=43.0.0",
"httpx>=0.28.0",
"python-multipart>=0.0.12",
"stripe>=11.0.0",
diff --git a/services/frontend/index.html b/services/frontend/index.html
new file mode 100644
index 0000000..ae12fc6
--- /dev/null
+++ b/services/frontend/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+ Fractafrag
+
+
+
+
+
+
diff --git a/services/frontend/postcss.config.js b/services/frontend/postcss.config.js
new file mode 100644
index 0000000..2aa7205
--- /dev/null
+++ b/services/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/services/frontend/public/fracta.svg b/services/frontend/public/fracta.svg
new file mode 100644
index 0000000..022b34e
--- /dev/null
+++ b/services/frontend/public/fracta.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+ ff
+
diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx
new file mode 100644
index 0000000..83871a7
--- /dev/null
+++ b/services/frontend/src/App.tsx
@@ -0,0 +1,34 @@
+import { Routes, Route } from 'react-router-dom';
+import Layout from './components/Layout';
+import Feed from './pages/Feed';
+import Explore from './pages/Explore';
+import ShaderDetail from './pages/ShaderDetail';
+import Editor from './pages/Editor';
+import Generate from './pages/Generate';
+import Bounties from './pages/Bounties';
+import BountyDetail from './pages/BountyDetail';
+import Profile from './pages/Profile';
+import Settings from './pages/Settings';
+import Login from './pages/Login';
+import Register from './pages/Register';
+
+export default function App() {
+ return (
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ } />
+ } />
+
+ );
+}
diff --git a/services/frontend/src/components/Layout.tsx b/services/frontend/src/components/Layout.tsx
new file mode 100644
index 0000000..6c673d6
--- /dev/null
+++ b/services/frontend/src/components/Layout.tsx
@@ -0,0 +1,13 @@
+import { Outlet } from 'react-router-dom';
+import Navbar from './Navbar';
+
+export default function Layout() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/services/frontend/src/components/Navbar.tsx b/services/frontend/src/components/Navbar.tsx
new file mode 100644
index 0000000..9d3c105
--- /dev/null
+++ b/services/frontend/src/components/Navbar.tsx
@@ -0,0 +1,65 @@
+import { Link, useNavigate } from 'react-router-dom';
+import { useAuthStore } from '@/stores/auth';
+import api from '@/lib/api';
+
+export default function Navbar() {
+ const { user, isAuthenticated, logout } = useAuthStore();
+ const navigate = useNavigate();
+
+ const handleLogout = async () => {
+ try {
+ await api.post('/auth/logout');
+ } catch {
+ // Best-effort
+ }
+ logout();
+ navigate('/');
+ };
+
+ return (
+
+
+ {/* Logo + Nav */}
+
+
+
⬡
+
+ fractafrag
+
+
+
+
+ Feed
+ Explore
+ Editor
+ Bounties
+ Generate
+
+
+
+ {/* Auth */}
+
+ {isAuthenticated() && user ? (
+ <>
+
+ {user.username}
+
+ Settings
+
+ Logout
+
+ >
+ ) : (
+ <>
+ Login
+ Sign Up
+ >
+ )}
+
+
+
+ );
+}
diff --git a/services/frontend/src/components/ShaderCanvas.tsx b/services/frontend/src/components/ShaderCanvas.tsx
new file mode 100644
index 0000000..836df40
--- /dev/null
+++ b/services/frontend/src/components/ShaderCanvas.tsx
@@ -0,0 +1,228 @@
+/**
+ * ShaderCanvas — Core WebGL component for rendering GLSL shaders.
+ *
+ * Shadertoy-compatible: accepts mainImage(out vec4 fragColor, in vec2 fragCoord)
+ * Injects uniforms: iTime, iResolution, iMouse
+ *
+ * Used in the editor (full-size), feed items (thumbnail), and shader detail page.
+ */
+
+import { useRef, useEffect, useCallback } from 'react';
+
+interface ShaderCanvasProps {
+ code: string;
+ width?: number;
+ height?: number;
+ className?: string;
+ animate?: boolean;
+ onError?: (error: string) => void;
+ onCompileSuccess?: () => void;
+}
+
+const VERTEX_SHADER = `#version 300 es
+in vec4 a_position;
+void main() { gl_Position = a_position; }
+`;
+
+function buildFragmentShader(userCode: string): string {
+ const prefix = `#version 300 es
+precision highp float;
+uniform float iTime;
+uniform vec3 iResolution;
+uniform vec4 iMouse;
+out vec4 outColor;
+`;
+
+ if (userCode.includes('mainImage')) {
+ return prefix + userCode + `
+void main() {
+ vec4 col;
+ mainImage(col, gl_FragCoord.xy);
+ outColor = col;
+}`;
+ }
+
+ // If user has void main(), replace gl_FragColor with outColor
+ return prefix + userCode.replace(/gl_FragColor/g, 'outColor');
+}
+
+function createShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader | null {
+ const shader = gl.createShader(type);
+ if (!shader) return null;
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+ const info = gl.getShaderInfoLog(shader) || 'Unknown error';
+ gl.deleteShader(shader);
+ throw new Error(info);
+ }
+ return shader;
+}
+
+export default function ShaderCanvas({
+ code,
+ width,
+ height,
+ className = '',
+ animate = true,
+ onError,
+ onCompileSuccess,
+}: ShaderCanvasProps) {
+ const canvasRef = useRef(null);
+ const glRef = useRef(null);
+ const programRef = useRef(null);
+ const animRef = useRef(0);
+ const startTimeRef = useRef(0);
+ const mouseRef = useRef<[number, number, number, number]>([0, 0, 0, 0]);
+
+ const cleanup = useCallback(() => {
+ if (animRef.current) {
+ cancelAnimationFrame(animRef.current);
+ animRef.current = 0;
+ }
+ const gl = glRef.current;
+ if (gl && programRef.current) {
+ gl.deleteProgram(programRef.current);
+ programRef.current = null;
+ }
+ }, []);
+
+ const compile = useCallback(() => {
+ const canvas = canvasRef.current;
+ if (!canvas || !code.trim()) return;
+
+ let gl = glRef.current;
+ if (!gl) {
+ gl = canvas.getContext('webgl2', {
+ antialias: false,
+ preserveDrawingBuffer: true,
+ });
+ if (!gl) {
+ onError?.('WebGL2 not supported');
+ return;
+ }
+ glRef.current = gl;
+ }
+
+ // Clean previous program
+ if (programRef.current) {
+ gl.deleteProgram(programRef.current);
+ programRef.current = null;
+ }
+
+ try {
+ const vs = createShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
+ const fs = createShader(gl, gl.FRAGMENT_SHADER, buildFragmentShader(code));
+ if (!vs || !fs) throw new Error('Failed to create shaders');
+
+ const program = gl.createProgram()!;
+ gl.attachShader(program, vs);
+ gl.attachShader(program, fs);
+ gl.linkProgram(program);
+
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+ const err = gl.getProgramInfoLog(program) || 'Link failed';
+ gl.deleteProgram(program);
+ throw new Error(err);
+ }
+
+ // Clean up individual shaders
+ gl.deleteShader(vs);
+ gl.deleteShader(fs);
+
+ programRef.current = program;
+ gl.useProgram(program);
+
+ // Set up 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 posLoc = gl.getAttribLocation(program, 'a_position');
+ gl.enableVertexAttribArray(posLoc);
+ gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
+
+ startTimeRef.current = performance.now();
+ onCompileSuccess?.();
+ onError?.('');
+
+ // Start render loop
+ const render = () => {
+ if (!programRef.current || !glRef.current) return;
+ const gl = glRef.current;
+ const w = canvas.width;
+ const h = canvas.height;
+ const t = (performance.now() - startTimeRef.current) / 1000;
+
+ gl.viewport(0, 0, w, h);
+
+ const uTime = gl.getUniformLocation(programRef.current, 'iTime');
+ const uRes = gl.getUniformLocation(programRef.current, 'iResolution');
+ const uMouse = gl.getUniformLocation(programRef.current, 'iMouse');
+
+ if (uTime) gl.uniform1f(uTime, t);
+ if (uRes) gl.uniform3f(uRes, w, h, 1.0);
+ if (uMouse) gl.uniform4f(uMouse, ...mouseRef.current);
+
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
+
+ if (animate) {
+ animRef.current = requestAnimationFrame(render);
+ }
+ };
+
+ if (animRef.current) cancelAnimationFrame(animRef.current);
+ render();
+
+ } catch (e: any) {
+ onError?.(e.message || 'Compilation failed');
+ }
+ }, [code, animate, onError, onCompileSuccess]);
+
+ useEffect(() => {
+ compile();
+ return cleanup;
+ }, [compile, cleanup]);
+
+ // Resize handling
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const observer = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ const w = entry.contentRect.width;
+ const h = entry.contentRect.height;
+ if (w > 0 && h > 0) {
+ canvas.width = w * (window.devicePixelRatio > 1 ? 1.5 : 1);
+ canvas.height = h * (window.devicePixelRatio > 1 ? 1.5 : 1);
+ }
+ }
+ });
+
+ observer.observe(canvas);
+ return () => observer.disconnect();
+ }, []);
+
+ // Mouse tracking
+ const handleMouseMove = (e: React.MouseEvent) => {
+ const rect = canvasRef.current?.getBoundingClientRect();
+ if (!rect) return;
+ mouseRef.current = [
+ e.clientX - rect.left,
+ rect.height - (e.clientY - rect.top),
+ mouseRef.current[2],
+ mouseRef.current[3],
+ ];
+ };
+
+ return (
+
+ );
+}
diff --git a/services/frontend/src/index.css b/services/frontend/src/index.css
new file mode 100644
index 0000000..4a77028
--- /dev/null
+++ b/services/frontend/src/index.css
@@ -0,0 +1,62 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ color-scheme: dark;
+ }
+
+ body {
+ @apply bg-surface-0 text-gray-100;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+
+ /* Custom scrollbar */
+ ::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+ }
+ ::-webkit-scrollbar-track {
+ @apply bg-surface-1;
+ }
+ ::-webkit-scrollbar-thumb {
+ @apply bg-surface-4 rounded-full;
+ }
+ ::-webkit-scrollbar-thumb:hover {
+ @apply bg-fracta-600;
+ }
+}
+
+@layer components {
+ .btn {
+ @apply inline-flex items-center justify-center gap-2 px-4 py-2
+ font-medium rounded-lg transition-all duration-150
+ focus:outline-none focus:ring-2 focus:ring-fracta-500/50
+ disabled:opacity-50 disabled:cursor-not-allowed;
+ }
+ .btn-primary {
+ @apply btn bg-fracta-600 hover:bg-fracta-500 text-white;
+ }
+ .btn-secondary {
+ @apply btn bg-surface-3 hover:bg-surface-4 text-gray-200;
+ }
+ .btn-ghost {
+ @apply btn bg-transparent hover:bg-surface-3 text-gray-300;
+ }
+ .btn-danger {
+ @apply btn bg-red-600/20 hover:bg-red-600/30 text-red-400 border border-red-600/30;
+ }
+
+ .input {
+ @apply w-full px-3 py-2 bg-surface-2 border border-surface-4
+ rounded-lg text-gray-100 placeholder-gray-500
+ focus:outline-none focus:border-fracta-500 focus:ring-1 focus:ring-fracta-500/30
+ transition-colors;
+ }
+
+ .card {
+ @apply bg-surface-1 border border-surface-3 rounded-xl overflow-hidden;
+ }
+}
diff --git a/services/frontend/src/lib/api.ts b/services/frontend/src/lib/api.ts
new file mode 100644
index 0000000..befb98a
--- /dev/null
+++ b/services/frontend/src/lib/api.ts
@@ -0,0 +1,54 @@
+/**
+ * API client — Axios instance with JWT auth and automatic refresh.
+ */
+
+import axios from 'axios';
+import { useAuthStore } from '@/stores/auth';
+
+const API_BASE = import.meta.env.VITE_API_URL || '/api';
+
+const api = axios.create({
+ baseURL: `${API_BASE}/v1`,
+ headers: { 'Content-Type': 'application/json' },
+ withCredentials: true, // Send refresh token cookie
+});
+
+// Request interceptor: attach access token
+api.interceptors.request.use((config) => {
+ const token = useAuthStore.getState().accessToken;
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+});
+
+// Response interceptor: auto-refresh on 401
+api.interceptors.response.use(
+ (response) => response,
+ async (error) => {
+ const original = error.config;
+
+ if (error.response?.status === 401 && !original._retry) {
+ original._retry = true;
+
+ try {
+ const { data } = await axios.post(
+ `${API_BASE}/v1/auth/refresh`,
+ {},
+ { withCredentials: true },
+ );
+ useAuthStore.getState().setAccessToken(data.access_token);
+ original.headers.Authorization = `Bearer ${data.access_token}`;
+ return api(original);
+ } catch {
+ useAuthStore.getState().logout();
+ window.location.href = '/login';
+ return Promise.reject(error);
+ }
+ }
+
+ return Promise.reject(error);
+ },
+);
+
+export default api;
diff --git a/services/frontend/src/main.tsx b/services/frontend/src/main.tsx
new file mode 100644
index 0000000..8a512d9
--- /dev/null
+++ b/services/frontend/src/main.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import App from './App';
+import './index.css';
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 30_000,
+ retry: 1,
+ refetchOnWindowFocus: false,
+ },
+ },
+});
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+
+
+
+ ,
+);
diff --git a/services/frontend/src/pages/Bounties.tsx b/services/frontend/src/pages/Bounties.tsx
new file mode 100644
index 0000000..0ccc4c8
--- /dev/null
+++ b/services/frontend/src/pages/Bounties.tsx
@@ -0,0 +1,75 @@
+/**
+ * Bounties page — browse open desire queue.
+ */
+
+import { useQuery } from '@tanstack/react-query';
+import { Link } from 'react-router-dom';
+import api from '@/lib/api';
+
+export default function Bounties() {
+ const { data: desires = [], isLoading } = useQuery({
+ queryKey: ['desires'],
+ queryFn: async () => {
+ const { data } = await api.get('/desires', { params: { limit: 30 } });
+ return data;
+ },
+ });
+
+ return (
+
+
+
+
Desire Queue
+
+ What the community wants to see. Fulfill a desire to earn tips.
+
+
+
+
+ {isLoading ? (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ ) : desires.length > 0 ? (
+
+ {desires.map((desire: any) => (
+
+
+
+
{desire.prompt_text}
+
+
+ 🔥 Heat: {desire.heat_score.toFixed(1)}
+
+ {desire.tip_amount_cents > 0 && (
+
+ 💰 ${(desire.tip_amount_cents / 100).toFixed(2)} tip
+
+ )}
+ {new Date(desire.created_at).toLocaleDateString()}
+
+
+
+ {desire.status}
+
+
+
+ ))}
+
+ ) : (
+
+ No open desires yet. The queue is empty.
+
+ )}
+
+ );
+}
diff --git a/services/frontend/src/pages/BountyDetail.tsx b/services/frontend/src/pages/BountyDetail.tsx
new file mode 100644
index 0000000..daffc77
--- /dev/null
+++ b/services/frontend/src/pages/BountyDetail.tsx
@@ -0,0 +1,103 @@
+/**
+ * Bounty detail page — single desire with fulfillment option.
+ */
+
+import { useParams, Link } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import api from '@/lib/api';
+
+export default function BountyDetail() {
+ const { id } = useParams<{ id: string }>();
+
+ const { data: desire, isLoading } = useQuery({
+ queryKey: ['desire', id],
+ queryFn: async () => {
+ const { data } = await api.get(`/desires/${id}`);
+ return data;
+ },
+ enabled: !!id,
+ });
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!desire) {
+ return (
+
+ Desire not found
+
+ );
+ }
+
+ return (
+
+
+ ← Back to Bounties
+
+
+
+
+
+
{desire.prompt_text}
+
+ 🔥 Heat: {desire.heat_score.toFixed(1)}
+ {desire.tip_amount_cents > 0 && (
+
+ 💰 ${(desire.tip_amount_cents / 100).toFixed(2)} bounty
+
+ )}
+ {new Date(desire.created_at).toLocaleDateString()}
+
+
+
+ {desire.status}
+
+
+
+ {desire.style_hints && (
+
+
Style hints
+
+ {JSON.stringify(desire.style_hints, null, 2)}
+
+
+ )}
+
+ {desire.status === 'open' && (
+
+
+ Fulfill this Desire →
+
+
+ Write a shader that matches this description, then submit it as fulfillment.
+
+
+ )}
+
+ {desire.fulfilled_by_shader && (
+
+
Fulfilled by
+
+ View shader →
+
+
+ )}
+
+
+ );
+}
diff --git a/services/frontend/src/pages/Editor.tsx b/services/frontend/src/pages/Editor.tsx
new file mode 100644
index 0000000..73b6698
--- /dev/null
+++ b/services/frontend/src/pages/Editor.tsx
@@ -0,0 +1,232 @@
+/**
+ * Editor page — GLSL editor with live WebGL preview.
+ *
+ * Split pane: code editor (left), live preview (right).
+ * Uses a textarea for M1 (Monaco editor integration comes later).
+ */
+
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import api from '@/lib/api';
+import { useAuthStore } from '@/stores/auth';
+import ShaderCanvas from '@/components/ShaderCanvas';
+
+const DEFAULT_SHADER = `// Fractafrag — write your shader here
+// Shadertoy-compatible: mainImage(out vec4 fragColor, in vec2 fragCoord)
+// Available uniforms: iTime, iResolution, iMouse
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+ vec2 uv = fragCoord / iResolution.xy;
+ float t = iTime;
+
+ // Gradient with time-based animation
+ vec3 col = 0.5 + 0.5 * cos(t + uv.xyx + vec3(0, 2, 4));
+
+ // Add some structure
+ float d = length(uv - 0.5);
+ col *= 1.0 - smoothstep(0.0, 0.5, d);
+ col += 0.05;
+
+ fragColor = vec4(col, 1.0);
+}
+`;
+
+export default function Editor() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const { isAuthenticated, user } = useAuthStore();
+
+ const [code, setCode] = useState(DEFAULT_SHADER);
+ const [liveCode, setLiveCode] = useState(DEFAULT_SHADER);
+ const [title, setTitle] = useState('Untitled Shader');
+ const [description, setDescription] = useState('');
+ const [tags, setTags] = useState('');
+ const [shaderType, setShaderType] = useState('2d');
+ const [compileError, setCompileError] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [submitError, setSubmitError] = useState('');
+ const [showMeta, setShowMeta] = useState(false);
+
+ const debounceRef = useRef>();
+
+ // Load existing shader for forking
+ const { data: existingShader } = useQuery({
+ queryKey: ['shader', id],
+ queryFn: async () => {
+ const { data } = await api.get(`/shaders/${id}`);
+ return data;
+ },
+ enabled: !!id,
+ });
+
+ useEffect(() => {
+ if (existingShader) {
+ setCode(existingShader.glsl_code);
+ setLiveCode(existingShader.glsl_code);
+ setTitle(`Fork of ${existingShader.title}`);
+ setShaderType(existingShader.shader_type);
+ setTags(existingShader.tags?.join(', ') || '');
+ }
+ }, [existingShader]);
+
+ // Debounced live preview update
+ const handleCodeChange = useCallback((value: string) => {
+ setCode(value);
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => {
+ setLiveCode(value);
+ }, 400);
+ }, []);
+
+ const handleSubmit = async () => {
+ if (!isAuthenticated()) {
+ navigate('/login');
+ return;
+ }
+
+ setSubmitting(true);
+ setSubmitError('');
+
+ try {
+ const { data } = await api.post('/shaders', {
+ title,
+ description,
+ glsl_code: code,
+ tags: tags.split(',').map(t => t.trim()).filter(Boolean),
+ shader_type: shaderType,
+ });
+ navigate(`/shader/${data.id}`);
+ } catch (err: any) {
+ const detail = err.response?.data?.detail;
+ if (typeof detail === 'object' && detail.errors) {
+ setSubmitError(detail.errors.join('\n'));
+ } else {
+ setSubmitError(detail || 'Submission failed');
+ }
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+ {/* Toolbar */}
+
+
+ setTitle(e.target.value)}
+ className="bg-transparent text-lg font-medium text-gray-100 focus:outline-none
+ border-b border-transparent focus:border-fracta-500 transition-colors"
+ placeholder="Shader title..."
+ />
+ setShowMeta(!showMeta)}
+ className="btn-ghost text-xs py-1 px-2"
+ >
+ {showMeta ? 'Hide details' : 'Details'}
+
+
+
+
+ {compileError && (
+
+ ⚠ {compileError.split('\n')[0]}
+
+ )}
+
+ {submitting ? 'Publishing...' : 'Publish'}
+
+
+
+
+ {/* Metadata panel */}
+ {showMeta && (
+
+
+ Description
+ setDescription(e.target.value)}
+ className="input text-sm mt-1"
+ placeholder="What does this shader do?"
+ />
+
+
+ Tags (comma-separated)
+ setTags(e.target.value)}
+ className="input text-sm mt-1"
+ placeholder="fractal, noise, 3d"
+ />
+
+
+ Type
+ setShaderType(e.target.value)}
+ className="input text-sm mt-1"
+ >
+ 2D
+ 3D
+ Audio
+
+
+
+ )}
+
+ {/* Submit error */}
+ {submitError && (
+
+ {submitError}
+
+ )}
+
+ {/* Split pane: editor + preview */}
+
+ {/* Code editor */}
+
+
+ {/* Live preview */}
+
+
setCompileError(err)}
+ onCompileSuccess={() => setCompileError('')}
+ />
+ {!liveCode.trim() && (
+
+ Write some GLSL to see it rendered live
+
+ )}
+
+
+
+ );
+}
diff --git a/services/frontend/src/pages/Explore.tsx b/services/frontend/src/pages/Explore.tsx
new file mode 100644
index 0000000..776f196
--- /dev/null
+++ b/services/frontend/src/pages/Explore.tsx
@@ -0,0 +1,127 @@
+/**
+ * Explore page — browse shaders by tag, trending, new, top.
+ */
+
+import { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { Link, useSearchParams } from 'react-router-dom';
+import api from '@/lib/api';
+import ShaderCanvas from '@/components/ShaderCanvas';
+
+type SortOption = 'trending' | 'new' | 'top';
+
+export default function Explore() {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [sort, setSort] = useState((searchParams.get('sort') as SortOption) || 'trending');
+ const [query, setQuery] = useState(searchParams.get('q') || '');
+ const tagFilter = searchParams.get('tags')?.split(',').filter(Boolean) || [];
+
+ const { data: shaders = [], isLoading } = useQuery({
+ queryKey: ['explore', sort, query, tagFilter.join(',')],
+ queryFn: async () => {
+ const params: any = { sort, limit: 30 };
+ if (query) params.q = query;
+ if (tagFilter.length) params.tags = tagFilter;
+ const { data } = await api.get('/shaders', { params });
+ return data;
+ },
+ });
+
+ const handleSearch = (e: React.FormEvent) => {
+ e.preventDefault();
+ setSearchParams({ sort, q: query });
+ };
+
+ return (
+
+
+
Explore
+
+ {/* Sort tabs */}
+
+ {(['trending', 'new', 'top'] as SortOption[]).map((s) => (
+ setSort(s)}
+ className={`px-3 py-1 text-sm rounded-md transition-colors ${
+ sort === s ? 'bg-fracta-600 text-white' : 'text-gray-400 hover:text-gray-200'
+ }`}
+ >
+ {s.charAt(0).toUpperCase() + s.slice(1)}
+
+ ))}
+
+
+
+ {/* Search */}
+
+
+ {/* Tag filter pills */}
+ {tagFilter.length > 0 && (
+
+ {tagFilter.map((tag) => (
+
+ #{tag}
+ {
+ const newTags = tagFilter.filter(t => t !== tag);
+ setSearchParams(newTags.length ? { tags: newTags.join(',') } : {});
+ }}
+ className="hover:text-white"
+ >
+ ×
+
+
+ ))}
+
+ )}
+
+ {/* Grid */}
+ {isLoading ? (
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+ ))}
+
+ ) : shaders.length > 0 ? (
+
+ {shaders.map((shader: any) => (
+
+
+
+
+
+
+ {shader.title}
+
+
+ {shader.shader_type}
+ ·
+ {shader.view_count} views
+
+
+
+ ))}
+
+ ) : (
+
+ No shaders found. Try a different search or sort.
+
+ )}
+
+ );
+}
diff --git a/services/frontend/src/pages/Feed.tsx b/services/frontend/src/pages/Feed.tsx
new file mode 100644
index 0000000..3076458
--- /dev/null
+++ b/services/frontend/src/pages/Feed.tsx
@@ -0,0 +1,203 @@
+/**
+ * Feed page — infinite scroll of live-rendered shaders.
+ * Dwell time tracking via IntersectionObserver.
+ */
+
+import { useInfiniteQuery } from '@tanstack/react-query';
+import { useRef, useEffect, useCallback } from 'react';
+import { Link } from 'react-router-dom';
+import api from '@/lib/api';
+import { useAuthStore } from '@/stores/auth';
+import ShaderCanvas from '@/components/ShaderCanvas';
+
+interface Shader {
+ id: string;
+ title: string;
+ author_id: string | null;
+ glsl_code: string;
+ thumbnail_url: string | null;
+ tags: string[];
+ shader_type: string;
+ score: number;
+ view_count: number;
+ is_ai_generated: boolean;
+ style_metadata: any;
+ created_at: string;
+}
+
+function FeedCard({ shader }: { shader: Shader }) {
+ const cardRef = useRef(null);
+ const startTimeRef = useRef(null);
+ const { isAuthenticated } = useAuthStore();
+
+ // Dwell time tracking
+ useEffect(() => {
+ const el = cardRef.current;
+ if (!el) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (entry.isIntersecting) {
+ startTimeRef.current = Date.now();
+ } else if (startTimeRef.current) {
+ const dwell = (Date.now() - startTimeRef.current) / 1000;
+ if (dwell > 1) {
+ // Fire-and-forget dwell report
+ api.post('/feed/dwell', {
+ shader_id: shader.id,
+ dwell_secs: dwell,
+ replayed: false,
+ }).catch(() => {}); // best effort
+ }
+ startTimeRef.current = null;
+ }
+ }
+ },
+ { threshold: 0.5 },
+ );
+
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [shader.id]);
+
+ return (
+
+
+
+
+ {shader.is_ai_generated && (
+
+ AI
+
+ )}
+
+
+
+
+
+
+ {shader.title}
+
+
+
+
+ {shader.shader_type}
+ ·
+ {shader.view_count} views
+
+
+ {shader.tags.slice(0, 3).map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+
+ );
+}
+
+export default function Feed() {
+ const sentinelRef = useRef(null);
+
+ const {
+ data,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ isLoading,
+ error,
+ } = useInfiniteQuery({
+ queryKey: ['feed'],
+ queryFn: async ({ pageParam = 0 }) => {
+ const { data } = await api.get('/feed', { params: { offset: pageParam, limit: 20 } });
+ return data;
+ },
+ getNextPageParam: (lastPage, allPages) => {
+ if (lastPage.length < 20) return undefined;
+ return allPages.flat().length;
+ },
+ initialPageParam: 0,
+ });
+
+ // Infinite scroll trigger
+ useEffect(() => {
+ const sentinel = sentinelRef.current;
+ if (!sentinel) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ },
+ { rootMargin: '200px' },
+ );
+
+ observer.observe(sentinel);
+ return () => observer.disconnect();
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
+
+ const shaders = data?.pages.flat() ?? [];
+
+ return (
+
+
+
Your Feed
+
+ + New Shader
+
+
+
+ {isLoading && (
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {error && (
+
+ Failed to load feed. Please try again.
+
+ )}
+
+ {shaders.length > 0 && (
+
+ {shaders.map((shader: Shader) => (
+
+ ))}
+
+ )}
+
+ {shaders.length === 0 && !isLoading && (
+
+
No shaders yet
+
Be the first to create one
+
+ Open Editor
+
+
+ )}
+
+ {/* Infinite scroll sentinel */}
+
+ {isFetchingNextPage && (
+
Loading more...
+ )}
+
+ );
+}
diff --git a/services/frontend/src/pages/Generate.tsx b/services/frontend/src/pages/Generate.tsx
new file mode 100644
index 0000000..eeb6b7e
--- /dev/null
+++ b/services/frontend/src/pages/Generate.tsx
@@ -0,0 +1,106 @@
+/**
+ * AI Generation page — prompt-to-shader interface.
+ * Stub for M5 — shows UI with "coming soon" state.
+ */
+
+import { useState } from 'react';
+import { useAuthStore } from '@/stores/auth';
+import { Link } from 'react-router-dom';
+
+export default function Generate() {
+ const { isAuthenticated, user } = useAuthStore();
+ const [prompt, setPrompt] = useState('');
+
+ return (
+
+
+
AI Shader Generator
+
+ Describe what you want to see and let AI write the shader for you.
+
+
+
+
+ {/* Prompt input */}
+
+ What do you want to see?
+
+
+ {/* Style controls */}
+
+
+ Chaos Level
+
+
+
+ Color Temperature
+
+ Warm
+ Cool
+ Neutral
+ Monochrome
+
+
+
+ Motion Type
+
+ Fluid
+ Geometric
+ Pulsing
+ Static
+
+
+
+
+ {/* Generate button / status */}
+
+
+ ✨ Generate Shader
+
+
+ AI generation is coming in M5. For now, use the{' '}
+ editor{' '}
+ to write shaders manually.
+
+
+ {isAuthenticated() && user && (
+
+ Credits remaining: {user.ai_credits_remaining}
+
+ )}
+
+
+
+ {/* Teaser examples */}
+
+
Example prompts (coming soon)
+
+ {[
+ "Ragdoll physics but dark and slow",
+ "Underwater caustics with bioluminescent particles",
+ "Infinite fractal zoom through a crystal cathedral",
+ "VHS glitch art with neon pink scanlines",
+ ].map((example) => (
+ setPrompt(example)}
+ className="text-left p-3 bg-surface-2 hover:bg-surface-3 rounded-lg text-sm text-gray-400 transition-colors"
+ >
+ "{example}"
+
+ ))}
+
+
+
+ );
+}
diff --git a/services/frontend/src/pages/Login.tsx b/services/frontend/src/pages/Login.tsx
new file mode 100644
index 0000000..524eab3
--- /dev/null
+++ b/services/frontend/src/pages/Login.tsx
@@ -0,0 +1,97 @@
+import { useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import api from '@/lib/api';
+import { useAuthStore } from '@/stores/auth';
+
+export default function Login() {
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+ const navigate = useNavigate();
+ const { login } = useAuthStore();
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+
+ try {
+ const { data } = await api.post('/auth/login', {
+ email,
+ password,
+ turnstile_token: 'dev-bypass', // TODO: Turnstile widget
+ });
+
+ // Fetch user profile
+ const profileResp = await api.get('/me', {
+ headers: { Authorization: `Bearer ${data.access_token}` },
+ });
+
+ login(data.access_token, profileResp.data);
+ navigate('/');
+ } catch (err: any) {
+ setError(err.response?.data?.detail || 'Login failed');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ fractafrag
+
+
+
Welcome back
+
+
+
+
+
+ );
+}
diff --git a/services/frontend/src/pages/Profile.tsx b/services/frontend/src/pages/Profile.tsx
new file mode 100644
index 0000000..c971e03
--- /dev/null
+++ b/services/frontend/src/pages/Profile.tsx
@@ -0,0 +1,91 @@
+/**
+ * Profile page — user's shaders, stats.
+ */
+
+import { useParams } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { Link } from 'react-router-dom';
+import api from '@/lib/api';
+import ShaderCanvas from '@/components/ShaderCanvas';
+
+export default function Profile() {
+ const { username } = useParams<{ username: string }>();
+
+ const { data: profile, isLoading: loadingProfile } = useQuery({
+ queryKey: ['profile', username],
+ queryFn: async () => {
+ const { data } = await api.get(`/users/${username}`);
+ return data;
+ },
+ enabled: !!username,
+ });
+
+ const { data: shaders = [] } = useQuery({
+ queryKey: ['user-shaders', username],
+ queryFn: async () => {
+ // Use search to find shaders by this user
+ const { data } = await api.get('/shaders', { params: { limit: 50 } });
+ // Filter client-side for now — proper user-shader endpoint in future
+ return data.filter((s: any) => s.author_id === profile?.id);
+ },
+ enabled: !!profile?.id,
+ });
+
+ if (loadingProfile) {
+ return (
+ Loading...
+ );
+ }
+
+ if (!profile) {
+ return (
+ User not found
+ );
+ }
+
+ return (
+
+ {/* Profile header */}
+
+
+ {profile.username.charAt(0).toUpperCase()}
+
+
+
+ {profile.username}
+ {profile.is_verified_creator && (
+ ✓ Verified
+ )}
+
+
+ Joined {new Date(profile.created_at).toLocaleDateString()}
+ ·
+ {profile.subscription_tier} tier
+
+
+
+
+ {/* Shaders grid */}
+
Shaders ({shaders.length})
+
+ {shaders.length > 0 ? (
+
+ {shaders.map((shader: any) => (
+
+
+
+
+
+
+ {shader.title}
+
+
+
+ ))}
+
+ ) : (
+
No shaders yet
+ )}
+
+ );
+}
diff --git a/services/frontend/src/pages/Register.tsx b/services/frontend/src/pages/Register.tsx
new file mode 100644
index 0000000..3f61cd8
--- /dev/null
+++ b/services/frontend/src/pages/Register.tsx
@@ -0,0 +1,116 @@
+import { useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import api from '@/lib/api';
+import { useAuthStore } from '@/stores/auth';
+
+export default function Register() {
+ const [username, setUsername] = useState('');
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+ const navigate = useNavigate();
+ const { login } = useAuthStore();
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+
+ try {
+ const { data } = await api.post('/auth/register', {
+ username,
+ email,
+ password,
+ turnstile_token: 'dev-bypass', // TODO: Turnstile widget
+ });
+
+ const profileResp = await api.get('/me', {
+ headers: { Authorization: `Bearer ${data.access_token}` },
+ });
+
+ login(data.access_token, profileResp.data);
+ navigate('/');
+ } catch (err: any) {
+ setError(err.response?.data?.detail || 'Registration failed');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ fractafrag
+
+
+
Create your account
+
+
+
+
+
+ );
+}
diff --git a/services/frontend/src/pages/Settings.tsx b/services/frontend/src/pages/Settings.tsx
new file mode 100644
index 0000000..f5072b4
--- /dev/null
+++ b/services/frontend/src/pages/Settings.tsx
@@ -0,0 +1,188 @@
+/**
+ * Settings page — account, subscription, API keys.
+ */
+
+import { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useNavigate } from 'react-router-dom';
+import api from '@/lib/api';
+import { useAuthStore } from '@/stores/auth';
+
+export default function Settings() {
+ const { user, isAuthenticated } = useAuthStore();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+
+ const [newKeyName, setNewKeyName] = useState('');
+ const [createdKey, setCreatedKey] = useState(null);
+
+ if (!isAuthenticated() || !user) {
+ navigate('/login');
+ return null;
+ }
+
+ const { data: apiKeys = [] } = useQuery({
+ queryKey: ['api-keys'],
+ queryFn: async () => {
+ const { data } = await api.get('/me/api-keys');
+ return data;
+ },
+ });
+
+ const createKey = useMutation({
+ mutationFn: async (name: string) => {
+ const { data } = await api.post('/me/api-keys', { name });
+ return data;
+ },
+ onSuccess: (data) => {
+ setCreatedKey(data.full_key);
+ setNewKeyName('');
+ queryClient.invalidateQueries({ queryKey: ['api-keys'] });
+ },
+ });
+
+ const revokeKey = useMutation({
+ mutationFn: async (keyId: string) => {
+ await api.delete(`/me/api-keys/${keyId}`);
+ },
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['api-keys'] }),
+ });
+
+ return (
+
+
Settings
+
+ {/* Account info */}
+
+ Account
+
+
+ Username
+ {user.username}
+
+
+ Email
+ {user.email}
+
+
+ Subscription
+ {user.subscription_tier}
+
+
+ AI Credits
+ {user.ai_credits_remaining}
+
+
+
+
+ {/* API Keys */}
+
+ API Keys (MCP)
+
+ Connect AI tools like Claude Desktop to Fractafrag.
+
+
+ {/* New key created alert */}
+ {createdKey && (
+
+
+ Key created! Copy it now — it won't be shown again.
+
+
+ {createdKey}
+
+
{
+ navigator.clipboard.writeText(createdKey);
+ setCreatedKey(null);
+ }}
+ className="btn-secondary text-xs mt-2"
+ >
+ Copy & Dismiss
+
+
+ )}
+
+ {/* Existing keys */}
+ {apiKeys.length > 0 && (
+
+ {apiKeys.map((key: any) => (
+
+
+ {key.name}
+ {key.key_prefix}...
+ ({key.trust_tier})
+
+
revokeKey.mutate(key.id)}
+ className="btn-danger text-xs py-1 px-2"
+ >
+ Revoke
+
+
+ ))}
+
+ )}
+
+ {/* Create new key */}
+
+ setNewKeyName(e.target.value)}
+ className="input text-sm flex-1"
+ placeholder="Key name (e.g., Claude Desktop)"
+ />
+ newKeyName && createKey.mutate(newKeyName)}
+ disabled={!newKeyName || createKey.isPending}
+ className="btn-primary text-sm"
+ >
+ Create Key
+
+
+ {user.subscription_tier === 'free' && (
+
+ API key creation requires Pro or Studio subscription.
+
+ )}
+
+
+ {/* Subscription */}
+
+ Subscription
+
+ {[
+ { name: 'Free', price: '$0/mo', features: ['5 shaders/month', 'Browse & vote', 'Read-only API'] },
+ { name: 'Pro', price: '$12/mo', features: ['Unlimited shaders', '50 AI generations', 'BYOK support', 'MCP API access'] },
+ { name: 'Studio', price: '$39/mo', features: ['Everything in Pro', '200 AI generations', 'Trusted API tier', 'Priority support'] },
+ ].map((tier) => (
+
+
{tier.name}
+
{tier.price}
+
+ {tier.features.map((f) => (
+ ✓ {f}
+ ))}
+
+ {user.subscription_tier === tier.name.toLowerCase() ? (
+
Current plan
+ ) : (
+
+ {tier.name === 'Free' ? 'Downgrade' : 'Upgrade'}
+
+ )}
+
+ ))}
+
+
+
+ );
+}
diff --git a/services/frontend/src/pages/ShaderDetail.tsx b/services/frontend/src/pages/ShaderDetail.tsx
new file mode 100644
index 0000000..f64c924
--- /dev/null
+++ b/services/frontend/src/pages/ShaderDetail.tsx
@@ -0,0 +1,149 @@
+/**
+ * Shader detail page — full-screen view, code, vote controls.
+ */
+
+import { useState } from 'react';
+import { useParams, Link, useNavigate } from 'react-router-dom';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import api from '@/lib/api';
+import { useAuthStore } from '@/stores/auth';
+import ShaderCanvas from '@/components/ShaderCanvas';
+
+export default function ShaderDetail() {
+ const { id } = useParams<{ id: string }>();
+ const { isAuthenticated, user } = useAuthStore();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const [showCode, setShowCode] = useState(false);
+
+ const { data: shader, isLoading, error } = useQuery({
+ queryKey: ['shader', id],
+ queryFn: async () => {
+ const { data } = await api.get(`/shaders/${id}`);
+ return data;
+ },
+ enabled: !!id,
+ });
+
+ const voteMutation = useMutation({
+ mutationFn: async (value: number) => {
+ await api.post(`/shaders/${id}/vote`, { value });
+ },
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shader', id] }),
+ });
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error || !shader) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* Shader preview */}
+
+
+ {/* Info bar */}
+
+
+
{shader.title}
+ {shader.description && (
+
{shader.description}
+ )}
+
+ {shader.shader_type.toUpperCase()}
+ ·
+ {shader.view_count} views
+ ·
+ {new Date(shader.created_at).toLocaleDateString()}
+ {shader.is_ai_generated && (
+ <>
+ ·
+ AI Generated
+ >
+ )}
+
+
+
+ {/* Actions */}
+
+ isAuthenticated() ? voteMutation.mutate(1) : navigate('/login')}
+ className="btn-secondary text-sm"
+ >
+ ▲ Upvote
+
+ isAuthenticated() ? voteMutation.mutate(-1) : navigate('/login')}
+ className="btn-ghost text-sm"
+ >
+ ▼
+
+
+ Fork
+
+
+
+
+ {/* Tags */}
+ {shader.tags?.length > 0 && (
+
+ {shader.tags.map((tag: string) => (
+
+ #{tag}
+
+ ))}
+
+ )}
+
+ {/* Code toggle */}
+
+
setShowCode(!showCode)}
+ className="btn-secondary text-sm"
+ >
+ {showCode ? 'Hide Code' : 'View Source'}
+
+
+ {showCode && (
+
+
+ fragment.glsl
+ navigator.clipboard.writeText(shader.glsl_code)}
+ className="btn-ghost text-xs py-0.5 px-2"
+ >
+ Copy
+
+
+
+ {shader.glsl_code}
+
+
+ )}
+
+
+ );
+}
diff --git a/services/frontend/src/stores/auth.ts b/services/frontend/src/stores/auth.ts
new file mode 100644
index 0000000..b63e3c1
--- /dev/null
+++ b/services/frontend/src/stores/auth.ts
@@ -0,0 +1,50 @@
+/**
+ * Auth store — JWT token management via Zustand.
+ */
+
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+export interface User {
+ id: string;
+ username: string;
+ email: string;
+ role: string;
+ subscription_tier: string;
+ ai_credits_remaining: number;
+ trust_tier: string;
+ is_verified_creator: boolean;
+ created_at: string;
+}
+
+interface AuthState {
+ accessToken: string | null;
+ user: User | null;
+ setAccessToken: (token: string) => void;
+ setUser: (user: User) => void;
+ login: (token: string, user: User) => void;
+ logout: () => void;
+ isAuthenticated: () => boolean;
+}
+
+export const useAuthStore = create()(
+ persist(
+ (set, get) => ({
+ accessToken: null,
+ user: null,
+
+ setAccessToken: (token) => set({ accessToken: token }),
+ setUser: (user) => set({ user }),
+ login: (token, user) => set({ accessToken: token, user }),
+ logout: () => set({ accessToken: null, user: null }),
+ isAuthenticated: () => !!get().accessToken,
+ }),
+ {
+ name: 'fractafrag-auth',
+ partialize: (state) => ({
+ accessToken: state.accessToken,
+ user: state.user,
+ }),
+ },
+ ),
+);
diff --git a/services/frontend/src/vite-env.d.ts b/services/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..d6d7cab
--- /dev/null
+++ b/services/frontend/src/vite-env.d.ts
@@ -0,0 +1,10 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_API_URL: string;
+ readonly VITE_MCP_URL: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/services/frontend/tailwind.config.js b/services/frontend/tailwind.config.js
new file mode 100644
index 0000000..fe63cfa
--- /dev/null
+++ b/services/frontend/tailwind.config.js
@@ -0,0 +1,50 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
+ theme: {
+ extend: {
+ colors: {
+ fracta: {
+ 50: '#f0f0ff',
+ 100: '#e0e0ff',
+ 200: '#c4c0ff',
+ 300: '#9f94ff',
+ 400: '#7a60ff',
+ 500: '#5b30ff',
+ 600: '#4d10f0',
+ 700: '#4008cc',
+ 800: '#350aa5',
+ 900: '#2b0d80',
+ 950: '#1a0550',
+ },
+ surface: {
+ 0: '#0a0a0f',
+ 1: '#12121a',
+ 2: '#1a1a25',
+ 3: '#222230',
+ 4: '#2a2a3a',
+ },
+ },
+ fontFamily: {
+ sans: ['Inter', 'system-ui', 'sans-serif'],
+ mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
+ },
+ animation: {
+ 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
+ 'fade-in': 'fadeIn 0.3s ease-out',
+ 'slide-up': 'slideUp 0.3s ease-out',
+ },
+ keyframes: {
+ fadeIn: {
+ '0%': { opacity: '0' },
+ '100%': { opacity: '1' },
+ },
+ slideUp: {
+ '0%': { opacity: '0', transform: 'translateY(10px)' },
+ '100%': { opacity: '1', transform: 'translateY(0)' },
+ },
+ },
+ },
+ },
+ plugins: [],
+};
diff --git a/services/frontend/tsconfig.json b/services/frontend/tsconfig.json
new file mode 100644
index 0000000..ef3600f
--- /dev/null
+++ b/services/frontend/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/services/frontend/vite.config.ts b/services/frontend/vite.config.ts
new file mode 100644
index 0000000..34f6aaa
--- /dev/null
+++ b/services/frontend/vite.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import { resolve } from 'path';
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': resolve(__dirname, './src'),
+ },
+ },
+ server: {
+ host: '0.0.0.0',
+ port: 5173,
+ proxy: {
+ '/api': {
+ target: 'http://localhost:8000',
+ changeOrigin: true,
+ },
+ },
+ },
+});
diff --git a/services/renderer/server.js b/services/renderer/server.js
index 4ab5244..1716753 100644
--- a/services/renderer/server.js
+++ b/services/renderer/server.js
@@ -1,15 +1,17 @@
/**
* Fractafrag Renderer — Headless Chromium shader render service.
- *
+ *
* Accepts GLSL code via POST /render, renders in an isolated browser context,
- * returns thumbnail + preview video.
- *
- * Full implementation in Track C.
+ * 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 { writeFileSync, mkdirSync, existsSync } from 'fs';
-import path from 'path';
+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' }));
@@ -17,42 +19,242 @@ 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', (req, res) => {
- res.json({ status: 'ok', service: 'renderer' });
+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 (stub — Track C)
+// Render endpoint
app.post('/render', async (req, res) => {
- const { glsl, duration = 5, width = 640, height = 360, fps = 30 } = req.body;
+ 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' });
}
- // TODO: Track C implementation
- // 1. Launch Puppeteer page
- // 2. Inject GLSL into shader template HTML
- // 3. Capture frames for `duration` seconds
- // 4. Encode to WebM/MP4 + extract thumbnail
- // 5. Write to OUTPUT_DIR
- // 6. Return URLs
+ const renderId = shader_id || randomUUID();
+ const renderDir = join(OUTPUT_DIR, renderId);
+ mkdirSync(renderDir, { recursive: true });
- res.status(501).json({
- error: 'Renderer implementation coming in Track C',
- thumbnail_url: null,
- preview_url: null,
- });
+ 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));
});