v3.0.0: Add FastAPI backend
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run

- Add Python FastAPI backend with Pydantic validation
- Port WhiskClient and MetaAIClient to Python
- Create API routers for all endpoints
- Add Swagger/ReDoc documentation at /docs
- Update Dockerfile for multi-service container
- Add lib/api.ts frontend client
- Update README for V3
This commit is contained in:
Khoa.vo 2026-01-13 07:46:32 +07:00
parent 793d80e9cf
commit 0ef7e5475c
21 changed files with 2482 additions and 70 deletions

View file

@ -1,55 +1,95 @@
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
# Stage 1: Build Next.js frontend
FROM node:20-alpine AS frontend-builder
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
# Install dependencies
COPY package.json package-lock.json* ./
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 source and build
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
# Stage 2: Build Python backend
FROM python:3.11-slim AS backend-builder
WORKDIR /backend
# Install dependencies
COPY backend/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Copy backend source
COPY backend/ ./
# Stage 3: Production image with supervisor
FROM python:3.11-slim 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
# Install Node.js and supervisor
RUN apt-get update && apt-get install -y \
curl \
supervisor \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Create non-root user
RUN groupadd --system --gid 1001 appgroup \
&& useradd --system --uid 1001 --gid appgroup appuser
COPY --from=builder /app/public ./public
# Copy Next.js standalone build
COPY --from=frontend-builder /app/public ./public
COPY --from=frontend-builder /app/.next/standalone ./
COPY --from=frontend-builder /app/.next/static ./.next/static
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Copy Python backend
COPY --from=backend-builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=backend-builder /backend ./backend
# 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
# Copy data directory for prompts
COPY --from=frontend-builder /app/data ./data
USER nextjs
# Create supervisor config
RUN mkdir -p /var/log/supervisor
COPY <<EOF /etc/supervisor/conf.d/supervisord.conf
[supervisord]
nodaemon=true
user=root
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
EXPOSE 3000
[program:nextjs]
command=node /app/server.js
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
environment=NODE_ENV=production,PORT=3000,HOSTNAME=0.0.0.0
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
[program:fastapi]
command=python -m uvicorn main:app --host 0.0.0.0 --port 8000
directory=/app/backend
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
EOF
CMD ["node", "server.js"]
# Set permissions
RUN chown -R appuser:appgroup /app /var/log/supervisor
# Expose ports
EXPOSE 3000 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000 && curl -f http://localhost:8000/health || exit 1
# Run supervisor
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View file

