Architecture — Shader versioning & draft system:
- New shader_versions table: immutable snapshots of every edit
- Shaders now have status: draft, published, archived
- current_version counter tracks version number
- Every create/update creates a ShaderVersion record
- Restore-from-version endpoint creates new version (never destructive)
- Drafts are private, only visible to author
- Forks start as drafts
- Free tier rate limit applies only to published shaders (drafts unlimited)
Architecture — Platform identity:
- System account 'fractafrag' (UUID 00000000-...-000001) created in init.sql
- is_system flag on users and shaders
- system_label field: 'fractafrag-curated', future: 'fractafrag-generated'
- Feed/explore can filter by is_system
- System shaders display distinctly from user/AI content
API changes:
- GET /shaders/mine — user workspace (drafts, published, archived)
- GET /shaders/{id}/versions — version history
- GET /shaders/{id}/versions/{n} — specific version
- POST /shaders/{id}/versions/{n}/restore — restore old version
- POST /shaders accepts status: 'draft' | 'published'
- PUT /shaders/{id} accepts change_note for version descriptions
- PUT status transitions: draft→published, published→archived, archived→published
Frontend — Editor improvements:
- Resizable split pane with drag handle (20-80% range, smooth col-resize cursor)
- Save Draft button (creates/updates as draft, no publish)
- Publish button (validates, publishes, redirects to shader page)
- Version badge shows current version number when editing existing
- Owner detection: editing own shader vs forking someone else's
- Saved status indicator ('Draft saved', 'Published')
Frontend — My Shaders workspace:
- /my-shaders route with status tabs (All, Draft, Published, Archived)
- Count badges per tab
- Status badges on shader cards (draft=yellow, published=green, archived=grey)
- Version badges (v1, v2, etc.)
- Quick actions: Edit, Publish, Archive, Restore, Delete per status
- Drafts link to editor, published link to detail page
Seed data — 200 fractafrag-curated shaders:
- 171 2D + 29 3D shaders
- 500 unique tags across all shaders
- All 200 titles are unique
- Covers: fractals (Mandelbrot, Julia sets), noise (fbm, Voronoi, Perlin),
raymarching (metaballs, terrain, torus knots, metall/glass),
effects (glitch, VHS, plasma, aurora, lightning, fireworks),
patterns (circuit, hex grid, stained glass, herringbone, moiré),
physics (wave interference, pendulum, caustics, gravity lens),
minimal (single shapes, gradients, dot grids),
nature (ink, watercolor, smoke, sand garden, coral, nebula),
color theory (RGB separation, CMY overlap, hue wheel),
domain warping (acid trip, lava rift, storm eye),
particles (fireflies, snow, ember, bubbles)
- Each shader has style_metadata (chaos_level, color_temperature, motion_type)
- Distributed creation times over 30 days for feed ranking variety
- Random initial scores for algorithm testing
- All authored by 'fractafrag' system account, is_system=true
- system_label='fractafrag-curated' for clear provenance
Schema:
- shader_versions table with (shader_id, version_number) unique constraint
- HNSW indexes on version lookup
- System account indexes
- Status-aware feed indexes
414 lines
15 KiB
Python
414 lines
15 KiB
Python
"""Shaders router — CRUD, versioning, drafts, 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
|
|
|
|
from app.database import get_db
|
|
from app.models import User, Shader, ShaderVersion
|
|
from app.schemas import ShaderCreate, ShaderUpdate, ShaderPublic, ShaderVersionPublic
|
|
from app.middleware.auth import get_current_user, get_optional_user
|
|
from app.services.glsl_validator import validate_glsl
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── Public list / search ──────────────────────────────────
|
|
|
|
@router.get("", response_model=list[ShaderPublic])
|
|
async def list_shaders(
|
|
q: str | None = Query(None, description="Search query"),
|
|
tags: list[str] | None = Query(None, description="Filter by tags"),
|
|
shader_type: str | None = Query(None, description="Filter by type: 2d, 3d, audio-reactive"),
|
|
sort: str = Query("trending", description="Sort: trending, new, top"),
|
|
is_system: bool | None = Query(None, description="Filter to system/platform shaders"),
|
|
limit: int = Query(20, ge=1, le=50),
|
|
offset: int = Query(0, ge=0),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
query = select(Shader).where(
|
|
Shader.is_public == True,
|
|
Shader.status == "published",
|
|
)
|
|
|
|
if q:
|
|
query = query.where(Shader.title.ilike(f"%{q}%"))
|
|
if tags:
|
|
query = query.where(Shader.tags.overlap(tags))
|
|
if shader_type:
|
|
query = query.where(Shader.shader_type == shader_type)
|
|
if is_system is not None:
|
|
query = query.where(Shader.is_system == is_system)
|
|
|
|
if sort == "new":
|
|
query = query.order_by(Shader.created_at.desc())
|
|
elif sort == "top":
|
|
query = query.order_by(Shader.score.desc())
|
|
else:
|
|
query = query.order_by(Shader.score.desc(), Shader.created_at.desc())
|
|
|
|
query = query.limit(limit).offset(offset)
|
|
result = await db.execute(query)
|
|
return result.scalars().all()
|
|
|
|
|
|
# ── My shaders (workspace) ───────────────────────────────
|
|
|
|
@router.get("/mine", response_model=list[ShaderPublic])
|
|
async def my_shaders(
|
|
status_filter: str | None = Query(None, alias="status", description="draft, published, archived"),
|
|
limit: int = Query(50, ge=1, le=100),
|
|
offset: int = Query(0, ge=0),
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
"""List the authenticated user's shaders — drafts, published, archived."""
|
|
query = select(Shader).where(Shader.author_id == user.id)
|
|
if status_filter:
|
|
query = query.where(Shader.status == status_filter)
|
|
query = query.order_by(Shader.updated_at.desc()).limit(limit).offset(offset)
|
|
result = await db.execute(query)
|
|
return result.scalars().all()
|
|
|
|
|
|
# ── Single shader ─────────────────────────────────────────
|
|
|
|
@router.get("/{shader_id}", response_model=ShaderPublic)
|
|
async def get_shader(
|
|
shader_id: UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User | None = Depends(get_optional_user),
|
|
):
|
|
result = await db.execute(select(Shader).where(Shader.id == shader_id))
|
|
shader = result.scalar_one_or_none()
|
|
if not shader:
|
|
raise HTTPException(status_code=404, detail="Shader not found")
|
|
|
|
# Drafts are only visible to their author
|
|
if shader.status == "draft" and (not user or user.id != shader.author_id):
|
|
raise HTTPException(status_code=404, detail="Shader not found")
|
|
if not shader.is_public and (not user or user.id != shader.author_id):
|
|
raise HTTPException(status_code=404, detail="Shader not found")
|
|
|
|
shader.view_count += 1
|
|
return shader
|
|
|
|
|
|
# ── Version history ───────────────────────────────────────
|
|
|
|
@router.get("/{shader_id}/versions", response_model=list[ShaderVersionPublic])
|
|
async def list_versions(
|
|
shader_id: UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User | None = Depends(get_optional_user),
|
|
):
|
|
"""Get the version history of a shader."""
|
|
shader = (await db.execute(select(Shader).where(Shader.id == shader_id))).scalar_one_or_none()
|
|
if not shader:
|
|
raise HTTPException(status_code=404, detail="Shader not found")
|
|
if shader.status == "draft" and (not user or user.id != shader.author_id):
|
|
raise HTTPException(status_code=404, detail="Shader not found")
|
|
|
|
result = await db.execute(
|
|
select(ShaderVersion)
|
|
.where(ShaderVersion.shader_id == shader_id)
|
|
.order_by(ShaderVersion.version_number.desc())
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.get("/{shader_id}/versions/{version_number}", response_model=ShaderVersionPublic)
|
|
async def get_version(
|
|
shader_id: UUID,
|
|
version_number: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(ShaderVersion).where(
|
|
ShaderVersion.shader_id == shader_id,
|
|
ShaderVersion.version_number == version_number,
|
|
)
|
|
)
|
|
version = result.scalar_one_or_none()
|
|
if not version:
|
|
raise HTTPException(status_code=404, detail="Version not found")
|
|
return version
|
|
|
|
|
|
# ── Create shader (draft or published) ───────────────────
|
|
|
|
@router.post("", response_model=ShaderPublic, status_code=status.HTTP_201_CREATED)
|
|
async def create_shader(
|
|
body: ShaderCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
# Rate limit published shaders for free tier (drafts are unlimited)
|
|
if body.status == "published" and 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.status == "published",
|
|
Shader.created_at >= month_start,
|
|
)
|
|
)
|
|
monthly_count = count_result.scalar()
|
|
if monthly_count >= 5:
|
|
raise HTTPException(status_code=429, detail="Free tier: 5 published shaders/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,
|
|
title=body.title,
|
|
description=body.description,
|
|
glsl_code=body.glsl_code,
|
|
tags=body.tags,
|
|
shader_type=body.shader_type,
|
|
is_public=body.is_public if body.status == "published" else False,
|
|
status=body.status,
|
|
style_metadata=body.style_metadata,
|
|
render_status="ready" if body.status == "draft" else "pending",
|
|
current_version=1,
|
|
)
|
|
db.add(shader)
|
|
await db.flush()
|
|
|
|
# Create version 1 snapshot
|
|
v1 = ShaderVersion(
|
|
shader_id=shader.id,
|
|
version_number=1,
|
|
glsl_code=body.glsl_code,
|
|
title=body.title,
|
|
description=body.description,
|
|
tags=body.tags,
|
|
style_metadata=body.style_metadata,
|
|
change_note="Initial version",
|
|
)
|
|
db.add(v1)
|
|
|
|
# Enqueue render for published shaders
|
|
if body.status == "published":
|
|
from app.worker import celery_app
|
|
try:
|
|
celery_app.send_task("render_shader", args=[str(shader.id)])
|
|
except Exception:
|
|
shader.render_status = "ready"
|
|
|
|
# Link to desire if fulfilling
|
|
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
|
|
|
|
|
|
# ── Update shader (creates new version) ──────────────────
|
|
|
|
@router.put("/{shader_id}", response_model=ShaderPublic)
|
|
async def update_shader(
|
|
shader_id: UUID,
|
|
body: ShaderUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
result = await db.execute(select(Shader).where(Shader.id == shader_id))
|
|
shader = result.scalar_one_or_none()
|
|
if not shader:
|
|
raise HTTPException(status_code=404, detail="Shader not found")
|
|
if shader.author_id != user.id and user.role != "admin":
|
|
raise HTTPException(status_code=403, detail="Not the shader owner")
|
|
|
|
updates = body.model_dump(exclude_unset=True)
|
|
change_note = updates.pop("change_note", None)
|
|
code_changed = "glsl_code" in updates
|
|
|
|
# Re-validate GLSL if code changed
|
|
if code_changed:
|
|
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,
|
|
})
|
|
|
|
# Apply updates
|
|
for field, value in updates.items():
|
|
setattr(shader, field, value)
|
|
|
|
# Create a new version snapshot if code or metadata changed
|
|
if code_changed or "title" in updates or "description" in updates or "tags" in updates:
|
|
shader.current_version += 1
|
|
new_version = ShaderVersion(
|
|
shader_id=shader.id,
|
|
version_number=shader.current_version,
|
|
glsl_code=shader.glsl_code,
|
|
title=shader.title,
|
|
description=shader.description,
|
|
tags=shader.tags,
|
|
style_metadata=shader.style_metadata,
|
|
change_note=change_note,
|
|
)
|
|
db.add(new_version)
|
|
|
|
# Re-render if code changed and shader is published
|
|
if code_changed and shader.status == "published":
|
|
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"
|
|
|
|
# If publishing a draft, ensure it's public and queue render
|
|
if "status" in updates and updates["status"] == "published" and shader.render_status != "ready":
|
|
shader.is_public = True
|
|
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"
|
|
|
|
shader.updated_at = datetime.now(timezone.utc)
|
|
return shader
|
|
|
|
|
|
# ── Delete ────────────────────────────────────────────────
|
|
|
|
@router.delete("/{shader_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_shader(
|
|
shader_id: UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
result = await db.execute(select(Shader).where(Shader.id == shader_id))
|
|
shader = result.scalar_one_or_none()
|
|
if not shader:
|
|
raise HTTPException(status_code=404, detail="Shader not found")
|
|
if shader.author_id != user.id and user.role != "admin":
|
|
raise HTTPException(status_code=403, detail="Not the shader owner")
|
|
await db.delete(shader)
|
|
|
|
|
|
# ── Fork ──────────────────────────────────────────────────
|
|
|
|
@router.post("/{shader_id}/fork", response_model=ShaderPublic, status_code=status.HTTP_201_CREATED)
|
|
async def fork_shader(
|
|
shader_id: UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
result = await db.execute(select(Shader).where(Shader.id == shader_id))
|
|
original = result.scalar_one_or_none()
|
|
if not original:
|
|
raise HTTPException(status_code=404, detail="Shader not found")
|
|
if not original.is_public and original.status != "published":
|
|
raise HTTPException(status_code=404, detail="Shader not found")
|
|
|
|
forked = Shader(
|
|
author_id=user.id,
|
|
title=f"Fork of {original.title}",
|
|
description=f"Forked from {original.title}",
|
|
glsl_code=original.glsl_code,
|
|
tags=original.tags,
|
|
shader_type=original.shader_type,
|
|
forked_from=original.id,
|
|
style_metadata=original.style_metadata,
|
|
status="draft", # Forks start as drafts
|
|
is_public=False,
|
|
render_status="ready",
|
|
current_version=1,
|
|
)
|
|
db.add(forked)
|
|
await db.flush()
|
|
|
|
v1 = ShaderVersion(
|
|
shader_id=forked.id,
|
|
version_number=1,
|
|
glsl_code=original.glsl_code,
|
|
title=forked.title,
|
|
description=forked.description,
|
|
tags=original.tags,
|
|
style_metadata=original.style_metadata,
|
|
change_note=f"Forked from {original.title}",
|
|
)
|
|
db.add(v1)
|
|
|
|
return forked
|
|
|
|
|
|
# ── Restore a version ────────────────────────────────────
|
|
|
|
@router.post("/{shader_id}/versions/{version_number}/restore", response_model=ShaderPublic)
|
|
async def restore_version(
|
|
shader_id: UUID,
|
|
version_number: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
"""Restore a shader to a previous version (creates a new version snapshot)."""
|
|
shader = (await db.execute(select(Shader).where(Shader.id == shader_id))).scalar_one_or_none()
|
|
if not shader:
|
|
raise HTTPException(status_code=404, detail="Shader not found")
|
|
if shader.author_id != user.id and user.role != "admin":
|
|
raise HTTPException(status_code=403, detail="Not the shader owner")
|
|
|
|
version = (await db.execute(
|
|
select(ShaderVersion).where(
|
|
ShaderVersion.shader_id == shader_id,
|
|
ShaderVersion.version_number == version_number,
|
|
)
|
|
)).scalar_one_or_none()
|
|
if not version:
|
|
raise HTTPException(status_code=404, detail="Version not found")
|
|
|
|
# Apply version data to shader
|
|
shader.glsl_code = version.glsl_code
|
|
shader.title = version.title
|
|
shader.description = version.description
|
|
shader.tags = version.tags
|
|
shader.style_metadata = version.style_metadata
|
|
shader.current_version += 1
|
|
shader.updated_at = datetime.now(timezone.utc)
|
|
|
|
# Create a new version snapshot for the restore
|
|
restore_v = ShaderVersion(
|
|
shader_id=shader.id,
|
|
version_number=shader.current_version,
|
|
glsl_code=version.glsl_code,
|
|
title=version.title,
|
|
description=version.description,
|
|
tags=version.tags,
|
|
style_metadata=version.style_metadata,
|
|
change_note=f"Restored from version {version_number}",
|
|
)
|
|
db.add(restore_v)
|
|
|
|
# Re-render if published
|
|
if shader.status == "published":
|
|
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"
|
|
|
|
return shader
|