Skip to content

Repository files navigation

Luma Studio

Luma Studio

Image-to-image restyling that preserves the subject.

Upload a photo, pick a curated art direction, and get back a restyled render — with identity, pose, and composition intact.

Live Demo · Architecture · Local Setup · API


Next.js React TypeScript PostgreSQL Drizzle Sentry


Luma Studio workbench — upload panel, six style presets, and side-by-side original/result preview with generation history

Overview

Most AI image tools generate from a text prompt. Luma Studio does the harder thing: it edits an existing image, restyling it into one of six art directions while holding the original subject steady. Every style prompt is written to preserve identity, pose, framing, and scene relationships — the model changes the rendering, not the content.

The product is a full subscription SaaS: authenticated studio, per-plan monthly quotas enforced server-side, persistent generation history, and CDN-backed asset delivery.

Why it's built this way — three constraints drove most of the design:

  1. Image uploads must not pass through the server. Serverless request-body limits make proxying user images fragile. Uploads go browser → CDN directly, authorized by a short-lived signature the server mints.
  2. Quota must not be forgeable or drift. Rather than a counter column that can desync on retries or partial failures, remaining quota is derived on demand by counting rows in the current UTC month.
  3. AI telemetry must be useful without leaking user content. Generation spans record model, latency, and token usage — never pixel data or source images.

Features

Capability Implementation
Restyling Six curated presets — Storybook 3D, Anime Cel, Clay Render, Pixart, Voxel Block, Marble Sculpture Identity-preserving prompts in lib/style-presets.ts
Model choice gpt-image-1, gpt-image-1.5, and gemini-2.5-flash-image, selectable per generation Vercel AI SDK generateImage across OpenAI + Google providers
Aspect fidelity Output dimensions inferred from source geometry, not hardcoded sharp metadata → ratio thresholds
Auth Google, GitHub, email + password with verification Clerk, route-protected via middleware
Billing Free / Pro / Studio tiers, monthly + yearly, prorated upgrades Clerk Billing — entitlements read via has({ plan })
Quota Server-enforced monthly caps, 429 with structured error payload COUNT(*) over UTC month window
History Per-user generation archive, server-rendered on load Postgres + Drizzle, ordered by created_at DESC
Assets Direct-to-CDN upload, per-user folder isolation ImageKit signed upload params
Observability Errors, logs, and custom gen_ai.* spans with token usage Sentry (client, server, edge)

Plan limits are defined in lib/generation-quota.ts and are the single source of truth in code:

Plan Generations / month
Free 3
Pro 75
Studio 175

Prices and billing cycles are configured in the Clerk dashboard and rendered by Clerk's <PricingTable> — they are intentionally not duplicated in this codebase.


Architecture

Generation pipeline

Browser                     Next.js (server)              External
   │
   │  1. GET /api/upload
   ├──────────────────────────────►  auth() → mint signed
   │                                 ImageKit upload params
   │  ◄──────────────────────────────  { token, expire, signature }
   │
   │  2. upload file directly (bypasses the server entirely)
   ├───────────────────────────────────────────────────────►  ImageKit CDN
   │  ◄───────────────────────────────────────────────────────  source URL
   │
   │  3. POST /api/generate-image { sourceImageUrl, styleSlug, model }
   ├──────────────────────────────►  auth()
   │                                 ├─ quota check ──────►  Postgres COUNT
   │                                 │     └─ over limit → 429 QUOTA_EXCEEDED
   │                                 ├─ validate mime / style / model
   │                                 ├─ fetch source → sharp → infer size
   │                                 ├─ generateImage ────►  OpenAI
   │                                 │     (wrapped in Sentry gen_ai span)
   │                                 ├─ upload result ────►  ImageKit CDN
   │                                 └─ persist row ──────►  Postgres
   │  ◄──────────────────────────────  { imageBase64, savedGeneration, ... }
   │
   4. optimistic history prepend + local quota decrement

Design decisions

Signed direct-to-CDN uploads. app/api/upload/route.ts authenticates the caller and returns short-lived ImageKit credentials. The browser uploads with those credentials, so image bytes never traverse the Next.js server — no body-size ceiling, no serverless memory pressure, no wasted bandwidth. Files land in /users/{userId}/uploads, keeping per-user assets namespaced.

Derived quota, not stored counters. lib/generation-quota.ts computes usage as a COUNT(*) of rows since utcMonthStart(). There is no counter to increment, so there is nothing to desync when a generation fails midway, and quota resets monthly with no cron job. The tradeoff is one indexed count per request, which is cheap at this scale and trivially correct.

Entitlements live in Clerk. Plan checks use has({ plan }) rather than a local subscription table. Billing state has exactly one owner, which eliminates the webhook-reconciliation class of bugs entirely.

Aspect-ratio inference. inferImageDimensions() reads dimensions via sharp once and maps the ratio to both an OpenAI pixel size and a Gemini aspect ratio — landscape above 1.08, portrait below 0.92, square between. OpenAI edits take size; Gemini rejects size and accepts only aspectRatio, so the route passes whichever the selected provider supports. A portrait photo doesn't come back letterboxed into a square. Metadata failures fall back to square rather than erroring the request.

Privacy-conscious AI telemetry. The generateImage call is wrapped in a Sentry span carrying gen_ai.request.model and input/output/total token counts. The source image is recorded as a placeholder string and the response as a note that pixel data was withheld — full cost and latency visibility, zero user content in the telemetry pipeline.

