From 73251d7a06103266be09ec6940e7e169a36ad3a4 Mon Sep 17 00:00:00 2001 From: Pycomet Date: Sun, 12 Jul 2026 05:09:25 +0100 Subject: [PATCH 01/36] Add PixelAI v2 core redesign design spec Co-Authored-By: Claude Fable 5 --- ...6-07-12-pixelai-v2-core-redesign-design.md | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-12-pixelai-v2-core-redesign-design.md diff --git a/docs/superpowers/specs/2026-07-12-pixelai-v2-core-redesign-design.md b/docs/superpowers/specs/2026-07-12-pixelai-v2-core-redesign-design.md new file mode 100644 index 0000000..1c1a27d --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-pixelai-v2-core-redesign-design.md @@ -0,0 +1,140 @@ +# PixelAI v2 — Core Redesign Design + +**Date:** 2026-07-12 +**Status:** Approved by owner (Alfred) +**Predecessors:** `../../pixelai` (Next 14 + NextUI + Firebase prototype), `../../ai-sdk-image-generator` (AI SDK v4 thumbnail-generator fork of Vercel's template) + +## 1. Context and goal + +PixelAI is an AI-powered thumbnail generator. Two prior codebases exist: + +- **pixelai** — a working prototype with a dual-provider (Stability AI, HuggingFace) server-side generation pipeline and Firebase Auth, but with unenforced auth (commented-out `AuthWrapper`, unauthenticated public generation API), no persistence of generated images, stubbed pages, an architecturally broken Firebase static-hosting deploy (static export config vs. server API routes), and an abandoned Clerk migration in CI. +- **ai-sdk-image-generator** — a fork of Vercel's template extended into a YouTube thumbnail generator. Its multi-provider parallel fan-out core (provider registry, per-provider requests, timeout handling, per-provider retry) is the strongest architecture in either repo, but it is a stateless demo (no auth, no rate limiting, no persistence, base64-in-state images) on outdated majors (AI SDK v4, Next 15, Tailwind v3). + +**Decision:** build a fresh app, `pixelai-v2`, that adopts the template's engine *design* on current dependency majors and adds the product layers neither repo has. + +**Product scope:** a neutral image-generation engine with the **thumbnail experience as the first product surface**. Future surfaces (general image generation) reuse the engine untouched. + +**V1 scope:** generation engine + thumbnail UI + enforced auth + history/storage + rate limiting/quotas. **Deferred to v2:** billing/subscriptions (Stripe), job-based generation service, video-scale outputs. + +## 2. Stack + +| Layer | Choice | +|---|---| +| Framework | Next.js App Router — latest stable major at scaffold time (16 as of this writing), React 19, TypeScript strict | +| UI | Tailwind v4 + shadcn/ui (replaces NextUI/HeroUI) | +| AI | AI SDK — latest stable major at scaffold time (v7 docs are current as of this writing; stable `generateImage` from `ai`) | +| Auth | Firebase Auth (email/password, Google, GitHub) — client SDK on the front end, `firebase-admin` ID-token verification on every protected API route | +| Data | Firestore (`users`, `generations`) | +| Image storage | Vercel Blob (public URLs) | +| Hosting | Vercel (replaces broken Firebase static hosting) | + +At implementation time, verify exact current APIs against official docs (ai-sdk.dev, nextjs.org, vercel.com/docs) before writing code; do not rely on memorized signatures. + +## 3. Repo layout + +``` +app/ + page.tsx # landing (ported branding/hero from pixelai) + dashboard/ # generator surface + history/ # gallery of past generations + api/generate/route.ts # the one generation endpoint +lib/ + ai/ # engine: registry.ts, adapters/, prompt-builder.ts + firebase/ # client.ts, admin.ts, auth helpers + storage/ # blob upload + quota/ # transactional quota check + plan config +components/ + ui/ # shadcn primitives + generator/ # form, provider select, result cards + gallery/ + auth/ # AuthGate, login/register forms +docs/superpowers/specs/ # this doc and successors +``` + +## 4. Generation core (the engine) + +Neutral and thumbnail-agnostic. Three pieces: + +### 4.1 Provider registry (`lib/ai/registry.ts`) + +Each entry: display metadata (name, icon, color), model configs (performance/quality tiers, capability flags such as `img2img`), and a factory returning an AI SDK image model. + +- **First-party AI SDK providers:** OpenAI (`@ai-sdk/openai`, gpt-image), Replicate (`@ai-sdk/replicate`, FLUX), Fal (`@ai-sdk/fal`) or Fireworks (`@ai-sdk/fireworks`) — pick per key availability at implementation. +- **Custom adapters:** Stability AI and HuggingFace have no first-party AI SDK image provider. Port pixelai's existing REST/inference code (`lib/ai/stability.ts`, `lib/ai/huggingface.ts`) behind the AI SDK `ImageModel` interface so the registry treats all providers uniformly. +- Dimension-format abstraction from the template (`size` vs `aspectRatio` per provider) is kept. + +### 4.2 API route (`POST /api/generate`) + +One provider per call; the client fans out N parallel requests (Approach A — chosen over single-provider and job-based alternatives). Per-request server flow: + +1. Verify Firebase ID token (`firebase-admin`). Unauthenticated → 401. No exceptions. +2. Validate input (prompt length, provider/model whitelist, dimension whitelist). +3. Transactional quota check (see §7). Over quota → 429 with clear message. +4. `generateImage` with a 55-second `abortSignal` timeout (under Vercel's function cap). +5. Upload the image to Vercel Blob. +6. Write a `generations` doc to Firestore. +7. Return `{ url, generationId, provider, timing }`. + +The image is persisted **before** the client sees it — history is never a separate code path. Failures return classified, sanitized errors (timeout / auth / rate-limit / content-policy / quota); full detail goes to server logs with a per-request ID. + +### 4.3 Prompt layer (`lib/ai/prompt-builder.ts`) + +A neutral `buildPrompt(spec)` core that assembles positive/negative prompts from a structured spec. The thumbnail surface composes it with 16:9 defaults, style presets, emotion presets, and overlay-text guidance (ported from the template fork's `prompt-builder.ts`). Style/negative-prompt strings live in one place — no duplication across provider files (a pixelai defect). + +## 5. Auth and security + +- Firebase Auth flows ported from pixelai (email/password, Google, GitHub). **Every** auth method writes/merges `users/{uid}` on sign-in (fixes pixelai's social-login TODO). +- Enforcement is server-side: ID token on every generation/history-mutation request, verified with `firebase-admin`. Client-side `AuthGate` on `/dashboard` and `/history` is UX only, not security. +- Exactly **one** dev auth-bypass mechanism: a single server-checked env flag, active only outside production. Replaces pixelai's two conflicting flags. +- Password reset via Firebase's built-in email flow (replaces the `export {}` stubs). +- No diagnostic endpoint equivalent to pixelai's `/api/test-ai` (it disclosed key configuration and burned quota publicly). + +## 6. Data model and storage + +**Firestore:** + +- `users/{uid}`: `displayName`, `email`, `photoURL`, `plan` (`"free"` initially), `quotaUsedToday`, `quotaResetAt`, `createdAt`. +- `generations/{id}`: `uid`, `prompt` (final string), `spec` (structured form input), `provider`, `model`, `width`/`height` or `aspectRatio`, `blobUrl`, `timingMs`, `status` (`"succeeded"` | `"failed"`), `createdAt`. One doc per provider result. + +**Vercel Blob:** actual images at `pixelai/{uid}/{generationId}.png`, public access. No base64 in React state, Firestore, or API responses beyond the transient server-side handoff. + +Firestore security rules: users read/write only their own `users` doc; `generations` readable/deletable only by their `uid` owner; writes to `generations` come only from the server (admin SDK bypasses rules). + +## 7. Quotas / rate limiting + +Firestore transaction on `users/{uid}` before generation: + +- Lazily reset `quotaUsedToday` to 0 when `quotaResetAt` has passed (set next reset to next UTC midnight). +- Increment and compare against the per-plan daily cap. Each provider result counts as 1 (a 3-provider fan-out consumes 3). +- Over cap → transaction aborts → 429; the UI shows an honest quota message. + +Plan caps live in `lib/quota/plans.ts` (e.g. `free: 10/day`) so v2 billing only changes plan assignment, never enforcement. + +## 8. Product surfaces (v1) + +- **Landing:** pixelai's branding and hero, prompt box that routes into the dashboard through the auth gate. Router navigation (no `window.location` full reloads). +- **Dashboard/generator:** thumbnail form (title, subject, style preset, emotion preset, overlay text — ported from the template fork), provider multi-select, parallel result cards with per-provider status/timing/retry, download button. Every successful result is already in history automatically. YouTube URL import (oEmbed) and LLM title inference are **ported behind a feature flag** (`FEATURE_YT_IMPORT`) — nice-to-haves, OpenAI-key-dependent. +- **History:** grid of the user's past generations (Firestore query, newest first), filter by provider/date, re-run prompt (pre-fills the form), delete (removes Blob file + Firestore doc). + +## 9. Error handling + +- Server: per-request IDs for log correlation; sanitized user-facing messages; error classification (timeout, provider auth, provider rate-limit, content policy, quota). +- Client: a mounted top-level error boundary (pixelai built one and never mounted it); per-provider failure cards with individual retry; toasts via a shadcn/sonner-style queue (fixes the single-message toast limitation). + +## 10. Testing + +Targeted at the money paths, not coverage theater: + +- Unit: provider registry (config integrity, capability flags), prompt builder (spec → prompt snapshots), quota transaction logic (Firebase emulator), API route input validation (401/400/429 paths with mocked verify). +- E2E: one Playwright smoke — sign in (emulator), submit generation (mocked provider), see result card, see it in history. + +## 11. Explicitly dropped from the old repos + +Firebase Hosting config + `firebase-hosting-*.yml` CI workflows, Clerk CI secrets, `stability-ai` npm SDK (raw REST kept instead), `sharp`, `react-slick`, `/api/test-ai`, static-export assumptions, NextUI/HeroUI, cogo-toast. + +## 12. Alternatives considered + +- **Single-provider pipeline** (pixelai's model): simpler and cheaper, but discards the side-by-side comparison UX that is the template core's main product value. Rejected. +- **Job-based generation service** (status docs + durable workflow + realtime listeners): most robust, natural fit for long jobs; overkill for 5–30s image generations in v1. Revisit for v2 alongside billing or video-scale outputs. +- **Evolving either existing repo in place:** rejected — the template fork requires an AI SDK v4→v6+ and Tailwind v3→v4 migration before feature work; pixelai carries NextUI legacy, broken deploy config, and stubbed pages. From dfef5f3d6552f1a3a0f9dacde13f81b225d6dba8 Mon Sep 17 00:00:00 2001 From: Pycomet Date: Sun, 12 Jul 2026 05:10:11 +0100 Subject: [PATCH 02/36] Update spec: rebuild happens in-repo on v2-rebuild branch Co-Authored-By: Claude Fable 5 --- .../specs/2026-07-12-pixelai-v2-core-redesign-design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-07-12-pixelai-v2-core-redesign-design.md b/docs/superpowers/specs/2026-07-12-pixelai-v2-core-redesign-design.md index 1c1a27d..e1c8cde 100644 --- a/docs/superpowers/specs/2026-07-12-pixelai-v2-core-redesign-design.md +++ b/docs/superpowers/specs/2026-07-12-pixelai-v2-core-redesign-design.md @@ -2,7 +2,7 @@ **Date:** 2026-07-12 **Status:** Approved by owner (Alfred) -**Predecessors:** `../../pixelai` (Next 14 + NextUI + Firebase prototype), `../../ai-sdk-image-generator` (AI SDK v4 thumbnail-generator fork of Vercel's template) +**Predecessors:** this repo's `main` branch (Next 14 + NextUI + Firebase prototype), `../ai-sdk-image-generator` (AI SDK v4 thumbnail-generator fork of Vercel's template) ## 1. Context and goal @@ -11,7 +11,7 @@ PixelAI is an AI-powered thumbnail generator. Two prior codebases exist: - **pixelai** — a working prototype with a dual-provider (Stability AI, HuggingFace) server-side generation pipeline and Firebase Auth, but with unenforced auth (commented-out `AuthWrapper`, unauthenticated public generation API), no persistence of generated images, stubbed pages, an architecturally broken Firebase static-hosting deploy (static export config vs. server API routes), and an abandoned Clerk migration in CI. - **ai-sdk-image-generator** — a fork of Vercel's template extended into a YouTube thumbnail generator. Its multi-provider parallel fan-out core (provider registry, per-provider requests, timeout handling, per-provider retry) is the strongest architecture in either repo, but it is a stateless demo (no auth, no rate limiting, no persistence, base64-in-state images) on outdated majors (AI SDK v4, Next 15, Tailwind v3). -**Decision:** build a fresh app, `pixelai-v2`, that adopts the template's engine *design* on current dependency majors and adds the product layers neither repo has. +**Decision:** rebuild fresh *inside the `pixelai` repository* on the `v2-rebuild` branch — a wholesale replacement (new dependency tree, new app code) rather than an incremental evolution of the old code. The repo, remote, and git history are kept; the old app remains recoverable in history and consultable at `origin/main` while pieces are ported. The rebuild adopts the template's engine *design* on current dependency majors and adds the product layers neither repo has. **Product scope:** a neutral image-generation engine with the **thumbnail experience as the first product surface**. Future surfaces (general image generation) reuse the engine untouched. From 5840c628899242990510cb06b0462e304500a943 Mon Sep 17 00:00:00 2001 From: Pycomet Date: Sun, 12 Jul 2026 05:25:55 +0100 Subject: [PATCH 03/36] docs: add v2 rebuild implementation plan Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-12-pixelai-v2-rebuild.md | 1757 +++++++++++++++++ 1 file changed, 1757 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-12-pixelai-v2-rebuild.md diff --git a/docs/superpowers/plans/2026-07-12-pixelai-v2-rebuild.md b/docs/superpowers/plans/2026-07-12-pixelai-v2-rebuild.md new file mode 100644 index 0000000..1a2190e --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-pixelai-v2-rebuild.md @@ -0,0 +1,1757 @@ +# PixelAI v2 Rebuild Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild PixelAI on the `v2-rebuild` branch as a fresh Next.js + AI SDK app: a neutral multi-provider image-generation engine with a thumbnail-generator surface, enforced Firebase auth, Firestore/Blob persistence, and transactional quotas. + +**Architecture:** Parallel fan-out generation — the client fires one `POST /api/generate` request per selected provider; each request verifies the Firebase ID token, consumes quota transactionally, calls AI SDK `generateImage`, uploads the result to Vercel Blob, writes a `generations` doc, and returns the Blob URL. A provider registry treats first-party AI SDK providers (OpenAI, Replicate, Fal) and custom adapters (Stability, HuggingFace) uniformly. + +**Tech Stack:** Next.js (latest, App Router), React 19, TypeScript strict, Tailwind v4, shadcn/ui, AI SDK (`ai` latest + `@ai-sdk/openai`, `@ai-sdk/replicate`, `@ai-sdk/fal`), `firebase` (client), `firebase-admin` (server), `@vercel/blob`, zod, Vitest, Playwright. + +**Spec:** `docs/superpowers/specs/2026-07-12-pixelai-v2-core-redesign-design.md` + +## Global Constraints + +- Branch: all work happens on `v2-rebuild` in this repo. Never commit to `main`. +- Install **latest stable** majors at scaffold time (`next@latest`, `ai@latest`, etc.). The AI SDK image-model spec interface is versioned (`ImageModelV4` on current main; older majors use `ImageModelV2/V3`) — Task 4 pins this against the *installed* `@ai-sdk/provider` and every adapter must match it. +- Package manager: **pnpm**. +- TypeScript `strict: true`. No `any` in new code (`@typescript-eslint/no-explicit-any` stays on). +- Provider API keys (`OPENAI_API_KEY`, `REPLICATE_API_TOKEN`, `FAL_API_KEY`, `STABILITY_API_KEY`, `HUGGINGFACE_API_KEY`, `BLOB_READ_WRITE_TOKEN`, `FIREBASE_PRIVATE_KEY`…) are **server-only**: never `NEXT_PUBLIC_`, never in `next.config` `env`. +- Exactly **one** dev auth bypass: `AUTH_DEV_BYPASS=1`, honored only when not in production (checked server-side in `verifyRequest`). No other bypass flags anywhere. +- Images are never persisted or returned as base64. Server returns Blob URLs only. +- Every generation is persisted (Blob + Firestore) server-side **before** the API responds. Quota: each provider result costs 1; free plan = 10/day, reset at UTC midnight. +- Error responses always use the shape `{ error: { code, message } }` with codes: `unauthenticated`, `invalid_input`, `quota_exceeded`, `timeout`, `content_policy`, `provider_error`. +- Old-app code on `main` is reference material — port logic, don't import from it. + +--- + +### Task 1: Scaffold the replacement app + +**Files:** +- Delete (git rm): all legacy app source — `app/`, `components/`, `contexts/`, `lib/`, `config/`, `types/`, `styles/`, `assets/`, `public/`, `__tests__/`, `__mocks__/`, `jest.config.ts`, `jest.setup.js`, `tailwind.config.js`, `postcss.config.js`, `next.config.js`, `next-env.d.ts`, `tsconfig.json`, `package.json`, `yarn.lock`, `.eslintrc.json`, `.prettierrc`, `firebase.json`, `.firebaserc`, `public/index.html`, `.github/workflows/firebase-hosting-*.yml`, `todo.txt` +- Keep: `docs/`, `LICENSE`, `.gitignore` (will be replaced by scaffold's), `README.md` (rewritten in Task 15) +- Create: fresh Next.js scaffold at repo root + `vitest.config.ts` + `.env.example` + +**Interfaces:** +- Produces: a building Next.js app with Tailwind v4, shadcn/ui initialized, all runtime deps installed, `pnpm test` running Vitest. + +- [ ] **Step 1: Verify preconditions** + +```bash +cd /Users/codefred/Documents/PixelAI/pixelai +git status --porcelain # must be empty +git branch --show-current # must print: v2-rebuild +node --version # must be >= 20 +``` + +- [ ] **Step 2: Remove legacy app source** + +```bash +git rm -r app components contexts lib config types styles assets public __tests__ __mocks__ +git rm jest.config.ts jest.setup.js tailwind.config.js postcss.config.js next.config.js next-env.d.ts tsconfig.json package.json yarn.lock .eslintrc.json .prettierrc firebase.json .firebaserc todo.txt +git rm -r .github/workflows +git commit -m "chore!: remove v1 app source for v2 rebuild (recoverable on main)" +``` + +- [ ] **Step 3: Scaffold Next.js into a temp dir and copy over** + +`create-next-app` refuses non-empty dirs, so scaffold in `/tmp` and copy everything except `.git`: + +```bash +cd /tmp && npx create-next-app@latest pixelai-scaffold --typescript --tailwind --eslint --app --no-src-dir --import-alias "@/*" --use-pnpm +rsync -a --exclude .git /tmp/pixelai-scaffold/ /Users/codefred/Documents/PixelAI/pixelai/ +cd /Users/codefred/Documents/PixelAI/pixelai && pnpm install && pnpm dev & +``` + +Verify: `curl -s localhost:3000 | grep -qi next` then kill the dev server. If the CLI flags above have drifted, run `npx create-next-app@latest pixelai-scaffold` interactively and choose: TypeScript yes, ESLint yes, Tailwind yes, `src/` no, App Router yes, import alias `@/*`. + +- [ ] **Step 4: Set package name and strictness** + +In `package.json`: `"name": "pixelai"`, `"version": "2.0.0"`. In `tsconfig.json` confirm `"strict": true`. Add scripts: + +```json +"test": "vitest run", +"test:watch": "vitest" +``` + +- [ ] **Step 5: Install runtime + dev deps** + +```bash +pnpm add ai @ai-sdk/openai @ai-sdk/replicate @ai-sdk/fal @ai-sdk/provider @ai-sdk/provider-utils firebase firebase-admin @vercel/blob zod react-hook-form @hookform/resolvers +pnpm add -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom @playwright/test firebase-tools +``` + +- [ ] **Step 6: Initialize shadcn/ui** + +```bash +pnpm dlx shadcn@latest init +pnpm dlx shadcn@latest add button card input label select textarea sonner dialog dropdown-menu skeleton badge tabs +``` + +Accept defaults (style: default is fine; base color: neutral; CSS variables: yes). + +- [ ] **Step 7: Create `vitest.config.ts`** + +```ts +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "node", + environmentMatchGlobs: [["**/*.test.tsx", "jsdom"], ["hooks/**", "jsdom"]], + }, + resolve: { alias: { "@": path.resolve(__dirname, ".") } }, +}); +``` + +- [ ] **Step 8: Create `.env.example`** + +```bash +# --- Firebase (client — safe to expose, but keep consistent) --- +NEXT_PUBLIC_FIREBASE_API_KEY= +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= +NEXT_PUBLIC_FIREBASE_PROJECT_ID= +NEXT_PUBLIC_FIREBASE_APP_ID= +# --- Firebase admin (server only) --- +FIREBASE_PROJECT_ID= +FIREBASE_CLIENT_EMAIL= +FIREBASE_PRIVATE_KEY= +# --- Image providers (server only; leave blank to disable a provider) --- +OPENAI_API_KEY= +REPLICATE_API_TOKEN= +FAL_API_KEY= +STABILITY_API_KEY= +HUGGINGFACE_API_KEY= +# --- Storage --- +BLOB_READ_WRITE_TOKEN= +# --- Dev only: skip auth verification outside production --- +AUTH_DEV_BYPASS= +# --- Feature flags --- +FEATURE_YT_IMPORT= +``` + +- [ ] **Step 9: Verify build + commit** + +```bash +pnpm build && pnpm lint +git add -A && git commit -m "feat: scaffold v2 app (Next latest, Tailwind v4, shadcn, AI SDK, Vitest)" +``` + +--- + +### Task 2: Engine types and provider registry + +**Files:** +- Create: `lib/ai/types.ts`, `lib/ai/registry.ts` +- Test: `lib/ai/registry.test.ts` + +**Interfaces:** +- Produces: + - `type ProviderKey = "openai" | "replicate" | "fal" | "stability" | "huggingface"` + - `type ModelMode = "performance" | "quality"` + - `interface ModelConfig { id: string; label: string; supportsImg2Img: boolean }` + - `interface ProviderEntry { key: ProviderKey; displayName: string; dimensionFormat: "size" | "aspectRatio"; envKey: string; models: ModelConfig[]; defaultModel: Record; createModel(modelId: string): ImageModel }` + - `const PROVIDERS: Record` + - `const PROVIDER_ORDER: ProviderKey[]` + - `function getProvider(key: string): ProviderEntry` (throws on unknown key) + - `function isModelAllowed(provider: ProviderKey, modelId: string): boolean` + - `function enabledProviders(): ProviderKey[]` (filters by `process.env[envKey]` being set) + +- [ ] **Step 1: Write the failing test** (`lib/ai/registry.test.ts`) + +```ts +import { describe, expect, it } from "vitest"; +import { PROVIDERS, PROVIDER_ORDER, getProvider, isModelAllowed, enabledProviders } from "./registry"; + +describe("provider registry", () => { + it("has an entry for every ordered provider with a valid default model", () => { + for (const key of PROVIDER_ORDER) { + const p = PROVIDERS[key]; + expect(p.key).toBe(key); + expect(p.models.length).toBeGreaterThan(0); + const ids = p.models.map((m) => m.id); + expect(ids).toContain(p.defaultModel.performance); + expect(ids).toContain(p.defaultModel.quality); + } + }); + + it("getProvider throws on unknown keys", () => { + expect(() => getProvider("midjourney")).toThrow(/unknown provider/i); + }); + + it("isModelAllowed rejects models not in the registry", () => { + expect(isModelAllowed("openai", "dall-e-1-fake")).toBe(false); + expect(isModelAllowed("replicate", PROVIDERS.replicate.defaultModel.quality)).toBe(true); + }); + + it("enabledProviders reflects env keys", () => { + process.env.OPENAI_API_KEY = "sk-test"; + delete process.env.REPLICATE_API_TOKEN; + const enabled = enabledProviders(); + expect(enabled).toContain("openai"); + expect(enabled).not.toContain("replicate"); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** — `pnpm vitest run lib/ai/registry.test.ts` → FAIL (module not found). + +- [ ] **Step 3: Implement `lib/ai/types.ts`** + +```ts +export type ProviderKey = "openai" | "replicate" | "fal" | "stability" | "huggingface"; +export type ModelMode = "performance" | "quality"; + +export interface ModelConfig { + id: string; + label: string; + supportsImg2Img: boolean; +} + +export interface GenerationSpec { + title?: string; + subject: string; + stylePreset: string; + emotionPreset?: string; + overlayText?: string; +} + +export interface GenerationResult { + generationId: string; + url: string; + provider: ProviderKey; + modelId: string; + timingMs: number; +} + +export type ApiErrorCode = + | "unauthenticated" + | "invalid_input" + | "quota_exceeded" + | "timeout" + | "content_policy" + | "provider_error"; + +export interface ApiError { + error: { code: ApiErrorCode; message: string }; +} +``` + +- [ ] **Step 4: Implement `lib/ai/registry.ts`** + +Model IDs below are current as of writing — verify each against the installed provider package docs before finalizing (Replicate/Fal catalogs move fast). + +```ts +import { openai } from "@ai-sdk/openai"; +import { replicate } from "@ai-sdk/replicate"; +import { fal } from "@ai-sdk/fal"; +import type { ImageModel } from "ai"; +import { createStabilityImageModel } from "./adapters/stability"; +import { createHuggingFaceImageModel } from "./adapters/huggingface"; +import type { ModelConfig, ModelMode, ProviderKey } from "./types"; + +export interface ProviderEntry { + key: ProviderKey; + displayName: string; + dimensionFormat: "size" | "aspectRatio"; + envKey: string; + models: ModelConfig[]; + defaultModel: Record; + createModel(modelId: string): ImageModel; +} + +export const PROVIDERS: Record = { + openai: { + key: "openai", + displayName: "OpenAI", + dimensionFormat: "size", + envKey: "OPENAI_API_KEY", + models: [ + { id: "gpt-image-1", label: "GPT Image 1", supportsImg2Img: false }, + { id: "dall-e-3", label: "DALL·E 3", supportsImg2Img: false }, + ], + defaultModel: { performance: "dall-e-3", quality: "gpt-image-1" }, + createModel: (id) => openai.image(id), + }, + replicate: { + key: "replicate", + displayName: "Replicate", + dimensionFormat: "aspectRatio", + envKey: "REPLICATE_API_TOKEN", + models: [ + { id: "black-forest-labs/flux-schnell", label: "FLUX Schnell", supportsImg2Img: false }, + { id: "black-forest-labs/flux-1.1-pro", label: "FLUX 1.1 Pro", supportsImg2Img: false }, + ], + defaultModel: { performance: "black-forest-labs/flux-schnell", quality: "black-forest-labs/flux-1.1-pro" }, + createModel: (id) => replicate.image(id), + }, + fal: { + key: "fal", + displayName: "Fal", + dimensionFormat: "aspectRatio", + envKey: "FAL_API_KEY", + models: [ + { id: "fal-ai/flux/schnell", label: "FLUX Schnell", supportsImg2Img: true }, + { id: "fal-ai/flux-pro/v1.1", label: "FLUX Pro 1.1", supportsImg2Img: true }, + ], + defaultModel: { performance: "fal-ai/flux/schnell", quality: "fal-ai/flux-pro/v1.1" }, + createModel: (id) => fal.image(id), + }, + stability: { + key: "stability", + displayName: "Stability AI", + dimensionFormat: "aspectRatio", + envKey: "STABILITY_API_KEY", + models: [{ id: "sd3.5-large", label: "SD 3.5 Large", supportsImg2Img: false }], + defaultModel: { performance: "sd3.5-large", quality: "sd3.5-large" }, + createModel: (id) => createStabilityImageModel(id), + }, + huggingface: { + key: "huggingface", + displayName: "HuggingFace", + dimensionFormat: "size", + envKey: "HUGGINGFACE_API_KEY", + models: [ + { id: "stabilityai/stable-diffusion-xl-base-1.0", label: "SDXL", supportsImg2Img: false }, + { id: "black-forest-labs/FLUX.1-schnell", label: "FLUX.1 Schnell", supportsImg2Img: false }, + ], + defaultModel: { + performance: "black-forest-labs/FLUX.1-schnell", + quality: "stabilityai/stable-diffusion-xl-base-1.0", + }, + createModel: (id) => createHuggingFaceImageModel(id), + }, +}; + +export const PROVIDER_ORDER: ProviderKey[] = ["openai", "replicate", "fal", "stability", "huggingface"]; + +export function getProvider(key: string): ProviderEntry { + const entry = PROVIDERS[key as ProviderKey]; + if (!entry) throw new Error(`Unknown provider: ${key}`); + return entry; +} + +export function isModelAllowed(provider: ProviderKey, modelId: string): boolean { + return PROVIDERS[provider].models.some((m) => m.id === modelId); +} + +export function enabledProviders(): ProviderKey[] { + return PROVIDER_ORDER.filter((key) => !!process.env[PROVIDERS[key].envKey]); +} +``` + +Until Task 4/5 exist, create stub files so the import resolves — `lib/ai/adapters/stability.ts` and `lib/ai/adapters/huggingface.ts` each exporting a factory that throws `new Error("not implemented")` **with the correct signature** (`(modelId: string) => ImageModel`). Tasks 4 and 5 replace the bodies. + +- [ ] **Step 5: Run tests** — `pnpm vitest run lib/ai/registry.test.ts` → PASS. + +- [ ] **Step 6: Commit** — `git add lib && git commit -m "feat(engine): provider registry with five providers"` + +--- + +### Task 3: Prompt builder + +**Files:** +- Create: `lib/ai/prompt-builder.ts` +- Test: `lib/ai/prompt-builder.test.ts` + +**Interfaces:** +- Consumes: `GenerationSpec` from `lib/ai/types.ts`. +- Produces: + - `const STYLE_PRESETS: Record` (keys: `bold`, `minimal`, `tech`, `gaming`, `vlog`, `photoreal`) + - `const EMOTION_PRESETS: Record` (keys: `excited`, `shocked`, `serious`, `happy`, `curious`) + - `const NEGATIVE_PROMPT_BASE: string` + - `function buildPrompt(spec: GenerationSpec): { prompt: string; negativePrompt: string }` + +This is the **single home** for all prompt/style strings (v1 duplicated them across provider files — do not repeat that). Port the preset wording from `origin/main:lib/ai/stability.ts` (`stylePrompts`, `universalNegativePrompt`) and the template fork's `../ai-sdk-image-generator/lib/prompt-builder.ts`, merged and deduplicated. + +- [ ] **Step 1: Write the failing test** (`lib/ai/prompt-builder.test.ts`) + +```ts +import { describe, expect, it } from "vitest"; +import { buildPrompt, STYLE_PRESETS, EMOTION_PRESETS } from "./prompt-builder"; + +describe("buildPrompt", () => { + it("composes subject, style, and emotion into the prompt", () => { + const { prompt, negativePrompt } = buildPrompt({ + subject: "a developer reviewing code at night", + stylePreset: "tech", + emotionPreset: "excited", + overlayText: "AI CODE REVIEW", + }); + expect(prompt).toContain("a developer reviewing code at night"); + expect(prompt).toContain(STYLE_PRESETS.tech.positive); + expect(prompt).toContain(EMOTION_PRESETS.excited); + expect(prompt).toMatch(/AI CODE REVIEW/); + expect(negativePrompt.length).toBeGreaterThan(10); + }); + + it("throws on unknown style preset", () => { + expect(() => buildPrompt({ subject: "x", stylePreset: "vaporwave" })).toThrow(/unknown style/i); + }); + + it("omits overlay-text instruction when absent", () => { + const { prompt } = buildPrompt({ subject: "cat", stylePreset: "minimal" }); + expect(prompt).not.toMatch(/text overlay/i); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** — `pnpm vitest run lib/ai/prompt-builder.test.ts` → FAIL. + +- [ ] **Step 3: Implement `lib/ai/prompt-builder.ts`** + +```ts +import type { GenerationSpec } from "./types"; + +export const STYLE_PRESETS: Record = { + bold: { label: "Bold & Punchy", positive: "bold vibrant colors, high contrast, dramatic lighting, eye-catching composition" }, + minimal: { label: "Minimal", positive: "clean minimal design, generous negative space, flat modern aesthetic" }, + tech: { label: "Tech", positive: "sleek futuristic tech aesthetic, neon accents, digital interface elements, crisp detail" }, + gaming: { label: "Gaming", positive: "energetic gaming aesthetic, saturated neon-punk palette, dynamic action framing" }, + vlog: { label: "Vlog", positive: "warm lifestyle photography, natural light, authentic candid feel, shallow depth of field" }, + photoreal: { label: "Photoreal", positive: "photorealistic, professional DSLR photo, sharp focus, studio lighting" }, +}; + +export const EMOTION_PRESETS: Record = { + excited: "excited energetic expression, dynamic pose", + shocked: "shocked wide-eyed expression, dramatic reaction", + serious: "serious focused expression, confident posture", + happy: "warm genuine smile, welcoming body language", + curious: "curious intrigued expression, leaning in", +}; + +export const NEGATIVE_PROMPT_BASE = + "blurry, low quality, distorted, deformed, disfigured, watermark, signature, low resolution, oversaturated, amateurish, cluttered composition"; + +export function buildPrompt(spec: GenerationSpec): { prompt: string; negativePrompt: string } { + const style = STYLE_PRESETS[spec.stylePreset]; + if (!style) throw new Error(`Unknown style preset: ${spec.stylePreset}`); + + const parts: string[] = []; + if (spec.title) parts.push(`YouTube thumbnail for a video titled "${spec.title}".`); + parts.push(spec.subject); + parts.push(style.positive); + if (spec.emotionPreset && EMOTION_PRESETS[spec.emotionPreset]) parts.push(EMOTION_PRESETS[spec.emotionPreset]); + if (spec.overlayText) { + parts.push(`prominent bold text overlay reading "${spec.overlayText}", large legible typography`); + } + parts.push("16:9 composition, thumbnail-optimized framing, high detail"); + + const negatives = [NEGATIVE_PROMPT_BASE, style.negative].filter(Boolean).join(", "); + return { prompt: parts.join(", "), negativePrompt: negatives }; +} +``` + +- [ ] **Step 4: Run tests** — `pnpm vitest run lib/ai/prompt-builder.test.ts` → PASS. +- [ ] **Step 5: Commit** — `git commit -am "feat(engine): neutral prompt builder with thumbnail presets"` + +--- + +### Task 4: Stability adapter (custom AI SDK image model) + +**Files:** +- Create: `lib/ai/adapters/stability.ts` (replace stub) +- Test: `lib/ai/adapters/stability.test.ts` + +**Interfaces:** +- Produces: `createStabilityImageModel(modelId: string): ImageModel` — an object implementing the installed AI SDK image-model spec, calling Stability's current REST API (`https://api.stability.ai/v2beta/stable-image/generate/sd3`) with `STABILITY_API_KEY`. + +**Spec-version pin (do this first):** open `node_modules/@ai-sdk/provider/dist/index.d.ts` and find the exported image-model interface (`ImageModelV4` on current main: `{ specificationVersion: 'v4', provider, modelId, maxImagesPerCall, doGenerate(options) }`, where `doGenerate` receives `{ prompt, n, size, aspectRatio, seed, providerOptions, abortSignal, headers }` and returns `{ images: Array | Array, warnings, response: { timestamp, modelId, headers } }`). If the installed package exports `ImageModelV3`/`V2` instead, use that interface and its `specificationVersion` string — the shape is near-identical. The code below assumes V4; adjust the import and literal accordingly. + +- [ ] **Step 1: Write the failing test** (`lib/ai/adapters/stability.test.ts`) + +```ts +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createStabilityImageModel } from "./stability"; + +const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + +describe("stability adapter", () => { + beforeEach(() => { + process.env.STABILITY_API_KEY = "sk-stability-test"; + vi.stubGlobal("fetch", vi.fn(async () => + new Response(PNG_BYTES, { status: 200, headers: { "content-type": "image/png" } }), + )); + }); + afterEach(() => vi.unstubAllGlobals()); + + it("calls the Stability REST API and returns image bytes", async () => { + const model = createStabilityImageModel("sd3.5-large"); + const result = await model.doGenerate({ + prompt: "a red fox", + n: 1, + aspectRatio: "16:9", + size: undefined, + seed: 42, + providerOptions: {}, + }); + expect(result.images).toHaveLength(1); + expect(result.images[0]).toBeInstanceOf(Uint8Array); + const call = (fetch as ReturnType).mock.calls[0]; + expect(String(call[0])).toContain("api.stability.ai"); + expect(call[1].headers.Authorization).toBe("Bearer sk-stability-test"); + }); + + it("throws a descriptive error on non-200 responses", async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ errors: ["invalid prompt"] }), { status: 400 }), + ); + const model = createStabilityImageModel("sd3.5-large"); + await expect( + model.doGenerate({ prompt: "x", n: 1, size: undefined, aspectRatio: "16:9", seed: undefined, providerOptions: {} }), + ).rejects.toThrow(/stability/i); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** — `pnpm vitest run lib/ai/adapters/stability.test.ts` → FAIL (stub throws "not implemented"). + +- [ ] **Step 3: Implement the adapter** + +Before writing, verify the request format against https://platform.stability.ai/docs/api-reference (Stable Image Generate endpoints; the v1 SDXL endpoint v1 used is deprecated). Current shape: multipart form (`prompt`, `negative_prompt`, `aspect_ratio`, `seed`, `output_format`) with `Accept: image/*`. + +```ts +import type { ImageModelV4, ImageModelV4CallOptions } from "@ai-sdk/provider"; + +const STABILITY_URL = "https://api.stability.ai/v2beta/stable-image/generate/sd3"; + +export function createStabilityImageModel(modelId: string): ImageModelV4 { + return { + specificationVersion: "v4", + provider: "stability", + modelId, + maxImagesPerCall: 1, + async doGenerate(options: ImageModelV4CallOptions) { + const apiKey = process.env.STABILITY_API_KEY; + if (!apiKey) throw new Error("STABILITY_API_KEY is not set"); + + const form = new FormData(); + form.set("prompt", options.prompt ?? ""); + form.set("model", modelId); + form.set("output_format", "png"); + if (options.aspectRatio) form.set("aspect_ratio", options.aspectRatio); + if (options.seed !== undefined) form.set("seed", String(options.seed)); + const negative = (options.providerOptions?.stability as { negativePrompt?: string } | undefined)?.negativePrompt; + if (negative) form.set("negative_prompt", negative); + + const res = await fetch(STABILITY_URL, { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}`, Accept: "image/*" }, + body: form, + signal: options.abortSignal, + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`Stability API error ${res.status}: ${detail.slice(0, 300)}`); + } + const bytes = new Uint8Array(await res.arrayBuffer()); + return { + images: [bytes], + warnings: [], + response: { timestamp: new Date(), modelId, headers: undefined }, + }; + }, + }; +} +``` + +- [ ] **Step 4: Run tests** — `pnpm vitest run lib/ai/adapters/stability.test.ts` and `lib/ai/registry.test.ts` → PASS. +- [ ] **Step 5: Commit** — `git commit -am "feat(engine): Stability custom image-model adapter"` + +--- + +### Task 5: HuggingFace adapter + +**Files:** +- Create: `lib/ai/adapters/huggingface.ts` (replace stub) +- Test: `lib/ai/adapters/huggingface.test.ts` + +**Interfaces:** +- Produces: `createHuggingFaceImageModel(modelId: string): ImageModel` — same spec interface as Task 4, calling the HF Inference API (`https://router.huggingface.co/hf-inference/models/{modelId}`) with `HUGGINGFACE_API_KEY`. Raw `fetch`, no `@huggingface/inference` dependency. + +- [ ] **Step 1: Write the failing test** (`lib/ai/adapters/huggingface.test.ts`) — mirror of Task 4's test: stub `fetch` returning PNG bytes; assert URL contains `huggingface.co` and the model id, `Authorization: Bearer` header, `images[0] instanceof Uint8Array`; assert a 503 response (HF model cold-start) rejects with a message matching `/loading|huggingface/i`. + +```ts +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createHuggingFaceImageModel } from "./huggingface"; + +const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + +describe("huggingface adapter", () => { + beforeEach(() => { + process.env.HUGGINGFACE_API_KEY = "hf_test"; + vi.stubGlobal("fetch", vi.fn(async () => + new Response(PNG_BYTES, { status: 200, headers: { "content-type": "image/png" } }), + )); + }); + afterEach(() => vi.unstubAllGlobals()); + + it("POSTs to the model inference URL with inputs payload", async () => { + const model = createHuggingFaceImageModel("stabilityai/stable-diffusion-xl-base-1.0"); + const result = await model.doGenerate({ + prompt: "a red fox", n: 1, size: "1344x768", aspectRatio: undefined, seed: undefined, providerOptions: {}, + }); + expect(result.images[0]).toBeInstanceOf(Uint8Array); + const [url, init] = (fetch as ReturnType).mock.calls[0]; + expect(String(url)).toContain("stabilityai/stable-diffusion-xl-base-1.0"); + expect(init.headers.Authorization).toBe("Bearer hf_test"); + expect(JSON.parse(init.body).inputs).toBe("a red fox"); + }); + + it("surfaces cold-start/unavailable errors", async () => { + vi.mocked(fetch).mockResolvedValueOnce(new Response("model loading", { status: 503 })); + const model = createHuggingFaceImageModel("stabilityai/stable-diffusion-xl-base-1.0"); + await expect( + model.doGenerate({ prompt: "x", n: 1, size: undefined, aspectRatio: undefined, seed: undefined, providerOptions: {} }), + ).rejects.toThrow(/huggingface/i); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** — FAIL (stub). + +- [ ] **Step 3: Implement** — verify the current inference endpoint at https://huggingface.co/docs/inference-providers first (the router URL replaced `api-inference.huggingface.co`): + +```ts +import type { ImageModelV4, ImageModelV4CallOptions } from "@ai-sdk/provider"; + +export function createHuggingFaceImageModel(modelId: string): ImageModelV4 { + return { + specificationVersion: "v4", + provider: "huggingface", + modelId, + maxImagesPerCall: 1, + async doGenerate(options: ImageModelV4CallOptions) { + const apiKey = process.env.HUGGINGFACE_API_KEY; + if (!apiKey) throw new Error("HUGGINGFACE_API_KEY is not set"); + + const [width, height] = options.size ? options.size.split("x").map(Number) : [1344, 768]; + const negative = (options.providerOptions?.huggingface as { negativePrompt?: string } | undefined)?.negativePrompt; + + const res = await fetch(`https://router.huggingface.co/hf-inference/models/${modelId}`, { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + inputs: options.prompt ?? "", + parameters: { width, height, ...(negative ? { negative_prompt: negative } : {}) }, + }), + signal: options.abortSignal, + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`HuggingFace API error ${res.status}: ${detail.slice(0, 300)}`); + } + const bytes = new Uint8Array(await res.arrayBuffer()); + return { images: [bytes], warnings: [], response: { timestamp: new Date(), modelId, headers: undefined } }; + }, + }; +} +``` + +- [ ] **Step 4: Run tests** — PASS. Run the whole suite: `pnpm test` → all green. +- [ ] **Step 5: Commit** — `git commit -am "feat(engine): HuggingFace custom image-model adapter"` + +--- + +### Task 6: Firebase client + admin setup + +**Files:** +- Create: `lib/firebase/client.ts`, `lib/firebase/admin.ts`, `lib/firebase/auth-client.ts` +- Test: `lib/firebase/auth-client.test.ts` + +**Interfaces:** +- Produces: + - `client.ts`: `const firebaseApp`, `const auth` (client `Auth`), `const db` (client `Firestore`). Throws at init if any `NEXT_PUBLIC_FIREBASE_*` var is missing (no silent `"demo-key"` fallbacks — that was a v1 defect). + - `admin.ts`: `function adminApp(): App`, `function adminAuth(): Auth`, `function adminDb(): Firestore` — lazy singletons; init from `FIREBASE_PROJECT_ID`/`FIREBASE_CLIENT_EMAIL`/`FIREBASE_PRIVATE_KEY` (with `\n` unescaping), or bare `projectId` when `FIRESTORE_EMULATOR_HOST` is set. + - `auth-client.ts`: `signInWithGoogle()`, `signInWithGithub()`, `signInWithPassword(email, pw)`, `registerWithPassword(name, email, pw)`, `sendReset(email)`, `signOutUser()`, and `ensureUserDoc(user: User): Promise` — **called by every sign-in path**, `setDoc(..., { merge: true })` on `users/{uid}` with `{ displayName, email, photoURL, plan: "free", createdAt }` (only sets `plan`/`createdAt` when absent). + +- [ ] **Step 1: Write the failing test** for the one pure-logic piece, `ensureUserDoc`'s payload builder. Export a helper `buildUserDocPayload(user, existing)` and test it: + +```ts +import { describe, expect, it } from "vitest"; +import { buildUserDocPayload } from "./auth-client"; + +describe("buildUserDocPayload", () => { + it("initializes plan and quota fields for new users", () => { + const p = buildUserDocPayload( + { uid: "u1", displayName: "Al", email: "a@b.c", photoURL: null } as never, + undefined, + ); + expect(p.plan).toBe("free"); + expect(p.quotaUsedToday).toBe(0); + expect(p.displayName).toBe("Al"); + }); + + it("does not overwrite plan or quota for existing users", () => { + const p = buildUserDocPayload( + { uid: "u1", displayName: "Al", email: "a@b.c", photoURL: null } as never, + { plan: "pro", quotaUsedToday: 7 }, + ); + expect(p.plan).toBeUndefined(); + expect(p.quotaUsedToday).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure**, then implement the three files. `auth-client.ts` core: + +```ts +import { GithubAuthProvider, GoogleAuthProvider, createUserWithEmailAndPassword, sendPasswordResetEmail, signInWithEmailAndPassword, signInWithPopup, signOut, updateProfile, type User } from "firebase/auth"; +import { doc, getDoc, serverTimestamp, setDoc } from "firebase/firestore"; +import { auth, db } from "./client"; + +export function buildUserDocPayload( + user: Pick, + existing: { plan?: string; quotaUsedToday?: number } | undefined, +) { + return { + displayName: user.displayName ?? "", + email: user.email ?? "", + photoURL: user.photoURL ?? null, + ...(existing?.plan ? {} : { plan: "free" as const }), + ...(existing?.quotaUsedToday !== undefined ? {} : { quotaUsedToday: 0, quotaResetAt: null }), + ...(existing ? {} : { createdAt: serverTimestamp() }), + }; +} + +export async function ensureUserDoc(user: User): Promise { + const ref = doc(db, "users", user.uid); + const snap = await getDoc(ref); + await setDoc(ref, buildUserDocPayload(user, snap.exists() ? (snap.data() as never) : undefined), { merge: true }); +} + +export async function signInWithGoogle() { + const cred = await signInWithPopup(auth, new GoogleAuthProvider()); + await ensureUserDoc(cred.user); + return cred.user; +} +export async function signInWithGithub() { + const cred = await signInWithPopup(auth, new GithubAuthProvider()); + await ensureUserDoc(cred.user); + return cred.user; +} +export async function signInWithPassword(email: string, password: string) { + const cred = await signInWithEmailAndPassword(auth, email, password); + await ensureUserDoc(cred.user); + return cred.user; +} +export async function registerWithPassword(displayName: string, email: string, password: string) { + const cred = await createUserWithEmailAndPassword(auth, email, password); + await updateProfile(cred.user, { displayName }); + await ensureUserDoc(cred.user); + return cred.user; +} +export const sendReset = (email: string) => sendPasswordResetEmail(auth, email); +export const signOutUser = () => signOut(auth); +``` + +`admin.ts`: + +```ts +import { cert, getApps, initializeApp, type App } from "firebase-admin/app"; +import { getAuth, type Auth } from "firebase-admin/auth"; +import { getFirestore, type Firestore } from "firebase-admin/firestore"; + +let app: App | undefined; + +export function adminApp(): App { + if (app) return app; + if (getApps().length) return (app = getApps()[0]); + const projectId = process.env.FIREBASE_PROJECT_ID; + if (!projectId) throw new Error("FIREBASE_PROJECT_ID is not set"); + if (process.env.FIRESTORE_EMULATOR_HOST) { + app = initializeApp({ projectId }); + } else { + const clientEmail = process.env.FIREBASE_CLIENT_EMAIL; + const privateKey = process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, "\n"); + if (!clientEmail || !privateKey) throw new Error("Firebase admin credentials are not set"); + app = initializeApp({ credential: cert({ projectId, clientEmail, privateKey }) }); + } + return app; +} +export const adminAuth = (): Auth => getAuth(adminApp()); +export const adminDb = (): Firestore => getFirestore(adminApp()); +``` + +`client.ts` reads the four `NEXT_PUBLIC_FIREBASE_*` vars, throws listing any that are missing, then `initializeApp` + `getAuth` + `getFirestore` (guard `getApps().length` for HMR). + +- [ ] **Step 3: Run tests** — `pnpm vitest run lib/firebase` → PASS; `pnpm build` still green. +- [ ] **Step 4: Commit** — `git commit -am "feat(auth): firebase client/admin setup, user doc on every sign-in path"` + +--- + +### Task 7: Quota module (transactional) + +**Files:** +- Create: `lib/quota/plans.ts`, `lib/quota/consume.ts`, `firebase.json` (emulator config only) +- Test: `lib/quota/consume.test.ts` (runs against the Firestore emulator) + +**Interfaces:** +- Produces: + - `plans.ts`: `const PLANS = { free: { dailyGenerations: 10 } } as const; type PlanId = keyof typeof PLANS; function planCap(plan: string): number` (unknown plan → free cap). + - `consume.ts`: `async function consumeQuota(uid: string, count: number): Promise<{ ok: true; remaining: number } | { ok: false; resetAt: Date }>` — Firestore transaction on `users/{uid}`: lazily reset `quotaUsedToday` to 0 when `quotaResetAt` ≤ now (next reset = next UTC midnight), then increment by `count` iff it stays ≤ cap; otherwise abort with `ok: false`. Missing user doc → treat as free plan with zero usage and create the fields. + +- [ ] **Step 1: Create `firebase.json`** (replaces the deleted hosting config — emulators only, no hosting block): + +```json +{ + "firestore": { "rules": "firestore.rules" }, + "emulators": { + "firestore": { "port": 8080 }, + "auth": { "port": 9099 }, + "ui": { "enabled": false } + } +} +``` + +Add a placeholder `firestore.rules` (finalized in Task 13): + +``` +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + match /{document=**} { allow read, write: if false; } + } +} +``` + +- [ ] **Step 2: Write the failing test** (`lib/quota/consume.test.ts`) + +```ts +import { beforeEach, describe, expect, it } from "vitest"; +import { adminDb } from "@/lib/firebase/admin"; +import { consumeQuota } from "./consume"; + +// Requires FIRESTORE_EMULATOR_HOST — run via `pnpm test:quota`. +describe("consumeQuota", () => { + beforeEach(async () => { + await adminDb().doc("users/u1").set({ plan: "free", quotaUsedToday: 0, quotaResetAt: null }); + }); + + it("increments usage and reports remaining", async () => { + const r = await consumeQuota("u1", 3); + expect(r).toEqual({ ok: true, remaining: 7 }); + const snap = await adminDb().doc("users/u1").get(); + expect(snap.get("quotaUsedToday")).toBe(3); + }); + + it("rejects when the cap would be exceeded", async () => { + await adminDb().doc("users/u1").set({ plan: "free", quotaUsedToday: 9, quotaResetAt: null }, { merge: true }); + const r = await consumeQuota("u1", 2); + expect(r.ok).toBe(false); + }); + + it("lazily resets when quotaResetAt has passed", async () => { + await adminDb().doc("users/u1").set( + { quotaUsedToday: 10, quotaResetAt: new Date(Date.now() - 60_000) }, { merge: true }, + ); + const r = await consumeQuota("u1", 1); + expect(r).toEqual({ ok: true, remaining: 9 }); + }); + + it("creates quota fields for a missing user doc", async () => { + const r = await consumeQuota("brand-new-uid", 1); + expect(r).toEqual({ ok: true, remaining: 9 }); + }); +}); +``` + +Add to `package.json` scripts: + +```json +"test:quota": "firebase emulators:exec --only firestore --project demo-pixelai \"cross-env FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 FIREBASE_PROJECT_ID=demo-pixelai vitest run lib/quota\"" +``` + +(`pnpm add -D cross-env`. Exclude `lib/quota/**` from the default `test` script via `vitest.config.ts` `test.exclude` so plain `pnpm test` doesn't require the emulator.) + +- [ ] **Step 3: Run to verify failure** — `pnpm test:quota` → FAIL (consume.ts missing). + +- [ ] **Step 4: Implement `lib/quota/plans.ts` and `lib/quota/consume.ts`** + +```ts +// plans.ts +export const PLANS = { free: { dailyGenerations: 10 } } as const; +export type PlanId = keyof typeof PLANS; +export function planCap(plan: string | undefined): number { + return PLANS[(plan as PlanId) ?? "free"]?.dailyGenerations ?? PLANS.free.dailyGenerations; +} +``` + +```ts +// consume.ts +import { Timestamp } from "firebase-admin/firestore"; +import { adminDb } from "@/lib/firebase/admin"; +import { planCap } from "./plans"; + +function nextUtcMidnight(now: Date): Date { + const d = new Date(now); + d.setUTCHours(24, 0, 0, 0); + return d; +} + +export async function consumeQuota( + uid: string, + count: number, +): Promise<{ ok: true; remaining: number } | { ok: false; resetAt: Date }> { + const ref = adminDb().doc(`users/${uid}`); + return adminDb().runTransaction(async (tx) => { + const snap = await tx.get(ref); + const now = new Date(); + const data = snap.exists ? snap.data()! : {}; + const resetAtRaw = data.quotaResetAt as Timestamp | Date | null | undefined; + const resetAt = resetAtRaw instanceof Timestamp ? resetAtRaw.toDate() : resetAtRaw ?? null; + + let used = (data.quotaUsedToday as number) ?? 0; + let nextReset = resetAt; + if (!nextReset || nextReset <= now) { + used = 0; + nextReset = nextUtcMidnight(now); + } + + const cap = planCap(data.plan as string | undefined); + if (used + count > cap) return { ok: false as const, resetAt: nextReset }; + + tx.set(ref, { plan: data.plan ?? "free", quotaUsedToday: used + count, quotaResetAt: nextReset }, { merge: true }); + return { ok: true as const, remaining: cap - used - count }; + }); +} +``` + +- [ ] **Step 5: Run tests** — `pnpm test:quota` → PASS (4 tests). +- [ ] **Step 6: Commit** — `git commit -am "feat(quota): transactional daily quota with lazy UTC reset"` + +--- + +### Task 8: Blob storage module + +**Files:** +- Create: `lib/storage/blob.ts` +- Test: `lib/storage/blob.test.ts` + +**Interfaces:** +- Produces: + - `async function saveGeneratedImage(args: { uid: string; generationId: string; bytes: Uint8Array; contentType: string }): Promise<{ url: string }>` — `put()` to `pixelai/{uid}/{generationId}.png` with `access: "public"`, `contentType`, `addRandomSuffix: true`. + - `async function deleteGeneratedImage(url: string): Promise` — `del(url)`. + +- [ ] **Step 1: Write the failing test** (`lib/storage/blob.test.ts`) + +```ts +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@vercel/blob", () => ({ + put: vi.fn(async (pathname: string) => ({ url: `https://blob.test/${pathname}` })), + del: vi.fn(async () => undefined), +})); + +import { del, put } from "@vercel/blob"; +import { deleteGeneratedImage, saveGeneratedImage } from "./blob"; + +describe("blob storage", () => { + it("uploads under the user's folder with public access", async () => { + const { url } = await saveGeneratedImage({ + uid: "u1", generationId: "g1", bytes: new Uint8Array([1]), contentType: "image/png", + }); + expect(url).toContain("pixelai/u1/g1"); + expect(vi.mocked(put).mock.calls[0][2]).toMatchObject({ + access: "public", contentType: "image/png", addRandomSuffix: true, + }); + }); + + it("deletes by url", async () => { + await deleteGeneratedImage("https://blob.test/x"); + expect(del).toHaveBeenCalledWith("https://blob.test/x"); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure**, then implement: + +```ts +import { del, put } from "@vercel/blob"; + +export async function saveGeneratedImage(args: { + uid: string; generationId: string; bytes: Uint8Array; contentType: string; +}): Promise<{ url: string }> { + const ext = args.contentType === "image/webp" ? "webp" : args.contentType === "image/jpeg" ? "jpg" : "png"; + const blob = await put(`pixelai/${args.uid}/${args.generationId}.${ext}`, Buffer.from(args.bytes), { + access: "public", + contentType: args.contentType, + addRandomSuffix: true, + }); + return { url: blob.url }; +} + +export async function deleteGeneratedImage(url: string): Promise { + await del(url); +} +``` + +- [ ] **Step 3: Run tests** → PASS. **Step 4: Commit** — `git commit -am "feat(storage): blob upload/delete for generated images"` + +--- + +### Task 9: Request auth helper + `POST /api/generate` + +**Files:** +- Create: `lib/auth/verify-request.ts`, `lib/api/errors.ts`, `app/api/generate/route.ts` +- Test: `lib/api/errors.test.ts`, `app/api/generate/route.test.ts` + +**Interfaces:** +- Consumes: `getProvider`, `isModelAllowed` (Task 2); `buildPrompt` (Task 3); `consumeQuota` (Task 7); `saveGeneratedImage` (Task 8); `adminAuth`, `adminDb` (Task 6). +- Produces: + - `verifyRequest(req: Request): Promise<{ uid: string } | null>` — reads `Authorization: Bearer `, verifies via `adminAuth().verifyIdToken`; returns `{ uid: "dev-user" }` when `AUTH_DEV_BYPASS === "1"` **and** `process.env.NODE_ENV !== "production"` **and** `process.env.VERCEL_ENV !== "production"`. + - `classifyError(err: unknown): { code: ApiErrorCode; message: string; status: number }` in `lib/api/errors.ts`. + - Route request body (zod-validated): `{ provider: ProviderKey; modelId?: string; spec: GenerationSpec }`. Response 200: `GenerationResult`. Errors per Global Constraints. + +- [ ] **Step 1: Write failing tests for `classifyError`** (`lib/api/errors.test.ts`) + +```ts +import { describe, expect, it } from "vitest"; +import { classifyError } from "./errors"; + +describe("classifyError", () => { + it("maps abort/timeout errors", () => { + const e = new DOMException("The operation timed out", "TimeoutError"); + expect(classifyError(e)).toMatchObject({ code: "timeout", status: 504 }); + }); + it("maps content-policy messages", () => { + expect(classifyError(new Error("Your request was rejected by the safety system"))).toMatchObject({ + code: "content_policy", status: 422, + }); + }); + it("falls back to provider_error without leaking detail", () => { + const r = classifyError(new Error("secret key sk-123 invalid")); + expect(r.code).toBe("provider_error"); + expect(r.message).not.toContain("sk-123"); + }); +}); +``` + +- [ ] **Step 2: Implement `lib/api/errors.ts`** + +```ts +import type { ApiErrorCode } from "@/lib/ai/types"; + +export function classifyError(err: unknown): { code: ApiErrorCode; message: string; status: number } { + const msg = err instanceof Error ? err.message : String(err); + const name = err instanceof Error ? err.name : ""; + if (name === "TimeoutError" || name === "AbortError" || /timed? ?out/i.test(msg)) + return { code: "timeout", message: "The provider took too long. Try again or pick a faster model.", status: 504 }; + if (/safety|content.polic|moderation|nsfw/i.test(msg)) + return { code: "content_policy", message: "The prompt was rejected by the provider's content policy.", status: 422 }; + if (/rate.?limit|429/i.test(msg)) + return { code: "provider_error", message: "The provider is rate-limiting requests. Try again shortly.", status: 502 }; + return { code: "provider_error", message: "Image generation failed. Please try again.", status: 502 }; +} +``` + +- [ ] **Step 3: Implement `lib/auth/verify-request.ts`** + +```ts +import { adminAuth } from "@/lib/firebase/admin"; + +export async function verifyRequest(req: Request): Promise<{ uid: string } | null> { + if ( + process.env.AUTH_DEV_BYPASS === "1" && + process.env.NODE_ENV !== "production" && + process.env.VERCEL_ENV !== "production" + ) { + return { uid: "dev-user" }; + } + const header = req.headers.get("authorization"); + const token = header?.startsWith("Bearer ") ? header.slice(7) : undefined; + if (!token) return null; + try { + const decoded = await adminAuth().verifyIdToken(token); + return { uid: decoded.uid }; + } catch { + return null; + } +} +``` + +- [ ] **Step 4: Write failing route tests** (`app/api/generate/route.test.ts`) — mock every collaborator; test the route's control flow, not the SDKs: + +```ts +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/auth/verify-request", () => ({ verifyRequest: vi.fn() })); +vi.mock("@/lib/quota/consume", () => ({ consumeQuota: vi.fn() })); +vi.mock("@/lib/storage/blob", () => ({ saveGeneratedImage: vi.fn(async () => ({ url: "https://blob.test/img.png" })) })); +vi.mock("@/lib/firebase/admin", () => ({ + adminDb: vi.fn(() => ({ collection: () => ({ doc: () => ({ id: "gen123", set: vi.fn() }) }) })), +})); +vi.mock("ai", () => ({ + generateImage: vi.fn(async () => ({ + image: { uint8Array: new Uint8Array([1]), mediaType: "image/png" }, + })), +})); + +import { generateImage } from "ai"; +import { verifyRequest } from "@/lib/auth/verify-request"; +import { consumeQuota } from "@/lib/quota/consume"; +import { POST } from "./route"; + +const validBody = { + provider: "replicate", + spec: { subject: "a red fox", stylePreset: "tech" }, +}; +const makeReq = (body: unknown) => + new Request("http://test/api/generate", { method: "POST", body: JSON.stringify(body) }); + +describe("POST /api/generate", () => { + beforeEach(() => { + vi.mocked(verifyRequest).mockResolvedValue({ uid: "u1" }); + vi.mocked(consumeQuota).mockResolvedValue({ ok: true, remaining: 9 }); + process.env.REPLICATE_API_TOKEN = "r8-test"; + }); + + it("returns 401 without valid auth", async () => { + vi.mocked(verifyRequest).mockResolvedValue(null); + const res = await POST(makeReq(validBody)); + expect(res.status).toBe(401); + expect((await res.json()).error.code).toBe("unauthenticated"); + }); + + it("returns 400 on invalid body", async () => { + const res = await POST(makeReq({ provider: "nope", spec: {} })); + expect(res.status).toBe(400); + }); + + it("returns 429 when quota is exhausted", async () => { + vi.mocked(consumeQuota).mockResolvedValue({ ok: false, resetAt: new Date() }); + const res = await POST(makeReq(validBody)); + expect(res.status).toBe(429); + expect((await res.json()).error.code).toBe("quota_exceeded"); + }); + + it("generates, persists, and returns the blob url", async () => { + const res = await POST(makeReq(validBody)); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.url).toBe("https://blob.test/img.png"); + expect(json.provider).toBe("replicate"); + expect(vi.mocked(generateImage).mock.calls[0][0]).toMatchObject({ aspectRatio: "16:9" }); + }); + + it("maps generation failures through classifyError", async () => { + vi.mocked(generateImage).mockRejectedValueOnce(new DOMException("timeout", "TimeoutError")); + const res = await POST(makeReq(validBody)); + expect(res.status).toBe(504); + }); +}); +``` + +- [ ] **Step 5: Implement `app/api/generate/route.ts`** + +```ts +import { generateImage } from "ai"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getProvider, isModelAllowed } from "@/lib/ai/registry"; +import { buildPrompt, STYLE_PRESETS, EMOTION_PRESETS } from "@/lib/ai/prompt-builder"; +import { classifyError } from "@/lib/api/errors"; +import { verifyRequest } from "@/lib/auth/verify-request"; +import { adminDb } from "@/lib/firebase/admin"; +import { consumeQuota } from "@/lib/quota/consume"; +import { saveGeneratedImage } from "@/lib/storage/blob"; + +export const maxDuration = 60; + +const TIMEOUT_MS = 55_000; + +const bodySchema = z.object({ + provider: z.enum(["openai", "replicate", "fal", "stability", "huggingface"]), + modelId: z.string().min(1).max(200).optional(), + spec: z.object({ + title: z.string().max(200).optional(), + subject: z.string().min(3).max(500), + stylePreset: z.enum(Object.keys(STYLE_PRESETS) as [string, ...string[]]), + emotionPreset: z.enum(Object.keys(EMOTION_PRESETS) as [string, ...string[]]).optional(), + overlayText: z.string().max(80).optional(), + }), +}); + +function err(code: string, message: string, status: number) { + return NextResponse.json({ error: { code, message } }, { status }); +} + +export async function POST(req: Request) { + const requestId = crypto.randomUUID().slice(0, 8); + + const authed = await verifyRequest(req); + if (!authed) return err("unauthenticated", "Sign in to generate images.", 401); + + let parsed; + try { + parsed = bodySchema.safeParse(await req.json()); + } catch { + return err("invalid_input", "Request body must be JSON.", 400); + } + if (!parsed.success) return err("invalid_input", parsed.error.issues[0]?.message ?? "Invalid input.", 400); + const { provider: providerKey, spec } = parsed.data; + + const provider = getProvider(providerKey); + if (!process.env[provider.envKey]) return err("invalid_input", `${provider.displayName} is not configured.`, 400); + const modelId = parsed.data.modelId ?? provider.defaultModel.quality; + if (!isModelAllowed(providerKey, modelId)) return err("invalid_input", "Unknown model for this provider.", 400); + + const quota = await consumeQuota(authed.uid, 1); + if (!quota.ok) { + return err("quota_exceeded", `Daily limit reached. Resets at ${quota.resetAt.toISOString()}.`, 429); + } + + const { prompt, negativePrompt } = buildPrompt(spec); + const dims = provider.dimensionFormat === "size" ? { size: "1792x1024" as const } : { aspectRatio: "16:9" as const }; + const started = Date.now(); + + try { + const { image } = await generateImage({ + model: provider.createModel(modelId), + prompt, + ...dims, + ...(providerKey !== "openai" ? { seed: Math.floor(Math.random() * 1_000_000) } : {}), + providerOptions: { + stability: { negativePrompt }, + huggingface: { negativePrompt }, + }, + abortSignal: AbortSignal.timeout(TIMEOUT_MS), + }); + const timingMs = Date.now() - started; + + const genRef = adminDb().collection("generations").doc(); + const { url } = await saveGeneratedImage({ + uid: authed.uid, + generationId: genRef.id, + bytes: image.uint8Array, + contentType: image.mediaType ?? "image/png", + }); + await genRef.set({ + uid: authed.uid, + prompt, + spec, + provider: providerKey, + modelId, + dimensions: dims, + blobUrl: url, + timingMs, + status: "succeeded", + createdAt: new Date(), + }); + + return NextResponse.json({ generationId: genRef.id, url, provider: providerKey, modelId, timingMs }); + } catch (e) { + const mapped = classifyError(e); + console.error(`[generate:${requestId}] ${providerKey}/${modelId} failed:`, e); + return err(mapped.code, mapped.message, mapped.status); + } +} +``` + +Note: quota is consumed even if generation then fails — acceptable for v1 (documented behavior); refunding on failure invites race bugs. Keep it. + +- [ ] **Step 6: Run tests** — `pnpm vitest run app/api lib/api` → PASS (all cases). +- [ ] **Step 7: Commit** — `git commit -am "feat(api): authenticated, quota-gated generate endpoint with persistence"` + +--- + +### Task 10: Client generation hook (fan-out) + +**Files:** +- Create: `hooks/use-generation.ts` +- Test: `hooks/use-generation.test.tsx` + +**Interfaces:** +- Consumes: `GenerationSpec`, `GenerationResult`, `ProviderKey` (Task 2/3 types); Firebase client `auth` for `getIdToken()`. +- Produces: + - `type ProviderRunState = { status: "idle" | "loading" | "done" | "error"; result?: GenerationResult; error?: { code: string; message: string }; startedAt?: number }` + - `function useGeneration(): { runs: Partial>; isGenerating: boolean; generate(spec: GenerationSpec, providers: ProviderKey[], mode: ModelMode): Promise; retry(provider: ProviderKey): Promise; reset(): void }` + - Fan-out: one `fetch("/api/generate")` per provider, all in flight concurrently, each updating its own `runs[provider]` as it settles (no `Promise.all` barrier for UI updates). + +- [ ] **Step 1: Write the failing test** (`hooks/use-generation.test.tsx`) + +```tsx +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/firebase/client", () => ({ + auth: { currentUser: { getIdToken: vi.fn(async () => "id-token") } }, +})); + +import { useGeneration } from "./use-generation"; + +const spec = { subject: "a red fox", stylePreset: "tech" }; + +describe("useGeneration", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + it("runs providers independently — one failure doesn't block the other", async () => { + vi.mocked(fetch).mockImplementation(async (url, init) => { + const body = JSON.parse(String(init!.body)); + if (body.provider === "openai") + return new Response(JSON.stringify({ error: { code: "provider_error", message: "boom" } }), { status: 502 }); + return new Response( + JSON.stringify({ generationId: "g1", url: "https://blob.test/a.png", provider: body.provider, modelId: "m", timingMs: 1200 }), + { status: 200 }, + ); + }); + + const { result } = renderHook(() => useGeneration()); + await act(() => result.current.generate(spec, ["openai", "replicate"], "quality")); + + await waitFor(() => { + expect(result.current.runs.replicate?.status).toBe("done"); + expect(result.current.runs.openai?.status).toBe("error"); + }); + expect(result.current.runs.replicate?.result?.url).toBe("https://blob.test/a.png"); + expect(result.current.isGenerating).toBe(false); + }); + + it("sends the bearer token", async () => { + vi.mocked(fetch).mockResolvedValue( + new Response(JSON.stringify({ generationId: "g", url: "u", provider: "fal", modelId: "m", timingMs: 1 })), + ); + const { result } = renderHook(() => useGeneration()); + await act(() => result.current.generate(spec, ["fal"], "performance")); + const init = vi.mocked(fetch).mock.calls[0][1]!; + expect((init.headers as Record).Authorization).toBe("Bearer id-token"); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure**, then implement `hooks/use-generation.ts`: + +```ts +"use client"; +import { useCallback, useRef, useState } from "react"; +import type { GenerationResult, GenerationSpec, ModelMode, ProviderKey } from "@/lib/ai/types"; +import { auth } from "@/lib/firebase/client"; + +export type ProviderRunState = { + status: "idle" | "loading" | "done" | "error"; + result?: GenerationResult; + error?: { code: string; message: string }; + startedAt?: number; +}; + +export function useGeneration() { + const [runs, setRuns] = useState>>({}); + const lastArgs = useRef<{ spec: GenerationSpec; mode: ModelMode } | null>(null); + + const runOne = useCallback(async (provider: ProviderKey, spec: GenerationSpec, mode: ModelMode) => { + setRuns((r) => ({ ...r, [provider]: { status: "loading", startedAt: Date.now() } })); + try { + const token = await auth.currentUser?.getIdToken(); + const res = await fetch("/api/generate", { + method: "POST", + headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: JSON.stringify({ provider, spec, mode }), + }); + const json = await res.json(); + if (!res.ok) { + setRuns((r) => ({ ...r, [provider]: { status: "error", error: json.error ?? { code: "provider_error", message: "Failed" } } })); + } else { + setRuns((r) => ({ ...r, [provider]: { status: "done", result: json } })); + } + } catch { + setRuns((r) => ({ ...r, [provider]: { status: "error", error: { code: "provider_error", message: "Network error" } } })); + } + }, []); + + const generate = useCallback( + async (spec: GenerationSpec, providers: ProviderKey[], mode: ModelMode) => { + lastArgs.current = { spec, mode }; + setRuns(Object.fromEntries(providers.map((p) => [p, { status: "loading", startedAt: Date.now() }]))); + await Promise.allSettled(providers.map((p) => runOne(p, spec, mode))); + }, + [runOne], + ); + + const retry = useCallback( + async (provider: ProviderKey) => { + if (lastArgs.current) await runOne(provider, lastArgs.current.spec, lastArgs.current.mode); + }, + [runOne], + ); + + const isGenerating = Object.values(runs).some((r) => r?.status === "loading"); + const reset = useCallback(() => setRuns({}), []); + + return { runs, isGenerating, generate, retry, reset }; +} +``` + +Also extend the Task 9 body schema with `mode: z.enum(["performance", "quality"]).optional()` and use it for the default model (`provider.defaultModel[mode ?? "quality"]`); update the route test's `validBody` accordingly. + +- [ ] **Step 3: Run tests** — `pnpm vitest run hooks` → PASS. +- [ ] **Step 4: Commit** — `git commit -am "feat(client): parallel fan-out generation hook with per-provider retry"` + +--- + +### Task 11: Auth context, gate, and forms + +**Files:** +- Create: `contexts/auth-context.tsx`, `components/auth/auth-gate.tsx`, `components/auth/login-form.tsx`, `components/auth/register-form.tsx`, `app/login/page.tsx`, `app/register/page.tsx` +- Modify: `app/layout.tsx` (wrap children in `AuthProvider`, add `` from sonner) + +**Interfaces:** +- Consumes: Task 6 `auth-client.ts` functions and `auth` from `lib/firebase/client`. +- Produces: + - `AuthProvider` + `useAuth(): { user: User | null; loading: boolean }` (subscribes to `onAuthStateChanged`). + - `` — client component: while `loading` render a skeleton; if no `user`, `router.replace("/login?next=" + pathname)`; else render children. **UX only — security is the API layer.** + - `/login` and `/register` pages using react-hook-form + zod: email/password fields, Google and GitHub buttons, "Forgot password?" link that calls `sendReset(email)` and toasts confirmation (this replaces v1's empty `resetPassword.tsx` stub). On success both redirect to `?next` or `/dashboard`. + +No unit tests for these (covered by the Task 16 Playwright smoke); the test cycle is typecheck + build + manual click-through. + +- [ ] **Step 1: Implement `contexts/auth-context.tsx`** + +```tsx +"use client"; +import { onAuthStateChanged, type User } from "firebase/auth"; +import { createContext, useContext, useEffect, useState } from "react"; +import { auth } from "@/lib/firebase/client"; + +const AuthContext = createContext<{ user: User | null; loading: boolean }>({ user: null, loading: true }); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + useEffect(() => onAuthStateChanged(auth, (u) => { setUser(u); setLoading(false); }), []); + return {children}; +} + +export const useAuth = () => useContext(AuthContext); +``` + +- [ ] **Step 2: Implement `components/auth/auth-gate.tsx`** + +```tsx +"use client"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAuth } from "@/contexts/auth-context"; + +export function AuthGate({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth(); + const router = useRouter(); + const pathname = usePathname(); + useEffect(() => { + if (!loading && !user) router.replace(`/login?next=${encodeURIComponent(pathname)}`); + }, [loading, user, router, pathname]); + if (loading) return
; + if (!user) return null; + return <>{children}; +} +``` + +- [ ] **Step 3: Implement login/register forms and pages.** Both forms: zod schema (`email: z.string().email()`, `password: z.string().min(8)`, register adds `displayName: z.string().min(2)`), `useForm` with `zodResolver`, shadcn `Input`/`Button`/`Label`/`Card`, social buttons calling `signInWithGoogle`/`signInWithGithub`, errors toasted via `sonner`. Login form includes: + +```tsx + +``` + +On submit success: `router.push(searchParams.get("next") ?? "/dashboard")`. + +- [ ] **Step 4: Wire `app/layout.tsx`** — wrap `{children}` with ``, render ``, set metadata `title: "PixelAI — AI Thumbnail Generator"`. + +- [ ] **Step 5: Verify** — `pnpm build` green; `pnpm dev`, visit `/login`, `/register`, confirm redirect from `/dashboard` when signed out (dashboard page exists in Task 12 — for now create `app/dashboard/page.tsx` containing just `
dashboard
`). + +- [ ] **Step 6: Commit** — `git commit -am "feat(auth): auth context, gate, login/register with password reset"` + +--- + +### Task 12: Generator surface (dashboard) + +**Files:** +- Create: `components/generator/thumbnail-form.tsx`, `components/generator/provider-select.tsx`, `components/generator/result-card.tsx`, `components/generator/generator.tsx` +- Modify: `app/dashboard/page.tsx` + +**Interfaces:** +- Consumes: `useGeneration` (Task 10), `STYLE_PRESETS`/`EMOTION_PRESETS` (Task 3), `PROVIDERS`/`PROVIDER_ORDER` (Task 2), `AuthGate` (Task 11). +- Produces: `/dashboard` — form on the left (subject textarea required, title input, style select, emotion select, overlay-text input, mode toggle via `Tabs`), provider multi-select checkboxes (default: first two enabled providers), Generate button; result grid on the right, one `ResultCard` per selected provider. + +Component behavior contracts: +- `ProviderSelect({ value, onChange }: { value: ProviderKey[]; onChange(v: ProviderKey[]): void })` — renders a checkbox row per provider in `PROVIDER_ORDER`. The list of *configured* providers comes from a tiny route `GET /api/providers` returning `{ providers: ProviderKey[] }` (implemented in this task — it calls `enabledProviders()`; unconfigured ones render disabled with "no API key" tooltip). +- `ResultCard({ provider, run, onRetry })` — `loading`: skeleton + elapsed seconds ticking; `done`: `` + provider name + `timingMs` badge + Download button (``); `error`: message + Retry button. +- `Generator` — owns form state; on submit calls `generate(spec, selectedProviders, mode)`; disables submit while `isGenerating`; "New generation" button calls `reset()`. Reads `?prompt=` from search params to pre-fill `subject` (landing hand-off), wrapped in ``. + +- [ ] **Step 1: Implement `GET /api/providers`** (`app/api/providers/route.ts`): + +```ts +import { NextResponse } from "next/server"; +import { enabledProviders } from "@/lib/ai/registry"; + +export function GET() { + return NextResponse.json({ providers: enabledProviders() }); +} +``` + +- [ ] **Step 2: Implement the four components** per the contracts above (complete JSX, shadcn primitives, no placeholder comments). `app/dashboard/page.tsx`: + +```tsx +import { Suspense } from "react"; +import { AuthGate } from "@/components/auth/auth-gate"; +import { Generator } from "@/components/generator/generator"; + +export default function DashboardPage() { + return ( + +
+ + + +
+
+ ); +} +``` + +- [ ] **Step 3: Verify** — `pnpm build` green. `pnpm dev` with `AUTH_DEV_BYPASS=1` and at least one real provider key: submit a generation, see the card go loading → image, confirm a `generations` doc appears in Firestore and the image URL is a Blob URL. This is the end-to-end proof for Tasks 2–10. +- [ ] **Step 4: Commit** — `git commit -am "feat(ui): dashboard generator with parallel provider result cards"` + +--- + +### Task 13: History page, delete route, Firestore rules + +**Files:** +- Create: `app/history/page.tsx`, `components/gallery/generation-grid.tsx`, `app/api/generations/[id]/route.ts` +- Modify: `firestore.rules` +- Test: `app/api/generations/[id]/route.test.ts` + +**Interfaces:** +- Consumes: `verifyRequest` (Task 9), `adminDb` (Task 6), `deleteGeneratedImage` (Task 8), client `db` for the history query. +- Produces: + - `DELETE /api/generations/:id` — 401 unauthenticated; 404 if doc missing **or** `doc.uid !== caller.uid` (no existence leak); else delete Blob then doc, return `{ ok: true }`. + - History page: client query `query(collection(db, "generations"), where("uid", "==", user.uid), orderBy("createdAt", "desc"), limit(50))`; grid cards with image, prompt excerpt, provider badge, date; per-card Delete (calls the route, optimistic removal, toast) and "Re-run" (router push `/dashboard?prompt=` + encoded subject). + +- [ ] **Step 1: Write failing delete-route tests** — mock `verifyRequest`, `adminDb` (doc `get` returning `{ exists, data: () => ({ uid, blobUrl }) }`, `delete`), `deleteGeneratedImage`. Cases: 401; 404 when `uid` mismatch; 200 deletes blob then doc (assert call order via `vi.mocked(...).mock.invocationCallOrder`). + +- [ ] **Step 2: Implement the route**: + +```ts +import { NextResponse } from "next/server"; +import { verifyRequest } from "@/lib/auth/verify-request"; +import { adminDb } from "@/lib/firebase/admin"; +import { deleteGeneratedImage } from "@/lib/storage/blob"; + +export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const authed = await verifyRequest(_req); + if (!authed) return NextResponse.json({ error: { code: "unauthenticated", message: "Sign in." } }, { status: 401 }); + + const { id } = await params; + const ref = adminDb().collection("generations").doc(id); + const snap = await ref.get(); + if (!snap.exists || snap.get("uid") !== authed.uid) + return NextResponse.json({ error: { code: "invalid_input", message: "Not found." } }, { status: 404 }); + + const blobUrl = snap.get("blobUrl") as string | undefined; + if (blobUrl) await deleteGeneratedImage(blobUrl); + await ref.delete(); + return NextResponse.json({ ok: true }); +} +``` + +(Confirm the `params: Promise<...>` signature against the installed Next major — it's a Promise since Next 15.) + +- [ ] **Step 3: Implement history page + grid** per the contract; wrap page in ``. + +- [ ] **Step 4: Finalize `firestore.rules`** + +``` +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + match /users/{uid} { + allow read, update: if request.auth != null && request.auth.uid == uid; + allow create: if request.auth != null && request.auth.uid == uid; + allow delete: if false; + } + match /generations/{id} { + allow read: if request.auth != null && resource.data.uid == request.auth.uid; + allow write: if false; // server (admin SDK) only + } + } +} +``` + +Client-side quota writes are blocked (`users` `update` allows profile edits but the API always trusts only its own transactional counts — acceptable v1 tradeoff; tighten with field-level rules in v2 if needed). + +- [ ] **Step 5: Run tests + build** — `pnpm test && pnpm build` → green. Manual: generate, open `/history`, delete one, confirm Blob and doc gone. +- [ ] **Step 6: Commit** — `git commit -am "feat(history): gallery, owner-scoped delete, firestore rules"` + +--- + +### Task 14: Landing page, navbar, and error boundary + +**Files:** +- Create: `components/navbar.tsx`, `app/error.tsx` +- Modify: `app/page.tsx`, `app/layout.tsx` (mount navbar) + +**Interfaces:** +- Consumes: `useAuth` (Task 11), `signOutUser` (Task 6). +- Produces: Landing hero (port copy from `origin/main:app/page.tsx`: "Elevate Your Visual Storytelling with AI-Powered Thumbnails"), prompt input that routes to `/dashboard?prompt=...` via `router.push` (never `window.location`), CTA button. Navbar: logo/wordmark, Dashboard + History links, user dropdown (avatar, sign out) when authed, Sign in button when not. Hidden on `/login` and `/register` (check `usePathname()`). + +- [ ] **Step 1: Implement navbar + landing** with complete JSX; landing prompt box: + +```tsx +const [prompt, setPrompt] = useState(""); +const router = useRouter(); +// on submit: +router.push(user ? `/dashboard?prompt=${encodeURIComponent(prompt)}` : `/login?next=${encodeURIComponent(`/dashboard?prompt=${prompt}`)}`); +``` + +- [ ] **Step 2: Implement `app/error.tsx`** (the spec requires a *mounted* error boundary — v1 built one and never mounted it): + +```tsx +"use client"; +import { Button } from "@/components/ui/button"; + +export default function RouteError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+

Something went wrong

+

+ An unexpected error occurred{error.digest ? ` (ref ${error.digest})` : ""}. Your generations are safe in History. +

+ +
+ ); +} +``` + +- [ ] **Step 3: Verify** — `pnpm build`; manual: signed-out landing → prompt → login → lands on dashboard with prompt pre-filled. +- [ ] **Step 4: Commit** — `git commit -am "feat(ui): landing page, navbar, mounted error boundary"` + +--- + +### Task 15: Repo hygiene — README, CI, deploy config + +**Files:** +- Create: `.github/workflows/ci.yml`, `vercel.json` (or `vercel.ts` if `@vercel/config` is adopted — keep it minimal either way) +- Modify: `README.md` + +**Interfaces:** none downstream. + +- [ ] **Step 1: Rewrite `README.md`** — product description (PixelAI: multi-provider AI thumbnail generator), stack list, setup (`pnpm install`, copy `.env.example` → `.env.local`, fill Firebase + at least one provider key + Blob token), scripts table (`dev`, `build`, `test`, `test:quota`), architecture sketch (registry → fan-out → Blob/Firestore), deploy notes (Vercel project, env vars, Firebase console: enable Auth providers, deploy `firestore.rules` via `firebase deploy --only firestore:rules`). + +- [ ] **Step 2: Create `.github/workflows/ci.yml`** + +```yaml +name: CI +on: + push: { branches: [main, v2-rebuild] } + pull_request: +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: { version: 10 } + - uses: actions/setup-node@v4 + with: { node-version: 22, cache: pnpm } + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm test + - run: pnpm build + env: + NEXT_PUBLIC_FIREBASE_API_KEY: ci-placeholder + NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ci.firebaseapp.com + NEXT_PUBLIC_FIREBASE_PROJECT_ID: ci-placeholder + NEXT_PUBLIC_FIREBASE_APP_ID: ci-placeholder +``` + +(If `pnpm build` fails on placeholder Firebase values because client init runs at build time, make `lib/firebase/client.ts` lazy — init inside a `getFirebase()` accessor — and adjust imports. Verify during this task.) + +- [ ] **Step 3: `vercel.json`** — only if needed; `maxDuration` is already set per-route via segment config. If nothing else is required, skip the file and note that in the commit message. + +- [ ] **Step 4: Commit** — `git commit -am "chore: README, CI workflow, deploy notes"` + +--- + +### Task 16: Playwright smoke test + +**Files:** +- Create: `playwright.config.ts`, `e2e/generate-flow.spec.ts` + +**Interfaces:** consumes everything; proves the seams. + +Strategy: run `next dev` with `AUTH_DEV_BYPASS=1` (no Firebase emulator needed for the smoke) and a **mocked provider route**: Playwright's `page.route("**/api/generate", ...)` fulfills with a canned success JSON, so no provider keys or network are needed. The smoke verifies UI wiring, not providers (unit tests cover the route; Task 12 step 3 covered real generation manually). + +- [ ] **Step 1: `playwright.config.ts`** + +```ts +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "e2e", + use: { baseURL: "http://localhost:3100" }, + webServer: { + command: "pnpm dev --port 3100", + port: 3100, + reuseExistingServer: !process.env.CI, + env: { AUTH_DEV_BYPASS: "1" }, + }, +}); +``` + +- [ ] **Step 2: `e2e/generate-flow.spec.ts`** + +```ts +import { expect, test } from "@playwright/test"; + +test("generate flow renders a result card", async ({ page }) => { + await page.route("**/api/providers", (r) => + r.fulfill({ json: { providers: ["replicate"] } }), + ); + await page.route("**/api/generate", (r) => + r.fulfill({ + json: { generationId: "g1", url: "https://placehold.co/640x360.png", provider: "replicate", modelId: "m", timingMs: 1234 }, + }), + ); + await page.goto("/dashboard"); + await page.getByLabel(/subject/i).fill("a developer reviewing code at night"); + await page.getByRole("button", { name: /generate/i }).click(); + await expect(page.getByRole("img", { name: /replicate/i })).toBeVisible({ timeout: 15_000 }); +}); +``` + +Caveat: `AuthGate` will redirect to `/login` since there's no signed-in client user even with the server bypass. Make `AuthGate` honor a client-visible dev flag: in `auth-gate.tsx`, skip the redirect when `process.env.NEXT_PUBLIC_AUTH_DEV_BYPASS === "1"`, and set that var in the Playwright `webServer.env` too. This is the same single-bypass mechanism surfaced to the client — document it in `.env.example` (`NEXT_PUBLIC_AUTH_DEV_BYPASS=` with a "never set in production" comment). It only affects rendering; the API still enforces auth in production. + +- [ ] **Step 3: Run** — `pnpm exec playwright install chromium && pnpm exec playwright test` → PASS. +- [ ] **Step 4: Commit** — `git commit -am "test(e2e): dashboard generate-flow smoke"` + +--- + +### Task 17 (optional, feature-flagged): YouTube import + title inference + +**Files:** +- Create: `app/api/youtube-metadata/route.ts`, `app/api/infer-from-title/route.ts`, `components/generator/youtube-import.tsx` +- Test: `app/api/youtube-metadata/route.test.ts` + +Port from `../ai-sdk-image-generator`: `lib/youtube-fetcher.ts` (oEmbed, no API key) and `lib/title-inference.ts` (structured output suggesting `GenerationSpec` fields — **rewrite the v4 `generateObject` call against the installed AI SDK major's docs before porting**). Both routes: require `verifyRequest` auth, return 404 (`NextResponse.json(..., { status: 404 })`) unless `process.env.FEATURE_YT_IMPORT === "1"`. `youtube-import.tsx` renders nothing when a `GET /api/providers`-style flag probe says the feature is off (add `featureYtImport: boolean` to the `/api/providers` response). Tests: flag off → 404; flag on + invalid URL → 400; flag on + valid URL → mocked oEmbed fetch → `{ title }`. + +- [ ] Steps follow the standard cycle: failing route test → implement → pass → commit `feat(import): flagged YouTube import + title inference`. + +--- + +## Verification checklist (run after the final task) + +1. `pnpm lint && pnpm test && pnpm test:quota && pnpm build` — all green. +2. `pnpm exec playwright test` — smoke passes. +3. Manual with real keys: sign in with Google → generate against 2 providers → both cards resolve → `/history` shows both → delete one → Blob + doc gone → quota decremented in Firestore. +4. Security spot-checks: `curl -X POST localhost:3000/api/generate -d '{}'` → 401 (with `AUTH_DEV_BYPASS` unset); no `NEXT_PUBLIC_` provider keys in the bundle (`grep -r "STABILITY_API_KEY" .next/static` → empty). +5. Vercel: create project, set env vars, deploy `v2-rebuild` as preview; confirm generate works on the preview URL; deploy `firestore.rules`. From d6632f9ee91b19081107a2ec91eb63bd80fb8f0b Mon Sep 17 00:00:00 2001 From: Pycomet Date: Sun, 12 Jul 2026 05:28:24 +0100 Subject: [PATCH 04/36] chore!: remove v1 app source for v2 rebuild (recoverable on main) --- .eslintrc.json | 33 - .firebaserc | 5 - .github/workflows/firebase-hosting-merge.yml | 36 - .../firebase-hosting-pull-request.yml | 31 - .prettierrc | 7 - __mocks__/firebaseConfig.js | 16 - __tests__/app/about/page.test.jsx | 12 - app/about/page.tsx | 14 - app/api/generate-thumbnail/route.ts | 242 - app/api/test-ai/route.ts | 43 - app/dashboard/page.tsx | 664 -- app/error.tsx | 30 - app/layout.tsx | 46 - app/page.tsx | 43 - app/providers.tsx | 31 - app/register/page.tsx | 10 - .../facebook_cover_photo_1.png | Bin 6828 -> 0 bytes .../facebook_cover_photo_2.png | Bin 2989 -> 0 bytes .../facebook_profile_image.png | Bin 5214 -> 0 bytes assets/HatchfulExport-All/favicon.png | Bin 203 -> 0 bytes .../instagram_profile_image.png | Bin 4315 -> 0 bytes .../linkedin_banner_image_1.png | Bin 6488 -> 0 bytes .../linkedin_banner_image_2.png | Bin 2932 -> 0 bytes .../linkedin_profile_image.png | Bin 4315 -> 0 bytes .../pinterest_board_photo.png | Bin 3309 -> 0 bytes .../pinterest_profile_image.png | Bin 1435 -> 0 bytes .../twitter_header_photo_1.png | Bin 6299 -> 0 bytes .../twitter_header_photo_2.png | Bin 2301 -> 0 bytes .../twitter_profile_image.png | Bin 3309 -> 0 bytes .../youtube_profile_image.png | Bin 3309 -> 0 bytes assets/logo-old.png | Bin 57729 -> 0 bytes assets/logo.png | Bin 5214 -> 0 bytes assets/logo_transparent.png | Bin 5767 -> 0 bytes assets/stock1.jpg | Bin 1329761 -> 0 bytes assets/thumbnails/test1.png | Bin 152798 -> 0 bytes assets/thumbnails/test2.png | Bin 22691 -> 0 bytes assets/thumbnails/test3.png | Bin 399541 -> 0 bytes assets/thumbnails/test4.png | Bin 67418 -> 0 bytes components/error-boundary.tsx | 143 - components/forms/confirmPassword.tsx | 1 - components/forms/login.tsx | 207 - components/forms/register.tsx | 230 - components/forms/resetPassword.tsx | 1 - components/icons.tsx | 328 - components/layouts/pageLayout.tsx | 69 - components/logo.tsx | 23 - components/motion.tsx | 113 - components/navbar/index.tsx | 193 - components/primitives.ts | 80 - components/search/searchComponent.tsx | 114 - components/sliders/imageSlider.tsx | 40 - components/theme-switch.tsx | 90 - components/wrappers/authWrapper.tsx | 29 - config/fonts.ts | 11 - config/routes.ts | 9 - config/site.ts | 44 - contexts/messageContext.tsx | 78 - contexts/userContext.tsx | 69 - firebase.json | 27 - jest.config.ts | 20 - jest.setup.js | 2 - lib/ai/huggingface.ts | 282 - lib/ai/index.ts | 153 - lib/ai/providers.ts | 150 - lib/ai/stability.ts | 244 - lib/firebase/auth.ts | 104 - lib/firebase/firebaseConfig.ts | 23 - next-env.d.ts | 5 - next.config.js | 14 - package.json | 77 - postcss.config.js | 6 - public/favicon.ico | Bin 25931 -> 0 bytes public/index.html | 89 - public/next.svg | 1 - public/vercel.svg | 1 - styles/globals.css | 3 - tailwind.config.js | 24 - todo.txt | 6 - tsconfig.json | 42 - types/index.ts | 5 - yarn.lock | 8604 ----------------- 81 files changed, 13017 deletions(-) delete mode 100644 .eslintrc.json delete mode 100644 .firebaserc delete mode 100644 .github/workflows/firebase-hosting-merge.yml delete mode 100644 .github/workflows/firebase-hosting-pull-request.yml delete mode 100644 .prettierrc delete mode 100644 __mocks__/firebaseConfig.js delete mode 100644 __tests__/app/about/page.test.jsx delete mode 100644 app/about/page.tsx delete mode 100644 app/api/generate-thumbnail/route.ts delete mode 100644 app/api/test-ai/route.ts delete mode 100644 app/dashboard/page.tsx delete mode 100644 app/error.tsx delete mode 100644 app/layout.tsx delete mode 100644 app/page.tsx delete mode 100644 app/providers.tsx delete mode 100644 app/register/page.tsx delete mode 100644 assets/HatchfulExport-All/facebook_cover_photo_1.png delete mode 100644 assets/HatchfulExport-All/facebook_cover_photo_2.png delete mode 100644 assets/HatchfulExport-All/facebook_profile_image.png delete mode 100644 assets/HatchfulExport-All/favicon.png delete mode 100644 assets/HatchfulExport-All/instagram_profile_image.png delete mode 100644 assets/HatchfulExport-All/linkedin_banner_image_1.png delete mode 100644 assets/HatchfulExport-All/linkedin_banner_image_2.png delete mode 100644 assets/HatchfulExport-All/linkedin_profile_image.png delete mode 100644 assets/HatchfulExport-All/pinterest_board_photo.png delete mode 100644 assets/HatchfulExport-All/pinterest_profile_image.png delete mode 100644 assets/HatchfulExport-All/twitter_header_photo_1.png delete mode 100644 assets/HatchfulExport-All/twitter_header_photo_2.png delete mode 100644 assets/HatchfulExport-All/twitter_profile_image.png delete mode 100644 assets/HatchfulExport-All/youtube_profile_image.png delete mode 100644 assets/logo-old.png delete mode 100644 assets/logo.png delete mode 100644 assets/logo_transparent.png delete mode 100644 assets/stock1.jpg delete mode 100644 assets/thumbnails/test1.png delete mode 100644 assets/thumbnails/test2.png delete mode 100644 assets/thumbnails/test3.png delete mode 100644 assets/thumbnails/test4.png delete mode 100644 components/error-boundary.tsx delete mode 100644 components/forms/confirmPassword.tsx delete mode 100644 components/forms/login.tsx delete mode 100644 components/forms/register.tsx delete mode 100644 components/forms/resetPassword.tsx delete mode 100644 components/icons.tsx delete mode 100644 components/layouts/pageLayout.tsx delete mode 100644 components/logo.tsx delete mode 100644 components/motion.tsx delete mode 100644 components/navbar/index.tsx delete mode 100644 components/primitives.ts delete mode 100644 components/search/searchComponent.tsx delete mode 100644 components/sliders/imageSlider.tsx delete mode 100644 components/theme-switch.tsx delete mode 100644 components/wrappers/authWrapper.tsx delete mode 100644 config/fonts.ts delete mode 100644 config/routes.ts delete mode 100644 config/site.ts delete mode 100644 contexts/messageContext.tsx delete mode 100644 contexts/userContext.tsx delete mode 100644 firebase.json delete mode 100644 jest.config.ts delete mode 100644 jest.setup.js delete mode 100644 lib/ai/huggingface.ts delete mode 100644 lib/ai/index.ts delete mode 100644 lib/ai/providers.ts delete mode 100644 lib/ai/stability.ts delete mode 100644 lib/firebase/auth.ts delete mode 100644 lib/firebase/firebaseConfig.ts delete mode 100644 next-env.d.ts delete mode 100644 next.config.js delete mode 100644 package.json delete mode 100644 postcss.config.js delete mode 100644 public/favicon.ico delete mode 100644 public/index.html delete mode 100644 public/next.svg delete mode 100644 public/vercel.svg delete mode 100644 styles/globals.css delete mode 100644 tailwind.config.js delete mode 100644 todo.txt delete mode 100644 tsconfig.json delete mode 100644 types/index.ts delete mode 100644 yarn.lock diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index a7f6126..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "env": { - "browser": true, - "es2021": true - }, - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "next", - "next/core-web-vitals", - "plugin:prettier/recommended" - ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaFeatures": { - "jsx": true - }, - "ecmaVersion": "latest", - "sourceType": "module" - }, - "plugins": [ - "@typescript-eslint", - "prettier" - ], - "rules": { - "semi": ["error", "always"], - "quotes": ["error", "double"], - "@typescript-eslint/no-unused-vars": ["error", { "varsIgnorePattern": "^_" }], - "@typescript-eslint/no-explicit-any": "off", - "prettier/prettier": "error" - } -} - \ No newline at end of file diff --git a/.firebaserc b/.firebaserc deleted file mode 100644 index a8e095f..0000000 --- a/.firebaserc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "projects": { - "default": "pixelai-30f7a" - } -} diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml deleted file mode 100644 index 42daf98..0000000 --- a/.github/workflows/firebase-hosting-merge.yml +++ /dev/null @@ -1,36 +0,0 @@ -# This file was auto-generated by the Firebase CLI -# https://github.com/firebase/firebase-tools - -name: Deploy Frontend To Firebase Hosting On Merge -'on': - push: - branches: - - main -jobs: - build_and_deploy: - runs-on: ubuntu-latest - environment: Mail Envs - env: - NEXT_FIREBASE_API_KEY: ${{ secrets.NEXT_FIREBASE_API_KEY }} - NEXT_FIREBASE_AUTH_DOMAIN: ${{ secrets.NEXT_FIREBASE_AUTH_DOMAIN }} - NEXT_FIREBASE_PROJECT_ID: ${{ secrets.NEXT_FIREBASE_PROJECT_ID }} - NEXT_FIREBASE_STORAGE_BUCKET: ${{ secrets.NEXT_FIREBASE_STORAGE_BUCKET }} - NEXT_FIREBASE_SENDER_ID: ${{ secrets.NEXT_FIREBASE_SENDER_ID }} - NEXT_FIREBASE_APP_ID: ${{ secrets.NEXT_FIREBASE_APP_ID }} - NEXT_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_FIREBASE_MEASUREMENT_ID }} - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} - NEXT_CLERK_SECRET_KEY: ${{ secrets.NEXT_CLERK_SECRET_KEY }} - NEXT_CLERK_FRONTEND_URL: ${{ secrets.NEXT_CLERK_FRONTEND_URL }} - NEXT_CLERK_BACKEND_URL: ${{ secrets.NEXT_CLERK_BACKEND_URL }} - steps: - - uses: actions/checkout@v2 - - - name: Run frontend build - run: npm install --legacy-peer-deps && npm run build - - - uses: FirebaseExtended/action-hosting-deploy@v0 - with: - repoToken: '${{ secrets.GITHUB_TOKEN }}' - firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_PIXELAI_30F7A }}' - channelId: live - projectId: pixelai-30f7a diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml deleted file mode 100644 index f22d961..0000000 --- a/.github/workflows/firebase-hosting-pull-request.yml +++ /dev/null @@ -1,31 +0,0 @@ -# This file was auto-generated by the Firebase CLI -# https://github.com/firebase/firebase-tools - -name: Deploy Frontend To Firebase Hosting On PR -'on': pull_request -jobs: - build_and_preview: - if: '${{ github.event.pull_request.head.repo.full_name == github.repository }}' - runs-on: ubuntu-latest - environment: Mail Envs - env: - NEXT_FIREBASE_API_KEY: ${{ secrets.NEXT_FIREBASE_API_KEY }} - NEXT_FIREBASE_AUTH_DOMAIN: ${{ secrets.NEXT_FIREBASE_AUTH_DOMAIN }} - NEXT_FIREBASE_PROJECT_ID: ${{ secrets.NEXT_FIREBASE_PROJECT_ID }} - NEXT_FIREBASE_STORAGE_BUCKET: ${{ secrets.NEXT_FIREBASE_STORAGE_BUCKET }} - NEXT_FIREBASE_SENDER_ID: ${{ secrets.NEXT_FIREBASE_SENDER_ID }} - NEXT_FIREBASE_APP_ID: ${{ secrets.NEXT_FIREBASE_APP_ID }} - NEXT_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_FIREBASE_MEASUREMENT_ID }} - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} - NEXT_CLERK_SECRET_KEY: ${{ secrets.NEXT_CLERK_SECRET_KEY }} - NEXT_CLERK_FRONTEND_URL: ${{ secrets.NEXT_CLERK_FRONTEND_URL }} - NEXT_CLERK_BACKEND_URL: ${{ secrets.NEXT_CLERK_BACKEND_URL }} - steps: - - uses: actions/checkout@v2 - - - run: npm install --legacy-peer-deps && npm run build - - uses: FirebaseExtended/action-hosting-deploy@v0 - with: - repoToken: '${{ secrets.GITHUB_TOKEN }}' - firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_PIXELAI_30F7A }}' - projectId: pixelai-30f7a diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 58066b0..0000000 --- a/.prettierrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "semi": true, - "singleQuote": false, - "printWidth": 80, - "trailingComma": "es5", - "tabWidth": 2 -} \ No newline at end of file diff --git a/__mocks__/firebaseConfig.js b/__mocks__/firebaseConfig.js deleted file mode 100644 index 2e818fd..0000000 --- a/__mocks__/firebaseConfig.js +++ /dev/null @@ -1,16 +0,0 @@ -/* eslint-disable no-undef */ -export const getAuth = jest.fn(() => ({ - signInWithEmailAndPassword: jest.fn(), - createUserWithEmailAndPassword: jest.fn(), -})); - -export const getFirestore = jest.fn(() => ({ - collection: jest.fn(() => ({ - doc: jest.fn(() => ({ - set: jest.fn(), - get: jest.fn(), - })), - })), -})); - -export const initializeApp = jest.fn(); diff --git a/__tests__/app/about/page.test.jsx b/__tests__/app/about/page.test.jsx deleted file mode 100644 index cd1b72f..0000000 --- a/__tests__/app/about/page.test.jsx +++ /dev/null @@ -1,12 +0,0 @@ -/* eslint-disable no-undef */ -jest.mock("../../../lib/firebase/firebaseConfig"); - -import "@testing-library/jest-dom"; -import { render } from "@testing-library/react"; -import Page from "../../../app/about/page"; - -describe("About Us Page", () => { - it("renders wuthout crashing", () => { - render(); - }); -}); diff --git a/app/about/page.tsx b/app/about/page.tsx deleted file mode 100644 index fd8253e..0000000 --- a/app/about/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { PageLayout } from "@/components/layouts/pageLayout"; -import { title } from "@/components/primitives"; - -export default function AboutPage() { - return ( - -
-
-

About

-
-
-
- ); -} diff --git a/app/api/generate-thumbnail/route.ts b/app/api/generate-thumbnail/route.ts deleted file mode 100644 index 76f8290..0000000 --- a/app/api/generate-thumbnail/route.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { generateThumbnail, ThumbnailGenerationOptions } from "@/lib/ai"; - -// Enhanced error response helper -function createErrorResponse( - message: string, - status: number = 500, - type?: string -) { - return NextResponse.json( - { - error: message, - type: type || "api", - success: false, - timestamp: new Date().toISOString(), - }, - { status } - ); -} - -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const { - prompt, - style = "tech", - model = "sdxl", - quality = "balanced", - provider = "huggingface", - userId, - refinementPrompt, - } = body; - - // Enhanced input validation - if (!prompt || typeof prompt !== "string" || prompt.trim().length === 0) { - return createErrorResponse( - "Please enter a video description", - 400, - "validation" - ); - } - - if (prompt.length > 500) { - return createErrorResponse( - "Description must be less than 500 characters", - 400, - "validation" - ); - } - - if (prompt.trim().length < 5) { - return createErrorResponse( - "Description must be at least 5 characters long", - 400, - "validation" - ); - } - - // Validate parameters - const validProviders = ["huggingface", "stability"]; - const validQualities = ["fast", "balanced", "high"]; - const validStyles = ["tech", "gaming", "tutorial", "lifestyle"]; - - if (!validProviders.includes(provider)) { - return createErrorResponse( - "Invalid AI provider selected. Please choose a valid provider.", - 400, - "validation" - ); - } - - if (!validQualities.includes(quality)) { - return createErrorResponse( - "Invalid quality setting selected. Please choose a valid quality level.", - 400, - "validation" - ); - } - - if (!validStyles.includes(style)) { - return createErrorResponse( - "Invalid style selected. Please choose a valid style.", - 400, - "validation" - ); - } - - // Check for API key based on provider - let apiKeyMissing = false; - let apiKeyName = ""; - - switch (provider) { - case "huggingface": - apiKeyMissing = !process.env.HUGGINGFACE_API_KEY; - apiKeyName = "HUGGINGFACE_API_KEY"; - break; - case "stability": - apiKeyMissing = !process.env.STABILITY_API_KEY; - apiKeyName = "STABILITY_API_KEY"; - break; - } - - if (apiKeyMissing) { - return createErrorResponse( - `AI service is not configured (${apiKeyName} missing). Please contact support.`, - 500, - "api" - ); - } - - // Generate thumbnail with enhanced error handling - const options: ThumbnailGenerationOptions = { - prompt: prompt.trim(), - style, - model, - quality, - provider, - userId, - refinementPrompt: refinementPrompt?.trim(), - }; - - console.log("Generating thumbnail with options:", { - prompt: prompt.substring(0, 50) + "...", - style, - model, - quality, - provider, - userId: userId ? "***" : "none", - refinement: refinementPrompt ? "yes" : "no", - }); - - const result = await generateThumbnail(options); - - // Convert blob to base64 for response - const arrayBuffer = await result.imageBlob.arrayBuffer(); - const base64 = Buffer.from(arrayBuffer).toString("base64"); - const dataUrl = `data:image/png;base64,${base64}`; - - return NextResponse.json({ - success: true, - imageUrl: dataUrl, - prompt: result.prompt, - style: result.style, - model: result.model, - provider: result.provider, - parameters: result.parameters, - timestamp: new Date().toISOString(), - }); - } catch (error) { - console.error("API Error:", error); - - // Enhanced error categorization - if (error instanceof Error) { - const errorMessage = error.message.toLowerCase(); - - // Network/connection errors - if (errorMessage.includes("fetch") || errorMessage.includes("network")) { - return createErrorResponse( - "Unable to connect to AI service. Please check your internet connection.", - 503, - "network" - ); - } - - // Rate limiting errors - if ( - errorMessage.includes("rate") || - errorMessage.includes("limit") || - errorMessage.includes("quota") - ) { - return createErrorResponse( - "Too many requests. Please wait a moment before trying again.", - 429, - "quota" - ); - } - - // Model-specific errors - if (errorMessage.includes("model") || errorMessage.includes("loading")) { - return createErrorResponse( - "AI model is currently unavailable. Try switching to a different model or wait a moment.", - 503, - "model" - ); - } - - // Authentication errors - if ( - errorMessage.includes("unauthorized") || - errorMessage.includes("forbidden") - ) { - return createErrorResponse( - "AI service authentication failed. Please contact support.", - 401, - "api" - ); - } - - // Timeout errors - if ( - errorMessage.includes("timeout") || - errorMessage.includes("aborted") - ) { - return createErrorResponse( - "Request timed out. Please try again with a shorter description.", - 408, - "network" - ); - } - - // Return the actual error message for debugging - return createErrorResponse( - `Generation failed: ${error.message}`, - 500, - "api" - ); - } - - // Fallback for unknown errors - return createErrorResponse( - "An unexpected error occurred. Please try again.", - 500, - "unknown" - ); - } -} - -export async function GET() { - return NextResponse.json({ - message: "PixelAI Thumbnail Generation API", - version: "1.0.0", - status: "online", - supportedStyles: ["tech", "gaming", "tutorial", "lifestyle"], - supportedModels: ["sdxl", "flux", "realistic"], - supportedQualities: ["fast", "balanced", "high"], - limits: { - maxPromptLength: 500, - minPromptLength: 5, - }, - timestamp: new Date().toISOString(), - }); -} diff --git a/app/api/test-ai/route.ts b/app/api/test-ai/route.ts deleted file mode 100644 index 8b486ef..0000000 --- a/app/api/test-ai/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NextResponse } from "next/server"; -import { testAllProviders, getProviderStatus } from "@/lib/ai"; - -export async function GET() { - try { - console.log("🧪 Testing AI Providers..."); - - // Test environment variables - const envStatus = { - HUGGINGFACE_API_KEY: process.env.HUGGINGFACE_API_KEY ? "Set" : "Not Set", - STABILITY_API_KEY: process.env.STABILITY_API_KEY ? "Set" : "Not Set", - }; - - console.log("Environment variables:", envStatus); - - // Test all providers - const providerResults = await testAllProviders(); - console.log("Provider test results:", providerResults); - - // Get detailed provider status - const providerStatus = await getProviderStatus(); - console.log("Provider status:", providerStatus); - - return NextResponse.json({ - success: true, - environmentVariables: envStatus, - providerResults, - providerStatus, - timestamp: new Date().toISOString(), - }); - } catch (error) { - console.error("❌ Test failed:", error); - - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - timestamp: new Date().toISOString(), - }, - { status: 500 } - ); - } -} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx deleted file mode 100644 index 4cf88fc..0000000 --- a/app/dashboard/page.tsx +++ /dev/null @@ -1,664 +0,0 @@ -"use client"; -import { useState, useEffect, Suspense } from "react"; -import { - Button, - Card, - CardBody, - CardHeader, - Image, - Chip, - Select, - SelectItem, - Progress, - Spinner, - RadioGroup, - Radio, - Textarea, -} from "@nextui-org/react"; -import { PageLayout } from "@/components/layouts/pageLayout"; -import { useUser } from "@/contexts/userContext"; -import { useMessage } from "@/contexts/messageContext"; -import { title, subtitle, button } from "@/components/primitives"; -import { useSearchParams } from "next/navigation"; -import { AnimatedDiv } from "@/components/motion"; -import { Download, Sparkles, Zap, Shield } from "lucide-react"; - -type ThumbnailStyle = "tech" | "gaming" | "tutorial" | "lifestyle"; - -interface GenerationResult { - success: boolean; - imageUrl: string; - prompt: string; - style: string; - model: string; - provider: string; - parameters: { - steps: number; - guidance_scale: number; - width: number; - height: number; - }; -} - -interface ErrorInfo { - type: "validation" | "api" | "network" | "quota" | "model"; - message: string; - retryable: boolean; -} - -const authDisabled = process.env.NODE_ENV === "development"; - -// Simplified style options with visual indicators -const styleOptions = [ - { - key: "tech", - label: "Tech & Reviews", - icon: "💻", - description: "Modern, clean tech product presentations", - color: "primary" as const, - }, - { - key: "gaming", - label: "Gaming", - icon: "🎮", - description: "Vibrant gaming content with energy", - color: "secondary" as const, - }, - { - key: "tutorial", - label: "Tutorial", - icon: "📚", - description: "Educational and instructional content", - color: "success" as const, - }, - { - key: "lifestyle", - label: "Lifestyle", - icon: "✨", - description: "Personal and lifestyle content", - color: "warning" as const, - }, -]; - -// Simplified provider options -const providerOptions = [ - { - id: "stability", - name: "Stability AI", - description: "Best Quality • Free", - icon: , - badge: "Recommended", - badgeColor: "success" as const, - }, - { - id: "huggingface", - name: "HuggingFace", - description: "Good Quality • Free with limits", - icon: , - badge: "Backup", - badgeColor: "primary" as const, - }, -]; - -// Simplified quality options -const qualityOptions = [ - { value: "fast", label: "Fast", description: "Quick generation (~10s)" }, - { value: "balanced", label: "Balanced", description: "Good quality (~20s)" }, - { value: "high", label: "High", description: "Best quality (~30s)" }, -]; - -// Quick prompt suggestions -const promptSuggestions = [ - "iPhone 15 Pro Max review with surprised reaction", - "Gaming setup tour with RGB lighting", - "How to cook perfect pasta tutorial", - "Morning routine lifestyle content", - "Unboxing the latest tech gadget", - "Minecraft building tutorial castle", -]; - -function categorizeError(error: any): ErrorInfo { - const errorMessage = error?.message || error?.toString() || "Unknown error"; - const lowerMessage = errorMessage.toLowerCase(); - - if (lowerMessage.includes("enter a video description")) { - return { - type: "validation", - message: "Please enter a description for your content", - retryable: false, - }; - } - - if (lowerMessage.includes("rate") || lowerMessage.includes("quota")) { - return { - type: "quota", - message: "Too many requests. Please wait a moment and try again.", - retryable: true, - }; - } - - if (lowerMessage.includes("network") || lowerMessage.includes("fetch")) { - return { - type: "network", - message: "Network error. Please check your connection and try again.", - retryable: true, - }; - } - - if ( - lowerMessage.includes("api key") || - lowerMessage.includes("unauthorized") - ) { - return { - type: "api", - message: "AI service not configured. Please try a different provider.", - retryable: false, - }; - } - - return { - type: "api", - message: "Something went wrong. Please try again.", - retryable: true, - }; -} - -function DashboardContent() { - const { user, loading: userLoading } = useUser(); - const { message } = useMessage(); - const searchParams = useSearchParams(); - - // Simplified state management - const [prompt, setPrompt] = useState(""); - const [style, setStyle] = useState("tech"); - const [provider, setProvider] = useState("stability"); - const [quality, setQuality] = useState("balanced"); - const [loading, setLoading] = useState(false); - const [progress, setProgress] = useState(0); - const [result, setResult] = useState(null); - const [error, setError] = useState(null); - - // Handle search parameters - useEffect(() => { - const searchPrompt = searchParams.get("prompt"); - if (searchPrompt) { - setPrompt(searchPrompt); - } - }, [searchParams]); - - const handleGenerate = async () => { - if (!prompt.trim()) { - setError({ - type: "validation", - message: "Please enter a description for your content", - retryable: false, - }); - return; - } - - setLoading(true); - setError(null); - setResult(null); - setProgress(0); - - // Smooth progress animation - const progressInterval = setInterval(() => { - setProgress((prev) => Math.min(prev + 8, 90)); - }, 600); - - try { - const userId = authDisabled ? "demo-user" : user?.uid; - const response = await fetch("/api/generate-thumbnail", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - prompt, - style, - quality, - provider, - userId, - }), - }); - - clearInterval(progressInterval); - setProgress(100); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - - if (data.success) { - setResult(data); - message("🎉 Thumbnail generated successfully!", "success"); - } else { - const errorInfo = categorizeError(data.error); - setError(errorInfo); - } - } catch (err) { - clearInterval(progressInterval); - const errorInfo = categorizeError(err); - setError(errorInfo); - console.error("Generation error:", err); - } finally { - setLoading(false); - setTimeout(() => setProgress(0), 2000); - } - }; - - const handleSuggestionClick = (suggestion: string) => { - setPrompt(suggestion); - setError(null); - }; - - const handleDownload = () => { - if (!result) return; - const link = document.createElement("a"); - link.href = result.imageUrl; - link.download = `thumbnail-${Date.now()}.png`; - link.click(); - }; - - if (userLoading) { - return ( - -
- -
-
- ); - } - - return ( - -
- {/* Header */} - -

Create Perfect 

-

Thumbnails

-

- Generate eye-catching thumbnails in seconds with AI's power -

-
- -
- {/* Generation Form */} -
- {/* Step 1: Describe Your Content */} - - - -
-
- 1 -
-
-

- Describe Your Content -

-

- What's your video about? -

-
-
-
- -