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