feat: Initial commit with multi-provider image generation
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run

This commit is contained in:
Khoa.vo 2026-01-05 13:50:35 +07:00
commit 8741e3b89f
92 changed files with 18198 additions and 0 deletions

9
.dockerignore Normal file
View file

@ -0,0 +1,9 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
.env*
! .env.example

33
.github/workflows/ci.yml vendored Normal file
View file

@ -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

42
.gitignore vendored Normal file
View file

@ -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

45
CONTRIBUTING.md Normal file
View file

@ -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!

55
Dockerfile Normal file
View file

@ -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"]

79
README.md Normal file
View file

@ -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://<NAS_IP>: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

55
app/api/generate/route.ts Normal file
View file

@ -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 }
);
}
}

View file

@ -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 }
);
}
}

77
app/api/history/route.ts Normal file
View file

@ -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 });
}
}

View file

@ -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
);
}
}

View file

@ -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 });
}
}

22
app/api/prompts/route.ts Normal file
View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

99
app/globals.css Normal file
View file

@ -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;
}

24
app/layout.tsx Normal file
View file

@ -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 (
<html lang="en" className="dark" suppressHydrationWarning>
<body className={inter.className} suppressHydrationWarning>
{children}
</body>
</html>
);
}

53
app/page.tsx Normal file
View file

@ -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 (
<div className="flex h-screen w-full bg-background text-foreground overflow-hidden font-sans flex-col">
{/* Top Navbar */}
<Navbar />
{/* Main Content Area */}
<main className="flex-1 relative flex flex-col h-full w-full overflow-hidden mt-16">
{/* Scrollable Container */}
<div className="flex-1 overflow-y-auto w-full scroll-smooth">
<div className="min-h-full w-full max-w-[1600px] mx-auto p-4 md:p-6 pb-20">
{/* Always show Hero on Create View */}
{currentView === 'gallery' && (
<>
<PromptHero />
<Gallery />
</>
)}
{currentView === 'settings' && <Settings />}
{currentView === 'library' && (
<PromptLibrary onSelect={(p) => setCurrentView('gallery')} />
)}
{currentView === 'history' && <UploadHistory />}
</div>
</div>
</main>
</div>
);
}

View file

@ -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<void>;
}
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;
}) => (
<button
type="button"
onClick={() => onChange(!checked)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg border text-xs font-medium transition-all",
checked
? `${color} border-current/20 bg-current/10`
: "text-white/40 border-white/10 hover:border-white/20"
)}
>
<Lock className={cn("h-3.5 w-3.5", checked ? "opacity-100" : "opacity-30")} />
<span>{label}</span>
</button>
);
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/90 backdrop-blur-md p-4 animate-in fade-in duration-300">
{/* Modal Container */}
<div className="relative w-full max-w-2xl bg-[#1a1a1e] rounded-2xl border border-white/10 shadow-2xl shadow-black/50 overflow-hidden">
{/* Close Button */}
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 hover:bg-white/10 rounded-full transition-colors z-10 text-white/70 hover:text-white"
>
<X className="h-5 w-5" />
</button>
{/* Header with Gradient */}
<div className="relative px-6 pt-6 pb-4">
<div className="absolute inset-0 bg-gradient-to-br from-amber-500/10 via-transparent to-purple-500/10 pointer-events-none" />
<div className="flex items-center gap-4 relative">
<div className="p-3 bg-gradient-to-br from-amber-500/20 to-purple-500/20 rounded-xl border border-white/10 backdrop-blur-sm">
<Wand2 className="h-6 w-6 text-amber-400" />
</div>
<div>
<h2 className="text-xl font-semibold text-white">Remix Image</h2>
<p className="text-sm text-white/50">Generate a variation with consistent elements</p>
</div>
</div>
</div>
{/* Content */}
<div className="px-6 pb-6">
{/* Image Preview & Input Row */}
<div className="flex gap-5">
{/* Image Preview */}
<div className="relative w-32 h-32 shrink-0 rounded-xl overflow-hidden border border-white/10 group">
{image && (
<>
<img
src={`data:image/png;base64,${image.data}`}
alt="Source"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-end justify-center pb-2">
<span className="text-[10px] text-white/80 font-medium">Reference</span>
</div>
</>
)}
</div>
{/* Prompt Input */}
<div className="flex-1 flex flex-col gap-3">
<label className="text-sm font-medium text-white/70 flex items-center gap-2">
<Sparkles className="h-3.5 w-3.5 text-amber-400" />
New Prompt
</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="w-full h-24 p-3 rounded-xl bg-white/5 border border-white/10 resize-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500/50 outline-none text-sm text-white placeholder:text-white/30 transition-all"
placeholder="Describe your remix... The selected consistency options will be preserved..."
autoFocus
/>
</div>
</div>
{/* Consistency Toggles */}
<div className="mt-4">
<label className="text-xs font-medium text-white/50 mb-2 block">Keep Consistent:</label>
<div className="flex flex-wrap gap-2">
<ConsistencyToggle
label="Subject"
checked={keepSubject}
onChange={setKeepSubject}
color="text-blue-400"
/>
<ConsistencyToggle
label="Scene"
checked={keepScene}
onChange={setKeepScene}
color="text-green-400"
/>
<ConsistencyToggle
label="Style"
checked={keepStyle}
onChange={setKeepStyle}
color="text-purple-400"
/>
</div>
</div>
{/* Info about consistency */}
<div className="mt-4 p-3 bg-white/5 rounded-xl border border-white/10">
<p className="text-xs text-white/50">
<span className="text-amber-400">💡</span> Locked elements will be used as references to maintain visual consistency across generations.
</p>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 mt-6">
<button
onClick={onClose}
className="px-5 py-2.5 hover:bg-white/5 rounded-xl text-sm font-medium text-white/70 hover:text-white transition-colors"
disabled={loading}
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading || !prompt.trim()}
className={cn(
"px-6 py-2.5 rounded-xl text-sm font-semibold transition-all flex items-center gap-2",
"bg-gradient-to-r from-amber-500 to-purple-600 hover:from-amber-400 hover:to-purple-500",
"text-white shadow-lg shadow-amber-500/25",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none"
)}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span>Generating...</span>
</>
) : (
<>
<Wand2 className="h-4 w-4" />
<span>Remix</span>
</>
)}
</button>
</div>
</div>
{/* Loading Overlay */}
{loading && (
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm flex flex-col items-center justify-center gap-4 animate-in fade-in">
<div className="relative">
<div className="w-16 h-16 rounded-full border-2 border-amber-500/30" />
<div className="absolute inset-0 w-16 h-16 rounded-full border-2 border-t-amber-500 animate-spin" />
</div>
<div className="text-center">
<p className="text-white font-medium">Creating your remix...</p>
<p className="text-white/50 text-sm">Preserving selected elements</p>
</div>
</div>
)}
</div>
</div>
);
}

428
components/Gallery.tsx Normal file
View file

@ -0,0 +1,428 @@
"use client";
import React from 'react';
import { useStore } from '@/lib/store';
import { motion, AnimatePresence } from 'framer-motion';
import { Download, Maximize2, Sparkles, Trash2, X, ChevronLeft, ChevronRight, Copy, Film, Wand2 } from 'lucide-react';
import { VideoPromptModal } from './VideoPromptModal';
import { EditPromptModal } from './EditPromptModal';
export function Gallery() {
const { gallery, clearGallery, removeFromGallery, setPrompt, addVideo, addToGallery, settings, videos, removeVideo } = useStore();
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null);
const [videoModalOpen, setVideoModalOpen] = React.useState(false);
const [videoSource, setVideoSource] = React.useState<{ data: string, prompt: string } | null>(null);
const [editModalOpen, setEditModalOpen] = React.useState(false);
const [editSource, setEditSource] = React.useState<{ data: string, prompt: string } | null>(null);
const openVideoModal = (img: { data: string, prompt: string }) => {
setVideoSource(img);
setVideoModalOpen(true);
};
const openEditModal = (img: { data: string, prompt: string }) => {
setEditSource(img);
setEditModalOpen(true);
};
const handleGenerateVideo = async (prompt: string) => {
if (!videoSource) return;
if (!settings.whiskCookies) {
alert("Please set your Whisk Cookies in Settings first!");
throw new Error("Missing Whisk cookies");
}
const res = await fetch('/api/video/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: prompt,
imageBase64: videoSource.data,
// imageGenerationId: (videoSource as any).id, // REMOVE: "id" is a local DB ID (e.g. 1), not a Whisk Media ID.
cookies: settings.whiskCookies
})
});
const data = await res.json();
console.log("[Gallery] Video API response:", data);
if (data.success) {
console.log("[Gallery] Adding video to store:", { id: data.id, url: data.url?.substring(0, 50) });
addVideo({
id: data.id,
url: data.url,
prompt: prompt,
thumbnail: videoSource.data, // Use source image as thumb
createdAt: Date.now()
});
// Success notification
setTimeout(() => {
alert('🎬 Video generation complete!\n\nYour video has been saved. Go to the "Uploads" page and select the "Videos" tab to view it.');
}, 100);
} else {
console.error(data.error);
// Show user-friendly error messages for Google safety policies
let errorMessage = data.error;
if (data.error?.includes('NCII')) {
errorMessage = '🚫 Content Policy: Video blocked by Google\'s NCII (Non-Consensual Intimate Imagery) protection. Please try with a different source image.';
} else if (data.error?.includes('PROMINENT_PEOPLE') || data.error?.includes('prominent')) {
errorMessage = '🚫 Content Policy: Video blocked because the image contains a recognizable person. Try using a different image.';
} else if (data.error?.includes('safety') || data.error?.includes('SAFETY')) {
errorMessage = '⚠️ Content Policy: Video blocked by Google\'s safety filters. Try a different source image.';
}
alert(errorMessage);
throw new Error(data.error);
}
};
const handleRemix = async (prompt: string, options: { keepSubject: boolean; keepScene: boolean; keepStyle: boolean }) => {
if (!editSource) return;
if (!settings.whiskCookies) {
alert("Please set your Whisk Cookies in Settings first!");
throw new Error("Missing Whisk cookies");
}
// First upload the current image as a reference
const uploadRes = await fetch('/api/references/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
imageBase64: `data:image/png;base64,${editSource.data}`,
mimeType: 'image/png',
category: 'subject', // Use as subject reference
cookies: settings.whiskCookies
})
});
const uploadData = await uploadRes.json();
if (!uploadData.id) {
throw new Error("Failed to upload reference image");
}
// Build refs based on consistency options
const refs: { subject?: string[]; scene?: string[]; style?: string[] } = {};
if (options.keepSubject) refs.subject = [uploadData.id];
if (options.keepScene) refs.scene = [uploadData.id];
if (options.keepStyle) refs.style = [uploadData.id];
// Generate new image with references
const res = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: prompt,
aspectRatio: settings.aspectRatio,
imageCount: 1, // Generate one remix at a time
cookies: settings.whiskCookies,
refs: refs
})
});
const data = await res.json();
if (data.error) {
console.error(data.error);
alert("Remix generation failed: " + data.error);
throw new Error(data.error);
}
if (data.images) {
for (const img of data.images) {
await addToGallery({
data: img.data,
prompt: img.prompt,
aspectRatio: img.aspectRatio,
createdAt: Date.now()
});
}
}
};
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (selectedIndex === null) return;
if (e.key === 'Escape') setSelectedIndex(null);
if (e.key === 'ArrowLeft') setSelectedIndex(prev => (prev !== null && prev > 0 ? prev - 1 : prev));
if (e.key === 'ArrowRight') setSelectedIndex(prev => (prev !== null && prev < gallery.length - 1 ? prev + 1 : prev));
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedIndex, gallery.length]);
if (gallery.length === 0) {
return null; // Or return generic empty state if controlled by parent, but parent checks length usually
}
const handleClearAll = () => {
if (window.confirm("Delete all " + gallery.length + " images?")) {
clearGallery();
}
};
const selectedImage = selectedIndex !== null ? gallery[selectedIndex] : null;
return (
<div className="pb-32">
{/* Header with Clear All */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold">{gallery.length} Generated Images</h2>
</div>
<button
onClick={handleClearAll}
className="flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
>
<Trash2 className="h-4 w-4" />
<span>Clear All</span>
</button>
</div>
{/* Videos Section - Show generated videos */}
{videos.length > 0 && (
<div className="mb-8">
<div className="flex items-center gap-2 mb-4">
<Film className="h-5 w-5 text-blue-500" />
<h3 className="text-lg font-semibold">{videos.length} Generated Video{videos.length > 1 ? 's' : ''}</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{videos.map((vid) => (
<motion.div
key={vid.id}
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="group relative aspect-video rounded-xl overflow-hidden bg-black border border-white/10 shadow-lg"
>
<video
src={vid.url}
poster={vid.thumbnail ? `data:image/png;base64,${vid.thumbnail}` : undefined}
className="w-full h-full object-cover"
controls
preload="metadata"
/>
<button
onClick={() => removeVideo(vid.id)}
className="absolute top-2 right-2 p-1.5 bg-black/50 hover:bg-destructive/80 rounded-full text-white opacity-0 group-hover:opacity-100 transition-all"
title="Delete video"
>
<X className="h-4 w-4" />
</button>
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/80 to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
<p className="text-white text-xs line-clamp-1">{vid.prompt}</p>
</div>
</motion.div>
))}
</div>
</div>
)}
{/* Gallery Grid */}
<div className="columns-1 sm:columns-2 md:columns-3 lg:columns-4 gap-4 space-y-4">
<AnimatePresence mode='popLayout'>
{gallery.map((img, i) => (
<motion.div
key={img.id || `video-${i}`}
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="group relative break-inside-avoid rounded-xl overflow-hidden bg-card border shadow-sm"
>
<img
src={"data:image/png;base64," + img.data}
alt={img.prompt}
className="w-full h-auto object-cover transition-transform group-hover:scale-105 cursor-pointer"
onClick={() => setSelectedIndex(i)}
loading="lazy"
/>
{/* Delete button - Top right */}
<button
onClick={(e) => { e.stopPropagation(); if (img.id) removeFromGallery(img.id); }}
className="absolute top-2 right-2 p-1.5 bg-black/50 hover:bg-destructive/80 rounded-full text-white opacity-0 group-hover:opacity-100 transition-all"
title="Delete"
>
<X className="h-4 w-4" />
</button>
{/* Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-3 pointer-events-none">
<p className="text-white text-xs line-clamp-2 mb-2">{img.prompt}</p>
<div className="flex gap-2 justify-end pointer-events-auto">
<button
onClick={(e) => {
e.stopPropagation();
setPrompt(img.prompt);
navigator.clipboard.writeText(img.prompt);
// Optional: Toast feedback could go here
}}
className="p-1.5 bg-white/10 hover:bg-white/20 rounded-full text-white backdrop-blur-md transition-colors"
title="Use Prompt"
>
<Copy className="h-4 w-4" />
</button>
<a
href={"data:image/png;base64," + img.data}
download={"generated-" + i + "-" + Date.now() + ".png"}
className="p-1.5 bg-white/10 hover:bg-white/20 rounded-full text-white backdrop-blur-md transition-colors"
title="Download"
onClick={(e) => e.stopPropagation()}
>
<Download className="h-4 w-4" />
</a>
<button
onClick={(e) => {
e.stopPropagation();
openEditModal(img);
}}
className="p-2 bg-gradient-to-br from-amber-500/80 to-purple-600/80 hover:from-amber-500 hover:to-purple-600 rounded-full text-white shadow-lg shadow-purple-900/20 backdrop-blur-md transition-all hover:scale-105 border border-white/10"
title="Remix this image"
>
<Wand2 className="h-4 w-4" />
</button>
{/* Video button - only for 16:9 images */}
{img.aspectRatio === '16:9' ? (
<button
onClick={(e) => {
e.stopPropagation();
openVideoModal(img);
}}
className="p-2 bg-gradient-to-br from-blue-500/80 to-purple-600/80 hover:from-blue-500 hover:to-purple-600 rounded-full text-white shadow-lg shadow-blue-900/20 backdrop-blur-md transition-all hover:scale-105 border border-white/10"
title="Generate Video"
>
<Film className="h-4 w-4" />
</button>
) : (
<button
disabled
className="p-2 bg-gray-500/30 rounded-full text-white/30 cursor-not-allowed border border-white/5"
title="Video generation requires 16:9 images"
>
<Film className="h-4 w-4" />
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); setSelectedIndex(i); }}
className="p-1.5 bg-white/10 hover:bg-white/20 rounded-full text-white backdrop-blur-md transition-colors"
title="Maximize"
>
<Maximize2 className="h-4 w-4" />
</button>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
{/* Lightbox Modal */}
<AnimatePresence>
{selectedIndex !== null && selectedImage && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm p-4 md:p-8"
onClick={() => setSelectedIndex(null)}
>
{/* Close Button */}
<button
className="absolute top-4 right-4 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50"
onClick={() => setSelectedIndex(null)}
>
<X className="h-6 w-6" />
</button>
{/* Navigation Buttons */}
{selectedIndex > 0 && (
<button
className="absolute left-4 top-1/2 -translate-y-1/2 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50 hidden md:block"
onClick={(e) => { e.stopPropagation(); setSelectedIndex(prev => prev! - 1); }}
>
<ChevronLeft className="h-8 w-8" />
</button>
)}
{selectedIndex < gallery.length - 1 && (
<button
className="absolute right-4 top-1/2 -translate-y-1/2 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50 hidden md:block"
onClick={(e) => { e.stopPropagation(); setSelectedIndex(prev => prev! + 1); }}
>
<ChevronRight className="h-8 w-8" />
</button>
)}
{/* Image Container */}
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="relative max-w-7xl max-h-full flex flex-col items-center"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<img
src={"data:image/png;base64," + selectedImage.data}
alt={selectedImage.prompt}
className="max-w-full max-h-[85vh] object-contain rounded-lg shadow-2xl"
/>
<div className="mt-4 flex flex-col items-center gap-2 max-w-2xl text-center">
<p className="text-white/90 text-sm md:text-base font-medium line-clamp-2">
{selectedImage.prompt}
</p>
<div className="flex gap-3">
<a
href={"data:image/png;base64," + selectedImage.data}
download={"generated-" + selectedIndex + "-" + Date.now() + ".png"}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground hover:bg-primary/90 rounded-full font-medium transition-colors"
>
<Download className="h-4 w-4" />
Download Current
</a>
<button
onClick={() => {
if (selectedImage) openVideoModal(selectedImage);
}}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full font-medium transition-colors"
>
<Film className="h-4 w-4" />
Generate Video
</button>
<button
onClick={() => {
setPrompt(selectedImage.prompt);
navigator.clipboard.writeText(selectedImage.prompt);
setSelectedIndex(null); // Close lightbox? Or keep open? User said "reuse", likely wants to edit.
// Let's close it so they can see the input updating.
}}
className="flex items-center gap-2 px-4 py-2 bg-secondary text-secondary-foreground hover:bg-secondary/80 rounded-full font-medium transition-colors"
>
<Copy className="h-4 w-4" />
Use Prompt
</button>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Video Modal */}
<VideoPromptModal
isOpen={videoModalOpen}
onClose={() => setVideoModalOpen(false)}
image={videoSource}
onGenerate={handleGenerateVideo}
/>
{/* Edit/Remix Modal */}
<EditPromptModal
isOpen={editModalOpen}
onClose={() => setEditModalOpen(false)}
image={editSource}
onGenerate={handleRemix}
/>
</div >
);
}

78
components/Navbar.tsx Normal file
View file

@ -0,0 +1,78 @@
"use client";
import React from 'react';
import { useStore } from '@/lib/store';
import { Sparkles, LayoutGrid, Clock, Settings, User } from 'lucide-react';
import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
export function Navbar() {
const { currentView, setCurrentView, setSelectionMode } = useStore();
const navItems = [
{ id: 'gallery', label: 'Create', icon: Sparkles },
{ id: 'library', label: 'Prompt Library', icon: LayoutGrid },
{ id: 'history', label: 'Uploads', icon: Clock }, // CORRECTED: id should match store ViewType 'history' not 'uploads'
];
return (
<div className="fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-xl border-b border-border">
{/* Yellow Accent Line */}
<div className="h-1 w-full bg-primary" />
<div className="flex items-center justify-between px-4 h-16 max-w-7xl mx-auto">
{/* Logo Area */}
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-primary/20 flex items-center justify-center text-primary">
<Sparkles className="h-6 w-6" />
</div>
<span className="text-xl font-bold text-foreground tracking-tight">kv-pix</span>
</div>
{/* Center Navigation */}
<div className="hidden md:flex items-center gap-1 bg-secondary/50 p-1 rounded-full border border-border/50">
{navItems.map((item) => (
<button
key={item.id}
onClick={() => {
setCurrentView(item.id as any);
if (item.id === 'history') setSelectionMode(null);
}}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all",
currentView === item.id
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-secondary"
)}
>
<item.icon className="h-4 w-4" />
<span>{item.label}</span>
</button>
))}
</div>
{/* Right Actions */}
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentView('settings')}
className={cn(
"p-2 transition-colors",
currentView === 'settings'
? "text-primary bg-primary/10 rounded-full"
: "text-muted-foreground hover:text-primary"
)}
>
<Settings className="h-5 w-5" />
</button>
<div className="h-8 w-px bg-border mx-1" />
<button className="flex items-center gap-2 pl-1 pr-3 py-1 bg-card hover:bg-secondary border border-border rounded-full transition-colors">
<div className="h-7 w-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-bold">
KV
</div>
<span className="text-sm font-medium hidden sm:block">Khoa Vo</span>
</button>
</div>
</div>
</div>
);
}

