fractafrag/services/api/app/schemas/schemas.py
John Lightner 1047a1f5fe Versioning, drafts, resizable editor, My Shaders, 200 seed shaders
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
2026-03-24 22:00:10 -05:00

240 lines
8 KiB
Python

"""Fractafrag — Pydantic Request/Response Schemas."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from typing import Optional
from pydantic import BaseModel, EmailStr, Field, ConfigDict
# ════════════════════════════════════════════════════════════
# AUTH
# ════════════════════════════════════════════════════════════
class UserRegister(BaseModel):
username: str = Field(..., min_length=3, max_length=30, pattern=r"^[a-zA-Z0-9_-]+$")
email: EmailStr
password: str = Field(..., min_length=8, max_length=128)
turnstile_token: str
class UserLogin(BaseModel):
email: EmailStr
password: str
turnstile_token: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserPublic(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
username: str
role: str
is_system: bool
subscription_tier: str
is_verified_creator: bool
created_at: datetime
class UserMe(UserPublic):
email: str
ai_credits_remaining: int
trust_tier: str
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):
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
# ════════════════════════════════════════════════════════════
class ShaderCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=120)
description: Optional[str] = Field(None, max_length=1000)
glsl_code: str = Field(..., min_length=10)
tags: list[str] = Field(default_factory=list, max_length=10)
shader_type: str = Field(default="2d", pattern=r"^(2d|3d|audio-reactive)$")
is_public: bool = True
status: str = Field(default="published", pattern=r"^(draft|published)$")
style_metadata: Optional[dict] = None
fulfills_desire_id: Optional[UUID] = None
class ShaderUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=120)
description: Optional[str] = Field(None, max_length=1000)
glsl_code: Optional[str] = Field(None, min_length=10)
tags: Optional[list[str]] = None
is_public: Optional[bool] = None
status: Optional[str] = Field(None, pattern=r"^(draft|published|archived)$")
change_note: Optional[str] = Field(None, max_length=200)
class ShaderPublic(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
author_id: Optional[UUID]
title: str
description: Optional[str]
glsl_code: str
status: str
is_public: bool
is_ai_generated: bool
is_system: bool
ai_provider: Optional[str]
system_label: Optional[str]
thumbnail_url: Optional[str]
preview_url: Optional[str]
render_status: str
style_metadata: Optional[dict]
tags: list[str]
shader_type: str
forked_from: Optional[UUID]
current_version: int
view_count: int
score: float
created_at: datetime
updated_at: datetime
class ShaderFeedItem(BaseModel):
"""Lighter shader representation for feed responses."""
model_config = ConfigDict(from_attributes=True)
id: UUID
author_id: Optional[UUID]
title: str
thumbnail_url: Optional[str]
preview_url: Optional[str]
glsl_code: str
tags: list[str]
shader_type: str
score: float
view_count: int
is_ai_generated: bool
is_system: bool
system_label: Optional[str]
style_metadata: Optional[dict]
created_at: datetime
class ShaderVersionPublic(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
shader_id: UUID
version_number: int
glsl_code: str
title: str
description: Optional[str]
tags: list[str]
style_metadata: Optional[dict]
change_note: Optional[str]
thumbnail_url: Optional[str]
created_at: datetime
# ════════════════════════════════════════════════════════════
# VOTES & ENGAGEMENT
# ════════════════════════════════════════════════════════════
class VoteCreate(BaseModel):
value: int = Field(..., ge=-1, le=1)
class DwellReport(BaseModel):
shader_id: UUID
dwell_secs: float = Field(..., gt=0)
replayed: bool = False
session_id: Optional[str] = None
# ════════════════════════════════════════════════════════════
# DESIRES / BOUNTIES
# ════════════════════════════════════════════════════════════
class DesireCreate(BaseModel):
prompt_text: str = Field(..., min_length=5, max_length=500)
style_hints: Optional[dict] = None
class DesirePublic(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
author_id: Optional[UUID]
prompt_text: str
style_hints: Optional[dict]
tip_amount_cents: int
status: str
heat_score: float
fulfilled_by_shader: Optional[UUID]
fulfilled_at: Optional[datetime]
created_at: datetime
# ════════════════════════════════════════════════════════════
# AI GENERATION
# ════════════════════════════════════════════════════════════
class GenerateRequest(BaseModel):
prompt: str = Field(..., min_length=5, max_length=500)
provider: Optional[str] = None
style_metadata: Optional[dict] = None
class GenerateStatusResponse(BaseModel):
job_id: str
status: str
shader_id: Optional[UUID] = None
error: Optional[str] = None
# ════════════════════════════════════════════════════════════
# API KEYS
# ════════════════════════════════════════════════════════════
class ApiKeyCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
class ApiKeyPublic(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
key_prefix: str
name: Optional[str]
trust_tier: str
rate_limit_per_hour: int
last_used_at: Optional[datetime]
created_at: datetime
class ApiKeyCreated(ApiKeyPublic):
full_key: str
# ════════════════════════════════════════════════════════════
# PAGINATION
# ════════════════════════════════════════════════════════════
class PaginatedResponse(BaseModel):
items: list
cursor: Optional[str] = None
has_more: bool = False