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
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:
- 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.
- 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.
- AI telemetry must be useful without leaking user content. Generation spans record model, latency, and token usage — never pixel data or source images.
| 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.
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
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.
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)
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.
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 installCreate .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 devThen 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.
| 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 |
Returns short-lived ImageKit upload credentials. Requires an authenticated session.
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 |
| 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 |