609
components/PromptHero.tsx Normal file
View file

@ -0,0 +1,609 @@
"use client";
import React, { useRef, useState, useEffect } from "react";
import { useStore, ReferenceCategory } from "@/lib/store";
import { cn } from "@/lib/utils";
import { Sparkles, Image as ImageIcon, X, Hash, AlertTriangle, Upload, Zap, Brain } from "lucide-react";
const IMAGE_COUNTS = [1, 2, 4];
export function PromptHero() {
const {
prompt, setPrompt, addToGallery,
settings, setSettings,
references, setReference, addReference, clearReferences,
setSelectionMode, setCurrentView,
history, setHistory
} = useStore();
const [isGenerating, setIsGenerating] = useState(false);
const [uploadingRefs, setUploadingRefs] = useState<Record<string, boolean>>({});
const [errorNotification, setErrorNotification] = useState<{ message: string; type: 'error' | 'warning' } | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// File input refs for each reference category
const fileInputRefs = {
subject: useRef<HTMLInputElement>(null),
scene: useRef<HTMLInputElement>(null),
style: useRef<HTMLInputElement>(null),
};
// Auto-resize textarea
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 200) + 'px';
}
}, [prompt]);
const handleGenerate = async () => {
let finalPrompt = prompt.trim();
if (!finalPrompt || isGenerating) return;
// Try to parse JSON if it looks like it
if (finalPrompt.startsWith('{') && finalPrompt.endsWith('}')) {
try {
const json = JSON.parse(finalPrompt);
if (json.prompt || json.text || json.positive) {
finalPrompt = json.prompt || json.text || json.positive;
setPrompt(finalPrompt);
}
} catch (e) {
// Ignore parse errors
}
}
setIsGenerating(true);
try {
// Route to the selected provider
const provider = settings.provider || 'whisk';
let res: Response;
if (provider === 'grok') {
// Grok API
res = await fetch('/api/grok/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: finalPrompt,
apiKey: settings.grokApiKey,
cookies: settings.grokCookies,
imageCount: settings.imageCount
})
});
} else if (provider === 'meta') {
// Meta AI
res = await fetch('/api/meta/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: finalPrompt,
cookies: settings.metaCookies,
imageCount: settings.imageCount
})
});
} else {
// Default: Whisk (Google ImageFX)
const refsForApi = {
subject: references.subject?.map(r => r.id) || [],
scene: references.scene?.map(r => r.id) || [],
style: references.style?.map(r => r.id) || [],
};
res = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: finalPrompt,
aspectRatio: settings.aspectRatio,
preciseMode: settings.preciseMode,
imageCount: settings.imageCount,
cookies: settings.whiskCookies,
refs: refsForApi
})
});
}
const responseText = await res.text();
let data;
try {
data = JSON.parse(responseText);
} catch (e) {
console.error("API Error (Non-JSON response):", responseText.substring(0, 500));
throw new Error(`Server Error: ${res.status} ${res.statusText}`);
}
if (data.error) throw new Error(data.error);
if (data.images) {
// Add images one by one with createdAt
for (const img of data.images) {
await addToGallery({
data: img.data,
prompt: img.prompt,
aspectRatio: img.aspectRatio || settings.aspectRatio,
createdAt: Date.now()
});
}
}
} catch (e: any) {
console.error(e);
const errorMessage = e.message || '';
// Check for various Google safety policy errors
if (errorMessage.includes('PROMINENT_PEOPLE_FILTER_FAILED') ||
errorMessage.includes('prominent_people')) {
setErrorNotification({
message: '🚫 Content Policy: The reference image contains a recognizable person. Google blocks generating images of real/famous people. Try using a different reference image without identifiable faces.',
type: 'warning'
});
} else if (errorMessage.includes('Safety Filter') ||
errorMessage.includes('SAFETY_FILTER') ||
errorMessage.includes('content_policy')) {
setErrorNotification({
message: '⚠️ Content Moderation: Your prompt or image was flagged by Google\'s safety filters. Try using different wording or a safer subject.',
type: 'warning'
});
} else if (errorMessage.includes('NSFW') ||
errorMessage.includes('nsfw_filter')) {
setErrorNotification({
message: '🔞 Content Policy: The request was blocked for NSFW content. Please use appropriate prompts and images.',
type: 'warning'
});
} else if (errorMessage.includes('CHILD_SAFETY') ||
errorMessage.includes('child_safety')) {
setErrorNotification({
message: '⛔ Content Policy: Request blocked for child safety concerns.',
type: 'error'
});
} else if (errorMessage.includes('RATE_LIMIT') ||
errorMessage.includes('429') ||
errorMessage.includes('quota')) {
setErrorNotification({
message: '⏳ Rate Limited: Too many requests. Please wait a moment and try again.',
type: 'warning'
});
} else if (errorMessage.includes('401') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('cookies not found')) {
setErrorNotification({
message: '🔐 Authentication Error: Your Whisk cookies may have expired. Please update them in Settings.',
type: 'error'
});
} else {
setErrorNotification({
message: errorMessage || 'Generation failed. Please try again.',
type: 'error'
});
}
// Auto-dismiss after 8 seconds
setTimeout(() => setErrorNotification(null), 8000);
} finally {
setIsGenerating(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleGenerate();
}
};
const handlePaste = (e: React.ClipboardEvent) => {
try {
const text = e.clipboardData.getData('text');
if (text.trim().startsWith('{')) {
const json = JSON.parse(text);
const cleanPrompt = json.prompt || json.text || json.positive || json.caption;
if (cleanPrompt && typeof cleanPrompt === 'string') {
e.preventDefault();
setPrompt(cleanPrompt);
}
}
} catch (e) {
// Not JSON
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = async (e: React.DragEvent, category: ReferenceCategory) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const file = e.dataTransfer.files[0];
if (!file.type.startsWith('image/')) return;
await uploadReference(file, category);
}
};
const uploadReference = async (file: File, category: ReferenceCategory) => {
if (!settings.whiskCookies) {
alert("Please set your Whisk Cookies in Settings first!");
return;
}
setUploadingRefs(prev => ({ ...prev, [category]: true }));
try {
const reader = new FileReader();
reader.onload = async (e) => {
const base64 = e.target?.result as string;
if (!base64) return;
const res = await fetch('/api/references/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
imageBase64: base64,
mimeType: file.type,
category: category,
cookies: settings.whiskCookies
})
});
const data = await res.json();
if (data.id) {
// Add to array (supports multiple refs per category)
addReference(category, { id: data.id, thumbnail: base64 });
// Add to history
const newItem = {
id: data.id,
url: base64, // For local display history we use base64. Ideally we'd valid URL but this works for session.
category: category,
originalName: file.name
};
// exist check?
const exists = history.find(h => h.id === data.id);
if (!exists) {
setHistory([newItem, ...history]);
}
} else {
console.error("Upload failed details:", JSON.stringify(data));
alert(`Upload failed: ${data.error}\n\nDetails: ${JSON.stringify(data) || 'Check console'}`);
}
setUploadingRefs(prev => ({ ...prev, [category]: false }));
};
reader.readAsDataURL(file);
} catch (error) {
console.error(error);
setUploadingRefs(prev => ({ ...prev, [category]: false }));
}
};
// Handle file input change for click-to-upload
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>, category: ReferenceCategory) => {
const file = e.target.files?.[0];
if (file && file.type.startsWith('image/')) {
uploadReference(file, category);
}
// Reset input value so same file can be selected again
e.target.value = '';
};
// Open file picker for a category
const openFilePicker = (category: ReferenceCategory) => {
const inputRef = fileInputRefs[category];
if (inputRef.current) {
inputRef.current.click();
}
};
const toggleReference = (category: ReferenceCategory) => {
const hasRefs = references[category] && references[category]!.length > 0;
if (hasRefs) {
// If already has references, clear them
clearReferences(category);
} else {
// If no references, open file picker for upload
openFilePicker(category);
}
};
const nextAspectRatio = () => {
const ratios = ['1:1', '16:9', '9:16', '4:3', '3:4'];
const currentIdx = ratios.indexOf(settings.aspectRatio);
setSettings({ aspectRatio: ratios[(currentIdx + 1) % ratios.length] });
};
const cycleImageCount = () => {
const currentIdx = IMAGE_COUNTS.indexOf(settings.imageCount);
setSettings({ imageCount: IMAGE_COUNTS[(currentIdx + 1) % IMAGE_COUNTS.length] });
};
// Whisk-style gradient button helper
const GradientButton = ({ onClick, disabled, children, className }: any) => (
<button
onClick={onClick}
disabled={disabled}
className={cn(
"relative group overflow-hidden rounded-full px-6 py-2.5 font-semibold text-sm transition-all shadow-lg",
disabled
? "bg-white/5 text-white/30 cursor-not-allowed shadow-none"
: "text-white shadow-purple-500/20 hover:shadow-purple-500/40 hover:scale-[1.02]",
className
)}
>
<div className={cn(
"absolute inset-0 bg-gradient-to-r from-amber-500 to-purple-600 transition-opacity",
disabled ? "opacity-0" : "opacity-100 group-hover:opacity-90"
)} />
<div className="relative flex items-center gap-2">
{children}
</div>
</button>
);
return (
<div className="w-full max-w-4xl mx-auto my-8 md:my-12 px-4">
{/* Error/Warning Notification Toast */}
{errorNotification && (
<div className={cn(
"mb-4 p-4 rounded-xl border flex items-start gap-3 animate-in slide-in-from-top-4 duration-300",
errorNotification.type === 'warning'
? "bg-amber-500/10 border-amber-500/30 text-amber-200"
: "bg-red-500/10 border-red-500/30 text-red-200"
)}>
<AlertTriangle className={cn(
"h-5 w-5 shrink-0 mt-0.5",
errorNotification.type === 'warning' ? "text-amber-400" : "text-red-400"
)} />
<div className="flex-1">
<p className="text-sm font-medium">
{errorNotification.type === 'warning' ? '⚠️ Content Moderation' : '❌ Generation Error'}
</p>
<p className="text-xs mt-1 opacity-80">{errorNotification.message}</p>
</div>
<button
onClick={() => setErrorNotification(null)}
className="p-1 hover:bg-white/10 rounded-full transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
)}
<div className={cn(
"relative flex flex-col gap-4 rounded-3xl bg-[#1A1A1E]/90 bg-gradient-to-b from-white/[0.02] to-transparent p-6 shadow-2xl border border-white/5 backdrop-blur-sm transition-all",
isGenerating && "ring-1 ring-purple-500/30"
)}>
{/* Header / Title + Provider Toggle */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-2xl bg-gradient-to-br from-amber-500/20 to-purple-600/20 border border-white/5 flex items-center justify-center">
{settings.provider === 'grok' ? (
<Zap className="h-6 w-6 text-yellow-400" />
) : settings.provider === 'meta' ? (
<Brain className="h-6 w-6 text-blue-400" />
) : (
<Sparkles className="h-6 w-6 text-amber-300" />
)}
</div>
<div>
<h2 className="text-xl font-bold text-white tracking-tight">Create & Remix</h2>
<p className="text-xs text-white/50 font-medium">
Powered by <span className={cn(
settings.provider === 'grok' ? "text-yellow-400" :
settings.provider === 'meta' ? "text-blue-400" :
"text-amber-300"
)}>
{settings.provider === 'grok' ? 'Grok (xAI)' :
settings.provider === 'meta' ? 'Meta AI' :
'Google Whisk'}
</span>
</p>
</div>
</div>
{/* Provider Toggle */}
<div className="flex bg-black/40 p-1 rounded-xl border border-white/10 backdrop-blur-md">
<button
onClick={() => setSettings({ provider: 'whisk' })}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all",
settings.provider === 'whisk' || !settings.provider
? "bg-white/10 text-white shadow-sm"
: "text-white/40 hover:text-white/70 hover:bg-white/5"
)}
title="Google Whisk"
>
<Sparkles className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Whisk</span>
</button>
<button
onClick={() => setSettings({ provider: 'grok' })}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all",
settings.provider === 'grok'
? "bg-white/10 text-white shadow-sm"
: "text-white/40 hover:text-white/70 hover:bg-white/5"
)}
title="Grok (xAI)"
>
<Zap className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Grok</span>
</button>
<button
onClick={() => setSettings({ provider: 'meta' })}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all",
settings.provider === 'meta'
? "bg-white/10 text-white shadow-sm"
: "text-white/40 hover:text-white/70 hover:bg-white/5"
)}
title="Meta AI"
>
<Brain className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Meta</span>
</button>
</div>
</div>
{/* Input Area */}
<div className="relative group">
<div className="absolute -inset-0.5 bg-gradient-to-r from-amber-500/20 to-purple-600/20 rounded-2xl blur opacity-0 group-hover:opacity-100 transition duration-500" />
<textarea
ref={textareaRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder="Describe your imagination... (e.g. 'A futuristic city with flying cars')"
className="relative w-full resize-none bg-[#0E0E10] rounded-xl p-5 text-base md:text-lg text-white placeholder:text-white/20 outline-none min-h-[120px] border border-white/10 focus:border-purple-500/50 transition-all shadow-inner"
/>
</div>
{/* Controls Area */}
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-6 pt-2">
{/* Left Controls: References */}
<div className="flex flex-wrap gap-2">
{(['subject', 'scene', 'style'] as ReferenceCategory[]).map((cat) => {
const refs = references[cat] || [];
const hasRefs = refs.length > 0;
const firstRef = refs[0];
const isUploading = uploadingRefs[cat];
return (
<button
key={cat}
onClick={() => toggleReference(cat)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, cat)}
className={cn(
"group flex items-center gap-2 rounded-full px-4 py-2 text-xs font-medium transition-all border relative overflow-hidden",
hasRefs
? "bg-purple-500/10 text-purple-200 border-purple-500/30 hover:bg-purple-500/20"
: "bg-white/5 text-white/40 border-white/5 hover:bg-white/10 hover:text-white/70 hover:border-white/10",
isUploading && "animate-pulse cursor-wait"
)}
>
{isUploading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : firstRef?.thumbnail ? (
<img
src={firstRef.thumbnail}
alt={cat}
className="h-5 w-5 rounded-sm object-cover ring-1 ring-white/20"
/>
) : (
<Upload className="h-4 w-4" />
)}
<span className="capitalize tracking-wide">{cat}</span>
{refs.length > 1 && (
<span className="text-[10px] bg-purple-500/30 text-purple-100 rounded-full px-1.5 h-4 flex items-center">{refs.length}</span>
)}
{hasRefs && !isUploading && (
<div
className="ml-1.5 -mr-1 p-0.5 rounded-full hover:bg-black/20 text-current/70 hover:text-current"
onClick={(e) => { e.stopPropagation(); clearReferences(cat); }}
>
<X className="h-3 w-3" />
</div>
)}
</button>
);
})}
</div>
{/* Hidden file inputs for upload */}
<input
type="file"
ref={fileInputRefs.subject}
accept="image/*"
className="hidden"
onChange={(e) => handleFileInputChange(e, 'subject')}
/>
<input
type="file"
ref={fileInputRefs.scene}
accept="image/*"
className="hidden"
onChange={(e) => handleFileInputChange(e, 'scene')}
/>
<input
type="file"
ref={fileInputRefs.style}
accept="image/*"
className="hidden"
onChange={(e) => handleFileInputChange(e, 'style')}
/>
{/* Right Controls: Settings & Generate */}
<div className="flex flex-wrap items-center gap-3 w-full md:w-auto justify-end">
{/* Settings Group */}
<div className="flex items-center gap-1 bg-[#0E0E10] p-1.5 rounded-full border border-white/10">
{/* Image Count */}
<button
onClick={cycleImageCount}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium text-white/60 hover:text-white hover:bg-white/5 transition-colors"
title="Number of images"
>
<Hash className="h-3.5 w-3.5 opacity-70" />
<span>{settings.imageCount}</span>
</button>
<div className="w-px h-3 bg-white/10" />
{/* Aspect Ratio */}
<button
onClick={nextAspectRatio}
className="px-3 py-1.5 rounded-full text-xs font-medium text-white/60 hover:text-white hover:bg-white/5 transition-colors"
title="Aspect Ratio"
>
<span className="opacity-70">Ratio:</span>
<span className="ml-1 text-white/80">{settings.aspectRatio}</span>
</button>
<div className="w-px h-3 bg-white/10" />
{/* Precise Mode */}
<button
onClick={() => setSettings({ preciseMode: !settings.preciseMode })}
className={cn(
"px-3 py-1.5 rounded-full text-xs font-medium transition-all flex items-center gap-1.5",
settings.preciseMode
? "text-amber-300 bg-amber-500/10 ring-1 ring-amber-500/30"
: "text-white/40 hover:text-white hover:bg-white/5"
)}
title="Precise Mode: Uses images directly as visual reference"
>
<span>🍌</span>
<span>Precise</span>
</button>
</div>
{/* Generate Button */}
<GradientButton
onClick={handleGenerate}
disabled={isGenerating || !prompt.trim()}
>
{isGenerating ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>Creating...</span>
</>
) : (
<>
<Sparkles className="h-4 w-4" />
<span>Create</span>
</>
)}
</GradientButton>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,351 @@
"use client";
import React, { useEffect, useState } from 'react';
import { useStore } from '@/lib/store';
import { Copy, Sparkles, RefreshCw, Loader2, Image as ImageIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Prompt, PromptCache } from '@/lib/types';
import { motion, AnimatePresence } from 'framer-motion';
export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => void }) {
const { setPrompt, settings } = useStore();
const [prompts, setPrompts] = useState<Prompt[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<string>('All');
const [selectedSource, setSelectedSource] = useState<string>('All');
const [searchTerm, setSearchTerm] = useState('');
const [sortMode, setSortMode] = useState<'all' | 'latest' | 'history' | 'foryou'>('all');
const fetchPrompts = async () => {
setLoading(true);
try {
const res = await fetch('/api/prompts');
if (res.ok) {
const data: PromptCache = await res.json();
setPrompts(data.prompts);
}
} catch (error) {
console.error("Failed to fetch prompts", error);
} finally {
setLoading(false);
}
};
const syncPrompts = async () => {
setLoading(true);
try {
const syncRes = await fetch('/api/prompts/sync', { method: 'POST' });
if (!syncRes.ok) throw new Error('Sync failed');
await fetchPrompts();
} catch (error) {
console.error("Failed to sync prompts", error);
} finally {
setLoading(false);
}
};
const generateMissingPreviews = async () => {
if (!settings.whiskCookies) {
alert("Please set Whisk Cookies in Settings first!");
return;
}
setGenerating(true);
try {
// Find prompts without images
const missing = prompts.filter(p => !p.images || p.images.length === 0);
console.log(`Found ${missing.length} prompts without images.`);
for (const prompt of missing) {
try {
console.log(`Requesting preview for: ${prompt.title}`);
const res = await fetch('/api/prompts/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: prompt.id,
prompt: prompt.prompt,
cookies: settings.whiskCookies
})
});
if (res.ok) {
const { url } = await res.json();
setPrompts(prev => prev.map(p =>
p.id === prompt.id ? { ...p, images: [url, ...(p.images || [])] } : p
));
} else {
const err = await res.json();
if (res.status === 422) {
console.warn(`Skipped unsafe prompt "${prompt.title}": ${err.error}`);
} else {
console.error('API Error:', err);
}
}
// Delay is still good to prevent flooding backend/google
await new Promise(r => setTimeout(r, 2000));
} catch (error) {
console.error(`Failed to generate for ${prompt.id}:`, error);
}
}
} catch (e) {
console.error("Generation process failed:", e);
} finally {
setGenerating(false);
}
};
useEffect(() => {
fetchPrompts();
}, []);
const handleSelect = async (p: Prompt) => {
setPrompt(p.prompt);
if (onSelect) onSelect(p.prompt);
// Track usage
try {
await fetch('/api/prompts/use', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: p.id })
});
// Optimistic update
setPrompts(prev => prev.map(item =>
item.id === p.id
? { ...item, useCount: (item.useCount || 0) + 1, lastUsedAt: Date.now() }
: item
));
} catch (e) {
console.error("Failed to track usage", e);
}
};
// Derived State
const filteredPrompts = prompts.filter(p => {
const matchesSearch = p.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
p.prompt.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = selectedCategory === 'All' || p.category === selectedCategory;
const matchesSource = selectedSource === 'All' || p.source === selectedSource;
return matchesSearch && matchesCategory && matchesSource;
}).sort((a, b) => {
if (sortMode === 'latest') {
return (b.createdAt || 0) - (a.createdAt || 0);
}
if (sortMode === 'history') {
return (b.lastUsedAt || 0) - (a.lastUsedAt || 0);
}
return 0; // Default order (or ID)
});
const displayPrompts = () => {
if (sortMode === 'history') {
return filteredPrompts.filter(p => (p.useCount || 0) > 0);
}
if (sortMode === 'foryou') {
// Calculate top categories
const categoryCounts: Record<string, number> = {};
prompts.filter(p => (p.useCount || 0) > 0).forEach(p => {
categoryCounts[p.category] = (categoryCounts[p.category] || 0) + 1;
});
const topCategories = Object.entries(categoryCounts)
.sort(([, a], [, b]) => b - a)
.slice(0, 3)
.map(([cat]) => cat);
if (topCategories.length === 0) return filteredPrompts; // No history yet
return filteredPrompts.filter(p => topCategories.includes(p.category));
}
return filteredPrompts;
};
const finalPrompts = displayPrompts();
const uniqueCategories = ['All', ...Array.from(new Set(prompts.map(p => p.category)))].filter(Boolean);
const uniqueSources = ['All', ...Array.from(new Set(prompts.map(p => p.source)))].filter(Boolean);
return (
<div className="max-w-6xl mx-auto p-4 md:p-8 space-y-8 pb-32">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-3 bg-primary/10 rounded-xl text-primary">
<Sparkles className="h-6 w-6" />
</div>
<div>
<h2 className="text-2xl font-bold">Prompt Library</h2>
<p className="text-muted-foreground">Curated inspiration from the community.</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={generateMissingPreviews}
disabled={generating}
className={cn(
"p-2 hover:bg-secondary rounded-full transition-colors",
generating && "animate-pulse text-yellow-500"
)}
title="Auto-Generate Missing Previews"
>
<ImageIcon className="h-5 w-5" />
</button>
<button
onClick={syncPrompts}
disabled={loading}
className="p-2 hover:bg-secondary rounded-full transition-colors"
title="Sync from GitHub"
>
<RefreshCw className={cn("h-5 w-5", loading && "animate-spin")} />
</button>
<input
type="text"
placeholder="Search prompts..."
className="px-4 py-2 rounded-lg bg-card border focus:border-primary focus:outline-none w-full md:w-64"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
{generating && (
<div className="bg-primary/10 border border-primary/20 text-primary p-4 rounded-xl flex items-center gap-3 animate-in fade-in slide-in-from-top-2">
<Loader2 className="h-5 w-5 animate-spin" />
<span className="font-medium">Generating preview images for library prompts... This may take a while.</span>
</div>
)}
{/* Smart Tabs */}
<div className="flex items-center gap-1 bg-secondary/30 p-1 rounded-xl w-fit">
{(['all', 'latest', 'history', 'foryou'] as const).map(mode => (
<button
key={mode}
onClick={() => setSortMode(mode)}
className={cn(
"px-4 py-2 rounded-lg text-sm font-medium transition-all capitalize",
sortMode === mode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
)}
>
{mode === 'foryou' ? 'For You' : mode}
</button>
))}
</div>
{/* Sub-Categories (only show if NOT history/foryou to keep clean? Or keep it?) */}
{sortMode === 'all' && (
<div className="flex flex-wrap gap-2">
{uniqueCategories.map(cat => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={cn(
"px-4 py-2 rounded-full text-sm font-medium transition-colors",
selectedCategory === cat
? "bg-primary text-primary-foreground"
: "bg-card hover:bg-secondary text-muted-foreground"
)}
>
{cat}
</button>
))}
</div>
)}
{/* Source Filter */}
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm font-medium text-muted-foreground mr-2">Sources:</span>
{uniqueSources.map(source => (
<button
key={source}
onClick={() => setSelectedSource(source)}
className={cn(
"px-3 py-1 rounded-full text-xs font-medium transition-colors border",
selectedSource === source
? "bg-primary text-primary-foreground border-primary"
: "bg-card hover:bg-secondary text-muted-foreground border-secondary"
)}
>
{source}
</button>
))}
</div>
{loading && !prompts.length ? (
<div className="flex justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<AnimatePresence mode="popLayout">
{finalPrompts.map((p) => (
<motion.div
key={p.id}
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="group relative flex flex-col bg-card border rounded-xl overflow-hidden hover:border-primary/50 transition-all hover:shadow-lg"
>
{p.images && p.images.length > 0 ? (
<div className="aspect-video relative overflow-hidden bg-secondary/50">
<img
src={p.images[0]}
alt={p.title}
className="object-cover w-full h-full transition-transform group-hover:scale-105"
loading="lazy"
/>
</div>
) : (
<div className="aspect-video bg-gradient-to-br from-secondary to-background p-4 flex items-center justify-center text-muted-foreground/20">
<Sparkles className="h-12 w-12" />
</div>
)}
<div className="p-4 flex flex-col flex-1 gap-3">
<div className="flex justify-between items-start gap-2">
<h3 className="font-semibold line-clamp-1" title={p.title}>{p.title}</h3>
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary text-muted-foreground whitespace-nowrap">
{p.source}
</span>
</div>
<p className="text-sm text-muted-foreground line-clamp-3 flex-1 font-mono bg-secondary/30 p-2 rounded">
{p.prompt}
</p>
<div className="flex items-center justify-between pt-2 border-t mt-auto">
<button
onClick={() => handleSelect(p)}
className="text-xs font-medium text-primary hover:underline flex items-center gap-1"
>
Use Prompt
</button>
<button
onClick={() => navigator.clipboard.writeText(p.prompt)}
className="p-1.5 text-muted-foreground hover:text-primary transition-colors"
title="Copy to clipboard"
>
<Copy className="h-4 w-4" />
</button>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
)}
{!loading && finalPrompts.length === 0 && (
<div className="text-center py-20 text-muted-foreground">
{sortMode === 'history' ? "No prompts used yet." : "No prompts found."}
</div>
)}
</div>
);
}

157
components/Settings.tsx Normal file
View file

@ -0,0 +1,157 @@
"use client";
import React from 'react';
import { useStore } from '@/lib/store';
import { Save, Sparkles, Zap, Brain } from 'lucide-react';
import { cn } from '@/lib/utils';
type Provider = 'whisk' | 'grok' | 'meta';
const providers: { id: Provider; name: string; icon: any; description: string }[] = [
{ id: 'whisk', name: 'Google Whisk', icon: Sparkles, description: 'ImageFX / Imagen 3' },
{ id: 'grok', name: 'Grok (xAI)', icon: Zap, description: 'FLUX.1 model' },
{ id: 'meta', name: 'Meta AI', icon: Brain, description: 'Imagine / Emu' },
];
export function Settings() {
const { settings, setSettings } = useStore();
// Local state for form fields
const [provider, setProvider] = React.useState<Provider>(settings.provider || 'whisk');
const [whiskCookies, setWhiskCookies] = React.useState(settings.whiskCookies || '');
const [grokApiKey, setGrokApiKey] = React.useState(settings.grokApiKey || '');
const [grokCookies, setGrokCookies] = React.useState(settings.grokCookies || '');
const [metaCookies, setMetaCookies] = React.useState(settings.metaCookies || '');
const [saved, setSaved] = React.useState(false);
const handleSave = () => {
setSettings({
provider,
whiskCookies,
grokApiKey,
grokCookies,
metaCookies
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
return (
<div className="max-w-2xl mx-auto space-y-8 p-4 md:p-8">
<div>
<h2 className="text-2xl font-bold mb-2">Settings</h2>
<p className="text-muted-foreground">Configure your AI image generation provider.</p>
</div>
{/* Provider Selection */}
<div className="space-y-3">
<label className="text-sm font-medium">Image Generation Provider</label>
<div className="grid grid-cols-3 gap-3">
{providers.map((p) => (
<button
key={p.id}
onClick={() => setProvider(p.id)}
className={cn(
"flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all",
provider === p.id
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50 bg-card"
)}
>
<p.icon className={cn(
"h-6 w-6",
provider === p.id ? "text-primary" : "text-muted-foreground"
)} />
<span className={cn(
"font-medium text-sm",
provider === p.id ? "text-primary" : ""
)}>{p.name}</span>
<span className="text-xs text-muted-foreground">{p.description}</span>
</button>
))}
</div>
</div>
{/* Provider-specific settings */}
<div className="space-y-4 p-4 rounded-xl bg-card border">
{provider === 'whisk' && (
<div className="space-y-2">
<label className="text-sm font-medium">Google Whisk Cookies</label>
<textarea
value={whiskCookies}
onChange={(e) => setWhiskCookies(e.target.value)}
placeholder="Paste your cookies here..."
className="w-full h-32 p-3 rounded-lg bg-secondary/50 border border-border focus:ring-2 focus:ring-primary/50 outline-none font-mono text-xs"
/>
<p className="text-xs text-muted-foreground">
Get these from <a href="https://labs.google/fx/tools/image-fx" target="_blank" className="underline hover:text-primary">ImageFX</a> DevTools (Application &gt; Cookies).
</p>
</div>
)}
{provider === 'grok' && (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Grok API Key (Recommended)</label>
<input
type="password"
value={grokApiKey}
onChange={(e) => setGrokApiKey(e.target.value)}
placeholder="xai-..."
className="w-full p-3 rounded-lg bg-secondary/50 border border-border focus:ring-2 focus:ring-primary/50 outline-none font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Get your API key from <a href="https://console.x.ai" target="_blank" className="underline hover:text-primary">console.x.ai</a>
</p>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border"></div>
</div>
<div className="relative flex justify-center text-xs">
<span className="bg-card px-2 text-muted-foreground">or use cookies</span>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-muted-foreground">Grok Cookies (Alternative)</label>
<textarea
value={grokCookies}
onChange={(e) => setGrokCookies(e.target.value)}
placeholder="Paste cookies from grok.com..."
className="w-full h-24 p-3 rounded-lg bg-secondary/50 border border-border focus:ring-2 focus:ring-primary/50 outline-none font-mono text-xs"
/>
<p className="text-xs text-muted-foreground">
Get from logged-in <a href="https://grok.com" target="_blank" className="underline hover:text-primary">grok.com</a> session.
</p>
</div>
</div>
)}
{provider === 'meta' && (
<div className="space-y-2">
<label className="text-sm font-medium">Meta AI Cookies</label>
<textarea
value={metaCookies}
onChange={(e) => setMetaCookies(e.target.value)}
placeholder="Paste cookies from meta.ai..."
className="w-full h-32 p-3 rounded-lg bg-secondary/50 border border-border focus:ring-2 focus:ring-primary/50 outline-none font-mono text-xs"
/>
<p className="text-xs text-muted-foreground">
Get from logged-in <a href="https://www.meta.ai" target="_blank" className="underline hover:text-primary">meta.ai</a> session (requires Facebook login).
</p>
</div>
)}
</div>
<div className="pt-4">
<button
onClick={handleSave}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
<Save className="h-4 w-4" />
{saved ? "Saved!" : "Save Settings"}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,379 @@
"use client";
import React from 'react';
import { useStore, ReferenceCategory } from '@/lib/store';
import { Clock, Upload, Trash2, CheckCircle, X, Film, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
export function UploadHistory() {
const {
history, setHistory,
selectionMode, setSelectionMode,
setCurrentView,
settings,
videos, addVideo, removeVideo,
removeFromHistory,
// Multi-select support
references,
addReference,
removeReference,
clearReferences
} = useStore();
const handleClear = () => {
if (confirm("Clear all upload history?")) {
setHistory([]);
}
};
// Check if an item is currently selected as a reference
const isSelected = (item: any) => {
if (!selectionMode) return false;
const categoryRefs = references[selectionMode as ReferenceCategory] || [];
const itemId = item.mediaId || item.id;
return categoryRefs.some(ref => ref.id === itemId);
};
// Toggle selection - add or remove from references
const handleToggleSelect = (item: any) => {
if (!selectionMode) return;
const itemId = item.mediaId || item.id;
const ref = { id: itemId, thumbnail: item.url };
if (isSelected(item)) {
removeReference(selectionMode as ReferenceCategory, itemId);
} else {
addReference(selectionMode as ReferenceCategory, ref);
}
};
// Done - confirm selection and return to gallery
const handleDone = () => {
setSelectionMode(null);
setCurrentView('gallery');
};
const handleCancelSelection = () => {
// Clear selections for this category and go back
if (selectionMode) {
clearReferences(selectionMode as ReferenceCategory);
}
setSelectionMode(null);
setCurrentView('gallery');
};
// Get count of selected items for current category
const selectedCount = selectionMode
? (references[selectionMode as ReferenceCategory] || []).length
: 0;
const [filter, setFilter] = React.useState<string>('all');
const filteredHistory = history.filter(item => {
if (filter === 'all') return true;
return item.category === filter;
});
const [dragActive, setDragActive] = React.useState(false);
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
const file = e.dataTransfer.files[0];
if (!file.type.startsWith('image/')) return;
// Determine category
// 1. If selectionMode is active (e.g. "subject"), use that.
// 2. If filter is not 'all', use that.
// 3. Default to 'subject'.
let category: string = 'subject';
if (selectionMode) category = selectionMode;
else if (filter !== 'all') category = filter;
// Upload
try {
const reader = new FileReader();
reader.onload = async (ev) => {
const base64 = ev.target?.result as string;
if (!base64) return;
// Optimistic UI update could happen here
const res = await fetch('/api/references/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
imageBase64: base64,
mimeType: file.type,
category: category,
cookies: settings.whiskCookies
})
});
const data = await res.json();
if (data.id) {
const newItem = {
id: data.id,
url: base64,
category: category,
originalName: file.name
};
setHistory([newItem, ...history]);
// If in selection mode, auto-select it
if (selectionMode) {
handleToggleSelect(newItem);
}
} else {
alert(`Upload failed: ${data.error}`);
}
}
reader.readAsDataURL(file);
} catch (err) {
console.error(err);
}
}
};
return (
<div
className={cn("max-w-6xl mx-auto p-4 md:p-8 pb-32 min-h-[50vh]", dragActive && "bg-primary/5")}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
{/* Selection Mode Header - Sticky */}
{selectionMode && (
<div className="sticky top-0 z-10 mb-6 -mx-4 px-4 py-4 bg-background/80 backdrop-blur-md border-b border-primary/20 animate-in slide-in-from-top-2">
<div className="flex items-center justify-between max-w-6xl mx-auto">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary text-primary-foreground rounded-full">
<CheckCircle className="h-5 w-5" />
</div>
<div>
<h3 className="text-lg font-bold text-primary capitalize">Select {selectionMode}</h3>
<p className="text-xs text-muted-foreground">
{selectedCount > 0
? `${selectedCount} selected — click photos to add/remove`
: 'Click photos to select'}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{selectedCount > 0 && (
<button
onClick={() => clearReferences(selectionMode as ReferenceCategory)}
className="px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-secondary rounded-lg transition-colors"
>
Clear
</button>
)}
<button
onClick={handleCancelSelection}
className="px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleDone}
className="px-4 py-1.5 text-sm bg-primary text-primary-foreground hover:bg-primary/90 rounded-lg font-medium transition-colors"
>
Done{selectedCount > 0 ? ` (${selectedCount})` : ''}
</button>
</div>
</div>
</div>
)}
{!selectionMode && (
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<div className="p-3 bg-secondary rounded-xl text-primary">
<Clock className="h-6 w-6" />
</div>
<div>
<h2 className="text-2xl font-bold">Uploads</h2>
<p className="text-muted-foreground">Your reference collection.</p>
</div>
</div>
{history.length > 0 && (
<button
onClick={handleClear}
className="flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
>
<Trash2 className="h-4 w-4" />
<span>Clear All</span>
</button>
)}
</div>
)}
{/* Filter Tabs */}
<div className="flex items-center gap-2 mb-6 bg-secondary/30 p-1 rounded-xl w-fit">
{(['all', 'subject', 'scene', 'style', 'videos'] as const).map(cat => (
<button
key={cat}
onClick={() => setFilter(cat)}
className={cn(
"px-4 py-2 rounded-lg text-sm font-medium transition-all capitalize",
filter === cat
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
)}
>
{cat}
</button>
))}
</div>
{/* Content Area */}
{filter === 'videos' ? (
// Video Grid
videos.length === 0 ? (
<div className="flex flex-col items-center justify-center text-muted-foreground p-12 bg-card/50 rounded-3xl border border-dashed border-border">
<div className="p-4 bg-secondary/50 rounded-full mb-4">
<Film className="h-8 w-8 opacity-50" />
</div>
<h3 className="text-lg font-medium mb-1">No videos yet</h3>
<p className="text-sm text-center max-w-xs">
Generate videos from your gallery images.
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{videos.map((vid) => (
<div key={vid.id} className="group relative aspect-video rounded-xl overflow-hidden bg-black border shadow-sm">
<video
src={vid.url}
poster={`data:image/png;base64,${vid.thumbnail}`}
className="w-full h-full object-cover"
controls
preload="metadata"
/>
<div className="absolute top-2 right-2 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
<button
onClick={() => removeVideo(vid.id)}
className="p-1.5 bg-black/50 hover:bg-destructive text-white rounded-full transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/80 to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
<p className="text-white text-xs line-clamp-1">{vid.prompt}</p>
</div>
</div>
))}
</div>
)
) : (
// Image/Uploads Grid (Existing Logic)
history.length === 0 ? (
<div className="flex flex-col items-center justify-center text-muted-foreground p-12 bg-card/50 rounded-3xl border border-dashed border-border">
<div className="p-4 bg-secondary/50 rounded-full mb-4">
<Clock className="h-8 w-8 opacity-50" />
</div>
<h3 className="text-lg font-medium mb-1">No uploads yet</h3>
<p className="text-sm text-center max-w-xs">
Drag and drop images here to upload.
</p>
</div>
) : (
<>
{filteredHistory.length === 0 ? (
<div className="text-center py-20 text-muted-foreground">
No uploads in this category.
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
{filteredHistory.map((item) => {
const selected = isSelected(item);
return (
<div
key={item.id}
className={cn(
"group relative aspect-square rounded-xl overflow-hidden bg-card border-2 transition-all text-left",
selectionMode && selected
? "ring-4 ring-primary border-primary"
: selectionMode
? "hover:ring-2 hover:ring-primary/50 border-transparent"
: "border-transparent hover:border-primary/50"
)}
>
<img
src={item.url}
alt={item.originalName}
className="w-full h-full object-cover transition-transform group-hover:scale-105 pointer-events-none"
/>
{/* Selection Overlay - Handles Click */}
<div
onClick={() => handleToggleSelect(item)}
className="absolute inset-0 z-10 cursor-pointer"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleToggleSelect(item);
}
}}
/>
{/* Checkmark for selected items */}
{selectionMode && selected && (
<div className="absolute top-2 left-2 z-30 p-1 bg-primary rounded-full text-primary-foreground shadow-lg">
<Check className="h-4 w-4" strokeWidth={3} />
</div>
)}
{/* info overlay (z-20 inside, visual only) */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-3 pointer-events-none z-20">
<p className="text-white text-xs truncate mb-2">{item.originalName}</p>
<div className="flex gap-2 justify-end">
<span className="text-[10px] px-2 py-1 bg-white/20 rounded-full text-white uppercase backdrop-blur-md">
{item.category}
</span>
</div>
</div>
{/* Delete Button - Isolated on Top (z-50) */}
{!selectionMode && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeFromHistory(item.id);
}}
onMouseDown={(e) => e.stopPropagation()}
className="absolute top-2 right-2 p-2 bg-black/50 hover:bg-destructive text-white rounded-full opacity-0 group-hover:opacity-100 transition-all z-50 cursor-pointer pointer-events-auto"
title="Delete"
>
<Trash2 className="h-4 w-4 pointer-events-none" />
</button>
)}
</div>
);
})}
</div>
)}
</>
)
)}
</div>
);
}

View file

@ -0,0 +1,175 @@
"use client";
import React from 'react';
import { X, Film, Loader2, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
interface VideoPromptModalProps {
isOpen: boolean;
onClose: () => void;
image: { data: string; prompt: string } | null;
onGenerate: (prompt: string) => Promise<void>;
}
export function VideoPromptModal({ isOpen, onClose, image, onGenerate }: VideoPromptModalProps) {
const [prompt, setPrompt] = React.useState('');
const [loading, setLoading] = React.useState(false);
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);
onClose();
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/90 backdrop-blur-md p-4 animate-in fade-in duration-300">
{/* Modal Container - Whisk Style Dark Card */}
<div className="relative w-full max-w-2xl bg-[#1a1a1e] rounded-2xl border border-white/10 shadow-2xl shadow-black/50 overflow-hidden">
{/* Close Button */}
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 hover:bg-white/10 rounded-full transition-colors z-10 text-white/70 hover:text-white"
>
<X className="h-5 w-5" />
</button>
{/* Header with Gradient */}
<div className="relative px-6 pt-6 pb-4">
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/10 via-transparent to-blue-500/10 pointer-events-none" />
<div className="flex items-center gap-4 relative">
<div className="p-3 bg-gradient-to-br from-purple-500/20 to-blue-500/20 rounded-xl border border-white/10 backdrop-blur-sm">
<Film className="h-6 w-6 text-purple-400" />
</div>
<div>
<h2 className="text-xl font-semibold text-white">Animate Image</h2>
<p className="text-sm text-white/50">Transform your image into an 8-second video</p>
</div>
</div>
</div>
{/* Content */}
<div className="px-6 pb-6">
{/* Image Preview & Input Row */}
<div className="flex gap-5">
{/* Image Preview */}
<div className="relative w-32 h-32 shrink-0 rounded-xl overflow-hidden border border-white/10 group">
{image && (
<>
<img
src={`data:image/png;base64,${image.data}`}
alt="Source"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-end justify-center pb-2">
<span className="text-[10px] text-white/80 font-medium">Source Image</span>
</div>
</>
)}
</div>
{/* Prompt Input */}
<div className="flex-1 flex flex-col gap-3">
<label className="text-sm font-medium text-white/70 flex items-center gap-2">
<Sparkles className="h-3.5 w-3.5 text-purple-400" />
Motion Description
</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="w-full h-24 p-3 rounded-xl bg-white/5 border border-white/10 resize-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/50 outline-none text-sm text-white placeholder:text-white/30 transition-all"
placeholder="Describe how the image should move... (e.g., 'camera slowly zooms in, wind blows through hair')"
autoFocus
/>
</div>
</div>
{/* Info Chips */}
<div className="flex flex-wrap gap-2 mt-4">
<div className="px-2.5 py-1 bg-white/5 border border-white/10 rounded-full text-xs text-white/50">
<span className="text-white/70">Duration:</span> 8 seconds
</div>
<div className="px-2.5 py-1 bg-white/5 border border-white/10 rounded-full text-xs text-white/50">
<span className="text-white/70">Model:</span> Veo 3
</div>
<div className="px-2.5 py-1 bg-amber-500/10 border border-amber-500/20 rounded-full text-xs text-amber-300">
<span className="text-amber-400">📐</span> 16:9 Landscape
</div>
<div className="px-2.5 py-1 bg-purple-500/10 border border-purple-500/20 rounded-full text-xs text-purple-300">
<span className="text-purple-400"></span> AI Animation
</div>
</div>
{/* Aspect Ratio Note */}
<div className="mt-3 px-3 py-2 bg-amber-500/5 border border-amber-500/10 rounded-lg">
<p className="text-xs text-amber-200/70">
<span className="text-amber-400 font-medium">💡 Tip:</span> Video output is 16:9 landscape. For best results, use 16:9 source images.
</p>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 mt-6">
<button
onClick={onClose}
className="px-5 py-2.5 hover:bg-white/5 rounded-xl text-sm font-medium text-white/70 hover:text-white transition-colors"
disabled={loading}
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading || !prompt.trim()}
className={cn(
"px-6 py-2.5 rounded-xl text-sm font-semibold transition-all flex items-center gap-2",
"bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-500 hover:to-blue-500",
"text-white shadow-lg shadow-purple-500/25",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none"
)}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span>Generating...</span>
</>
) : (
<>
<Film className="h-4 w-4" />
<span>Animate</span>
</>
)}
</button>
</div>
</div>
{/* Loading Overlay */}
{loading && (
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm flex flex-col items-center justify-center gap-4 animate-in fade-in">
<div className="relative">
<div className="w-16 h-16 rounded-full border-2 border-purple-500/30" />
<div className="absolute inset-0 w-16 h-16 rounded-full border-2 border-t-purple-500 animate-spin" />
</div>
<div className="text-center">
<p className="text-white font-medium">Creating your video...</p>
<p className="text-white/50 text-sm">This may take up to a minute</p>
</div>
</div>
)}
</div>
</div>
);
}

