From 8741e3b89fc2efcaaff87e96dd410b736ce3eb50 Mon Sep 17 00:00:00 2001 From: "Khoa.vo" Date: Mon, 5 Jan 2026 13:50:35 +0700 Subject: [PATCH] feat: Initial commit with multi-provider image generation --- .dockerignore | 9 + .github/workflows/ci.yml | 33 + .gitignore | 42 + CONTRIBUTING.md | 45 + Dockerfile | 55 + README.md | 79 + app/api/generate/route.ts | 55 + app/api/grok/generate/route.ts | 60 + app/api/history/route.ts | 77 + app/api/meta/generate/route.ts | 60 + app/api/prompts/generate/route.ts | 67 + app/api/prompts/route.ts | 22 + app/api/prompts/sync/route.ts | 12 + app/api/prompts/upload/route.ts | 50 + app/api/prompts/use/route.ts | 30 + app/api/references/upload/route.ts | 99 + app/api/video/generate/route.ts | 47 + app/favicon.ico | Bin 0 -> 25931 bytes app/globals.css | 99 + app/layout.tsx | 24 + app/page.tsx | 53 + components/EditPromptModal.tsx | 213 + components/Gallery.tsx | 428 ++ components/Navbar.tsx | 78 + components/PromptHero.tsx | 609 ++ components/PromptLibrary.tsx | 351 + components/Settings.tsx | 157 + components/UploadHistory.tsx | 379 + components/VideoPromptModal.tsx | 175 + data/prompts.json | 4908 +++++++++++++ docker-compose.yml | 12 + eslint.config.mjs | 18 + lib/crawler.ts | 300 + lib/db.ts | 22 + lib/history.ts | 55 + lib/prompts-service.ts | 97 + lib/providers/grok-client.ts | 246 + lib/providers/meta-client.ts | 372 + lib/store.ts | 189 + lib/types.ts | 126 + lib/utils.ts | 6 + lib/whisk-client.test.ts | 23 + lib/whisk-client.ts | 556 ++ next.config.ts | 8 + package-lock.json | 7706 ++++++++++++++++++++ package.json | 36 + postcss.config.mjs | 7 + public/file.svg | 1 + public/globe.svg | 1 + public/next.svg | 1 + public/prompts/prompt_10_1767513744290.png | Bin 0 -> 457043 bytes public/prompts/prompt_11_1767513753443.png | Bin 0 -> 374939 bytes public/prompts/prompt_15_1767513789511.png | Bin 0 -> 420745 bytes public/prompts/prompt_16_1767513797657.png | Bin 0 -> 319529 bytes public/prompts/prompt_17_1767513806516.png | Bin 0 -> 425518 bytes public/prompts/prompt_17_1767513853924.png | Bin 0 -> 455183 bytes public/prompts/prompt_23_1767513817060.png | Bin 0 -> 377537 bytes public/prompts/prompt_23_1767513865467.png | Bin 0 -> 362542 bytes public/prompts/prompt_26_1767513874181.png | Bin 0 -> 233234 bytes public/prompts/prompt_27_1767513882606.png | Bin 0 -> 336052 bytes public/prompts/prompt_28_1767513896098.png | Bin 0 -> 528872 bytes public/prompts/prompt_29_1767513958838.png | Bin 0 -> 343431 bytes public/prompts/prompt_30_1767513971667.png | Bin 0 -> 384922 bytes public/prompts/prompt_31_1767513982861.png | Bin 0 -> 116003 bytes public/prompts/prompt_32_1767513991650.png | Bin 0 -> 369533 bytes public/prompts/prompt_33_1767514001141.png | Bin 0 -> 440283 bytes public/prompts/prompt_34_1767514016235.png | Bin 0 -> 423607 bytes public/prompts/prompt_35_1767514028270.png | Bin 0 -> 482157 bytes public/prompts/prompt_36_1767514037570.png | Bin 0 -> 231932 bytes public/prompts/prompt_37_1767514046002.png | Bin 0 -> 351123 bytes public/prompts/prompt_38_1767514054579.png | Bin 0 -> 362186 bytes public/prompts/prompt_39_1767514064360.png | Bin 0 -> 449573 bytes public/prompts/prompt_40_1767514077640.png | Bin 0 -> 677623 bytes public/prompts/prompt_41_1767514086831.png | Bin 0 -> 474476 bytes public/prompts/prompt_43_1767514103734.png | Bin 0 -> 488448 bytes public/prompts/prompt_44_1767514114131.png | Bin 0 -> 338499 bytes public/prompts/prompt_45_1767514122819.png | Bin 0 -> 466340 bytes public/prompts/prompt_46_1767514131824.png | Bin 0 -> 502545 bytes public/prompts/prompt_47_1767514140543.png | Bin 0 -> 166115 bytes public/prompts/prompt_49_1767514149683.png | Bin 0 -> 484976 bytes public/prompts/prompt_50_1767514157846.png | Bin 0 -> 198257 bytes public/prompts/prompt_53_1767514166425.png | Bin 0 -> 465560 bytes public/prompts/prompt_60_1767514175984.png | Bin 0 -> 486937 bytes public/prompts/prompt_68_1767514186988.png | Bin 0 -> 704024 bytes public/prompts/prompt_69_1767514200452.png | Bin 0 -> 413180 bytes public/prompts/prompt_70_1767514210404.png | Bin 0 -> 391460 bytes public/prompts/prompt_7_1767513716186.png | Bin 0 -> 345579 bytes public/prompts/prompt_8_1767513725028.png | Bin 0 -> 104885 bytes public/vercel.svg | 1 + public/window.svg | 1 + scripts/debug-meta-text.ts | 56 + tsconfig.json | 42 + 92 files changed, 18198 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/api/generate/route.ts create mode 100644 app/api/grok/generate/route.ts create mode 100644 app/api/history/route.ts create mode 100644 app/api/meta/generate/route.ts create mode 100644 app/api/prompts/generate/route.ts create mode 100644 app/api/prompts/route.ts create mode 100644 app/api/prompts/sync/route.ts create mode 100644 app/api/prompts/upload/route.ts create mode 100644 app/api/prompts/use/route.ts create mode 100644 app/api/references/upload/route.ts create mode 100644 app/api/video/generate/route.ts create mode 100644 app/favicon.ico create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 components/EditPromptModal.tsx create mode 100644 components/Gallery.tsx create mode 100644 components/Navbar.tsx create mode 100644 components/PromptHero.tsx create mode 100644 components/PromptLibrary.tsx create mode 100644 components/Settings.tsx create mode 100644 components/UploadHistory.tsx create mode 100644 components/VideoPromptModal.tsx create mode 100644 data/prompts.json create mode 100644 docker-compose.yml create mode 100644 eslint.config.mjs create mode 100644 lib/crawler.ts create mode 100644 lib/db.ts create mode 100644 lib/history.ts create mode 100644 lib/prompts-service.ts create mode 100644 lib/providers/grok-client.ts create mode 100644 lib/providers/meta-client.ts create mode 100644 lib/store.ts create mode 100644 lib/types.ts create mode 100644 lib/utils.ts create mode 100644 lib/whisk-client.test.ts create mode 100644 lib/whisk-client.ts create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 public/file.svg create mode 100644 public/globe.svg create mode 100644 public/next.svg create mode 100644 public/prompts/prompt_10_1767513744290.png create mode 100644 public/prompts/prompt_11_1767513753443.png create mode 100644 public/prompts/prompt_15_1767513789511.png create mode 100644 public/prompts/prompt_16_1767513797657.png create mode 100644 public/prompts/prompt_17_1767513806516.png create mode 100644 public/prompts/prompt_17_1767513853924.png create mode 100644 public/prompts/prompt_23_1767513817060.png create mode 100644 public/prompts/prompt_23_1767513865467.png create mode 100644 public/prompts/prompt_26_1767513874181.png create mode 100644 public/prompts/prompt_27_1767513882606.png create mode 100644 public/prompts/prompt_28_1767513896098.png create mode 100644 public/prompts/prompt_29_1767513958838.png create mode 100644 public/prompts/prompt_30_1767513971667.png create mode 100644 public/prompts/prompt_31_1767513982861.png create mode 100644 public/prompts/prompt_32_1767513991650.png create mode 100644 public/prompts/prompt_33_1767514001141.png create mode 100644 public/prompts/prompt_34_1767514016235.png create mode 100644 public/prompts/prompt_35_1767514028270.png create mode 100644 public/prompts/prompt_36_1767514037570.png create mode 100644 public/prompts/prompt_37_1767514046002.png create mode 100644 public/prompts/prompt_38_1767514054579.png create mode 100644 public/prompts/prompt_39_1767514064360.png create mode 100644 public/prompts/prompt_40_1767514077640.png create mode 100644 public/prompts/prompt_41_1767514086831.png create mode 100644 public/prompts/prompt_43_1767514103734.png create mode 100644 public/prompts/prompt_44_1767514114131.png create mode 100644 public/prompts/prompt_45_1767514122819.png create mode 100644 public/prompts/prompt_46_1767514131824.png create mode 100644 public/prompts/prompt_47_1767514140543.png create mode 100644 public/prompts/prompt_49_1767514149683.png create mode 100644 public/prompts/prompt_50_1767514157846.png create mode 100644 public/prompts/prompt_53_1767514166425.png create mode 100644 public/prompts/prompt_60_1767514175984.png create mode 100644 public/prompts/prompt_68_1767514186988.png create mode 100644 public/prompts/prompt_69_1767514200452.png create mode 100644 public/prompts/prompt_70_1767514210404.png create mode 100644 public/prompts/prompt_7_1767513716186.png create mode 100644 public/prompts/prompt_8_1767513725028.png create mode 100644 public/vercel.svg create mode 100644 public/window.svg create mode 100644 scripts/debug-meta-text.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..135fa6c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +Dockerfile +.dockerignore +node_modules +npm-debug.log +README.md +.next +.git +.env* +! .env.example diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a1e3059 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x] + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db3aff8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/node_modules_trash +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..900ce68 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,45 @@ +# Contributing to kv-pix + +We welcome contributions to kv-pix! + +## Getting Started + +1. **Fork** the repository. +2. **Clone** your fork: + ```bash + git clone https://github.com/your-username/kv-pix.git + cd kv-pix + ``` +3. **Install dependencies**: + ```bash + npm install + ``` + +## Development Flow + +1. Create a new branch for your feature or fix: + ```bash + git checkout -b feature/my-new-feature + ``` +2. Make your changes. +3. Run linting to ensure code quality: + ```bash + npm run lint + ``` +4. (Optional) Run build to check for errors: + ```bash + npm run build + ``` + +## Commit Guidelines + +- Use clear and descriptive commit messages. +- Mention relevant issue numbers if applicable. + +## Pull Requests + +1. Push your branch to GitHub. +2. Open a Pull Request against the `main` branch. +3. Describe your changes and why they are needed. + +Thank you for contributing! diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1799499 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +FROM node:18-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ +RUN npm ci + + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the build. +# ENV NEXT_TELEMETRY_DISABLED 1 + +RUN npm run build + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV production +# Uncomment the following line in case you want to disable telemetry during runtime. +# ENV NEXT_TELEMETRY_DISABLED 1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Set the correct permission for prerender cache +RUN mkdir .next +RUN chown nextjs:nodejs .next + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT 3000 +ENV HOSTNAME "0.0.0.0" + +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..53a9863 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# kv-pix (V2) + +A modern, lightweight AI Image Generator powered by Google ImageFX (Whisk), Grok, and Meta AI. Built with Next.js 14, TypeScript, and Tailwind CSS. + +## 🚀 Quick Start + +### Prerequisites +- Node.js 18+ +- Whisk/Grok/Meta Cookies (from respective services) + +### Installation +```bash +npm install +``` + +### Run Locally +```bash +npm run dev +# App will be live at http://localhost:3000 +``` + +## 🐳 Docker Deployment (Synology NAS / linux/amd64) + +### Using Docker Compose (Recommended) +```bash +docker-compose up -d +``` + +### Using Docker CLI +```bash +# Build +docker build -t kv-pix:latest . + +# Run +docker run -d -p 3001:3000 --name kv-pix kv-pix:latest +``` + +### Synology Container Manager +1. Pull the image or build locally. +2. Create a container from the image. +3. Map port `3000` (container) to your desired host port (e.g., `3001`). +4. Start the container. +5. Access the app at `http://:3001`. + +## 🏗️ Architecture +- **Framework**: [Next.js 14](https://nextjs.org/) (App Router) +- **Styling**: [Tailwind CSS](https://tailwindcss.com/) + Custom Components +- **State**: [Zustand](https://github.com/pmndrs/zustand) +- **Icons**: [Lucide React](https://lucide.dev/) + +### Project Structure +``` +app/ # Pages and API Routes +components/ # React UI Components +lib/ # Core Logic (whisk-client, meta-client, grok-client) +data/ # Prompt library JSON +public/ # Static assets +``` + +## ✨ Features +- **Multi-Provider**: Google Whisk (ImageFX), Grok (xAI), Meta AI (Imagine) +- **Prompt Library**: Curated prompts organized by categories and sources +- **Upload History**: Reuse previously uploaded reference images +- **Reference Chips**: Drag-and-drop references for Subject/Scene/Style +- **Video Generation**: Animate images with Whisk Animate (Veo) +- **Responsive Gallery**: Masonry layout with smooth animations +- **Dark Mode**: Material 3 inspired aesthetic + +## 🍪 Cookie Configuration +1. Go to the respective service: + - Whisk: [ImageFX](https://labs.google/fx/tools/image-fx) + - Grok: [grok.com](https://grok.com) + - Meta: [meta.ai](https://www.meta.ai) +2. Open DevTools (F12) -> Application -> Cookies. +3. Copy the cookie string (or use a "Get Cookies" extension to copy as JSON). +4. Paste into the **Settings** menu in the app. + +## 📝 License +MIT diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts new file mode 100644 index 0000000..1d32a6b --- /dev/null +++ b/app/api/generate/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { WhiskClient } from '@/lib/whisk-client'; +import { cookies } from 'next/headers'; + +export async function POST(req: NextRequest) { + try { + const { prompt, aspectRatio, refs, preciseMode, imageCount = 1, cookies: clientCookies } = await req.json(); + + if (!prompt) { + return NextResponse.json({ error: "Prompt is required" }, { status: 400 }); + } + + // Use cookies provided in request or fallback to server-side logic if implemented later + // For now, we expect the frontend to pass the 'whisk_cookies' it has stored. + const cookieString = clientCookies || req.cookies.get('whisk_cookies')?.value; + + if (!cookieString) { + return NextResponse.json({ error: "Whisk cookies not found. Please configure settings." }, { status: 401 }); + } + + const client = new WhiskClient(cookieString); + + // Generate images in parallel if imageCount > 1 + // Whisk API typically returns 1-4 images per call, but we treat each call as a request set + // If imageCount is requested, we make that many parallel requests to ensure sufficient output + // (Note: To be safer with rate limits, we cap parallelism at 4) + const parallelCount = Math.min(Math.max(1, imageCount), 4); + + console.log(`Starting ${parallelCount} parallel generation requests for prompt: "${prompt.substring(0, 20)}..."`); + + const promises = Array(parallelCount).fill(null).map(() => + client.generate(prompt, aspectRatio, refs, preciseMode) + .catch(err => { + console.error("Single generation request failed:", err); + return []; // Return empty array on failure to let others proceed + }) + ); + + const results = await Promise.all(promises); + const images = results.flat(); + + if (images.length === 0) { + throw new Error("All generation requests failed. Check logs or try again."); + } + + return NextResponse.json({ images }); + + } catch (error: any) { + console.error("Generate API Error:", error); + return NextResponse.json( + { error: error.message || "Generation failed" }, + { status: 500 } + ); + } +} diff --git a/app/api/grok/generate/route.ts b/app/api/grok/generate/route.ts new file mode 100644 index 0000000..1b26bfc --- /dev/null +++ b/app/api/grok/generate/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { GrokClient } from '@/lib/providers/grok-client'; + +export async function POST(req: NextRequest) { + try { + const { prompt, apiKey, cookies, imageCount = 1 } = await req.json(); + + if (!prompt) { + return NextResponse.json({ error: "Prompt is required" }, { status: 400 }); + } + + if (!apiKey && !cookies) { + return NextResponse.json( + { error: "Grok API key or cookies required. Configure in Settings." }, + { status: 401 } + ); + } + + console.log(`[Grok API Route] Generating ${imageCount} image(s) for: "${prompt.substring(0, 30)}..."`); + + const client = new GrokClient({ apiKey, cookies }); + const results = await client.generate(prompt, imageCount); + + // Download images as base64 for storage + const images = await Promise.all( + results.map(async (img) => { + let base64 = img.data; + if (!base64 && img.url && !img.url.startsWith('data:')) { + try { + base64 = await client.downloadAsBase64(img.url); + } catch (e) { + console.warn("[Grok API Route] Failed to download image:", e); + } + } + return { + data: base64 || '', + url: img.url, + prompt: img.prompt, + model: img.model, + aspectRatio: '1:1' // Grok default + }; + }) + ); + + const validImages = images.filter(img => img.data || img.url); + + if (validImages.length === 0) { + throw new Error("No valid images generated"); + } + + return NextResponse.json({ images: validImages }); + + } catch (error: any) { + console.error("[Grok API Route] Error:", error); + return NextResponse.json( + { error: error.message || "Grok generation failed" }, + { status: 500 } + ); + } +} diff --git a/app/api/history/route.ts b/app/api/history/route.ts new file mode 100644 index 0000000..3d6700b --- /dev/null +++ b/app/api/history/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { history } from '@/lib/history'; +import { WhiskClient } from '@/lib/whisk-client'; +import { v4 as uuidv4 } from 'uuid'; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const category = searchParams.get('category') || undefined; + + const items = history.getAll(category); + return NextResponse.json({ history: items }); +} + +export async function POST(req: NextRequest) { + try { + const formData = await req.formData(); + const file = formData.get('file') as File; + const category = formData.get('category') as string || 'subject'; + const cookieString = formData.get('cookies') as string; + + if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 }); + + const buffer = Buffer.from(await file.arrayBuffer()); + const base64 = buffer.toString('base64'); + const mimeType = file.type || 'image/png'; + + // 1. Upload to Whisk (if cookies provided) + let mediaId = undefined; + if (cookieString) { + const client = new WhiskClient(cookieString); + mediaId = await client.uploadReferenceImage(base64, mimeType, category) || undefined; + } + + // 2. Save to History + // Ideally we save the file to `public/uploads` and return a URL. + // For simplicity/speed in this MVP, we'll store the Data URI (Warning: Large JSON). + // BETTER: Save file to disk. + + // Saving file to public/uploads + // We need to assume the app runs where it can write to public. + // In dev it works. + + /* + const fs = require('fs'); + const path = require('path'); + const uploadDir = path.join(process.cwd(), 'public', 'uploads'); + if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); + + const fileName = `${uuidv4()}.png`; // Normalize to PNG? + const filePath = path.join(uploadDir, fileName); + fs.writeFileSync(filePath, buffer); + const url = `/uploads/${fileName}`; + */ + + // For now, let's use Data URI for immediate portability and less fs hassle in Next.js structure + // (Next.js public folder isn't always writable in production builds easily without config) + // But Data URI in JSON is bad. + // Let's use Data URI for the response but assume successful upload implies we can use it. + + // Changing strategy: Use Data URI for history.json for now (simpler migration) + const url = `data:${mimeType};base64,${base64}`; + + const newItem = history.add({ + id: uuidv4(), + url, + originalName: file.name, + category, + mediaId + }); + + return NextResponse.json(newItem); + + } catch (error: any) { + console.error("Upload error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/app/api/meta/generate/route.ts b/app/api/meta/generate/route.ts new file mode 100644 index 0000000..0dd753c --- /dev/null +++ b/app/api/meta/generate/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { MetaAIClient } from '@/lib/providers/meta-client'; + +export async function POST(req: NextRequest) { + try { + const { prompt, cookies, imageCount = 4 } = await req.json(); + + if (!prompt) { + return NextResponse.json({ error: "Prompt is required" }, { status: 400 }); + } + + if (!cookies) { + return NextResponse.json( + { error: "Meta AI cookies required. Configure in Settings." }, + { status: 401 } + ); + } + + console.log(`[Meta AI Route] Generating images for: "${prompt.substring(0, 30)}..."`); + + const client = new MetaAIClient({ cookies }); + const results = await client.generate(prompt, imageCount); + + // Download images as base64 for storage + const images = await Promise.all( + results.map(async (img) => { + let base64 = img.data; + if (!base64 && img.url) { + try { + base64 = await client.downloadAsBase64(img.url); + } catch (e) { + console.warn("[Meta AI Route] Failed to download image:", e); + } + } + return { + data: base64 || '', + url: img.url, + prompt: img.prompt, + model: img.model, + aspectRatio: '1:1' // Meta AI default + }; + }) + ); + + const validImages = images.filter(img => img.data || img.url); + + if (validImages.length === 0) { + throw new Error("No valid images generated"); + } + + return NextResponse.json({ images: validImages }); + + } catch (error: any) { + console.error("[Meta AI Route] Error:", error); + return NextResponse.json( + { error: error.message || "Meta AI generation failed" }, + { status: 422 } // Use 422 instead of 500 to avoid Next.js rendering error pages + ); + } +} diff --git a/app/api/prompts/generate/route.ts b/app/api/prompts/generate/route.ts new file mode 100644 index 0000000..841a54a --- /dev/null +++ b/app/api/prompts/generate/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from 'next/server'; +import fs from 'fs/promises'; +import path from 'path'; +import { PromptCache } from '@/lib/types'; +import { WhiskClient } from '@/lib/whisk-client'; + +const DATA_FILE = path.join(process.cwd(), 'data', 'prompts.json'); +const PUBLIC_DIR = path.join(process.cwd(), 'public'); + +export async function POST(req: Request) { + try { + const { id, prompt, cookies } = await req.json(); + + if (!id || !prompt || !cookies) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + console.log(`[API] Generating preview for prompt ${id}...`); + + // 1. Generate Image (Server-Side) + const client = new WhiskClient(cookies); + const images = await client.generate(prompt, "1:1"); // Square + + if (!images || images.length === 0) { + return NextResponse.json({ error: 'Generation returned no images' }, { status: 500 }); + } + + const imageBase64 = images[0].data; + + // 2. Save Image + const base64Data = imageBase64.replace(/^data:image\/\w+;base64,/, ""); + const buffer = Buffer.from(base64Data, 'base64'); + const filename = `prompt_${id}_${Date.now()}.png`; + const relativePath = `/prompts/${filename}`; + const fullPath = path.join(PUBLIC_DIR, 'prompts', filename); + + // Ensure dir exists + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, buffer); + + // 3. Update Prompt Cache + const fileContent = await fs.readFile(DATA_FILE, 'utf-8'); + const cache: PromptCache = JSON.parse(fileContent); + + const promptIndex = cache.prompts.findIndex(p => p.id === id); + if (promptIndex !== -1) { + if (!cache.prompts[promptIndex].images) { + cache.prompts[promptIndex].images = []; + } + cache.prompts[promptIndex].images.unshift(relativePath); + + await fs.writeFile(DATA_FILE, JSON.stringify(cache, null, 2), 'utf-8'); + } + + return NextResponse.json({ success: true, url: relativePath }); + + } catch (error: any) { + console.error("Generation API failed:", error); + + const msg = error.message || ''; + if (msg.includes("Safety Filter") || msg.includes("PROMINENT_PEOPLE") || msg.includes("UNSAFE")) { + return NextResponse.json({ error: 'Content Blocked by Safety Filters', code: 'SAFETY_FILTER' }, { status: 422 }); + } + + return NextResponse.json({ error: error.message || 'Generation failed' }, { status: 500 }); + } +} diff --git a/app/api/prompts/route.ts b/app/api/prompts/route.ts new file mode 100644 index 0000000..e24605e --- /dev/null +++ b/app/api/prompts/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server'; +import { getPrompts, syncPromptsService } from '@/lib/prompts-service'; + +export async function GET() { + try { + const cache = await getPrompts(); + + // Lazy Auto-Crawl: Check if sync is needed (every 1 hour) + const ONE_HOUR = 60 * 60 * 1000; + const lastSync = cache.lastSync || 0; + + if (Date.now() - lastSync > ONE_HOUR) { + console.log("[Auto-Crawl] Triggering background sync..."); + // Fire and forget - don't await to keep UI fast + syncPromptsService().catch(err => console.error("[Auto-Crawl] Failed:", err)); + } + + return NextResponse.json(cache); + } catch (error) { + return NextResponse.json({ error: 'Failed to load prompts' }, { status: 500 }); + } +} diff --git a/app/api/prompts/sync/route.ts b/app/api/prompts/sync/route.ts new file mode 100644 index 0000000..ff997e8 --- /dev/null +++ b/app/api/prompts/sync/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from 'next/server'; +import { syncPromptsService } from '@/lib/prompts-service'; + +export async function POST() { + try { + const result = await syncPromptsService(); + return NextResponse.json(result); + } catch (error) { + console.error("Sync failed:", error); + return NextResponse.json({ error: 'Sync failed' }, { status: 500 }); + } +} diff --git a/app/api/prompts/upload/route.ts b/app/api/prompts/upload/route.ts new file mode 100644 index 0000000..97de792 --- /dev/null +++ b/app/api/prompts/upload/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from 'next/server'; +import fs from 'fs/promises'; +import path from 'path'; +import { PromptCache } from '@/lib/types'; + +const DATA_FILE = path.join(process.cwd(), 'data', 'prompts.json'); +const PUBLIC_DIR = path.join(process.cwd(), 'public'); + +export async function POST(req: Request) { + try { + const { id, imageBase64 } = await req.json(); + + if (!id || !imageBase64) { + return NextResponse.json({ error: 'Missing id or imageBase64' }, { status: 400 }); + } + + // 1. Save Image + // Remove data header if present + const base64Data = imageBase64.replace(/^data:image\/\w+;base64,/, ""); + const buffer = Buffer.from(base64Data, 'base64'); + const filename = `prompt_${id}_${Date.now()}.png`; + const relativePath = `/prompts/${filename}`; + const fullPath = path.join(PUBLIC_DIR, 'prompts', filename); + + await fs.writeFile(fullPath, buffer); + + // 2. Update JSON + const fileContent = await fs.readFile(DATA_FILE, 'utf-8'); + const cache: PromptCache = JSON.parse(fileContent); + + const promptIndex = cache.prompts.findIndex(p => p.id === id); + if (promptIndex === -1) { + return NextResponse.json({ error: 'Prompt not found' }, { status: 404 }); + } + + // Add to images array + if (!cache.prompts[promptIndex].images) { + cache.prompts[promptIndex].images = []; + } + cache.prompts[promptIndex].images.unshift(relativePath); + + await fs.writeFile(DATA_FILE, JSON.stringify(cache, null, 2), 'utf-8'); + + return NextResponse.json({ success: true, url: relativePath }); + + } catch (error) { + console.error("Upload failed:", error); + return NextResponse.json({ error: 'Upload failed' }, { status: 500 }); + } +} diff --git a/app/api/prompts/use/route.ts b/app/api/prompts/use/route.ts new file mode 100644 index 0000000..39be4d6 --- /dev/null +++ b/app/api/prompts/use/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server'; +import fs from 'fs/promises'; +import path from 'path'; +import { PromptCache } from '@/lib/types'; + +const DATA_FILE = path.join(process.cwd(), 'data', 'prompts.json'); + +export async function POST(req: Request) { + try { + const { id } = await req.json(); + if (!id) return NextResponse.json({ error: 'Missing ID' }, { status: 400 }); + + const fileContent = await fs.readFile(DATA_FILE, 'utf-8'); + const cache: PromptCache = JSON.parse(fileContent); + + const promptIndex = cache.prompts.findIndex(p => p.id === id); + if (promptIndex !== -1) { + const now = Date.now(); + cache.prompts[promptIndex].useCount = (cache.prompts[promptIndex].useCount || 0) + 1; + cache.prompts[promptIndex].lastUsedAt = now; + + await fs.writeFile(DATA_FILE, JSON.stringify(cache, null, 2)); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Failed to update usage:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/app/api/references/upload/route.ts b/app/api/references/upload/route.ts new file mode 100644 index 0000000..c173365 --- /dev/null +++ b/app/api/references/upload/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from 'next/server'; +import { WhiskClient } from '@/lib/whisk-client'; + +export async function POST(req: Request) { + try { + const { imageBase64, mimeType, category, cookies } = await req.json(); + + if (!imageBase64 || !mimeType || !category || !cookies) { + const missing = []; + if (!imageBase64) missing.push('imageBase64'); + if (!mimeType) missing.push('mimeType'); + if (!category) missing.push('category'); + if (!cookies) missing.push('cookies'); + return NextResponse.json({ error: `Missing required fields: ${missing.join(', ')}` }, { status: 400 }); + } + + // 1. Validate MIME Type + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; + if (!allowedTypes.includes(mimeType)) { + return NextResponse.json({ error: `Unsupported file type: ${mimeType}. Please use JPG, PNG, or WEBP.` }, { status: 400 }); + } + + // 2. Normalize and Validate Cookies + let validCookies = cookies.trim(); + let isJson = false; + let parsedCookies = ""; + + // Auto-fix: Handle JSON array (e.g. from Cookie-Editor export) + if (validCookies.startsWith('[') || validCookies.startsWith('{')) { + isJson = true; + try { + const cookieArray = JSON.parse(validCookies); + + if (Array.isArray(cookieArray)) { + // Convert ALL cookies to a string, not just 1PSID + parsedCookies = cookieArray + .map((c: any) => `${c.name}=${c.value}`) + .join('; '); + + console.log(`[API] Successfully parsed ${cookieArray.length} cookies from JSON.`); + validCookies = parsedCookies; + + } else if (cookieArray.name && cookieArray.value) { + validCookies = `${cookieArray.name}=${cookieArray.value}`; + } + } catch (e) { + console.warn("[API] Failed to parse cookie JSON, falling back to raw value:", e); + // If parsing fails, just use the raw string, maybe it's not JSON after all. + } + } + + // Auto-fix: If user pasted just a raw value (heuristic) + if (!isJson && !validCookies.includes('=')) { + // If it's a long string without =, it might be a raw token. + // We can't know the key, so we might guess `__Secure-1PSID` but that failed before. + // Let's assume if it's raw, it's garbage unless we know for sure. + // But actually, we previously tried prepending. Let's keep that only if we are desperate. + // For now, let's just use what we have. + if (validCookies.length > 50) { + validCookies = `__Secure-1PSID=${validCookies}`; // Last resort guess + } + } + + // 3. Relaxed Validation: Don't error if 1PSID is missing, just try. + // Some users might have different cookie names (e.g. __Secure-3PSID). + if (!validCookies.includes('=')) { + return NextResponse.json({ + error: 'Invalid Cookie Format', + details: 'Cookies must be in "name=value" format or a JSON list.' + }, { status: 400 }); + } + + console.log(`[API] Uploading reference image (${category}, ${mimeType})...`); + console.log(`[API] Using cookies (first 50 chars): ${validCookies.substring(0, 50)}...`); + + const client = new WhiskClient(validCookies); + + // Remove header if present for WhiskClient (it expects clean base64 sometimes, or maybe not? + // whisk-client.ts uploadReferenceImage constructs dataUri itself: `data:${mimeType};base64,${fileBase64}` + // So we should pass RAW base64 without header. + + const rawBase64 = imageBase64.replace(/^data:image\/\w+;base64,/, ""); + + const mediaId = await client.uploadReferenceImage(rawBase64, mimeType, category); + + if (!mediaId) { + return NextResponse.json({ error: 'Upload returned no ID' }, { status: 500 }); + } + + return NextResponse.json({ success: true, id: mediaId }); + + } catch (error: any) { + console.error("Reference Upload API failed:", error); + return NextResponse.json({ + error: error.message || 'Upload failed', + details: error.toString() + }, { status: 500 }); + } +} diff --git a/app/api/video/generate/route.ts b/app/api/video/generate/route.ts new file mode 100644 index 0000000..a0ee569 --- /dev/null +++ b/app/api/video/generate/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { WhiskClient } from '@/lib/whisk-client'; + +export async function POST(req: NextRequest) { + try { + const { prompt, imageBase64, imageGenerationId, cookies: clientCookies } = await req.json(); + + if (!prompt) { + return NextResponse.json({ error: 'Missing prompt' }, { status: 400 }); + } + + // Get cookies from request or use provided cookies + const cookieString = clientCookies || req.cookies.get('whisk_cookies')?.value; + + if (!cookieString) { + return NextResponse.json( + { error: "Whisk cookies not found. Please configure settings." }, + { status: 401 } + ); + } + + console.log(`[Video API] Generating video for prompt: "${prompt.substring(0, 50)}..."`); + + const client = new WhiskClient(cookieString); + + // Generate video using WhiskClient + // Pass imageGenerationId if available, otherwise pass base64 to upload first + const result = await client.generateVideo( + imageGenerationId || '', + prompt, + imageBase64 + ); + + return NextResponse.json({ + success: true, + id: result.id, + url: result.url, + status: result.status + }); + + } catch (error: any) { + console.error("Video Generation API failed:", error); + return NextResponse.json({ + error: error.message || 'Video generation failed' + }, { status: 500 }); + } +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..7554243 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,99 @@ +@import "tailwindcss"; + +@theme { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --radius: var(--radius); +} + +@layer base { + :root { + /* Light Mode (from Reference) */ + --background: #F3F4F6; + --foreground: #111827; + --card: #FFFFFF; + --card-foreground: #111827; + --popover: #FFFFFF; + --popover-foreground: #111827; + --primary: #FFD700; + --primary-foreground: #111827; + --secondary: #E5E7EB; + --secondary-foreground: #111827; + --muted: #E5E7EB; + --muted-foreground: #6B7280; + --accent: #FFD700; + --accent-foreground: #111827; + --destructive: #EF4444; + --destructive-foreground: #FEF2F2; + --border: #E5E7EB; + --input: #E5E7EB; + --ring: #FFD700; + --radius: 0.5rem; + } + + .dark { + /* Dark Mode (from Reference) */ + --background: #1F2937; + --foreground: #F9FAFB; + --card: #374151; + --card-foreground: #F9FAFB; + --popover: #374151; + --popover-foreground: #F9FAFB; + --primary: #FFD700; + --primary-foreground: #111827; + --secondary: #4B5563; + --secondary-foreground: #F9FAFB; + --muted: #4B5563; + --muted-foreground: #9CA3AF; + --accent: #FFD700; + --accent-foreground: #111827; + --destructive: #EF4444; + --destructive-foreground: #FEF2F2; + --border: #4B5563; + --input: #4B5563; + --ring: #FFD700; + } + + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } +} + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #374151; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #4B5563; +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..51f8d0e --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "kv-pix | AI Image Generator", + description: "Generate images with Google ImageFX (Whisk)", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..24bf719 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect } from 'react'; + +import { useStore } from '@/lib/store'; +import { Navbar } from "@/components/Navbar"; +import { Gallery } from "@/components/Gallery"; +import { PromptHero } from "@/components/PromptHero"; +import { Settings } from "@/components/Settings"; +import { PromptLibrary } from "@/components/PromptLibrary"; +import { UploadHistory } from "@/components/UploadHistory"; + +export default function Home() { + const { currentView, setCurrentView, loadGallery } = useStore(); + + useEffect(() => { + loadGallery(); + }, [loadGallery]); + + return ( +
+ {/* Top Navbar */} + + + {/* Main Content Area */} +
+ + {/* Scrollable Container */} +
+
+ + {/* Always show Hero on Create View */} + {currentView === 'gallery' && ( + <> + + + + )} + + {currentView === 'settings' && } + + {currentView === 'library' && ( + setCurrentView('gallery')} /> + )} + + {currentView === 'history' && } + +
+
+
+
+ ); +} diff --git a/components/EditPromptModal.tsx b/components/EditPromptModal.tsx new file mode 100644 index 0000000..2314e39 --- /dev/null +++ b/components/EditPromptModal.tsx @@ -0,0 +1,213 @@ +"use client"; + +import React from 'react'; +import { X, Wand2, Loader2, Sparkles, Lock } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface EditPromptModalProps { + isOpen: boolean; + onClose: () => void; + image: { data: string; prompt: string } | null; + onGenerate: (prompt: string, options: { keepSubject: boolean; keepScene: boolean; keepStyle: boolean }) => Promise; +} + +export function EditPromptModal({ isOpen, onClose, image, onGenerate }: EditPromptModalProps) { + const [prompt, setPrompt] = React.useState(''); + const [loading, setLoading] = React.useState(false); + const [keepSubject, setKeepSubject] = React.useState(true); + const [keepScene, setKeepScene] = React.useState(true); + const [keepStyle, setKeepStyle] = React.useState(true); + + React.useEffect(() => { + if (isOpen && image) { + setPrompt(image.prompt); + } + }, [isOpen, image]); + + if (!isOpen) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + try { + await onGenerate(prompt, { keepSubject, keepScene, keepStyle }); + onClose(); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }; + + const ConsistencyToggle = ({ + label, + checked, + onChange, + color + }: { + label: string; + checked: boolean; + onChange: (v: boolean) => void; + color: string; + }) => ( + + ); + + return ( +
+ {/* Modal Container */} +
+ + {/* Close Button */} + + + {/* Header with Gradient */} +
+
+
+
+ +
+
+

Remix Image

+

Generate a variation with consistent elements

+
+
+
+ + {/* Content */} +
+ {/* Image Preview & Input Row */} +
+ {/* Image Preview */} +
+ {image && ( + <> + Source +
+ Reference +
+ + )} +
+ + {/* Prompt Input */} +
+ +