feat: Initial commit with multi-provider image generation
9
.dockerignore
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/api/grok/generate/route.ts
Normal 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
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/api/meta/generate/route.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/api/prompts/generate/route.ts
Normal 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
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/api/prompts/sync/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/api/prompts/upload/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/api/prompts/use/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
99
app/api/references/upload/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/api/video/generate/route.ts
Normal 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
|
After Width: | Height: | Size: 25 KiB |
99
app/globals.css
Normal 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
|
|
@ -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
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
components/EditPromptModal.tsx
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
351
components/PromptLibrary.tsx
Normal 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
|
|
@ -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 > 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
379
components/UploadHistory.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
components/VideoPromptModal.tsx
Normal 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
12
docker-compose.yml
Normal 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
|
|
@ -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
|
|
@ -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:  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
|
|
@ -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
|
|
@ -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
|
|
@ -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 };
|
||||||
|
}
|
||||||
246
lib/providers/grok-client.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
372
lib/providers/meta-client.ts
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
36
package.json
Normal 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
|
|
@ -0,0 +1,7 @@
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
public/file.svg
Normal 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
|
|
@ -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
|
|
@ -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 |
BIN
public/prompts/prompt_10_1767513744290.png
Normal file
|
After Width: | Height: | Size: 446 KiB |
BIN
public/prompts/prompt_11_1767513753443.png
Normal file
|
After Width: | Height: | Size: 366 KiB |
BIN
public/prompts/prompt_15_1767513789511.png
Normal file
|
After Width: | Height: | Size: 411 KiB |
BIN
public/prompts/prompt_16_1767513797657.png
Normal file
|
After Width: | Height: | Size: 312 KiB |
BIN
public/prompts/prompt_17_1767513806516.png
Normal file
|
After Width: | Height: | Size: 416 KiB |
BIN
public/prompts/prompt_17_1767513853924.png
Normal file
|
After Width: | Height: | Size: 444 KiB |
BIN
public/prompts/prompt_23_1767513817060.png
Normal file
|
After Width: | Height: | Size: 369 KiB |
BIN
public/prompts/prompt_23_1767513865467.png
Normal file
|
After Width: | Height: | Size: 354 KiB |
BIN
public/prompts/prompt_26_1767513874181.png
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
public/prompts/prompt_27_1767513882606.png
Normal file
|
After Width: | Height: | Size: 328 KiB |
BIN
public/prompts/prompt_28_1767513896098.png
Normal file
|
After Width: | Height: | Size: 516 KiB |
BIN
public/prompts/prompt_29_1767513958838.png
Normal file
|
After Width: | Height: | Size: 335 KiB |
BIN
public/prompts/prompt_30_1767513971667.png
Normal file
|
After Width: | Height: | Size: 376 KiB |
BIN
public/prompts/prompt_31_1767513982861.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
public/prompts/prompt_32_1767513991650.png
Normal file
|
After Width: | Height: | Size: 361 KiB |
BIN
public/prompts/prompt_33_1767514001141.png
Normal file
|
After Width: | Height: | Size: 430 KiB |
BIN
public/prompts/prompt_34_1767514016235.png
Normal file
|
After Width: | Height: | Size: 414 KiB |
BIN
public/prompts/prompt_35_1767514028270.png
Normal file
|
After Width: | Height: | Size: 471 KiB |
BIN
public/prompts/prompt_36_1767514037570.png
Normal file
|
After Width: | Height: | Size: 226 KiB |
BIN
public/prompts/prompt_37_1767514046002.png
Normal file
|
After Width: | Height: | Size: 343 KiB |
BIN
public/prompts/prompt_38_1767514054579.png
Normal file
|
After Width: | Height: | Size: 354 KiB |
BIN
public/prompts/prompt_39_1767514064360.png
Normal file
|
After Width: | Height: | Size: 439 KiB |
BIN
public/prompts/prompt_40_1767514077640.png
Normal file
|
After Width: | Height: | Size: 662 KiB |
BIN
public/prompts/prompt_41_1767514086831.png
Normal file
|
After Width: | Height: | Size: 463 KiB |
BIN
public/prompts/prompt_43_1767514103734.png
Normal file
|
After Width: | Height: | Size: 477 KiB |
BIN
public/prompts/prompt_44_1767514114131.png
Normal file
|
After Width: | Height: | Size: 331 KiB |
BIN
public/prompts/prompt_45_1767514122819.png
Normal file
|
After Width: | Height: | Size: 455 KiB |
BIN
public/prompts/prompt_46_1767514131824.png
Normal file
|
After Width: | Height: | Size: 491 KiB |
BIN
public/prompts/prompt_47_1767514140543.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
public/prompts/prompt_49_1767514149683.png
Normal file
|
After Width: | Height: | Size: 474 KiB |
BIN
public/prompts/prompt_50_1767514157846.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
public/prompts/prompt_53_1767514166425.png
Normal file
|
After Width: | Height: | Size: 455 KiB |
BIN
public/prompts/prompt_60_1767514175984.png
Normal file
|
After Width: | Height: | Size: 476 KiB |
BIN
public/prompts/prompt_68_1767514186988.png
Normal file
|
After Width: | Height: | Size: 688 KiB |
BIN
public/prompts/prompt_69_1767514200452.png
Normal file
|
After Width: | Height: | Size: 404 KiB |
BIN
public/prompts/prompt_70_1767514210404.png
Normal file
|
After Width: | Height: | Size: 382 KiB |
BIN
public/prompts/prompt_7_1767513716186.png
Normal file
|
After Width: | Height: | Size: 338 KiB |
BIN
public/prompts/prompt_8_1767513725028.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
1
public/vercel.svg
Normal 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
|
|
@ -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 |
56
scripts/debug-meta-text.ts
Normal 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
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||