Typed error taxonomy. The route distinguishes APICallError (propagates upstream status), NoImageGeneratedError (502), and quota exhaustion (429 with code: "QUOTA_EXCEEDED" plus limit/used). The client reads that payload to resync its quota display, so the UI self-corrects from the authoritative server count instead of drifting.

Project structure

app/
├── api/
│   ├── generate-image/route.ts   # quota → validate → generate → persist
│   └── upload/route.ts           # signed ImageKit credentials
├── studio/page.tsx               # protected; SSR history + quota
├── layout.tsx
├── page.tsx                      # marketing landing
└── global-error.tsx              # Sentry error boundary

components/
├── studio/                       # workbench, controls, preview, history dialog
└── ui/                           # shadcn primitives

context/
└── StudioWorkbenchContext.tsx    # upload → generate → optimistic update

db/
├── schema.ts                     # generations table
├── generations.ts                # queries (count, list, insert)
└── index.ts                      # Neon serverless driver

lib/
├── generation-quota.ts           # plan limits + entitlement resolution
├── style-presets.ts              # six identity-preserving prompts
├── image-models.ts               # model registry (id, label, provider)
├── image-models.server.ts        # server-only provider availability
├── openai.ts
├── google.ts
├── imagekit.ts                   # singleton upload client
└── constants.ts

proxy.ts                          # Clerk middleware — protects /studio/*
instrumentation*.ts               # Sentry init (server / client / edge)

Data model

generations {
  id                 uuid        primary key, default random
  clerk_user_id      text        not null   // quota + history scoping
  original_file_name text
  source_image_url   text        not null
  result_image_url   text        not null
  style_slug         text        not null
  style_label        text        not null   // denormalized for stable history
  model              text        not null
  prompt_used        text        not null   // exact prompt, for reproducibility
  created_at         timestamptz not null, default now()
}

prompt_used and style_label are stored per row rather than joined at read time, so historical entries stay faithful to what actually produced them even after presets are edited.


Local Setup

Prerequisites: Node.js 20+, a Neon Postgres database, and Clerk / ImageKit / OpenAI / Google AI Studio accounts.

git clone https://github.com/sivasankar55/Studio.git
cd Studio
npm install

Create .env.local:

# Database — Neon serverless Postgres
DATABASE_URL="postgresql://<user>:<password>@<host>/<db>?sslmode=require"

# Auth & Billing — Clerk
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_..."
CLERK_SECRET_KEY="sk_test_..."

# Storage & CDN — ImageKit
NEXT_PUBLIC_IMAGEKIT_PUBLIC_KEY="public_..."
IMAGEKIT_PRIVATE_KEY="private_..."

# AI — OpenAI image models
OPEN_AI_API_KEY="sk-..."

# AI — Google Gemini image models
GOOGLE_GENERATIVE_AI_API_KEY="AIza..."

# Observability — Sentry (build-time source map upload; optional locally)
SENTRY_AUTH_TOKEN="sntrys_..."

Provider keys are optional and independent. The model dropdown lists only models whose provider key is set, so an unconfigured provider is never offered rather than failing at generation time.

Push the schema and start the dev server:

npm run db:push
npm run dev

Then configure Free, Pro, and Studio plans in the Clerk dashboard. The plan keys must be exactly free, pro, and studio to match BILLING_PLAN_KEYS — a mismatch silently downgrades every user to the free tier.

Scripts

Command Purpose
npm run dev Development server
npm run build Production build
npm run start Serve production build
npm run lint ESLint
npm run db:push Push Drizzle schema to Postgres

API

GET /api/upload

Returns short-lived ImageKit upload credentials. Requires an authenticated session.

// 200
{ "token": "...", "expire": 1730000000, "signature": "...", "publicKey": "public_..." }
// 401 — no session
{ "error": "Unauthorized" }

POST /api/generate-image

Restyles an already-uploaded source image. Requires an authenticated session.

// Request
{
  "sourceImageUrl": "https://ik.imagekit.io/...",
  "sourceMimeType": "image/png",       // jpeg | png | webp
  "originalFileName": "portrait.png",
  "styleSlug": "anime-cel",
  "model": "gemini-2.5-flash-image"     // or gpt-image-1 | gpt-image-1.5
}

// 200
{
  "imageBase64": "...",
  "mimeType": "image/png",
  "promptUsed": "...",
  "style": { "slug": "anime-cel", "label": "Anime Cel" },
  "model": "gemini-2.5-flash-image",
  "savedGeneration": { "id": "uuid", "createdAt": "..." }
}

// 429 — quota exhausted
{ "error": "Monthly generation limit reached (3 images)...",
  "code": "QUOTA_EXCEEDED", "limit": 3, "used": 3 }
Status Meaning
400 Missing source image, unsupported MIME type, unknown style, or missing/unknown model
401 No authenticated session
404 Source image URL could not be fetched
429 Monthly quota exhausted
500 Missing API key, or generation failed
502 Model returned no image

Tech Stack

Layer Choice
Framework Next.js 16 (App Router, RSC)
Language TypeScript 5
UI Tailwind CSS 4, shadcn/ui, Radix, Motion
Auth & Billing Clerk + Clerk Billing
Database Neon serverless Postgres + Drizzle ORM
AI Vercel AI SDK + OpenAI (gpt-image-1, gpt-image-1.5) and Google Gemini (gemini-2.5-flash-image)
Image processing sharp
Storage / CDN ImageKit
Observability Sentry

About

AI Image Generator SaaS

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages