commit 8741e3b89fc2efcaaff87e96dd410b736ce3eb50 Author: Khoa.vo Date: Mon Jan 5 13:50:35 2026 +0700 feat: Initial commit with multi-provider image generation 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 0000000..718d6fe Binary files /dev/null and b/app/favicon.ico differ 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 */} +
+ +