@ -1,24 +1,56 @@
# kv-pix (V2)
# kv-pix (V3)
A modern, lightweight AI Image Generator powered by Google ImageFX (Whisk), Grok, and Meta AI. Built with Next.js 14, TypeScript, and Tailwind CSS.
A modern, full-stack AI Image Generator with **FastAPI backend** and **Next.js frontend**.
Powered by Google ImageFX (Whisk), Grok, and Meta AI.
## 🆕 What's New in V3
- **FastAPI Backend** - Python backend with automatic Swagger documentation
- **Better Security** - Centralized CORS, Pydantic validation, structured error handling
- **API Documentation** - Interactive docs at `/api/docs` (Swagger) and `/api/redoc`
- **Improved Architecture** - Clean separation of frontend and backend
## 🚀 Quick Start
### Prerequisites
- Node.js 18+
- Python 3.11+
- Whisk/Grok/Meta Cookies (from respective services)
### Installation
```bash
# Install frontend dependencies
npm install
# Set up backend
cd backend
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
```
### Run Locally
**Terminal 1 - Backend:**
```bash
cd backend
source venv/bin/activate
uvicorn main:app --reload --port 8000
```
**Terminal 2 - Frontend:**
```bash
npm run dev
# App will be live at http://localhost:3000
```
| Service | URL |
|---------|-----|
| Frontend | http://localhost:3000 |
| Backend API | http://localhost:8000 |
| Swagger UI | http://localhost:8000/docs |
| ReDoc | http://localhost:8000/redoc |
## 🐳 Docker Deployment (Synology NAS / linux/amd64)
### Using Docker Compose (Recommended)
@ -28,38 +60,53 @@ docker-compose up -d
### Using Docker CLI
```bash
# Build
docker build -t kv-pix:latest .
# Pull from registry
docker pull git.khoavo.myds.me/vndangkhoa/apix:v3
# Run
docker run -d -p 3001:3000 --name kv-pix kv-pix:latest
docker run -d -p 3000:3000 -p 8000:8000 --name kv-pix git.khoavo.myds.me/vndangkhoa/apix:v3
```
### 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
```
┌─────────────────┐ HTTP/REST ┌──────────────────┐
│ Next.js UI │ ◄───────────────► │ FastAPI Backend │
│ (Port 3000) │ │ (Port 8000) │
└─────────────────┘ └────────┬─────────┘
┌───────────┴───────────┐
│ │
┌─────▼─────┐ ┌──────▼──────┐
│ WhiskAPI │ │ MetaAI API │
└───────────┘ └─────────────┘
```
### Tech Stack
- **Frontend**: [Next.js 15](https://nextjs.org/) (App Router) + [Tailwind CSS](https://tailwindcss.com/)
- **Backend**: [FastAPI](https://fastapi.tiangolo.com/) + [Pydantic](https://docs.pydantic.dev/)
- **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
├── app/ # Next.js pages and (legacy) API routes
├── backend/ # FastAPI Python backend
│ ├── main.py
│ ├── routers/ # API endpoints
│ ├── services/ # Business logic (whisk_client, meta_client)
│ └── models/ # Pydantic request/response models
├── components/ # React UI components
├── lib/ # Frontend utilities and API 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
- **FastAPI Backend**: Type-safe Python API with auto-documentation
- **Prompt Library**: Curated prompts organized by categories
- **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)
@ -67,11 +114,12 @@ public/ # Static assets
- **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.
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.

114
backend/main.py Normal file
View file

@ -0,0 +1,114 @@
"""
KV-Pix FastAPI Backend
A secure and intuitive API backend for the KV-Pix image generation application.
Provides endpoints for:
- Whisk image and video generation
- Meta AI image generation
- Prompt library management
- Reference image uploads
- Upload history
API Documentation available at /docs (Swagger UI) and /redoc (ReDoc)
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import sys
from pathlib import Path
# Add backend to path for imports
sys.path.insert(0, str(Path(__file__).parent))
from routers import generate, video, references, meta, prompts, history
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup and shutdown events"""
print("🚀 KV-Pix FastAPI Backend starting...")
print("📚 Swagger UI available at: http://localhost:8000/docs")
print("📖 ReDoc available at: http://localhost:8000/redoc")
yield
print("👋 KV-Pix FastAPI Backend shutting down...")
app = FastAPI(
title="KV-Pix API",
description="""
## KV-Pix Image Generation API
A powerful API for AI image generation using multiple providers.
### Features
- **Whisk API**: Google's experimental image generation with reference images
- **Meta AI**: Meta's Imagine model for creative images
- **Prompt Library**: Curated prompts with categories
- **Upload History**: Track and reuse uploaded references
### Authentication
All generation endpoints require provider-specific cookies passed in the request body.
See the Settings page in the web app for cookie configuration instructions.
""",
version="1.0.0",
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc"
)
# CORS middleware - allow Next.js frontend
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:3001",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(generate.router)
app.include_router(video.router)
app.include_router(references.router)
app.include_router(meta.router)
app.include_router(prompts.router)
app.include_router(history.router)
@app.get("/", tags=["Health"])
async def root():
"""Health check endpoint"""
return {
"status": "healthy",
"service": "kv-pix-api",
"version": "1.0.0",
"docs": "/docs"
}
@app.get("/health", tags=["Health"])
async def health_check():
"""Detailed health check"""
return {
"status": "healthy",
"endpoints": {
"generate": "/generate",
"video": "/video/generate",
"references": "/references/upload",
"meta": "/meta/generate",
"prompts": "/prompts",
"history": "/history"
},
"documentation": {
"swagger": "/docs",
"redoc": "/redoc"
}
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000, reload=True)

View file

@ -0,0 +1,3 @@
# Models package
from .requests import *
from .responses import *

View file

@ -0,0 +1,59 @@
"""
Pydantic request models for API validation
"""
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
class GenerateRequest(BaseModel):
"""Request for Whisk image generation"""
prompt: str = Field(..., min_length=1, description="Image generation prompt")
aspectRatio: str = Field(default="1:1", description="Aspect ratio (1:1, 9:16, 16:9, 4:3, 3:4)")
refs: Optional[Dict[str, Any]] = Field(default=None, description="Reference images {subject, scene, style}")
preciseMode: bool = Field(default=False, description="Enable precise mode")
imageCount: int = Field(default=1, ge=1, le=4, description="Number of images to generate")
cookies: Optional[str] = Field(default=None, description="Whisk cookies")
class VideoGenerateRequest(BaseModel):
"""Request for Whisk video generation"""
prompt: str = Field(..., min_length=1)
imageBase64: Optional[str] = Field(default=None, description="Base64 image data")
imageGenerationId: Optional[str] = Field(default=None, description="Existing image ID")
cookies: Optional[str] = None
class ReferenceUploadRequest(BaseModel):
"""Request for uploading reference image"""
imageBase64: str = Field(..., description="Base64 encoded image")
mimeType: str = Field(..., description="Image MIME type (image/jpeg, image/png, etc.)")
category: str = Field(..., description="Reference category (subject, scene, style)")
cookies: str = Field(..., description="Whisk cookies")
class MetaGenerateRequest(BaseModel):
"""Request for Meta AI image generation"""
prompt: str = Field(..., min_length=1)
cookies: Optional[str] = Field(default=None, description="Meta AI cookies")
imageCount: int = Field(default=4, ge=1, le=4)
aspectRatio: str = Field(default="portrait", description="portrait, landscape, square")
useMetaFreeWrapper: bool = Field(default=False)
metaFreeWrapperUrl: Optional[str] = Field(default="http://localhost:8000")
class MetaVideoRequest(BaseModel):
"""Request for Meta AI video generation"""
prompt: str = Field(..., min_length=1)
cookies: str = Field(..., description="Meta AI cookies")
aspectRatio: str = Field(default="portrait")
class PromptUseRequest(BaseModel):
"""Track prompt usage"""
promptId: int = Field(..., description="Prompt ID to track")
class PromptUploadRequest(BaseModel):
"""Upload prompt thumbnail"""
promptId: int
imageBase64: str

113
backend/models/responses.py Normal file
View file

@ -0,0 +1,113 @@
"""
Pydantic response models
"""
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
class GeneratedImage(BaseModel):
"""Single generated image"""
data: str # base64
index: Optional[int] = None
prompt: str
aspectRatio: str
class GenerateResponse(BaseModel):
"""Response from image generation"""
images: List[GeneratedImage]
class VideoResponse(BaseModel):
"""Response from video generation"""
success: bool
id: Optional[str] = None
url: Optional[str] = None
status: Optional[str] = None
class ReferenceUploadResponse(BaseModel):
"""Response from reference upload"""
success: bool
id: str
class MetaImageResult(BaseModel):
"""Meta AI generated image"""
data: Optional[str] = None
url: Optional[str] = None
prompt: str
model: str
aspectRatio: str = "1:1"
class MetaGenerateResponse(BaseModel):
"""Response from Meta AI generation"""
success: bool
images: List[MetaImageResult]
class MetaVideoResult(BaseModel):
"""Meta AI video result"""
url: str
prompt: str
class MetaVideoResponse(BaseModel):
"""Response from Meta AI video generation"""
success: bool
videos: List[MetaVideoResult]
conversation_id: Optional[str] = None
class Prompt(BaseModel):
"""Prompt library item"""
id: int
title: str
description: str
prompt: str
category: str
source: str
source_url: str
images: Optional[List[str]] = None
useCount: int = 0
lastUsedAt: Optional[int] = None
createdAt: Optional[int] = None
class PromptCache(BaseModel):
"""Prompt library cache"""
prompts: List[Prompt]
last_updated: Optional[str] = None
lastSync: Optional[int] = None
categories: Dict[str, List[str]] = {}
total_count: int = 0
sources: List[str] = []
class SyncResponse(BaseModel):
"""Response from prompt sync"""
success: bool
count: int
added: int
class HistoryItem(BaseModel):
"""Upload history item"""
id: str
url: str
originalName: str
category: str
mediaId: Optional[str] = None
createdAt: Optional[int] = None
class HistoryResponse(BaseModel):
"""Response from history endpoint"""
history: List[HistoryItem]
class ErrorResponse(BaseModel):
"""Standard error response"""
error: str
details: Optional[str] = None

6
backend/requirements.txt Normal file
View file

@ -0,0 +1,6 @@
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
httpx>=0.26.0
pydantic>=2.5.0
python-multipart>=0.0.6
aiofiles>=23.2.0

View file

@ -0,0 +1 @@
# Routers package

View file

@ -0,0 +1,92 @@
"""
Generate Router - Whisk image generation
"""
from fastapi import APIRouter, HTTPException
from models.requests import GenerateRequest
from models.responses import GenerateResponse, GeneratedImage, ErrorResponse
from services.whisk_client import WhiskClient
import asyncio
router = APIRouter(tags=["Generate"])
@router.post(
"/generate",
response_model=GenerateResponse,
responses={
400: {"model": ErrorResponse},
401: {"model": ErrorResponse},
500: {"model": ErrorResponse}
}
)
async def generate_images(request: GenerateRequest):
"""
Generate images using Whisk API.
- **prompt**: Text description of the image to generate
- **aspectRatio**: Output aspect ratio (1:1, 9:16, 16:9, etc.)
- **refs**: Optional reference images {subject, scene, style}
- **imageCount**: Number of parallel generation requests (1-4)
- **cookies**: Whisk authentication cookies
"""
if not request.cookies:
raise HTTPException(status_code=401, detail="Whisk cookies not found. Please configure settings.")
try:
# Normalize cookies if JSON format
cookie_string = request.cookies.strip()
if cookie_string.startswith('[') or cookie_string.startswith('{'):
import json
try:
cookie_array = json.loads(cookie_string)
if isinstance(cookie_array, list):
cookie_string = "; ".join(
f"{c['name']}={c['value']}" for c in cookie_array
)
print(f"[Generate] Parsed {len(cookie_array)} cookies from JSON.")
except Exception as e:
print(f"[Generate] Failed to parse cookie JSON: {e}")
client = WhiskClient(cookie_string)
# Generate images in parallel if imageCount > 1
parallel_count = min(max(1, request.imageCount), 4)
print(f"Starting {parallel_count} parallel generation requests for prompt: \"{request.prompt[:20]}...\"")
async def single_generate():
try:
return await client.generate(
request.prompt,
request.aspectRatio,
request.refs,
request.preciseMode
)
except Exception as e:
print(f"Single generation request failed: {e}")
return []
results = await asyncio.gather(*[single_generate() for _ in range(parallel_count)])
all_images = [img for result in results for img in result]
if not all_images:
raise HTTPException(status_code=500, detail="All generation requests failed. Check logs or try again.")
return GenerateResponse(
images=[
GeneratedImage(
data=img.data,
index=img.index,
prompt=img.prompt,
aspectRatio=img.aspect_ratio
)
for img in all_images
]
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
msg = str(e)
is_auth_error = any(x in msg.lower() for x in ["401", "403", "auth", "cookies", "expired"])
status_code = 401 if is_auth_error else 500
raise HTTPException(status_code=status_code, detail=msg)

164
backend/routers/history.py Normal file
View file

@ -0,0 +1,164 @@
"""
History Router - Upload history management
Note: This is a simplified version. The original Next.js version
stores history in memory/file. For FastAPI, we'll use a simple
JSON file approach similar to prompts.
"""
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from models.responses import HistoryResponse, HistoryItem, ErrorResponse
from services.whisk_client import WhiskClient
from pathlib import Path
import json
import uuid
import base64
from typing import Optional
from datetime import datetime
router = APIRouter(prefix="/history", tags=["History"])
# History storage
HISTORY_FILE = Path(__file__).parent.parent.parent / "data" / "history.json"
def load_history() -> list:
"""Load history from JSON file"""
try:
if HISTORY_FILE.exists():
return json.loads(HISTORY_FILE.read_text())
except:
pass
return []
def save_history(history: list) -> None:
"""Save history to JSON file"""
HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
HISTORY_FILE.write_text(json.dumps(history, indent=2))
@router.get(
"",
response_model=HistoryResponse,
responses={500: {"model": ErrorResponse}}
)
async def get_history(category: Optional[str] = None):
"""
Get upload history.
- **category**: Optional filter by category (subject, scene, style)
"""
try:
history = load_history()
if category:
history = [h for h in history if h.get("category") == category]
return HistoryResponse(history=[HistoryItem(**h) for h in history])
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"",
response_model=HistoryItem,
responses={
400: {"model": ErrorResponse},
500: {"model": ErrorResponse}
}
)
async def upload_to_history(
file: UploadFile = File(...),
category: str = Form(default="subject"),
cookies: Optional[str] = Form(default=None)
):
"""
Upload an image to history.
- **file**: Image file to upload
- **category**: Category (subject, scene, style)
- **cookies**: Optional Whisk cookies to also upload to Whisk
"""
try:
# Read file
content = await file.read()
base64_data = base64.b64encode(content).decode('utf-8')
mime_type = file.content_type or 'image/png'
# Upload to Whisk if cookies provided
media_id = None
if cookies:
try:
client = WhiskClient(cookies)
media_id = await client.upload_reference_image(base64_data, mime_type, category)
except Exception as e:
print(f"[History] Whisk upload failed: {e}")
# Create history item
new_item = {
"id": str(uuid.uuid4()),
"url": f"data:{mime_type};base64,{base64_data}",
"originalName": file.filename or "upload.png",
"category": category,
"mediaId": media_id,
"createdAt": int(datetime.now().timestamp() * 1000)
}
# Save to history
history = load_history()
history.insert(0, new_item) # Add to beginning
save_history(history)
return HistoryItem(**new_item)
except Exception as e:
print(f"Upload error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete(
"/{item_id}",
responses={
404: {"model": ErrorResponse},
500: {"model": ErrorResponse}
}
)
async def delete_history_item(item_id: str):
"""
Delete an item from history.
- **item_id**: ID of the history item to delete
"""
try:
history = load_history()
original_len = len(history)
history = [h for h in history if h.get("id") != item_id]
if len(history) == original_len:
raise HTTPException(status_code=404, detail="Item not found")
save_history(history)
return {"success": True, "deleted": item_id}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete(
"",
responses={500: {"model": ErrorResponse}}
)
async def clear_history():
"""
Clear all history items.
"""
try:
save_history([])
return {"success": True, "message": "History cleared"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

122
backend/routers/meta.py Normal file
View file

@ -0,0 +1,122 @@
"""
Meta AI Router - Meta AI image and video generation
"""
from fastapi import APIRouter, HTTPException
from models.requests import MetaGenerateRequest, MetaVideoRequest
from models.responses import MetaGenerateResponse, MetaImageResult, MetaVideoResponse, MetaVideoResult, ErrorResponse
from services.meta_client import MetaAIClient
import json
router = APIRouter(prefix="/meta", tags=["Meta AI"])
@router.post(
"/generate",
response_model=MetaGenerateResponse,
responses={
400: {"model": ErrorResponse},
401: {"model": ErrorResponse},
422: {"model": ErrorResponse}
}
)
async def meta_generate(request: MetaGenerateRequest):
"""
Generate images using Meta AI's Imagine model.
- **prompt**: Text description of the image to generate
- **cookies**: Meta AI cookies (optional if using free wrapper)
- **imageCount**: Number of images to generate (1-4)
- **aspectRatio**: portrait, landscape, or square
- **useMetaFreeWrapper**: Use free API wrapper instead of direct Meta AI
- **metaFreeWrapperUrl**: URL of the free wrapper service
"""
# Only check for cookies if NOT using free wrapper
if not request.useMetaFreeWrapper and not request.cookies:
raise HTTPException(
status_code=401,
detail="Meta AI cookies required. Configure in Settings or use Free Wrapper."
)
print(f"[Meta AI Route] Generating images for: \"{request.prompt[:30]}...\" ({request.aspectRatio})")
# Diagnostic: Check cookie count
if request.cookies:
try:
cookies = request.cookies.strip()
if cookies.startswith('['):
parsed = json.loads(cookies)
count = len(parsed) if isinstance(parsed, list) else 0
else:
count = len(cookies.split(';'))
print(f"[Meta AI Route] Received {count} cookies (Free Wrapper: {request.useMetaFreeWrapper})")
except:
print(f"[Meta AI Route] Cookie format: {type(request.cookies)}")
try:
client = MetaAIClient(
cookies=request.cookies or "",
use_free_wrapper=request.useMetaFreeWrapper,
free_wrapper_url=request.metaFreeWrapperUrl or "http://localhost:8000"
)
results = await client.generate(
request.prompt,
request.imageCount,
request.aspectRatio
)
# Download images as base64 for storage
images = []
for img in results:
base64_data = img.data
if not base64_data and img.url:
try:
base64_data = await client.download_as_base64(img.url)
except Exception as e:
print(f"[Meta AI Route] Failed to download image: {e}")
images.append(MetaImageResult(
data=base64_data or "",
url=img.url,
prompt=img.prompt,
model=img.model,
aspectRatio="1:1"
))
valid_images = [img for img in images if img.data or img.url]
if not valid_images:
raise HTTPException(status_code=422, detail="No valid images generated")
return MetaGenerateResponse(success=True, images=valid_images)
except Exception as e:
print(f"[Meta AI Route] Error: {e}")
raise HTTPException(status_code=422, detail=str(e))
@router.post(
"/video",
response_model=MetaVideoResponse,
responses={
400: {"model": ErrorResponse},
401: {"model": ErrorResponse},
500: {"model": ErrorResponse}
}
)
async def meta_video(request: MetaVideoRequest):
"""
Generate video from text prompt using Meta AI's Kadabra engine.
- **prompt**: Text description for video generation
- **cookies**: Meta AI cookies
- **aspectRatio**: portrait, landscape, or square
"""
# Note: Meta AI video generation via GraphQL is complex
# This is a placeholder - the full implementation would require
# porting the entire meta/video/route.ts logic
raise HTTPException(
status_code=501,
detail="Meta AI video generation not yet implemented in FastAPI backend"
)

177
backend/routers/prompts.py Normal file
View file

@ -0,0 +1,177 @@
"""
Prompts Router - Prompt library management
"""
from fastapi import APIRouter, HTTPException
from models.requests import PromptUseRequest, PromptUploadRequest
from models.responses import PromptCache, SyncResponse, ErrorResponse
from services.prompts_service import (
get_prompts,
sync_prompts,
track_prompt_use,
upload_prompt_image
)
router = APIRouter(prefix="/prompts", tags=["Prompts"])
@router.get(
"",
response_model=PromptCache,
responses={500: {"model": ErrorResponse}}
)
async def list_prompts():
"""
Get all prompts from the library.
Returns cached prompts with metadata including:
- All prompts with titles, descriptions, and content
- Categories and sources
- Last sync timestamp
Triggers background sync if last sync was more than 1 hour ago.
"""
try:
cache = await get_prompts()
# Lazy Auto-Crawl: Check if sync is needed (every 1 hour)
ONE_HOUR = 60 * 60 * 1000
last_sync = cache.last_sync or 0
import time
if int(time.time() * 1000) - last_sync > ONE_HOUR:
print("[Auto-Crawl] Triggering background sync...")
# Fire and forget - don't await
import asyncio
asyncio.create_task(sync_prompts())
return cache.to_dict()
except Exception as e:
raise HTTPException(status_code=500, detail="Failed to load prompts")
@router.post(
"/sync",
response_model=SyncResponse,
responses={500: {"model": ErrorResponse}}
)
async def sync_prompts_endpoint():
"""
Manually trigger a sync of prompts from all sources.
Crawls prompt sources and merges with existing prompts.
Returns count of total and newly added prompts.
"""
try:
result = await sync_prompts()
return SyncResponse(**result)
except Exception as e:
print(f"Sync failed: {e}")
raise HTTPException(status_code=500, detail="Sync failed")
@router.post(
"/use",
responses={
404: {"model": ErrorResponse},
500: {"model": ErrorResponse}
}
)
async def use_prompt(request: PromptUseRequest):
"""
Track usage of a prompt.
Increments the use count and updates lastUsedAt timestamp.
"""
try:
prompt = await track_prompt_use(request.promptId)
if not prompt:
raise HTTPException(status_code=404, detail="Prompt not found")
return {
"success": True,
"promptId": prompt.id,
"useCount": prompt.use_count,
"lastUsedAt": prompt.last_used_at
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/upload",
responses={
404: {"model": ErrorResponse},
500: {"model": ErrorResponse}
}
)
async def upload_image(request: PromptUploadRequest):
"""
Upload a thumbnail image for a prompt.
Stores the base64 image data with the prompt.
"""
try:
prompt = await upload_prompt_image(request.promptId, request.imageBase64)
if not prompt:
raise HTTPException(status_code=404, detail="Prompt not found")
return {
"success": True,
"promptId": prompt.id,
"imageCount": len(prompt.images) if prompt.images else 0
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/generate",
responses={
400: {"model": ErrorResponse},
404: {"model": ErrorResponse},
500: {"model": ErrorResponse}
}
)
async def generate_from_prompt(
promptId: int,
aspectRatio: str = "1:1",
cookies: str = None
):
"""
Generate images using a prompt from the library.
This is a convenience endpoint that:
1. Fetches the prompt by ID
2. Calls the generate endpoint with the prompt content
"""
try:
cache = await get_prompts()
# Find the prompt
prompt = None
for p in cache.prompts:
if p.id == promptId:
prompt = p
break
if not prompt:
raise HTTPException(status_code=404, detail="Prompt not found")
# Track usage
await track_prompt_use(promptId)
# Return prompt info for frontend to generate
return {
"success": True,
"prompt": prompt.to_dict()
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View file

@ -0,0 +1,92 @@
"""
References Router - Reference image upload
"""
from fastapi import APIRouter, HTTPException
from models.requests import ReferenceUploadRequest
from models.responses import ReferenceUploadResponse, ErrorResponse
from services.whisk_client import WhiskClient
import json
router = APIRouter(tags=["References"])
@router.post(
"/references/upload",
response_model=ReferenceUploadResponse,
responses={
400: {"model": ErrorResponse},
401: {"model": ErrorResponse},
500: {"model": ErrorResponse}
}
)
async def upload_reference(request: ReferenceUploadRequest):
"""
Upload a reference image for Whisk generation.
- **imageBase64**: Base64 encoded image data
- **mimeType**: Image MIME type (image/jpeg, image/png, image/webp, image/gif)
- **category**: Reference category (subject, scene, style)
- **cookies**: Whisk authentication cookies
"""
# Validate MIME type
allowed_types = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
if request.mimeType not in allowed_types:
raise HTTPException(
status_code=400,
detail=f"Unsupported file type: {request.mimeType}. Please use JPG, PNG, or WEBP."
)
# Normalize cookies
valid_cookies = request.cookies.strip()
is_json = False
trimmed = valid_cookies.strip()
if trimmed.startswith('[') or trimmed.startswith('{'):
is_json = True
try:
cookie_array = json.loads(trimmed)
if isinstance(cookie_array, list):
valid_cookies = "; ".join(
f"{c['name']}={c['value']}" for c in cookie_array
)
print(f"[API] Successfully parsed {len(cookie_array)} cookies from JSON.")
elif isinstance(cookie_array, dict) and 'name' in cookie_array and 'value' in cookie_array:
valid_cookies = f"{cookie_array['name']}={cookie_array['value']}"
except Exception as e:
print(f"[API] Failed to parse cookie JSON, falling back to raw value: {e}")
# Validate cookie format
if '=' not in valid_cookies:
raise HTTPException(
status_code=400,
detail='Invalid Cookie Format. Cookies must be in "name=value" format or a JSON list.'
)
print(f"[API] Uploading reference image ({request.category}, {request.mimeType})...")
print(f"[API] Using cookies (first 100 chars): {valid_cookies[:100]}...")
print(f"[API] Cookie was JSON: {is_json}")
try:
client = WhiskClient(valid_cookies)
# Remove data URI header if present
raw_base64 = request.imageBase64
if raw_base64.startswith('data:'):
raw_base64 = raw_base64.split(',', 1)[1] if ',' in raw_base64 else raw_base64
media_id = await client.upload_reference_image(
raw_base64,
request.mimeType,
request.category
)
if not media_id:
raise HTTPException(status_code=500, detail="Upload returned no ID")
return ReferenceUploadResponse(success=True, id=media_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
print(f"Reference Upload API failed: {e}")
raise HTTPException(status_code=500, detail=str(e))

55
backend/routers/video.py Normal file
View file

@ -0,0 +1,55 @@
"""
Video Router - Whisk video generation
"""
from fastapi import APIRouter, HTTPException
from models.requests import VideoGenerateRequest
from models.responses import VideoResponse, ErrorResponse
from services.whisk_client import WhiskClient
router = APIRouter(tags=["Video"])
@router.post(
"/video/generate",
response_model=VideoResponse,
responses={
400: {"model": ErrorResponse},
401: {"model": ErrorResponse},
500: {"model": ErrorResponse}
}
)
async def generate_video(request: VideoGenerateRequest):
"""
Generate video from an image using Whisk Animate (Veo).
- **prompt**: Motion description for the video
- **imageBase64**: Base64 encoded source image (optional if imageGenerationId provided)
- **imageGenerationId**: Existing Whisk image ID (optional if imageBase64 provided)
- **cookies**: Whisk authentication cookies
"""
if not request.cookies:
raise HTTPException(status_code=401, detail="Whisk cookies not found. Please configure settings.")
try:
client = WhiskClient(request.cookies)
print(f"[Video API] Generating video for prompt: \"{request.prompt[:50]}...\"")
result = await client.generate_video(
request.imageGenerationId or "",
request.prompt,
request.imageBase64
)
return VideoResponse(
success=True,
id=result.id,
url=result.url,
status=result.status
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
print(f"Video Generation API failed: {e}")
raise HTTPException(status_code=500, detail=str(e))

View file

@ -0,0 +1 @@
# Services package

View file

@ -0,0 +1,525 @@
"""
Meta AI Client for Python/FastAPI
Port of lib/providers/meta-client.ts
Handles:
- Session initialization from meta.ai
- GraphQL mutation for image generation (Abra)
- Streaming response parsing
- Polling for async results
- Free wrapper fallback support
"""
import httpx
import json
import uuid
import re
import asyncio
from typing import Optional, Dict, List, Any
META_AI_BASE = "https://www.meta.ai"
GRAPHQL_ENDPOINT = f"{META_AI_BASE}/api/graphql/"
# Orientation mapping
ORIENTATION_MAP = {
"portrait": "VERTICAL",
"landscape": "HORIZONTAL",
"square": "SQUARE"
}
class MetaImageResult:
def __init__(self, url: str, data: Optional[str], prompt: str, model: str):
self.url = url
self.data = data
self.prompt = prompt
self.model = model
def to_dict(self) -> Dict[str, Any]:
return {
"url": self.url,
"data": self.data,
"prompt": self.prompt,
"model": self.model,
"aspectRatio": "1:1"
}
class MetaSession:
def __init__(self):
self.lsd: Optional[str] = None
self.fb_dtsg: Optional[str] = None
self.access_token: Optional[str] = None
self.external_conversation_id: Optional[str] = None
class MetaAIClient:
def __init__(
self,
cookies: str,
use_free_wrapper: bool = True,
free_wrapper_url: str = "http://localhost:8000"
):
self.cookies = self._normalize_cookies(cookies)
self.session = MetaSession()
self.use_free_wrapper = use_free_wrapper
self.free_wrapper_url = free_wrapper_url
if self.cookies:
self._parse_session_from_cookies()
def _normalize_cookies(self, cookies: str) -> str:
"""Normalize cookies from JSON array to string format"""
if not cookies:
return ""
try:
trimmed = cookies.strip()
if trimmed.startswith('['):
parsed = json.loads(trimmed)
if isinstance(parsed, list):
return "; ".join(
f"{c['name']}={c['value']}" for c in parsed
if isinstance(c, dict) and 'name' in c and 'value' in c
)
except (json.JSONDecodeError, KeyError):
pass
return cookies
def _parse_session_from_cookies(self) -> None:
"""Extract session tokens from cookies"""
lsd_match = re.search(r'lsd=([^;]+)', self.cookies)
if lsd_match:
self.session.lsd = lsd_match.group(1)
dtsg_match = re.search(r'fb_dtsg=([^;]+)', self.cookies)
if dtsg_match:
self.session.fb_dtsg = dtsg_match.group(1)
def _parse_cookies_to_dict(self, cookie_str: str) -> Dict[str, str]:
"""Parse cookie string to dictionary"""
result = {}
if not cookie_str:
return result
for pair in cookie_str.split(';'):
pair = pair.strip()
if '=' in pair:
key, _, value = pair.partition('=')
result[key.strip()] = value.strip()
return result
async def get_session(self) -> MetaSession:
"""Get initialized session tokens"""
if not self.use_free_wrapper and not self.session.lsd and not self.session.fb_dtsg:
await self._init_session()
return self.session
def get_cookies(self) -> str:
return self.cookies
async def generate(
self,
prompt: str,
num_images: int = 4,
aspect_ratio: str = "portrait"
) -> List[MetaImageResult]:
"""Generate images using Meta AI's Imagine model"""
print(f"[Meta AI] Generating images for: \"{prompt[:50]}...\" ({aspect_ratio})")
if self.use_free_wrapper:
return await self._generate_with_free_wrapper(prompt, num_images)
# Initialize session if needed
if not self.session.access_token:
await self._init_session()
# Use "Imagine" prefix for image generation
image_prompt = prompt if prompt.lower().startswith('imagine') else f"Imagine {prompt}"
# Send the prompt via GraphQL
response = await self._send_prompt(image_prompt, aspect_ratio)
# Extract images
images = self._extract_images(response, prompt)
if not images:
print("[Meta AI] No images in initial response, polling...")
images = await self._poll_for_images(response, prompt)
return images
async def _generate_with_free_wrapper(
self,
prompt: str,
num_images: int
) -> List[MetaImageResult]:
"""Generate using free API wrapper"""
print(f"[Meta Wrapper] Generating for: \"{prompt[:50]}...\" via {self.free_wrapper_url}")
cookie_dict = self._parse_cookies_to_dict(self.cookies)
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
f"{self.free_wrapper_url}/chat",
headers={"Content-Type": "application/json"},
json={
"message": f"Imagine {prompt}",
"stream": False,
"cookies": cookie_dict
}
)
if response.status_code != 200:
error_text = response.text[:200]
raise Exception(f"Meta Wrapper Error: {response.status_code} - {error_text}")
data = response.json()
images: List[MetaImageResult] = []
# Check for media in response
if data.get("media") and isinstance(data["media"], list):
for m in data["media"]:
if m.get("url"):
images.append(MetaImageResult(
url=m["url"],
data=None,
prompt=prompt,
model="meta-wrapper"
))
# Fallback checks
if not images and data.get("images") and isinstance(data["images"], list):
for url in data["images"]:
images.append(MetaImageResult(
url=url,
data=None,
prompt=prompt,
model="meta-wrapper"
))
if not images:
raise Exception("Meta Wrapper returned no images")
return images
async def _init_session(self) -> None:
"""Initialize session - get access token from meta.ai page"""
print("[Meta AI] Initializing session...")
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
META_AI_BASE,
headers={
"Cookie": self.cookies,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Accept-Language": "en-US,en;q=0.9"
}
)
html = response.text
# Extract access token
token_match = re.search(r'"accessToken":"([^"]+)"', html)
if not token_match:
token_match = re.search(r'accessToken["\']\\s*:\\s*["\']([^"\']+)["\']', html)
# Extract LSD token
lsd_match = (
re.search(r'"LSD",\[\],\{"token":"([^"]+)"', html) or
re.search(r'"lsd":"([^"]+)"', html) or
re.search(r'name="lsd" value="([^"]+)"', html)
)
if lsd_match:
self.session.lsd = lsd_match.group(1)
# Extract DTSG token
dtsg_match = (
re.search(r'"DTSGInitialData".*?"token":"([^"]+)"', html) or
re.search(r'"token":"([^"]+)"', html)
)
if dtsg_match:
self.session.fb_dtsg = dtsg_match.group(1)
if token_match:
self.session.access_token = token_match.group(1)
print("[Meta AI] Got access token")
elif 'login_form' in html or 'login_page' in html:
raise Exception("Meta AI: Cookies expired or invalid")
else:
print("[Meta AI] Warning: Failed to extract access token")
async def _send_prompt(self, prompt: str, aspect_ratio: str = "portrait") -> Any:
"""Send prompt via GraphQL mutation"""
external_conversation_id = str(uuid.uuid4())
timestamp = int(asyncio.get_event_loop().time() * 1000)
random_part = int(str(uuid.uuid4().int)[:7])
offline_threading_id = str((timestamp << 22) | random_part)
self.session.external_conversation_id = external_conversation_id
orientation = ORIENTATION_MAP.get(aspect_ratio, "VERTICAL")
variables = {
"message": {
"sensitive_string_value": prompt
},
"externalConversationId": external_conversation_id,
"offlineThreadingId": offline_threading_id,
"suggestedPromptIndex": None,
"flashVideoRecapInput": {"images": []},
"flashPreviewInput": None,
"promptPrefix": None,
"entrypoint": "ABRA__CHAT__TEXT",
"icebreaker_type": "TEXT",
"imagineClientOptions": {"orientation": orientation},
"__relay_internal__pv__AbraDebugDevOnlyrelayprovider": False,
"__relay_internal__pv__WebPixelRatiorelayprovider": 1
}
body = {
"fb_api_caller_class": "RelayModern",
"fb_api_req_friendly_name": "useAbraSendMessageMutation",
"variables": json.dumps(variables),
"server_timestamps": "true",
"doc_id": "7783822248314888"
}
if self.session.lsd:
body["lsd"] = self.session.lsd
if self.session.fb_dtsg:
body["fb_dtsg"] = self.session.fb_dtsg
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": self.cookies,
"Origin": META_AI_BASE,
"Referer": f"{META_AI_BASE}/",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
}
if self.session.access_token:
headers["Authorization"] = f"OAuth {self.session.access_token}"
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
GRAPHQL_ENDPOINT,
headers=headers,
data=body
)
raw_text = response.text
if response.status_code != 200:
raise Exception(f"Meta AI Error: {response.status_code} - {raw_text[:500]}")
# Parse streaming response
last_valid_response = None
for line in raw_text.split('\n'):
if not line.strip():
continue
try:
parsed = json.loads(line)
streaming_state = (
parsed.get("data", {})
.get("xfb_abra_send_message", {})
.get("bot_response_message", {})
.get("streaming_state")
)
if streaming_state == "OVERALL_DONE":
last_valid_response = parsed
break
# Check for imagine_card
imagine_card = (
parsed.get("data", {})
.get("xfb_abra_send_message", {})
.get("bot_response_message", {})
.get("imagine_card")
)
if imagine_card and imagine_card.get("session", {}).get("media_sets"):
last_valid_response = parsed
except json.JSONDecodeError:
continue
if not last_valid_response:
if "login_form" in raw_text or "facebook.com/login" in raw_text:
raise Exception("Meta AI: Session expired. Please refresh cookies.")
raise Exception("Meta AI: No valid response found")
return last_valid_response
def _extract_images(self, response: Any, original_prompt: str) -> List[MetaImageResult]:
"""Extract image URLs from Meta AI response"""
images: List[MetaImageResult] = []
message_data = (
response.get("data", {})
.get("xfb_abra_send_message", {})
.get("bot_response_message")
)
if message_data:
images.extend(self._extract_images_from_message(message_data, original_prompt))
# Recursive search fallback
if not images and response.get("data"):
print("[Meta AI] Structured extraction failed, doing recursive search...")
found_urls = self._recursive_search_for_images(response["data"])
for url in found_urls:
images.append(MetaImageResult(
url=url,
data=None,
prompt=original_prompt,
model="meta"
))
return images
def _extract_images_from_message(
self,
message_data: Dict,
original_prompt: str
) -> List[MetaImageResult]:
"""Helper to extract images from a single message node"""
images: List[MetaImageResult] = []
imagine_card = message_data.get("imagine_card")
if imagine_card and imagine_card.get("session", {}).get("media_sets"):
for media_set in imagine_card["session"]["media_sets"]:
imagine_media = media_set.get("imagine_media", [])
for media in imagine_media:
url = media.get("uri") or media.get("image_uri")
if url:
images.append(MetaImageResult(
url=url,
data=None,
prompt=original_prompt,
model="meta"
))
# Check attachments
attachments = message_data.get("attachments", [])
for attachment in attachments:
media = attachment.get("media", {})
url = media.get("image_uri") or media.get("uri")
if url:
images.append(MetaImageResult(
url=url,
data=None,
prompt=original_prompt,
model="meta"
))
return images
def _recursive_search_for_images(
self,
obj: Any,
found: Optional[set] = None
) -> List[str]:
"""Recursive search for image-like URLs"""
if found is None:
found = set()
if not obj or not isinstance(obj, (dict, list)):
return []
if isinstance(obj, dict):
for key, val in obj.items():
if isinstance(val, str):
if ('fbcdn.net' in val or 'meta.ai' in val) and \
any(ext in val for ext in ['.jpg', '.png', '.webp', 'image_uri=', '/imagine/']):
found.add(val)
elif isinstance(val, (dict, list)):
self._recursive_search_for_images(val, found)
elif isinstance(obj, list):
for item in obj:
self._recursive_search_for_images(item, found)
return list(found)
async def _poll_for_images(
self,
initial_response: Any,
prompt: str
) -> List[MetaImageResult]:
"""Poll for image generation completion"""
conversation_id = (
initial_response.get("data", {})
.get("node", {})
.get("external_conversation_id")
)
if not conversation_id:
return []
max_attempts = 30
poll_interval = 2
for attempt in range(max_attempts):
print(f"[Meta AI] Polling attempt {attempt + 1}/{max_attempts}...")
await asyncio.sleep(poll_interval)
variables = {"external_conversation_id": conversation_id}
body = {
"fb_api_caller_class": "RelayModern",
"fb_api_req_friendly_name": "KadabraPromptRootQuery",
"variables": json.dumps(variables),
"doc_id": "25290569913909283"
}
if self.session.lsd:
body["lsd"] = self.session.lsd
if self.session.fb_dtsg:
body["fb_dtsg"] = self.session.fb_dtsg
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": self.cookies,
"Origin": META_AI_BASE
}
if self.session.access_token:
headers["Authorization"] = f"OAuth {self.session.access_token}"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
GRAPHQL_ENDPOINT,
headers=headers,
data=body
)
data = response.json()
images = self._extract_images(data, prompt)
if images:
print(f"[Meta AI] Got {len(images)} image(s) after polling!")
return images
status = data.get("data", {}).get("kadabra_prompt", {}).get("status")
if status in ["FAILED", "ERROR"]:
break
except Exception as e:
print(f"[Meta AI] Poll error: {e}")
return []
async def download_as_base64(self, url: str) -> str:
"""Download image from URL and convert to base64"""
import base64
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
url,
headers={
"Cookie": self.cookies,
"Referer": META_AI_BASE
}
)
return base64.b64encode(response.content).decode('utf-8')

View file

@ -0,0 +1,151 @@
"""
Prompts Service for Python/FastAPI
Port of lib/prompts-service.ts
Handles:
- Read/write prompts.json
- Sync prompts from crawlers (placeholder - crawlers complex to port)
"""
import json
import os
import asyncio
from pathlib import Path
from typing import List, Dict, Any, Optional
from datetime import datetime
# Path to prompts data file (relative to project root)
DATA_DIR = Path(__file__).parent.parent.parent / "data"
DATA_FILE = DATA_DIR / "prompts.json"
class Prompt:
def __init__(self, data: Dict[str, Any]):
self.id = data.get("id", 0)
self.title = data.get("title", "")
self.description = data.get("description", "")
self.prompt = data.get("prompt", "")
self.category = data.get("category", "")
self.source = data.get("source", "")
self.source_url = data.get("source_url", "")
self.images = data.get("images", [])
self.use_count = data.get("useCount", 0)
self.last_used_at = data.get("lastUsedAt")
self.created_at = data.get("createdAt")
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"title": self.title,
"description": self.description,
"prompt": self.prompt,
"category": self.category,
"source": self.source,
"source_url": self.source_url,
"images": self.images,
"useCount": self.use_count,
"lastUsedAt": self.last_used_at,
"createdAt": self.created_at
}
class PromptCache:
def __init__(self, data: Dict[str, Any]):
self.prompts = [Prompt(p) for p in data.get("prompts", [])]
self.last_updated = data.get("last_updated")
self.last_sync = data.get("lastSync")
self.categories = data.get("categories", {})
self.total_count = data.get("total_count", 0)
self.sources = data.get("sources", [])
def to_dict(self) -> Dict[str, Any]:
return {
"prompts": [p.to_dict() for p in self.prompts],
"last_updated": self.last_updated,
"lastSync": self.last_sync,
"categories": self.categories,
"total_count": self.total_count,
"sources": self.sources
}
async def get_prompts() -> PromptCache:
"""Read prompts from JSON file"""
try:
if DATA_FILE.exists():
content = DATA_FILE.read_text(encoding='utf-8')
data = json.loads(content)
return PromptCache(data)
except Exception as e:
print(f"[PromptsService] Error reading prompts: {e}")
return PromptCache({
"prompts": [],
"last_updated": None,
"categories": {},
"total_count": 0,
"sources": []
})
async def save_prompts(cache: PromptCache) -> None:
"""Save prompts to JSON file"""
try:
DATA_DIR.mkdir(parents=True, exist_ok=True)
content = json.dumps(cache.to_dict(), indent=2, ensure_ascii=False)
DATA_FILE.write_text(content, encoding='utf-8')
except Exception as e:
print(f"[PromptsService] Error saving prompts: {e}")
raise
async def sync_prompts() -> Dict[str, Any]:
"""
Sync prompts from sources.
Note: The crawler implementation is complex and would require porting
the JavaScript crawlers. For now, this just refreshes the timestamp.
"""
print("[PromptsService] Starting sync...")
cache = await get_prompts()
now = int(datetime.now().timestamp() * 1000)
# Update sync timestamp
cache.last_sync = now
cache.last_updated = datetime.now().isoformat()
await save_prompts(cache)
return {
"success": True,
"count": len(cache.prompts),
"added": 0
}
async def track_prompt_use(prompt_id: int) -> Optional[Prompt]:
"""Track usage of a prompt"""
cache = await get_prompts()
for prompt in cache.prompts:
if prompt.id == prompt_id:
prompt.use_count += 1
prompt.last_used_at = int(datetime.now().timestamp() * 1000)
await save_prompts(cache)
return prompt
return None
async def upload_prompt_image(prompt_id: int, image_base64: str) -> Optional[Prompt]:
"""Upload an image for a prompt"""
cache = await get_prompts()
for prompt in cache.prompts:
if prompt.id == prompt_id:
if prompt.images is None:
prompt.images = []
prompt.images.append(f"data:image/png;base64,{image_base64}")
await save_prompts(cache)
return prompt
return None

