diff --git a/Dockerfile b/Dockerfile index 1799499..70026f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 <: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. diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..fa0901f --- /dev/null +++ b/backend/main.py @@ -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) diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..5de1522 --- /dev/null +++ b/backend/models/__init__.py @@ -0,0 +1,3 @@ +# Models package +from .requests import * +from .responses import * diff --git a/backend/models/requests.py b/backend/models/requests.py new file mode 100644 index 0000000..8274273 --- /dev/null +++ b/backend/models/requests.py @@ -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 diff --git a/backend/models/responses.py b/backend/models/responses.py new file mode 100644 index 0000000..d8af678 --- /dev/null +++ b/backend/models/responses.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..015c337 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..873f7bb --- /dev/null +++ b/backend/routers/__init__.py @@ -0,0 +1 @@ +# Routers package diff --git a/backend/routers/generate.py b/backend/routers/generate.py new file mode 100644 index 0000000..5c6592e --- /dev/null +++ b/backend/routers/generate.py @@ -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) diff --git a/backend/routers/history.py b/backend/routers/history.py new file mode 100644 index 0000000..d018de5 --- /dev/null +++ b/backend/routers/history.py @@ -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)) diff --git a/backend/routers/meta.py b/backend/routers/meta.py new file mode 100644 index 0000000..444e7fa --- /dev/null +++ b/backend/routers/meta.py @@ -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" + ) diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py new file mode 100644 index 0000000..6e66153 --- /dev/null +++ b/backend/routers/prompts.py @@ -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)) diff --git a/backend/routers/references.py b/backend/routers/references.py new file mode 100644 index 0000000..e8e7e0a --- /dev/null +++ b/backend/routers/references.py @@ -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)) diff --git a/backend/routers/video.py b/backend/routers/video.py new file mode 100644 index 0000000..c162d48 --- /dev/null +++ b/backend/routers/video.py @@ -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)) diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..a70b302 --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1 @@ +# Services package diff --git a/backend/services/meta_client.py b/backend/services/meta_client.py new file mode 100644 index 0000000..aa2ecd9 --- /dev/null +++ b/backend/services/meta_client.py @@ -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') diff --git a/backend/services/prompts_service.py b/backend/services/prompts_service.py new file mode 100644 index 0000000..99d726e --- /dev/null +++ b/backend/services/prompts_service.py @@ -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 diff --git a/backend/services/whisk_client.py b/backend/services/whisk_client.py new file mode 100644 index 0000000..553c276 --- /dev/null +++ b/backend/services/whisk_client.py @@ -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") diff --git a/data/prompts.json b/data/prompts.json index c5ba084..7f470f4 100644 --- a/data/prompts.json +++ b/data/prompts.json @@ -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", diff --git a/docker-compose.yml b/docker-compose.yml index 707306f..95b2524 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/lib/api.ts b/lib/api.ts new file mode 100644 index 0000000..3b4282b --- /dev/null +++ b/lib/api.ts @@ -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(response: Response): Promise { + 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; + 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); +}