4908
data/prompts.json Normal file

File diff suppressed because one or more lines are too long

12
docker-compose.yml Normal file
View file

@ -0,0 +1,12 @@
services:
kv-pix:
image: kv-pix:latest
build:
context: .
dockerfile: Dockerfile
container_name: kv-pix
restart: unless-stopped
ports:
- "3001:3000"
environment:
- NODE_ENV=production

18
eslint.config.mjs Normal file
View file

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

300
lib/crawler.ts Normal file
View file

@ -0,0 +1,300 @@
import { Prompt } from './types';
const JIMMYLV_SOURCE_URL = "https://raw.githubusercontent.com/JimmyLv/awesome-nano-banana/main/cases";
const YOUMIND_README_URL = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts/main/README.md";
const ZEROLU_README_URL = "https://raw.githubusercontent.com/ZeroLu/awesome-nanobanana-pro/main/README.md";
const MAX_CASE_ID = 200; // Increased limit slightly
const BATCH_SIZE = 10;
export class JimmyLvCrawler {
async crawl(limit: number = 300): Promise<Prompt[]> {
console.log(`Starting crawl for ${limit} cases...`);
const prompts: Prompt[] = [];
// Create batches of IDs to fetch
const ids = Array.from({ length: limit }, (_, i) => i + 1);
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
const batch = ids.slice(i, i + BATCH_SIZE);
// console.log(`Fetching batch ${i + 1} to ${i + batch.length}...`);
const results = await Promise.all(
batch.map(id => this.fetchCase(id))
);
results.forEach(p => {
if (p) prompts.push(p);
});
}
console.log(`[JimmyLv] Crawled ${prompts.length} valid prompts.`);
return prompts;
}
private async fetchCase(id: number): Promise<Prompt | null> {
try {
const url = `${JIMMYLV_SOURCE_URL}/${id}/case.yml`;
const res = await fetch(url);
if (!res.ok) {
// console.warn(`Failed to fetch ${url}: ${res.status}`);
return null;
}
const text = await res.text();
return this.parseCase(text, id);
} catch (error) {
console.error(`Error fetching case ${id}:`, error);
return null;
}
}
private parseCase(content: string, caseId: number): Prompt | null {
try {
// Extract title
let title = this.extract(content, /title_en:\s*(.+)/);
if (!title) title = this.extract(content, /title:\s*(.+)/) || "Unknown";
// Extract prompt (Multi-line block scalar)
let promptText = "";
const promptMatch = content.match(/prompt_en:\s*\|\s*\n((?: .+\n)+)/) ||
content.match(/prompt:\s*\|\s*\n((?: .+\n)+)/);
if (promptMatch) {
promptText = promptMatch[1]
.split('\n')
.map(line => line.trim())
.join(' ')
.trim();
}
if (!promptText) {
// Try simpler single line prompt
promptText = this.extract(content, /prompt:\s*(.+)/) || "";
}
if (!promptText) return null;
// Extract image filename
const imageFilename = this.extract(content, /image:\s*(.+)/);
let imageUrl = "";
if (imageFilename) {
imageUrl = `${JIMMYLV_SOURCE_URL}/${caseId}/${imageFilename}`;
}
// Extract author
const author = this.extract(content, /author:\s*"?([^"\n]+)"?/) || "JimmyLv Repo";
const category = this.inferCategory(title, promptText);
return {
id: 0, // Will be assigned by manager
title: title.slice(0, 150),
prompt: promptText,
category,
category_type: "style", // Simplified
description: promptText.slice(0, 200) + (promptText.length > 200 ? "..." : ""),
images: imageUrl ? [imageUrl] : [],
author,
source: "jimmylv",
source_url: `https://github.com/JimmyLv/awesome-nano-banana/tree/main/cases/${caseId}`
};
} catch (error) {
return null;
}
}
private extract(content: string, regex: RegExp): string | null {
const match = content.match(regex);
return match ? match[1].trim() : null;
}
private inferCategory(title: string, prompt: string): string {
const text = (title + " " + prompt).toLowerCase();
const rules: [string[], string][] = [
[["ghibli", "anime", "cartoon", "chibi", "comic", "illustration", "drawing"], "Illustration"],
[["icon", "logo", "symbol"], "Logo / Icon"],
[["product", "packaging", "mockup"], "Product"],
[["avatar", "profile", "headshot"], "Profile / Avatar"],
[["infographic", "chart", "diagram"], "Infographic / Edu Visual"],
[["cinematic", "film", "movie"], "Cinematic / Film Still"],
[["3d", "render", "blender"], "3D Render"],
[["pixel", "8-bit", "retro game"], "Pixel Art"],
];
for (const [keywords, cat] of rules) {
if (keywords.some(k => text.includes(k))) return cat;
}
return "Photography";
}
}
export class YouMindCrawler {
async crawl(): Promise<Prompt[]> {
console.log(`[YouMind] Starting crawl of README...`);
const prompts: Prompt[] = [];
try {
const res = await fetch(YOUMIND_README_URL);
if (!res.ok) throw new Error("Failed to fetch YouMind README");
const text = await res.text();
// Split by "### No." sections
const sections = text.split(/### No\./g).slice(1);
let idCounter = 1;
for (const section of sections) {
const prompt = this.parseSection(section, idCounter++);
if (prompt) prompts.push(prompt);
}
} catch (e) {
console.error("[YouMind] Crawl failed", e);
}
console.log(`[YouMind] Crawled ${prompts.length} valid prompts.`);
return prompts;
}
private parseSection(content: string, index: number): Prompt | null {
try {
// Title: First line after number
const titleMatch = content.match(/\s*\d+:\s*(.+)/);
const title = titleMatch ? titleMatch[1].trim() : `YouMind Case ${index}`;
// Prompt Block
const promptMatch = content.match(/```\s*([\s\S]*?)\s*```/);
// Some sections might have multiple blocks, assume first large one is prompt?
// The README format shows prompt in a code block under #### 📝 Prompt
// Better regex: look for #### 📝 Prompt\n\n```\n...
const strictPromptMatch = content.match(/#### 📝 Prompt\s+```[\s\S]*?\n([\s\S]*?)```/);
const promptText = strictPromptMatch ? strictPromptMatch[1].trim() : (promptMatch ? promptMatch[1].trim() : "");
if (!promptText) return null;
// Images
const imageMatches = [...content.matchAll(/<img src="(.*?)"/g)];
const images = imageMatches.map(m => m[1]).filter(url => !url.includes("img.shields.io")); // Exclude badges
// Author / Source
const authorMatch = content.match(/- \*\*Author:\*\* \[(.*?)\]/);
const author = authorMatch ? authorMatch[1] : "YouMind Community";
const sourceMatch = content.match(/- \*\*Source:\*\* \[(.*?)\]\((.*?)\)/);
const sourceUrl = sourceMatch ? sourceMatch[2] : `https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts#no-${index}`;
return {
id: 0,
title,
prompt: promptText,
category: this.inferCategory(title, promptText),
category_type: "style",
description: title,
images,
author,
source: "youmind",
source_url: sourceUrl
};
} catch (e) {
return null;
}
}
private inferCategory(title: string, prompt: string): string {
// Reuse similar logic, maybe static util later
const text = (title + " " + prompt).toLowerCase();
if (text.includes("logo") || text.includes("icon")) return "Logo / Icon";
if (text.includes("3d")) return "3D Render";
if (text.includes("photo") || text.includes("realistic")) return "Photography";
return "Illustration";
}
}
export class ZeroLuCrawler {
async crawl(): Promise<Prompt[]> {
console.log(`[ZeroLu] Starting crawl of README...`);
const prompts: Prompt[] = [];
try {
const res = await fetch(ZEROLU_README_URL);
if (!res.ok) throw new Error("Failed to fetch ZeroLu README");
const text = await res.text();
// Split by H3 headers like "### 1.1 " or "### 1.2 "
// The format is `### X.X. Title`
const sections = text.split(/### \d+\.\d+\.?\s+/).slice(1);
// We need to capture the title which was consumed by split, or use matchAll
// Better to use regex global match to find headers and their content positions.
// Or just split and accept title is lost? No, title is important.
// Alternative loop:
const regex = /### (\d+\.\d+\.?\s+.*?)\n([\s\S]*?)(?=### \d+\.\d+|$)/g;
let match;
let count = 0;
while ((match = regex.exec(text)) !== null) {
const title = match[1].trim();
const body = match[2];
const prompt = this.parseSection(title, body);
if (prompt) prompts.push(prompt);
count++;
}
} catch (e) {
console.error("[ZeroLu] Crawl failed", e);
}
console.log(`[ZeroLu] Crawled ${prompts.length} valid prompts.`);
return prompts;
}
private parseSection(title: string, content: string): Prompt | null {
// Extract Prompt
// Format: **Prompt:**\n\n```\n...\n```
const promptMatch = content.match(/\*\*Prompt:\*\*\s*[\n\r]*```[\w]*([\s\S]*?)```/);
if (!promptMatch) return null;
const promptText = promptMatch[1].trim();
// Extract Images
// Markdown image: ![...](url) or HTML <img src="...">
const mdImageMatch = content.match(/!\[.*?\]\((.*?)\)/);
const htmlImageMatch = content.match(/<img.*?src="(.*?)".*?>/);
let imageUrl = mdImageMatch ? mdImageMatch[1] : (htmlImageMatch ? htmlImageMatch[1] : "");
// Clean URL if it has query params (sometimes github adds them) unless needed
// Assuming raw github images work fine.
// Source
const sourceMatch = content.match(/Source: \[@(.*?)\]\((.*?)\)/);
const sourceUrl = sourceMatch ? sourceMatch[2] : `https://github.com/ZeroLu/awesome-nanobanana-pro#${title.toLowerCase().replace(/\s+/g, '-')}`;
const author = sourceMatch ? sourceMatch[1] : "ZeroLu Community";
return {
id: 0,
title,
prompt: promptText,
category: this.inferCategory(title, promptText),
category_type: "style",
description: title,
images: imageUrl ? [imageUrl] : [],
author,
source: "zerolu",
source_url: sourceUrl
};
}
private inferCategory(title: string, prompt: string): string {
const text = (title + " " + prompt).toLowerCase();
if (text.includes("logo") || text.includes("icon")) return "Logo / Icon";
if (text.includes("3d")) return "3D Render";
if (text.includes("photo") || text.includes("realistic") || text.includes("selfie")) return "Photography";
return "Illustration";
}
}

22
lib/db.ts Normal file
View file

@ -0,0 +1,22 @@
import Dexie, { Table } from 'dexie';
export interface ImageItem {
id?: number;
data: string; // Base64
prompt: string;
aspectRatio: string;
createdAt: number;
}
export class KeyValuePixDB extends Dexie {
gallery!: Table<ImageItem, number>;
constructor() {
super('kv-pix-db');
this.version(1).stores({
gallery: '++id, createdAt'
});
}
}
export const db = new KeyValuePixDB();

55
lib/history.ts Normal file
View file

@ -0,0 +1,55 @@
import fs from 'fs';
import path from 'path';
const DATA_DIR = path.join(process.cwd(), 'data');
const HISTORY_FILE = path.join(DATA_DIR, 'history.json');
// Ensure data dir exists
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
export interface HistoryItem {
id: string;
url: string; // Base64 data URI for now, or local path served via public
originalName: string;
category: string;
timestamp: number;
mediaId?: string; // Whisk Media ID if available
}
export const history = {
getAll: (category?: string): HistoryItem[] => {
if (!fs.existsSync(HISTORY_FILE)) return [];
try {
const data = JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf-8'));
let items: HistoryItem[] = Array.isArray(data) ? data : [];
if (category) {
items = items.filter(i => i.category === category);
}
return items.sort((a, b) => b.timestamp - a.timestamp);
} catch (e) {
return [];
}
},
add: (item: Omit<HistoryItem, 'timestamp'>) => {
const items = history.getAll();
const newItem: HistoryItem = { ...item, timestamp: Date.now() };
items.unshift(newItem); // Add to top
// Limit to 50 items per category? or total?
// Let's keep total 100 for now.
const trimmed = items.slice(0, 100);
fs.writeFileSync(HISTORY_FILE, JSON.stringify(trimmed, null, 2));
return newItem;
},
delete: (id: string) => {
const items = history.getAll();
const filtered = items.filter(i => i.id !== id);
fs.writeFileSync(HISTORY_FILE, JSON.stringify(filtered, null, 2));
}
};

97
lib/prompts-service.ts Normal file
View file

@ -0,0 +1,97 @@
import fs from 'fs/promises';
import path from 'path';
import { Prompt, PromptCache } from '@/lib/types';
import { JimmyLvCrawler, YouMindCrawler, ZeroLuCrawler } from '@/lib/crawler';
const DATA_FILE = path.join(process.cwd(), 'data', 'prompts.json');
export async function getPrompts(): Promise<PromptCache> {
try {
const fileContent = await fs.readFile(DATA_FILE, 'utf-8');
return JSON.parse(fileContent);
} catch (e) {
return { prompts: [], last_updated: null, categories: {}, total_count: 0, sources: [] };
}
}
export async function syncPromptsService(): Promise<{ success: boolean, count: number, added: number }> {
console.log("[SyncService] Starting sync...");
// 1. Crawl all sources
const jimmyCrawler = new JimmyLvCrawler();
const youMindCrawler = new YouMindCrawler();
const zeroLuCrawler = new ZeroLuCrawler();
const [jimmyPrompts, youMindPrompts, zeroLuPrompts] = await Promise.all([
jimmyCrawler.crawl(),
youMindCrawler.crawl(),
zeroLuCrawler.crawl()
]);
const crawledPrompts = [...jimmyPrompts, ...youMindPrompts, ...zeroLuPrompts];
console.log(`[SyncService] Total crawled ${crawledPrompts.length} prompts (Jimmy: ${jimmyPrompts.length}, YouMind: ${youMindPrompts.length}, ZeroLu: ${zeroLuPrompts.length}).`);
// 2. Read existing
const cache = await getPrompts();
const existingPrompts = cache.prompts || [];
// 3. Merge
let addedCount = 0;
const now = Date.now();
const finalPrompts: Prompt[] = [];
const existingMap = new Map<string, Prompt>();
existingPrompts.forEach(p => {
if (p.source_url) {
existingMap.set(p.source_url, p);
} else {
finalPrompts.push(p);
}
});
crawledPrompts.forEach(newP => {
const existing = existingMap.get(newP.source_url);
if (existing) {
finalPrompts.push({
...newP,
id: existing.id,
images: existing.images,
createdAt: existing.createdAt || (existing.published ? new Date(existing.published).getTime() : undefined),
useCount: existing.useCount,
lastUsedAt: existing.lastUsedAt
});
existingMap.delete(newP.source_url);
} else {
addedCount++;
finalPrompts.push({
...newP,
createdAt: now,
useCount: 0
});
}
});
// Add remaining existing
existingMap.forEach(p => finalPrompts.push(p));
// Re-ID
finalPrompts.forEach((p, i) => p.id = i + 1);
// Meta
const categories: Record<string, string[]> = {
"style": Array.from(new Set(finalPrompts.map(p => p.category)))
};
const newCache: PromptCache = {
last_updated: new Date(now).toISOString(),
lastSync: now,
categories,
total_count: finalPrompts.length,
sources: Array.from(new Set(finalPrompts.map(p => p.source))),
prompts: finalPrompts
};
await fs.writeFile(DATA_FILE, JSON.stringify(newCache, null, 2), 'utf-8');
return { success: true, count: finalPrompts.length, added: addedCount };
}

View file

@ -0,0 +1,246 @@
/**
* Grok/xAI Client for Image Generation
*
* Supports two authentication methods:
* 1. Official API Key from console.x.ai (recommended)
* 2. Cookie-based auth from logged-in grok.com session
*
* Image Model: FLUX.1 by Black Forest Labs
*/
// Official xAI API endpoint
const XAI_API_BASE = "https://api.x.ai/v1";
// Grok web interface endpoint (for cookie-based auth)
const GROK_WEB_BASE = "https://grok.com";
interface GrokGenerateOptions {
prompt: string;
apiKey?: string;
cookies?: string;
numImages?: number;
}
interface GrokImageResult {
url: string;
data?: string; // base64
prompt: string;
model: string;
}
export class GrokClient {
private apiKey?: string;
private cookies?: string;
constructor(options: { apiKey?: string; cookies?: string }) {
this.apiKey = options.apiKey;
this.cookies = this.normalizeCookies(options.cookies);
}
/**
* Normalize cookies from string or JSON format
* Handles cases where user pastes JSON array from extension/devtools
*/
private normalizeCookies(cookies?: string): string | undefined {
if (!cookies) return undefined;
try {
// Check if it looks like JSON
if (cookies.trim().startsWith('[')) {
const parsed = JSON.parse(cookies);
if (Array.isArray(parsed)) {
return parsed
.map((c: any) => `${c.name}=${c.value}`)
.join('; ');
}
}
} catch (e) {
// Not JSON, assume string
}
return cookies;
}
/**
* Generate images using Grok/xAI
* Prefers official API if apiKey is provided, falls back to cookie-based
*/
async generate(prompt: string, numImages: number = 1): Promise<GrokImageResult[]> {
if (this.apiKey) {
return this.generateWithAPI(prompt, numImages);
} else if (this.cookies) {
return this.generateWithCookies(prompt, numImages);
} else {
throw new Error("Grok: No API key or cookies provided. Configure in Settings.");
}
}
/**
* Generate using official xAI API (recommended)
* Requires API key from console.x.ai
*/
private async generateWithAPI(prompt: string, numImages: number): Promise<GrokImageResult[]> {
console.log(`[Grok API] Generating ${numImages} image(s) for: "${prompt.substring(0, 50)}..."`);
const response = await fetch(`${XAI_API_BASE}/images/generations`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${this.apiKey}`
},
body: JSON.stringify({
model: "grok-2-image",
prompt: prompt,
n: numImages,
response_format: "url" // or "b64_json"
})
});
if (!response.ok) {
const errorText = await response.text();
console.error("[Grok API] Error:", response.status, errorText);
throw new Error(`Grok API Error: ${response.status} - ${errorText.substring(0, 200)}`);
}
const data = await response.json();
console.log("[Grok API] Response:", JSON.stringify(data, null, 2));
// Parse response - xAI uses OpenAI-compatible format
const images: GrokImageResult[] = (data.data || []).map((img: any) => ({
url: img.url || (img.b64_json ? `data:image/png;base64,${img.b64_json}` : ''),
data: img.b64_json,
prompt: prompt,
model: "grok-2-image"
}));
if (images.length === 0) {
throw new Error("Grok API returned no images");
}
return images;
}
/**
* Generate using Grok web interface (cookie-based)
* Requires cookies from logged-in grok.com session
*/
private async generateWithCookies(prompt: string, numImages: number): Promise<GrokImageResult[]> {
console.log(`[Grok Web] Generating image for: "${prompt.substring(0, 50)}..."`);
// The Grok web interface uses a chat-based API
// We need to send a message asking for image generation
const imagePrompt = `Generate an image: ${prompt}`;
const response = await fetch(`${GROK_WEB_BASE}/rest/app-chat/conversations/new`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Cookie": this.cookies!,
"Origin": GROK_WEB_BASE,
"Referer": `${GROK_WEB_BASE}/`,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Dest": "empty"
},
body: JSON.stringify({
temporary: false,
modelName: "grok-3",
message: imagePrompt,
fileAttachments: [],
imageAttachments: [],
disableSearch: false,
enableImageGeneration: true,
returnImageBytes: false,
returnRawGrokInXaiRequest: false,
sendFinalMetadata: true,
customInstructions: "",
deepsearchPreset: "",
isReasoning: false
})
});
if (!response.ok) {
const errorText = await response.text();
console.error("[Grok Web] Error:", response.status, errorText);
throw new Error(`Grok Web Error: ${response.status} - ${errorText.substring(0, 200)}`);
}
// Parse streaming response to find image URLs
const text = await response.text();
console.log("[Grok Web] Response length:", text.length);
// Look for generated image URLs in the response
const imageUrls = this.extractImageUrls(text);
if (imageUrls.length === 0) {
console.warn("[Grok Web] No image URLs found in response. Response preview:", text.substring(0, 500));
throw new Error("Grok did not generate any images. Try a different prompt or check your cookies.");
}
return imageUrls.map(url => ({
url,
prompt,
model: "grok-3"
}));
}
/**
* Extract image URLs from Grok's streaming response
*/
private extractImageUrls(responseText: string): string[] {
const urls: string[] = [];
// Try to parse as JSON lines (NDJSON format)
const lines = responseText.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const data = JSON.parse(line);
// Check for generatedImageUrls field
if (data.generatedImageUrls && Array.isArray(data.generatedImageUrls)) {
urls.push(...data.generatedImageUrls);
}
// Check for imageUrls in result
if (data.result?.imageUrls) {
urls.push(...data.result.imageUrls);
}
// Check for media attachments
if (data.attachments) {
for (const attachment of data.attachments) {
if (attachment.type === 'image' && attachment.url) {
urls.push(attachment.url);
}
}
}
} catch {
// Not JSON, try regex extraction
}
}
// Fallback: regex for image URLs
if (urls.length === 0) {
const urlRegex = /https:\/\/[^"\s]+\.(png|jpg|jpeg|webp)/gi;
const matches = responseText.match(urlRegex);
if (matches) {
urls.push(...matches);
}
}
// Deduplicate
return [...new Set(urls)];
}
/**
* Download image from URL and convert to base64
*/
async downloadAsBase64(url: string): Promise<string> {
const response = await fetch(url);
const buffer = await response.arrayBuffer();
const base64 = Buffer.from(buffer).toString('base64');
return base64;
}
}

View file

@ -0,0 +1,372 @@
/**
* Meta AI Client for Image Generation
*
* Uses the Meta AI web interface via GraphQL
* Requires cookies from a logged-in meta.ai session
*
* Image Model: Imagine (Emu)
*
* Based on: https://github.com/Strvm/meta-ai-api
*/
const META_AI_BASE = "https://www.meta.ai";
const GRAPHQL_ENDPOINT = `${META_AI_BASE}/api/graphql/`;
interface MetaAIOptions {
cookies: string;
}
interface MetaImageResult {
url: string;
data?: string; // base64
prompt: string;
model: string;
}
interface MetaSession {
lsd?: string;
fb_dtsg?: string;
accessToken?: string;
}
export class MetaAIClient {
private cookies: string;
private session: MetaSession = {};
constructor(options: MetaAIOptions) {
this.cookies = this.normalizeCookies(options.cookies);
this.parseSessionFromCookies();
}
/**
* Normalize cookies from string or JSON format
* Handles cases where user pastes JSON array from extension/devtools
*/
private normalizeCookies(cookies: string): string {
if (!cookies) return "";
try {
// Check if it looks like JSON
if (cookies.trim().startsWith('[')) {
const parsed = JSON.parse(cookies);
if (Array.isArray(parsed)) {
return parsed
.map((c: any) => `${c.name}=${c.value}`)
.join('; ');
}
}
} catch (e) {
// Not JSON, assume string
}
return cookies;
}
/**
* Parse session tokens from cookies
*/
private parseSessionFromCookies(): void {
// Extract lsd token if present in cookies
const lsdMatch = this.cookies.match(/lsd=([^;]+)/);
if (lsdMatch) {
this.session.lsd = lsdMatch[1];
}
// Extract fb_dtsg if present
const dtsgMatch = this.cookies.match(/fb_dtsg=([^;]+)/);
if (dtsgMatch) {
this.session.fb_dtsg = dtsgMatch[1];
}
}
/**
* Generate images using Meta AI's Imagine model
*/
async generate(prompt: string, numImages: number = 4): Promise<MetaImageResult[]> {
console.log(`[Meta AI] Generating images for: "${prompt.substring(0, 50)}..."`);
// First, get the access token and session info if not already fetched
if (!this.session.accessToken) {
await this.initSession();
}
// Use "Imagine" prefix for image generation
const imagePrompt = prompt.toLowerCase().startsWith('imagine')
? prompt
: `Imagine ${prompt}`;
// Send the prompt via GraphQL
const response = await this.sendPrompt(imagePrompt);
// Extract image URLs from response
const images = this.extractImages(response, prompt);
if (images.length === 0) {
// Meta AI might return the prompt response without images immediately
// We may need to poll for the result
console.log("[Meta AI] No images in initial response, polling...");
return this.pollForImages(response, prompt);
}
return images;
}
/**
* Initialize session - get access token from meta.ai page
*/
private async initSession(): Promise<void> {
console.log("[Meta AI] Initializing session...");
const response = await fetch(META_AI_BASE, {
headers: {
"Cookie": this.cookies,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Dest": "document",
"Accept-Language": "en-US,en;q=0.9"
}
});
const html = await response.text();
// Extract access token from page HTML
// Pattern 1: Simple JSON key
let tokenMatch = html.match(/"accessToken":"([^"]+)"/);
// Pattern 2: Inside a config object or different spacing
if (!tokenMatch) {
tokenMatch = html.match(/accessToken["']\s*:\s*["']([^"']+)["']/);
}
// Pattern 3: LSD token backup (sometimes needed)
const lsdMatch = html.match(/"LSD",\[\],{"token":"([^"]+)"/) ||
html.match(/"lsd":"([^"]+)"/) ||
html.match(/name="lsd" value="([^"]+)"/);
if (lsdMatch) {
this.session.lsd = lsdMatch[1];
}
// Pattern 4: DTSG token (critical for some requests)
const dtsgMatch = html.match(/"DTSGInitialData".*?"token":"([^"]+)"/) ||
html.match(/"token":"([^"]+)"/); // Less specific fallback
if (tokenMatch) {
this.session.accessToken = tokenMatch[1];
console.log("[Meta AI] Got access token");
} else if (html.includes('login_form') || html.includes('login_page')) {
throw new Error("Meta AI: Cookies expired or invalid (Login page detected). Please update cookies.");
} else {
console.warn("[Meta AI] Warning: Failed to extract access token. functionality may be limited.");
console.log("HTML Preview:", html.substring(0, 200));
// Don't throw here, try to proceed with just cookies/LSD
}
if (dtsgMatch) {
this.session.fb_dtsg = dtsgMatch[1];
}
// We no longer strictly enforce accessToken presence here
// as some requests might work with just cookies
}
/**
* Send prompt via GraphQL mutation
*/
private async sendPrompt(prompt: string): Promise<any> {
const variables = {
message: {
text: prompt,
content_type: "TEXT"
},
source: "PDT_CHAT_INPUT",
external_message_id: Math.random().toString(36).substring(2) + Date.now().toString(36)
};
const body = new URLSearchParams({
fb_api_caller_class: "RelayModern",
fb_api_req_friendly_name: "useAbraSendMessageMutation",
variables: JSON.stringify(variables),
doc_id: "7783822248314888",
...(this.session.lsd && { lsd: this.session.lsd }),
...(this.session.fb_dtsg && { fb_dtsg: this.session.fb_dtsg })
});
const response = await fetch(GRAPHQL_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": this.cookies,
"Origin": META_AI_BASE,
"Referer": `${META_AI_BASE}/`,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Dest": "empty",
"Accept-Language": "en-US,en;q=0.9",
...(this.session.accessToken && { "Authorization": `OAuth ${this.session.accessToken}` })
},
body: body.toString()
});
if (!response.ok) {
const errorText = await response.text();
console.error("[Meta AI] GraphQL Error:", response.status, errorText);
throw new Error(`Meta AI Error: ${response.status} - ${errorText.substring(0, 200)}`);
}
// Check if response is actually JSON
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("text/html")) {
const text = await response.text();
if (text.includes("login_form") || text.includes("facebook.com/login")) {
throw new Error("Meta AI: Session expired. Please refresh your cookies.");
}
throw new Error(`Meta AI returned HTML error: ${text.substring(0, 100)}...`);
}
const data = await response.json();
console.log("[Meta AI] Response:", JSON.stringify(data, null, 2).substring(0, 500));
return data;
}
/**
* Extract image URLs from Meta AI response
*/
private extractImages(response: any, originalPrompt: string): MetaImageResult[] {
const images: MetaImageResult[] = [];
// Navigate through the response structure
const messageData = response?.data?.node?.bot_response_message ||
response?.data?.xabraAIPreviewMessageSendMutation?.message;
if (!messageData) {
return images;
}
// Check for imagine_card (image generation response)
const imagineCard = messageData?.imagine_card;
if (imagineCard?.session?.media_sets) {
for (const mediaSet of imagineCard.session.media_sets) {
if (mediaSet?.imagine_media) {
for (const media of mediaSet.imagine_media) {
if (media?.uri) {
images.push({
url: media.uri,
prompt: originalPrompt,
model: "imagine"
});
}
}
}
}
}
// Check for attachments
const attachments = messageData?.attachments;
if (attachments) {
for (const attachment of attachments) {
if (attachment?.media?.image_uri) {
images.push({
url: attachment.media.image_uri,
prompt: originalPrompt,
model: "imagine"
});
}
}
}
return images;
}
/**
* Poll for image generation completion
*/
private async pollForImages(initialResponse: any, prompt: string): Promise<MetaImageResult[]> {
const maxAttempts = 30;
const pollInterval = 2000;
// Get the fetch_id from initial response for polling
const fetchId = initialResponse?.data?.node?.id ||
initialResponse?.data?.xabraAIPreviewMessageSendMutation?.message?.id;
if (!fetchId) {
console.warn("[Meta AI] No fetch ID for polling, returning empty");
return [];
}
for (let attempt = 0; attempt < maxAttempts; attempt++) {
console.log(`[Meta AI] Polling attempt ${attempt + 1}/${maxAttempts}...`);
await new Promise(resolve => setTimeout(resolve, pollInterval));
try {
// Query for the message status
const statusResponse = await this.queryMessageStatus(fetchId);
const images = this.extractImages(statusResponse, prompt);
if (images.length > 0) {
console.log(`[Meta AI] Got ${images.length} images!`);
return images;
}
// Check if generation failed
const status = statusResponse?.data?.node?.imagine_card?.session?.status;
if (status === "FAILED" || status === "ERROR") {
throw new Error("Meta AI image generation failed");
}
} catch (e) {
console.error("[Meta AI] Poll error:", e);
if (attempt === maxAttempts - 1) throw e;
}
}
throw new Error("Meta AI: Image generation timed out");
}
/**
* Query message status for polling
*/
private async queryMessageStatus(messageId: string): Promise<any> {
const variables = {
id: messageId
};
const body = new URLSearchParams({
fb_api_caller_class: "RelayModern",
fb_api_req_friendly_name: "useAbraMessageQuery",
variables: JSON.stringify(variables),
doc_id: "7654946557897648",
...(this.session.lsd && { lsd: this.session.lsd }),
...(this.session.fb_dtsg && { fb_dtsg: this.session.fb_dtsg })
});
const response = await fetch(GRAPHQL_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": this.cookies,
"Origin": META_AI_BASE,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
...(this.session.accessToken && { "Authorization": `OAuth ${this.session.accessToken}` })
},
body: body.toString()
});
return response.json();
}
/**
* Download image from URL and convert to base64
*/
async downloadAsBase64(url: string): Promise<string> {
const response = await fetch(url, {
headers: {
"Cookie": this.cookies,
"Referer": META_AI_BASE
}
});
const buffer = await response.arrayBuffer();
return Buffer.from(buffer).toString('base64');
}
}

189
lib/store.ts Normal file
View file

@ -0,0 +1,189 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { db, ImageItem as DBImageItem } from './db';
// ImageItem definition is now in db.ts but we keep a compatible interface if needed,
// or import it. Let's reuse DBImageItem for consistency in the store.
export type ImageItem = DBImageItem;
export interface HistoryItem {
id: string;
url: string;
category: string;
originalName: string;
}
export interface VideoItem {
id: string;
url: string; // Blob URL or remote URL
prompt: string;
thumbnail?: string; // Optional thumbnail from source image
createdAt?: number;
}
export type ViewType = 'gallery' | 'settings' | 'library' | 'history';
export type ReferenceCategory = 'subject' | 'scene' | 'style' | 'video'; // Added 'video'
interface AppState {
// Navigation
currentView: ViewType;
setCurrentView: (view: ViewType) => void;
// Reference Selection
selectionMode: ReferenceCategory | null;
setSelectionMode: (mode: ReferenceCategory | null) => void;
// Multiple references per category (arrays)
references: {
subject?: Array<{ id: string; thumbnail?: string }>;
scene?: Array<{ id: string; thumbnail?: string }>;
style?: Array<{ id: string; thumbnail?: string }>;
video?: Array<{ id: string; thumbnail?: string }>;
};
// Add a reference to a category (appends to array)
addReference: (category: ReferenceCategory, ref: { id: string; thumbnail?: string }) => void;
// Remove a specific reference by ID from a category
removeReference: (category: ReferenceCategory, refId: string) => void;
// Clear all references for a category
clearReferences: (category: ReferenceCategory) => void;
// Legacy setter for backwards compatibility (replaces all refs in a category)
setReference: (category: ReferenceCategory, ref: { id: string; thumbnail?: string } | undefined) => void;
prompt: string;
setPrompt: (p: string) => void;
gallery: ImageItem[];
loadGallery: () => Promise<void>;
addToGallery: (image: ImageItem) => Promise<void>;
removeFromGallery: (id: number) => Promise<void>;
clearGallery: () => Promise<void>;
// Videos
videos: VideoItem[];
addVideo: (video: VideoItem) => void;
removeVideo: (id: string) => void;
history: HistoryItem[];
setHistory: (items: HistoryItem[]) => void;
removeFromHistory: (id: string) => void;
settings: {
aspectRatio: string;
preciseMode: boolean;
imageCount: number;
theme: 'light' | 'dark';
// Provider selection
provider: 'whisk' | 'grok' | 'meta';
// Whisk (Google)
whiskCookies: string;
// Grok (xAI)
grokApiKey: string;
grokCookies: string;
// Meta AI
metaCookies: string;
};
setSettings: (s: Partial<AppState['settings']>) => void;
}
export const useStore = create<AppState>()(
persist(
(set) => ({
currentView: 'gallery',
setCurrentView: (view) => set({ currentView: view }),
selectionMode: null,
setSelectionMode: (mode) => set({ selectionMode: mode }),
references: {},
// Add a reference to a category array
addReference: (category, ref) => set((state) => ({
references: {
...state.references,
[category]: [...(state.references[category] || []), ref]
}
})),
// Remove a specific reference by ID
removeReference: (category, refId) => set((state) => ({
references: {
...state.references,
[category]: (state.references[category] || []).filter(r => r.id !== refId)
}
})),
// Clear all references for a category
clearReferences: (category) => set((state) => ({
references: {
...state.references,
[category]: []
}
})),
// Legacy setter (replaces entire category with single ref or clears if undefined)
setReference: (category, ref) => set((state) => ({
references: { ...state.references, [category]: ref ? [ref] : undefined }
})),
prompt: '',
setPrompt: (p) => set({ prompt: p }),
gallery: [],
loadGallery: async () => {
const items = await db.gallery.toArray();
// Sort by createdAt desc if needed
items.sort((a, b) => b.createdAt - a.createdAt);
set({ gallery: items });
},
addToGallery: async (img) => {
const id = await db.gallery.add(img);
const newImg = { ...img, id };
set((state) => ({ gallery: [newImg, ...state.gallery] }));
},
removeFromGallery: async (id) => {
if (!id) return;
await db.gallery.delete(id);
set((state) => ({
gallery: state.gallery.filter((item) => item.id !== id)
}));
},
clearGallery: async () => {
await db.gallery.clear();
set({ gallery: [] });
},
// Videos
videos: [],
addVideo: (video) => set((state) => ({ videos: [video, ...state.videos] })),
removeVideo: (id) => set((state) => ({ videos: state.videos.filter(v => v.id !== id) })),
history: [],
setHistory: (items) => set({ history: items }),
removeFromHistory: (id) => set((state) => ({
history: state.history.filter(item => item.id !== id)
})),
settings: {
aspectRatio: '1:1',
preciseMode: false,
imageCount: 4,
theme: 'dark',
provider: 'whisk',
whiskCookies: '',
grokApiKey: '',
grokCookies: '',
metaCookies: ''
},
setSettings: (s) => set((state) => ({ settings: { ...state.settings, ...s } }))
}),
{
name: 'kv-pix-storage',
partialize: (state) => ({
settings: state.settings,
// gallery: state.gallery, // Don't persist gallery to localStorage
history: state.history,
videos: state.videos // Persist videos
}),
}
)
);

126
lib/types.ts Normal file
View file

@ -0,0 +1,126 @@
export interface Prompt {
id: number;
title: string;
prompt: string;
category: string;
category_type?: string;
description: string;
images: string[];
author: string;
source: string;
source_url: string;
published?: string;
tags?: string[];
createdAt?: number; // Timestamp
useCount?: number;
lastUsedAt?: number; // Timestamp
}
export interface PromptCache {
lastSync?: number;
last_updated: string | null;
categories: Record<string, string[]>;
total_count: number;
sources: string[];
prompts: Prompt[];
}
// Whisk API Types
export interface WhiskAuthResponse {
access_token?: string;
error?: string;
}
export interface WhiskUploadResponse {
result?: {
data?: {
json?: {
result?: {
uploadMediaGenerationId?: string;
}
}
}
}
}
export interface GeneratedImagePanel {
generatedImages?: Array<{
encodedImage?: string;
}>;
}
export interface WhiskGenerateResponse {
imagePanels?: GeneratedImagePanel[];
}
export interface MediaInput {
mediaInput: {
mediaCategory: string;
mediaGenerationId: string;
}
}
export interface WhiskPayloadClientContext {
workflowId: string;
tool: string;
sessionId: string;
}
export interface WhiskPayloadImageModelSettings {
imageModel: string;
aspectRatio: string;
prompt?: string;
seed?: number;
}
export interface WhiskGeneratePayload {
clientContext: WhiskPayloadClientContext;
imageModelSettings: WhiskPayloadImageModelSettings;
seed?: number; // Sometimes at root? Keeping consistent with existing logic
}
export interface WhiskRecipePayload {
clientContext: WhiskPayloadClientContext;
imageModelSettings: {
imageModel: string;
aspectRatio: string;
};
media_inputs: MediaInput[];
prompt: string;
}
// Whisk Video Generation Types
export interface WhiskVideoGenerateResponse {
videoGenerationId?: string;
status?: string;
error?: string;
}
export interface WhiskVideoOperationResponse {
operation: {
operation: {
name: string; // "44f03bf2a50bc4da8700b14104b0c4c6"
};
sceneId: string;
status: string; // "MEDIA_GENERATION_STATUS_PENDING"
};
}
export interface WhiskVideoStatusResponse {
status?: string;
state?: string;
videoUrl?: string; // TBD
video?: {
url?: string;
encodedVideo?: string;
};
encodedVideo?: string;
error?: string;
}
export interface WhiskVideoResult {
id: string;
url?: string;
status: 'pending' | 'COMPLETED' | 'FAILED';
}

6
lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

23
lib/whisk-client.test.ts Normal file
View file

@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { WhiskClient } from './whisk-client';
describe('WhiskClient', () => {
it('should throw error if no cookies provided', () => {
expect(() => new WhiskClient('')).toThrow('No valid cookies provided');
});
it('should parse cookie string correctly', () => {
const client = new WhiskClient('foo=bar; baz=qux');
// Accessing private property for testing via casting or just trusting constructor didn't fail
expect(client).toBeDefined();
});
it('should parse JSON cookies correctly', () => {
const jsonCookies = JSON.stringify([
{ name: 'foo', value: 'bar' },
{ name: 'baz', value: 'qux' }
]);
const client = new WhiskClient(jsonCookies);
expect(client).toBeDefined();
});
});

556
lib/whisk-client.ts Normal file
View file

@ -0,0 +1,556 @@
import { v4 as uuidv4 } from 'uuid';
import type {
WhiskAuthResponse,
WhiskGeneratePayload,
WhiskRecipePayload,
MediaInput,
WhiskGenerateResponse,
WhiskVideoResult
} from './types';
/**
* Whisk Client for Next.js
* Ported from whisk_client.py
*/
const ENDPOINTS = {
AUTH: "https://labs.google/fx/api/auth/session",
UPLOAD: "https://labs.google/fx/api/trpc/backbone.uploadImage",
GENERATE: "https://aisandbox-pa.googleapis.com/v1/whisk:generateImage",
RECIPE: "https://aisandbox-pa.googleapis.com/v1/whisk:runImageRecipe",
VIDEO_GENERATE: "https://aisandbox-pa.googleapis.com/v1/whisk:generateVideo",
VIDEO_STATUS: "https://aisandbox-pa.googleapis.com/v1:runVideoFxSingleClipsStatusCheck",
VIDEO_CREDITS: "https://aisandbox-pa.googleapis.com/v1/whisk:getVideoCreditStatus",
};
const DEFAULT_HEADERS = {
"Origin": "https://labs.google",
"Content-Type": "application/json",
"Referer": "https://labs.google/fx/tools/whisk/project",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
};
const ASPECT_RATIOS: Record<string, string> = {
"1:1": "IMAGE_ASPECT_RATIO_SQUARE",
"9:16": "IMAGE_ASPECT_RATIO_PORTRAIT",
"16:9": "IMAGE_ASPECT_RATIO_LANDSCAPE",
"4:3": "IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE",
"3:4": "IMAGE_ASPECT_RATIO_PORTRAIT",
"Auto": "IMAGE_ASPECT_RATIO_SQUARE"
};
const MEDIA_CATEGORIES: Record<string, string> = {
"subject": "MEDIA_CATEGORY_SUBJECT",
"scene": "MEDIA_CATEGORY_SCENE",
"style": "MEDIA_CATEGORY_STYLE"
};
export interface GeneratedImage {
data: string; // Base64 string
index: number;
prompt: string;
aspectRatio: string;
}
export class WhiskClient {
private cookies: Record<string, string>;
private accessToken: string | null = null;
private tokenExpires: number = 0;
private cookieString: string = '';
constructor(cookieInput: string) {
this.cookies = this.parseCookies(cookieInput);
// Construct cookie string header
this.cookieString = Object.entries(this.cookies)
.map(([k, v]) => `${k}=${v}`)
.join('; ');
if (!this.cookieString) {
throw new Error("No valid cookies provided");
}
}
private parseCookies(input: string): Record<string, string> {
if (!input) return {};
input = input.trim();
const cookies: Record<string, string> = {};
// Try JSON
if (input.startsWith('[') && input.endsWith(']')) {
try {
const list = JSON.parse(input);
for (const c of list) {
if (c.name && c.value) cookies[c.name] = c.value;
}
return cookies;
} catch (e) { /* ignore */ }
}
// Try header string
input.split(';').forEach(part => {
const [name, value] = part.split('=');
if (name && value) cookies[name.trim()] = value.trim();
});
return cookies;
}
private async getAccessToken(): Promise<string> {
if (this.accessToken && Date.now() / 1000 < this.tokenExpires) {
return this.accessToken;
}
console.log("Fetching access token...");
try {
const res = await fetch(ENDPOINTS.AUTH, {
headers: {
...DEFAULT_HEADERS,
"Cookie": this.cookieString
}
});
if (!res.ok) throw new Error(`Auth failed: ${res.status}`);
const data = await res.json();
if (!data.access_token) throw new Error("Missing access_token");
this.accessToken = data.access_token;
this.tokenExpires = (Date.now() / 1000) + 3300; // 55 mins
return this.accessToken!;
} catch (e) {
throw new Error(`Authentication failed: ${e}`);
}
}
async uploadReferenceImage(fileBase64: string, mimeType: string, category: string): Promise<string | null> {
const mediaCategory = MEDIA_CATEGORIES[category.toLowerCase()] || "MEDIA_CATEGORY_SUBJECT";
const dataUri = `data:${mimeType};base64,${fileBase64}`;
const payload = {
json: {
clientContext: {
workflowId: uuidv4(),
sessionId: Date.now().toString()
},
uploadMediaInput: {
mediaCategory,
rawBytes: dataUri,
caption: ""
}
}
};
const res = await fetch(ENDPOINTS.UPLOAD, {
method: "POST",
headers: {
...DEFAULT_HEADERS,
"Cookie": this.cookieString
},
body: JSON.stringify(payload)
});
if (!res.ok) {
const errText = await res.text();
console.error("Upload API Error:", errText);
throw new Error(`Upload status ${res.status}: ${errText.substring(0, 200)}`);
}
const data = await res.json();
const mediaId = data?.result?.data?.json?.result?.uploadMediaGenerationId;
if (mediaId) {
console.log(`Uploaded ${category} image: ${mediaId}`);
return mediaId;
}
console.error("Upload response missing ID:", JSON.stringify(data).substring(0, 200));
throw new Error("Upload successful but returned no ID");
}
async generate(
prompt: string,
aspectRatio: string = "1:1",
refs: { subject?: string | string[]; scene?: string | string[]; style?: string | string[] } = {},
preciseMode: boolean = false
): Promise<GeneratedImage[]> {
const token = await this.getAccessToken();
// Prepare Media Inputs (Assuming refs are Generation IDs already uploaded)
// Now supports multiple IDs per category
const mediaInputs: MediaInput[] = [];
// Helper to add refs (handles both single string and array)
const addRefs = (category: string, ids: string | string[] | undefined) => {
if (!ids) return;
const idArray = Array.isArray(ids) ? ids : [ids];
for (const id of idArray) {
mediaInputs.push({ mediaInput: { mediaCategory: category, mediaGenerationId: id } });
}
};
addRefs("MEDIA_CATEGORY_SUBJECT", refs.subject);
addRefs("MEDIA_CATEGORY_SCENE", refs.scene);
addRefs("MEDIA_CATEGORY_STYLE", refs.style);
const isImageToImage = mediaInputs.length > 0;
const endpoint = isImageToImage ? ENDPOINTS.RECIPE : ENDPOINTS.GENERATE;
const arEnum = ASPECT_RATIOS[aspectRatio] || "IMAGE_ASPECT_RATIO_SQUARE";
let payload: WhiskGeneratePayload | WhiskRecipePayload;
const clientContext = {
workflowId: uuidv4(),
tool: isImageToImage ? "BACKBONE" : "IMAGE_FX",
sessionId: Date.now().toString()
};
if (!isImageToImage) {
const seed = Math.floor(Math.random() * 100000);
payload = {
clientContext,
imageModelSettings: {
imageModel: "IMAGEN_3_5",
aspectRatio: arEnum
},
seed: seed,
prompt: prompt,
mediaCategory: "MEDIA_CATEGORY_BOARD"
} as any;
} else {
// Image-to-Image (Recipe) - uses runImageRecipe endpoint
// Uses recipeMediaInputs array with caption and mediaInput for each ref
const seed = Math.floor(Math.random() * 1000000);
// Build recipeMediaInputs array (handles multiple IDs per category)
const recipeMediaInputs: Array<{ caption: string; mediaInput: { mediaCategory: string; mediaGenerationId: string } }> = [];
// Helper to add recipe inputs (handles both single string and array)
const addRecipeRefs = (category: string, ids: string | string[] | undefined) => {
if (!ids) return;
const idArray = Array.isArray(ids) ? ids : [ids];
for (const id of idArray) {
recipeMediaInputs.push({
caption: "",
mediaInput: {
mediaCategory: category,
mediaGenerationId: id
}
});
}
};
addRecipeRefs("MEDIA_CATEGORY_SUBJECT", refs.subject);
addRecipeRefs("MEDIA_CATEGORY_SCENE", refs.scene);
addRecipeRefs("MEDIA_CATEGORY_STYLE", refs.style);
payload = {
clientContext,
imageModelSettings: {
imageModel: "R2I", // Recipe-to-Image model
aspectRatio: arEnum
},
seed: seed,
userInstruction: prompt, // Note: uses userInstruction instead of prompt
recipeMediaInputs: recipeMediaInputs
// Note: preciseMode field name TBD - needs API discovery
} as any;
}
console.log(`Generating: "${prompt.substring(0, 30)}..." (Refs: ${mediaInputs.length})`);
try {
const res = await fetch(endpoint, {
method: "POST",
headers: {
...DEFAULT_HEADERS,
"Authorization": `Bearer ${token}`
},
body: JSON.stringify(payload)
});
if (!res.ok) {
const errText = await res.text();
console.error("Whisk API Error Body:", errText);
throw new Error(`API Error ${res.status}: ${errText.substring(0, 500)}`);
}
const json = await res.json() as WhiskGenerateResponse;
const images: string[] = [];
if (json.imagePanels) {
for (const panel of json.imagePanels) {
for (const img of (panel.generatedImages || [])) {
if (img.encodedImage) images.push(img.encodedImage);
}
}
}
if (images.length === 0) throw new Error("No images returned");
return images.map((data, i) => ({
data,
index: i,
prompt,
aspectRatio
}));
} catch (e: unknown) {
console.error("Generation failed:", e);
const errMessage = e instanceof Error ? e.message : String(e);
// Check for safety filter
if (errMessage.includes("UNSAFE") || errMessage.includes("SEXUAL")) {
throw new Error("Safety Filter Blocked Request");
}
throw e;
}
}
/**
* Generate a video from an image using Whisk Animate (Veo)
* This is an async operation - submits request and polls for completion
*/
async generateVideo(
imageGenerationId: string,
prompt: string,
imageBase64?: string,
aspectRatio: string = "16:9"
): Promise<WhiskVideoResult> {
const token = await this.getAccessToken();
console.log("generateVideo: Starting video generation...", {
hasImageId: !!imageGenerationId,
hasBase64: !!imageBase64,
promptLength: prompt.length
});
// Generate IDs for client context
const sessionId = `;${Date.now()}`;
const workflowId = crypto.randomUUID();
// Map aspect ratio to video format
const videoAspectRatio = aspectRatio === "9:16"
? "VIDEO_ASPECT_RATIO_PORTRAIT"
: "VIDEO_ASPECT_RATIO_LANDSCAPE";
// Build promptImageInput - the nested object for prompt and image
const promptImageInput: Record<string, string> = {
prompt: prompt
};
// Add image reference
if (imageGenerationId) {
promptImageInput.mediaGenerationId = imageGenerationId;
} else if (imageBase64) {
// Clean and prepare base64 (remove data URI prefix)
const cleanBase64 = imageBase64.replace(/^data:image\/\w+;base64,/, '');
promptImageInput.rawBytes = cleanBase64;
} else {
throw new Error("Either imageGenerationId or imageBase64 is required");
}
// Build payload matching Whisk API structure (with correct field names)
const payload = {
clientContext: {
sessionId: sessionId,
tool: "BACKBONE",
workflowId: workflowId
},
promptImageInput: promptImageInput,
modelNameType: "VEO_3_1_I2V_12STEP",
loopVideo: false,
aspectRatio: videoAspectRatio
};
const endpoint = ENDPOINTS.VIDEO_GENERATE;
console.log("generateVideo: Sending payload to", endpoint, JSON.stringify(payload, null, 2));
try {
const res = await fetch(endpoint, {
method: "POST",
headers: {
...DEFAULT_HEADERS,
"Authorization": `Bearer ${token}`
},
body: JSON.stringify(payload),
});
if (!res.ok) {
const errorText = await res.text();
console.error("generateVideo: API Error", res.status, errorText);
throw new Error(`Whisk API Error: ${res.status} - ${errorText}`);
}
const data = await res.json();
console.log("generateVideo: Response Data", JSON.stringify(data, null, 2));
let resultId = '';
// Check for mediaGenerationId (Whisk video response format)
if (data.mediaGenerationId) {
resultId = data.mediaGenerationId;
}
// Check for operation ID (alternative response)
else if (data.operation?.operation?.name) {
resultId = data.operation.operation.name;
}
// Fallback checks
else {
const generations = data.mediaGenerations || [];
const videoGen = generations.find((g: unknown) => (g as { mediaContentType?: string }).mediaContentType === 'MEDIA_CONTENT_TYPE_VIDEO');
resultId = data.videoGenerationId || (videoGen as { id?: string })?.id || (generations[0] as { id?: string })?.id || data.id;
}
if (!resultId) {
console.error("generateVideo: No ID found in response", data);
throw new Error("Failed to start video generation: No ID returned");
}
console.log("generateVideo: Got ID, starting polling:", resultId);
// Start polling for result
return this.pollVideoStatus(resultId, token);
} catch (error) {
console.error("generateVideo: Failed", error);
throw error;
}
}
/**
* Poll for video generation status until complete or failed
* Uses the runVideoFxSingleClipsStatusCheck endpoint
*/
private async pollVideoStatus(videoGenId: string, token: string): Promise<WhiskVideoResult> {
const maxAttempts = 60; // 5 minutes max (5s intervals)
const pollInterval = 5000; // 5 seconds
console.log(`Starting video status polling for ID: ${videoGenId}`);
for (let attempt = 0; attempt < maxAttempts; attempt++) {
console.log(`Polling video status... attempt ${attempt + 1}/${maxAttempts}`);
try {
// Use POST request with operations array containing operation.name
// (video uses async operation model, not mediaGenerationId)
const statusPayload = {
operations: [
{
operation: {
name: videoGenId
}
}
]
};
const res = await fetch(ENDPOINTS.VIDEO_STATUS, {
method: "POST",
headers: {
...DEFAULT_HEADERS,
"Authorization": `Bearer ${token}`
},
body: JSON.stringify(statusPayload)
});
if (!res.ok) {
const errText = await res.text();
console.error("Video status error:", res.status, errText);
// Continue polling unless it's a fatal error (4xx)
if (res.status >= 400 && res.status < 500) {
throw new Error(`Video status error: ${errText}`);
}
} else {
const json = await res.json();
console.log("Video status response:", JSON.stringify(json, null, 2));
// Response is likely in operations array format
const operation = json.operations?.[0] || json;
const status = operation.status || operation.state || operation.taskStatus || json.status;
// Normalize status - check for completion
const isComplete = status === 'COMPLETED' || status === 'SUCCEEDED' ||
status === 'complete' || status === 'FINISHED' ||
status === 'MEDIA_GENERATION_STATUS_COMPLETE' ||
status === 'MEDIA_GENERATION_STATUS_SUCCEEDED' ||
status === 'MEDIA_GENERATION_STATUS_SUCCESSFUL' ||
status?.includes('SUCCESSFUL') || status?.includes('COMPLETE');
// Normalize status - check for failure
const isFailed = status === 'FAILED' || status === 'ERROR' ||
status === 'failed' || status === 'CANCELLED' ||
status === 'MEDIA_GENERATION_STATUS_FAILED' ||
status?.includes('FAILED') || status?.includes('ERROR');
if (isComplete) {
// Check multiple possible response formats (including nested in operations)
const result = operation.result || operation;
// Check for URL first
const videoUrl = result.videoUrl || result.video?.url || result.mediaUrl ||
result.generatedMedia?.url || result.generatedMedia?.uri ||
result.url || json.videoUrl || operation.generatedMedia?.uri;
// Check for base64 encoded video data - Whisk uses rawBytes field
const encodedVideo = operation.rawBytes || result.rawBytes ||
result.encodedVideo || result.video?.encodedVideo ||
result.generatedMedia?.encodedVideo || json.encodedVideo ||
operation.generatedMedia?.rawBytes;
if (videoUrl) {
console.log("Video generation complete with URL:", videoUrl);
return { id: videoGenId, url: videoUrl, status: 'COMPLETED' };
} else if (encodedVideo) {
console.log("Video generation complete with rawBytes/base64 data");
// Check if it's already a data URI or needs to be converted
const videoDataUri = encodedVideo.startsWith('data:')
? encodedVideo
: `data:video/mp4;base64,${encodedVideo}`;
return {
id: videoGenId,
url: videoDataUri,
status: 'COMPLETED'
};
} else {
console.warn("Video completed but no URL/data found in response:", JSON.stringify(json, null, 2));
// Try to find any media key that can be used
const mediaKey = operation.mediaKey || result.mediaKey;
if (mediaKey) {
console.log("Found mediaKey, but no direct URL:", mediaKey);
}
}
} else if (isFailed) {
// Extract error message from nested structure
const errorMsg = operation.operation?.error?.message ||
operation.error?.message ||
operation.error ||
json.error?.message ||
json.error ||
'Video generation failed';
// Immediately throw - don't continue polling on failure
console.error("Video generation FAILED:", errorMsg);
throw new Error(`Video generation failed: ${errorMsg}`);
}
// IN_PROGRESS, PENDING, PROCESSING, RUNNING - continue polling
console.log(`Video status: ${status} - continuing to poll...`);
}
} catch (e: any) {
// Check if this is a logical failure (should not retry) vs network error (should retry)
if (e.message?.includes('Video generation failed:') ||
e.message?.includes('NCII') ||
e.message?.includes('content policy') ||
e.message?.includes('safety')) {
// Logical failure - throw immediately
throw e;
}
console.error("Poll error (network/transient):", e);
if (attempt === maxAttempts - 1) throw e;
}
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
throw new Error("Video generation timed out after 5 minutes");
}
}

8
next.config.ts Normal file
View file

@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
};
export default nextConfig;

7706
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

36
package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "v2_temp",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "vitest",
"lint": "eslint"
},
"dependencies": {
"@types/uuid": "^10.0.0",
"clsx": "^2.1.1",
"dexie": "^4.0.1",
"framer-motion": "^11.18.2",
"lucide-react": "^0.562.0",
"next": "15.1.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"tailwind-merge": "^3.4.0",
"uuid": "^13.0.0",
"zustand": "^5.0.9"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^9",
"eslint-config-next": "15.1.0",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^1.0.0"
}
}

7
postcss.config.mjs Normal file
View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1 KiB

1
public/next.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

1
public/vercel.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View file

@ -0,0 +1,56 @@
import { MetaAIClient } from '../lib/providers/meta-client';
import fs from 'fs/promises';
// Using the same merged cookies as before
const cookies = JSON.stringify([
// Facebook.com Cookies
{ "name": "ps_l", "value": "1", "domain": ".facebook.com" },
{ "name": "datr", "value": "jmoAaYIQoplmU7vRl4HLcWR1", "domain": ".facebook.com" },
{ "name": "fr", "value": "1gwnCVQoEw5LWJxUB.AWc1MjEm7uVeIdEsLn-TUeiaZxnLxSQbM7dDffWSsNOPIgWYNF8.BpW0HQ..AAA.0.0.BpW05z.AWcDBTaFWpJX3Z6ZQg75ZOXRuVQ", "domain": ".facebook.com" },
{ "name": "xs", "value": "37%3ADiIYP9JHKsgiSg%3A2%3A1766630179%3A-1%3A-1%3A%3AAcxje68BFTHTAbgeI7wPp81xw202cNOkodYrSaBkoDI", "domain": ".facebook.com" },
{ "name": "c_user", "value": "100003107523811", "domain": ".facebook.com" },
{ "name": "presence", "value": "C%7B%22t3%22%3A%5B%5D%2C%22utc3%22%3A1767591541854%2C%22v%22%3A1%7D", "domain": ".facebook.com" },
{ "name": "ar_debug", "value": "1", "domain": ".facebook.com" },
{ "name": "dpr", "value": "1", "domain": ".facebook.com" },
{ "name": "pas", "value": "100003107523811%3AUog8PlK0u2", "domain": ".facebook.com" },
{ "name": "ps_n", "value": "1", "domain": ".facebook.com" },
{ "name": "sb", "value": "jmoAaRKnokS_7bViVC_l7BA7", "domain": ".facebook.com" },
{ "name": "wl_cbv", "value": "v2%3Bclient_version%3A3004%3Btimestamp%3A1764632896", "domain": ".facebook.com" },
// Meta.ai Cookies
{ "name": "abra_sess", "value": "Fqit0PrQ6JMDFg4YDmh1d096TE01eW8xTUhnFvyu15ANAA%3D%3D", "domain": ".meta.ai" }
]);
async function testMsg() {
console.log("Initializing MetaAIClient...");
const client = new MetaAIClient({ cookies });
try {
console.log("Sending simple text message 'Hello'...");
await (client as any).initSession();
console.log("Session State:", (client as any).session);
// Manually calling sendPrompt which is private, by casting to any
// Use a simple text prompt, not generating images
const response = await (client as any).sendPrompt("Hello, are you working?");
console.log("Raw Response:", JSON.stringify(response, null, 2));
// Check for success indicators
if (response?.data?.node?.bot_response_message || response?.data?.xabraAIPreviewMessageSendMutation?.message) {
console.log("SUCCESS: Message sent and response received.");
} else {
console.log("FAILURE: Unexpected response structure.");
}
} catch (e: any) {
console.error("Test Message Failed:", e.message);
if (e.message.includes("HTML error")) {
// If we get HTML error, maybe save it?
console.log("Saving HTML error for inspection...");
// This logic depends on throw in client, might not have access to raw text here easily unless we modify client again
}
}
}
testMsg();

42
tsconfig.json Normal file
View file

@ -0,0 +1,42 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": [
"node_modules"
]
}