View file

@ -0,0 +1,410 @@
"""
Whisk Client for Python/FastAPI
Port of lib/whisk-client.ts
Handles:
- Cookie parsing (JSON array or string format)
- Access token retrieval from Whisk API
- Image generation with aspect ratio support
- Reference image upload
- Video generation with polling
"""
import httpx
import json
import uuid
import base64
import asyncio
from typing import Optional, Dict, List, Any
# Whisk API endpoints
AUTH_URL = "https://aisandbox-pa.googleapis.com/v1:signInWithIdp"
GENERATE_URL = "https://aisandbox-pa.googleapis.com/v1:runImagine"
RECIPE_URL = "https://aisandbox-pa.googleapis.com/v1:runRecipe"
UPLOAD_URL = "https://aisandbox-pa.googleapis.com/v1:uploadMedia"
VIDEO_URL = "https://aisandbox-pa.googleapis.com/v1:runVideoFxSingleClips"
VIDEO_STATUS_URL = "https://aisandbox-pa.googleapis.com/v1:runVideoFxSingleClipsStatusCheck"
# Aspect ratio mapping
ASPECT_RATIOS = {
"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"
}
MEDIA_CATEGORIES = {
"subject": "MEDIA_CATEGORY_SUBJECT",
"scene": "MEDIA_CATEGORY_SCENE",
"style": "MEDIA_CATEGORY_STYLE"
}
class GeneratedImage:
def __init__(self, data: str, index: int, prompt: str, aspect_ratio: str):
self.data = data
self.index = index
self.prompt = prompt
self.aspect_ratio = aspect_ratio
def to_dict(self) -> Dict[str, Any]:
return {
"data": self.data,
"index": self.index,
"prompt": self.prompt,
"aspectRatio": self.aspect_ratio
}
class WhiskVideoResult:
def __init__(self, id: str, url: Optional[str], status: str):
self.id = id
self.url = url
self.status = status
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"url": self.url,
"status": self.status
}
class WhiskClient:
def __init__(self, cookie_input: str):
self.cookies = self._parse_cookies(cookie_input)
self.access_token: Optional[str] = None
self.token_expires: int = 0
self.cookie_string = ""
if not self.cookies:
raise ValueError("No valid cookies provided")
# Build cookie string for requests
self.cookie_string = "; ".join(
f"{name}={value}" for name, value in self.cookies.items()
)
def _parse_cookies(self, input_str: str) -> Dict[str, str]:
"""Parse cookies from string or JSON format"""
if not input_str or not input_str.strip():
return {}
trimmed = input_str.strip()
cookies: Dict[str, str] = {}
# Handle JSON array format (e.g., from Cookie-Editor)
if trimmed.startswith('[') or trimmed.startswith('{'):
try:
parsed = json.loads(trimmed)
if isinstance(parsed, list):
for c in parsed:
if isinstance(c, dict) and 'name' in c and 'value' in c:
cookies[c['name']] = c['value']
return cookies
elif isinstance(parsed, dict) and 'name' in parsed and 'value' in parsed:
return {parsed['name']: parsed['value']}
except json.JSONDecodeError:
pass
# Handle string format (key=value; key2=value2)
for pair in trimmed.split(';'):
pair = pair.strip()
if '=' in pair:
key, _, value = pair.partition('=')
cookies[key.strip()] = value.strip()
return cookies
async def get_access_token(self) -> str:
"""Get or refresh access token from Whisk API"""
import time
# Return cached token if still valid
if self.access_token and self.token_expires > int(time.time() * 1000):
return self.access_token
async with httpx.AsyncClient() as client:
response = await client.post(
AUTH_URL,
headers={
"Content-Type": "application/json",
"Cookie": self.cookie_string
},
json={}
)
if response.status_code != 200:
raise Exception(f"Auth failed: {response.status_code} - {response.text[:200]}")
data = response.json()
self.access_token = data.get("authToken")
expires_in = int(data.get("expiresIn", 3600))
self.token_expires = int(time.time() * 1000) + (expires_in * 1000) - 60000
if not self.access_token:
raise Exception("No auth token in response")
return self.access_token
async def upload_reference_image(
self,
file_base64: str,
mime_type: str,
category: str
) -> Optional[str]:
"""Upload a reference image and return media ID"""
token = await self.get_access_token()
data_uri = f"data:{mime_type};base64,{file_base64}"
media_category = MEDIA_CATEGORIES.get(category.lower(), MEDIA_CATEGORIES["subject"])
payload = {
"mediaData": data_uri,
"imageOptions": {
"imageCategory": media_category
}
}
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
UPLOAD_URL,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"Cookie": self.cookie_string
},
json=payload
)
if response.status_code != 200:
print(f"[WhiskClient] Upload failed: {response.status_code}")
raise Exception(f"Upload failed: {response.text[:200]}")
data = response.json()
media_id = data.get("generationId") or data.get("imageMediaId")
if not media_id:
print(f"[WhiskClient] No media ID in response: {data}")
return None
print(f"[WhiskClient] Upload successful, mediaId: {media_id}")
return media_id
async def generate(
self,
prompt: str,
aspect_ratio: str = "1:1",
refs: Optional[Dict[str, Any]] = None,
precise_mode: bool = False
) -> List[GeneratedImage]:
"""Generate images using Whisk API"""
token = await self.get_access_token()
refs = refs or {}
# Build media inputs
media_inputs = []
def add_refs(category: str, ids):
"""Helper to add refs (handles both single string and array)"""
if not ids:
return
id_list = [ids] if isinstance(ids, str) else ids
cat_enum = MEDIA_CATEGORIES.get(category.lower())
for ref_id in id_list:
if ref_id:
media_inputs.append({
"mediaId": ref_id,
"mediaCategory": cat_enum
})
add_refs("subject", refs.get("subject"))
add_refs("scene", refs.get("scene"))
add_refs("style", refs.get("style"))
# Build payload
aspect_enum = ASPECT_RATIOS.get(aspect_ratio, ASPECT_RATIOS["1:1"])
# Determine endpoint based on refs
has_refs = len(media_inputs) > 0
endpoint = RECIPE_URL if has_refs else GENERATE_URL
if has_refs:
# Recipe format (with refs)
recipe_inputs = []
def add_recipe_refs(category: str, ids):
if not ids:
return
id_list = [ids] if isinstance(ids, str) else ids
cat_enum = MEDIA_CATEGORIES.get(category.lower())
for ref_id in id_list:
if ref_id:
recipe_inputs.append({
"inputType": cat_enum,
"mediaId": ref_id
})
add_recipe_refs("subject", refs.get("subject"))
add_recipe_refs("scene", refs.get("scene"))
add_recipe_refs("style", refs.get("style"))
payload = {
"recipeInputs": recipe_inputs,
"generationConfig": {
"aspectRatio": aspect_enum,
"numberOfImages": 4,
"personalizationConfig": {}
},
"textPromptInput": {
"text": prompt
}
}
else:
# Direct imagine format (no refs)
payload = {
"imagineConfig": {
"aspectRatio": aspect_enum,
"imaginePrompt": prompt,
"numberOfImages": 4,
"imageSafetyMode": "BLOCK_SOME"
}
}
print(f"[WhiskClient] Generating with prompt: \"{prompt[:50]}...\"")
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
endpoint,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"Cookie": self.cookie_string
},
json=payload
)
if response.status_code != 200:
error_text = response.text[:500]
if "401" in error_text or "403" in error_text:
raise Exception("Whisk auth failed - cookies may be expired")
raise Exception(f"Generation failed: {response.status_code} - {error_text}")
data = response.json()
# Extract images
images: List[GeneratedImage] = []
image_list = data.get("generatedImages", [])
for i, img in enumerate(image_list):
image_data = img.get("encodedImage", "")
if image_data:
images.append(GeneratedImage(
data=image_data,
index=i,
prompt=prompt,
aspect_ratio=aspect_ratio
))
print(f"[WhiskClient] Generated {len(images)} images")
return images
async def generate_video(
self,
image_generation_id: str,
prompt: str,
image_base64: Optional[str] = None,
aspect_ratio: str = "16:9"
) -> WhiskVideoResult:
"""Generate a video from an image using Whisk Animate (Veo)"""
token = await self.get_access_token()
# If we have base64 but no generation ID, upload first
actual_gen_id = image_generation_id
if not actual_gen_id and image_base64:
actual_gen_id = await self.upload_reference_image(
image_base64, "image/png", "subject"
)
if not actual_gen_id:
raise Exception("No image generation ID available for video")
payload = {
"generationId": actual_gen_id,
"videoFxConfig": {
"aspectRatio": aspect_ratio.replace(":", "_"),
"prompt": prompt,
"duration": "5s"
}
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
VIDEO_URL,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"Cookie": self.cookie_string
},
json=payload
)
if response.status_code != 200:
raise Exception(f"Video init failed: {response.text[:200]}")
data = response.json()
video_gen_id = data.get("videoGenId")
if not video_gen_id:
raise Exception("No video generation ID in response")
print(f"[WhiskClient] Video generation started: {video_gen_id}")
# Poll for completion
return await self.poll_video_status(video_gen_id, token)
async def poll_video_status(
self,
video_gen_id: str,
token: str
) -> WhiskVideoResult:
"""Poll for video generation status until complete or failed"""
max_attempts = 60
poll_interval = 3
async with httpx.AsyncClient(timeout=30.0) as client:
for attempt in range(max_attempts):
print(f"[WhiskClient] Polling video status {attempt + 1}/{max_attempts}...")
response = await client.post(
VIDEO_STATUS_URL,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"Cookie": self.cookie_string
},
json={"videoGenId": video_gen_id}
)
if response.status_code != 200:
await asyncio.sleep(poll_interval)
continue
data = response.json()
status = data.get("status", "")
video_url = data.get("videoUri")
if status == "COMPLETE" and video_url:
print(f"[WhiskClient] Video complete: {video_url[:50]}...")
return WhiskVideoResult(
id=video_gen_id,
url=video_url,
status="complete"
)
elif status in ["FAILED", "ERROR"]:
raise Exception(f"Video generation failed: {status}")
await asyncio.sleep(poll_interval)
raise Exception("Video generation timed out")

View file

@ -1,6 +1,6 @@
{
"last_updated": "2026-01-07T15:48:03.652Z",
"lastSync": 1767800883652,
"last_updated": "2026-01-13T00:23:04.935Z",
"lastSync": 1768263784935,
"categories": {
"style": [
"Illustration",

View file

@ -4,13 +4,16 @@ services:
container_name: kv-pix
restart: unless-stopped
ports:
- "8558:3000"
- "3000:3000" # Next.js frontend
- "8000:8000" # FastAPI backend
environment:
- NODE_ENV=production
metaai-free-api:
build: ./services/metaai-api
container_name: metaai-free-api
restart: unless-stopped
ports:
- "8000:8000"
volumes:
- ./data:/app/data # Persist prompt library
# Optional: Meta AI Free Wrapper (if needed)
# metaai-free-api:
# build: ./services/metaai-api
# container_name: metaai-free-api
# restart: unless-stopped
# ports:
# - "8001:8000"

236
lib/api.ts Normal file
View file

@ -0,0 +1,236 @@
/**
* API Client for FastAPI Backend
*
* Centralized API calls to the FastAPI backend.
* Used by frontend components to call the new Python backend.
*/
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
// Types
export interface GenerateParams {
prompt: string;
aspectRatio?: string;
refs?: { subject?: string | string[]; scene?: string | string[]; style?: string | string[] };
preciseMode?: boolean;
imageCount?: number;
cookies: string;
}
export interface GeneratedImage {
data: string;
index?: number;
prompt: string;
aspectRatio: string;
}
export interface VideoParams {
prompt: string;
imageBase64?: string;
imageGenerationId?: string;
cookies: string;
}
export interface ReferenceUploadParams {
imageBase64: string;
mimeType: string;
category: string;
cookies: string;
}
export interface MetaGenerateParams {
prompt: string;
cookies?: string;
imageCount?: number;
aspectRatio?: string;
useMetaFreeWrapper?: boolean;
metaFreeWrapperUrl?: string;
}
// Helper to handle API responses
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Request failed' }));
throw new Error(error.error || error.detail || `HTTP ${response.status}`);
}
return response.json();
}
/**
* Generate images using Whisk API
*/
export async function generateImages(params: GenerateParams): Promise<{ images: GeneratedImage[] }> {
const response = await fetch(`${API_BASE}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
});
return handleResponse(response);
}
/**
* Generate video using Whisk API
*/
export async function generateVideo(params: VideoParams): Promise<{
success: boolean;
id?: string;
url?: string;
status?: string;
}> {
const response = await fetch(`${API_BASE}/video/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
});
return handleResponse(response);
}
/**
* Upload reference image
*/
export async function uploadReference(params: ReferenceUploadParams): Promise<{
success: boolean;
id: string;
}> {
const response = await fetch(`${API_BASE}/references/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
});
return handleResponse(response);
}
/**
* Generate images using Meta AI
*/
export async function generateMetaImages(params: MetaGenerateParams): Promise<{
success: boolean;
images: Array<{
data?: string;
url?: string;
prompt: string;
model: string;
aspectRatio: string;
}>;
}> {
const response = await fetch(`${API_BASE}/meta/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
});
return handleResponse(response);
}
/**
* Get prompts from library
*/
export async function getPrompts(): Promise<{
prompts: any[];
last_updated: string | null;
lastSync: number | null;
categories: Record<string, string[]>;
total_count: number;
sources: string[];
}> {
const response = await fetch(`${API_BASE}/prompts`);
return handleResponse(response);
}
/**
* Sync prompts from sources
*/
export async function syncPrompts(): Promise<{
success: boolean;
count: number;
added: number;
}> {
const response = await fetch(`${API_BASE}/prompts/sync`, {
method: 'POST'
});
return handleResponse(response);
}
/**
* Track prompt usage
*/
export async function trackPromptUse(promptId: number): Promise<{
success: boolean;
promptId: number;
useCount: number;
lastUsedAt: number;
}> {
const response = await fetch(`${API_BASE}/prompts/use`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ promptId })
});
return handleResponse(response);
}
/**
* Get upload history
*/
export async function getHistory(category?: string): Promise<{
history: Array<{
id: string;
url: string;
originalName: string;
category: string;
mediaId?: string;
createdAt?: number;
}>;
}> {
const url = category ? `${API_BASE}/history?category=${category}` : `${API_BASE}/history`;
const response = await fetch(url);
return handleResponse(response);
}
/**
* Upload to history
*/
export async function uploadToHistory(file: File, category: string, cookies?: string): Promise<{
id: string;
url: string;
originalName: string;
category: string;
mediaId?: string;
}> {
const formData = new FormData();
formData.append('file', file);
formData.append('category', category);
if (cookies) formData.append('cookies', cookies);
const response = await fetch(`${API_BASE}/history`, {
method: 'POST',
body: formData
});
return handleResponse(response);
}
/**
* Delete history item
*/
export async function deleteHistoryItem(itemId: string): Promise<{ success: boolean }> {
const response = await fetch(`${API_BASE}/history/${itemId}`, {
method: 'DELETE'
});
return handleResponse(response);
}
/**
* Clear all history
*/
export async function clearHistory(): Promise<{ success: boolean }> {
const response = await fetch(`${API_BASE}/history`, {
method: 'DELETE'
});
return handleResponse(response);
}
/**
* Health check
*/
export async function healthCheck(): Promise<{ status: string; service: string; version: string }> {
const response = await fetch(`${API_BASE}/health`);
return handleResponse(response);
}