Compare commits

...

20 commits
v1.0.0 ... main

Author SHA1 Message Date
KV-Pix Bot
6bf9f6e39c release: v2.5.0 - UI enhancements, pagination, and security
Some checks failed
CI / build (18.x) (push) Has been cancelled
CI / build (20.x) (push) Has been cancelled
2026-01-16 22:08:26 +07:00
Khoa.vo
072c7adf89 chore: Remove unused grok and meta-chat API routes
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:50:03 +07:00
Khoa.vo
e8978bb086 fix: Replace BigInt literal with BigInt function for ES compatibility
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:40:49 +07:00
Khoa.vo
ad19603f7c fix: Remove GrokChat import and usage from page.tsx
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:39:29 +07:00
Khoa.vo
6e833b24a6 fix: Remove all remaining grok references, delete GrokChat.tsx
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:38:27 +07:00
Khoa.vo
5d4413ff51 fix: Update Settings type to remove grok and add meta wrapper props
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:36:46 +07:00
Khoa.vo
962ff4667c fix: Add explicit type for mergedCookies
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:34:47 +07:00
Khoa.vo
ccfa897ac9 fix: Remove final grok reference from modal styling
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:32:59 +07:00
Khoa.vo
d43d979e43 fix: Remove grok from provider badge styling
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:31:25 +07:00
Khoa.vo
21abb11766 fix: Add removeVideo to store imports
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:29:57 +07:00
Khoa.vo
2e203dad19 fix: Replace addGeneratedImage with addToGallery
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:28:08 +07:00
Khoa.vo
bf4a56e550 fix: Remove grok from provider type union
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:26:50 +07:00
Khoa.vo
2173eb1446 fix: Add provider to editSource type in Gallery.tsx
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:25:07 +07:00
Khoa.vo
e69c6ba64d chore: Remove Grok integration, simplify Settings UI
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
- Removed all Grok-related code, API routes, and services
- Removed crawl4ai service and meta-crawl client
- Simplified Settings to always show cookie inputs for Meta AI
- Hid advanced wrapper settings behind collapsible section
- Provider selection now only shows Whisk and Meta AI
- Fixed unused imports and type definitions
2026-01-07 19:21:51 +07:00
Khoa.vo
537b1b80e5 refactor: simplify gallery thumbnail UI
Some checks failed
CI / build (18.x) (push) Has been cancelled
CI / build (20.x) (push) Has been cancelled
- Remove all action buttons from thumbnail overlay
- Keep only provider tag and delete button (X)
- Keep prompt text visible on hover
- All actions (download, video, remix, etc.) now in lightbox
2026-01-06 14:58:14 +07:00
Khoa.vo
c2ee01b7b7 feat: enable Subject upload for Meta AI video generation
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
- Subject button now enabled for Meta AI (Scene/Style still disabled)
- Video button appears when Subject reference is uploaded
- Uses Subject image for image-to-video generation
- Added handleGenerateVideo() in PromptHero
- Tooltip: 'Upload image to animate into video'
2026-01-06 14:51:08 +07:00
Khoa.vo
7aaa4c8166 refactor: simplify Meta AI video workflow
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
- Remove Video button from PromptHero (generate images first, video from gallery)
- Add Meta AI image-to-video in Gallery component
- Redesign lightbox with split panel layout (image left, controls right)
- Add handleGenerateMetaVideo() for Meta AI video from gallery
- Fix reference buttons (all disabled for Meta AI)
- Lightbox now shows: Download, Generate Video, Remix/Edit, Copy Prompt, Delete
2026-01-06 14:33:44 +07:00
Khoa.vo
bae4c487da feat: add image-to-video support for Meta AI
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
- Add Litterbox temporary image hosting for image URLs
- Update backend to accept image_base64 parameter
- Update TypeScript client and API route
- Subject button now enabled for Meta AI (for image-to-video)
- Button changes from 'Video' to 'Animate' when subject is set
- Pink/purple gradient for image-to-video, blue/cyan for text-to-video
2026-01-06 14:11:26 +07:00
Khoa.vo
0f87b8ef99 feat: add Meta AI video generation
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
- Add /video/generate endpoint to crawl4ai Python service
- Add VideoGenerateRequest and VideoGenerateResponse models
- Add generateVideo method to MetaCrawlClient TypeScript client
- Add /api/meta/video Next.js API route
- Add 'Video' button in PromptHero UI (visible only for Meta AI provider)
- Blue/cyan gradient styling for Video button to differentiate from Generate
2026-01-06 13:52:31 +07:00
Khoa.vo
2a4bf8b58b feat: updates before deployment
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-06 13:26:11 +07:00
2333 changed files with 55167 additions and 3743 deletions

View file

@ -7,3 +7,4 @@ README.md
.git .git
.env* .env*
! .env.example ! .env.example
.venv

6
.gitignore vendored
View file

@ -40,3 +40,9 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# local env
.venv
error.log
__pycache__
*.log

View file

@ -62,10 +62,20 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ images }); return NextResponse.json({ images });
} catch (error: any) { } catch (error: any) {
console.error("Generate API Error:", error); console.error("Generate API Error Details:", {
message: error.message,
stack: error.stack,
fullError: error
});
const msg = error.message || "";
const isAuthError = msg.includes("401") || msg.includes("403") ||
msg.includes("Auth") || msg.includes("auth") ||
msg.includes("cookies") || msg.includes("expired");
return NextResponse.json( return NextResponse.json(
{ error: error.message || "Generation failed" }, { error: error.message || "Generation failed" },
{ status: 500 } { status: isAuthError ? 401 : 500 }
); );
} }
} }

View file

@ -1,60 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { GrokClient } from '@/lib/providers/grok-client';
export async function POST(req: NextRequest) {
try {
const { prompt, apiKey, cookies, imageCount = 1 } = await req.json();
if (!prompt) {
return NextResponse.json({ error: "Prompt is required" }, { status: 400 });
}
if (!apiKey && !cookies) {
return NextResponse.json(
{ error: "Grok API key or cookies required. Configure in Settings." },
{ status: 401 }
);
}
console.log(`[Grok API Route] Generating ${imageCount} image(s) for: "${prompt.substring(0, 30)}..."`);
const client = new GrokClient({ apiKey, cookies });
const results = await client.generate(prompt, imageCount);
// Download images as base64 for storage
const images = await Promise.all(
results.map(async (img) => {
let base64 = img.data;
if (!base64 && img.url && !img.url.startsWith('data:')) {
try {
base64 = await client.downloadAsBase64(img.url);
} catch (e) {
console.warn("[Grok API Route] Failed to download image:", e);
}
}
return {
data: base64 || '',
url: img.url,
prompt: img.prompt,
model: img.model,
aspectRatio: '1:1' // Grok default
};
})
);
const validImages = images.filter(img => img.data || img.url);
if (validImages.length === 0) {
throw new Error("No valid images generated");
}
return NextResponse.json({ images: validImages });
} catch (error: any) {
console.error("[Grok API Route] Error:", error);
return NextResponse.json(
{ error: error.message || "Grok generation failed" },
{ status: 500 }
);
}
}

View file

@ -3,23 +3,39 @@ import { MetaAIClient } from '@/lib/providers/meta-client';
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const { prompt, cookies, imageCount = 4 } = await req.json(); const { prompt, cookies, imageCount = 4, aspectRatio = 'portrait', useMetaFreeWrapper, metaFreeWrapperUrl } = await req.json();
if (!prompt) { if (!prompt) {
return NextResponse.json({ error: "Prompt is required" }, { status: 400 }); return NextResponse.json({ error: "Prompt is required" }, { status: 400 });
} }
if (!cookies) { // Only check for cookies if NOT using free wrapper
if (!useMetaFreeWrapper && !cookies) {
return NextResponse.json( return NextResponse.json(
{ error: "Meta AI cookies required. Configure in Settings." }, { error: "Meta AI cookies required. Configure in Settings or use Free Wrapper." },
{ status: 401 } { status: 401 }
); );
} }
console.log(`[Meta AI Route] Generating images for: "${prompt.substring(0, 30)}..."`); console.log(`[Meta AI Route] Generating images for: "${prompt.substring(0, 30)}..." (${aspectRatio})`);
const client = new MetaAIClient({ cookies }); // Diagnostic: Check how many cookies we received
const results = await client.generate(prompt, imageCount); try {
const parsed = typeof cookies === 'string' && cookies.trim().startsWith('[')
? JSON.parse(cookies)
: cookies;
const count = Array.isArray(parsed) ? parsed.length : (typeof cookies === 'string' ? cookies.split(';').length : 0);
console.log(`[Meta AI Route] Received ${count} cookies (Free Wrapper: ${useMetaFreeWrapper})`);
} catch {
console.log(`[Meta AI Route] Cookie format: ${typeof cookies}`);
}
const client = new MetaAIClient({
cookies: cookies || '',
useFreeWrapper: useMetaFreeWrapper,
freeWrapperUrl: metaFreeWrapperUrl
});
const results = await client.generate(prompt, imageCount, aspectRatio);
// Download images as base64 for storage // Download images as base64 for storage
const images = await Promise.all( const images = await Promise.all(
@ -48,7 +64,7 @@ export async function POST(req: NextRequest) {
throw new Error("No valid images generated"); throw new Error("No valid images generated");
} }
return NextResponse.json({ images: validImages }); return NextResponse.json({ success: true, images: validImages });
} catch (error: any) { } catch (error: any) {
console.error("[Meta AI Route] Error:", error); console.error("[Meta AI Route] Error:", error);

371
app/api/meta/video/route.ts Normal file
View file

@ -0,0 +1,371 @@
import { NextRequest, NextResponse } from 'next/server';
import { MetaAIClient } from '@/lib/providers/meta-client';
/**
* POST /api/meta/video
*
* Generate a video from a text prompt using Meta AI's Kadabra engine.
* Uses MetaAIClient for session initialization (which works for images).
*/
const META_AI_BASE = "https://www.meta.ai";
const GRAPHQL_ENDPOINT = `${META_AI_BASE}/api/graphql/`;
// Video generation doc IDs from metaai-api
const VIDEO_INITIATE_DOC_ID = "25290947477183545"; // useKadabraSendMessageMutation
const VIDEO_POLL_DOC_ID = "25290569913909283"; // KadabraPromptRootQuery
export async function POST(req: NextRequest) {
try {
const { prompt, cookies: clientCookies, aspectRatio = 'portrait' } = await req.json();
if (!prompt) {
return NextResponse.json({ error: "Prompt is required" }, { status: 400 });
}
if (!clientCookies) {
return NextResponse.json(
{ error: "Meta AI cookies not found. Please configure settings." },
{ status: 401 }
);
}
console.log(`[Meta Video API] Generating video for: "${prompt.substring(0, 50)}..." (${aspectRatio})`);
// Use MetaAIClient for session initialization (proven to work)
const client = new MetaAIClient({ cookies: clientCookies });
const session = await client.getSession();
const cookieString = client.getCookies();
console.log("[Meta Video] Using MetaAIClient session:", {
hasLsd: !!session.lsd,
hasDtsg: !!session.fb_dtsg,
hasAccessToken: !!session.accessToken
});
// Generate unique IDs for this request
const externalConversationId = crypto.randomUUID();
const offlineThreadingId = Date.now().toString() + Math.random().toString().substring(2, 8);
// Initiate video generation with aspect ratio
await initiateVideoGeneration(prompt, externalConversationId, offlineThreadingId, session, cookieString, aspectRatio);
// Poll for video completion
const videos = await pollForVideoResult(externalConversationId, session, cookieString);
if (videos.length === 0) {
throw new Error("No videos generated");
}
return NextResponse.json({
success: true,
videos: videos.map(v => ({ url: v.url, prompt: prompt })),
conversation_id: externalConversationId
});
} catch (error: unknown) {
const err = error as Error;
console.error("[Meta Video API] Error:", err.message);
const msg = err.message || "";
const isAuthError = msg.includes("401") || msg.includes("403") ||
msg.includes("auth") || msg.includes("cookies") || msg.includes("expired") || msg.includes("Login");
return NextResponse.json(
{ error: err.message || "Video generation failed" },
{ status: isAuthError ? 401 : 500 }
);
}
}
interface MetaSession {
lsd?: string;
fb_dtsg?: string;
accessToken?: string;
}
/**
* Normalize cookies from JSON array to string format
*/
function normalizeCookies(cookies: string): string {
if (!cookies) return '';
try {
const trimmed = cookies.trim();
if (trimmed.startsWith('[')) {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed.map((c: any) => `${c.name}=${c.value}`).join('; ');
}
}
} catch (e) {
// Not JSON, assume it's already a string format
}
return cookies;
}
/**
* Initialize session - get access token and LSD from meta.ai page
*/
async function initSession(cookies: string, retryCount: number = 0): Promise<MetaSession> {
console.log("[Meta Video] Initializing session...");
console.log("[Meta Video] Cookie string length:", cookies.length);
// Add small delay to avoid rate limiting (especially after image generation)
if (retryCount > 0) {
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
}
const response = await fetch(META_AI_BASE, {
headers: {
"Cookie": cookies,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
}
});
const html = await response.text();
console.log("[Meta Video] HTML Preview:", html.substring(0, 200));
// Detect Facebook error page and retry
if (html.includes('<title>Error</title>') || html.includes('id="facebook"')) {
console.warn("[Meta Video] Received Facebook error page, retrying...");
if (retryCount < 3) {
return initSession(cookies, retryCount + 1);
}
throw new Error("Meta AI: Server temporarily unavailable. Please try again in a moment.");
}
const session: MetaSession = {};
// Extract LSD token - multiple patterns for different Meta AI versions
const lsdMatch = html.match(/"LSD",\[\],\{"token":"([^"]+)"/) ||
html.match(/"lsd":"([^"]+)"/) ||
html.match(/name="lsd" value="([^"]+)"/) ||
html.match(/"token":"([^"]+)".*?"name":"lsd"/);
if (lsdMatch) {
session.lsd = lsdMatch[1];
}
// Extract access token
const tokenMatch = html.match(/"accessToken":"([^"]+)"/) ||
html.match(/accessToken['"]\s*:\s*['"]([^'"]+)['"]/);
if (tokenMatch) {
session.accessToken = tokenMatch[1];
}
// Extract DTSG token - try multiple patterns
const dtsgMatch = html.match(/DTSGInitData",\[\],\{"token":"([^"]+)"/) ||
html.match(/"DTSGInitialData".*?"token":"([^"]+)"/) ||
html.match(/fb_dtsg['"]\s*:\s*\{[^}]*['"]token['"]\s*:\s*['"]([^'"]+)['"]/);
if (dtsgMatch) {
session.fb_dtsg = dtsgMatch[1];
}
// Also try to extract from cookies if not found in HTML
if (!session.lsd) {
const lsdCookie = cookies.match(/lsd=([^;]+)/);
if (lsdCookie) session.lsd = lsdCookie[1];
}
if (!session.fb_dtsg) {
const dtsgCookie = cookies.match(/fb_dtsg=([^;]+)/);
if (dtsgCookie) session.fb_dtsg = dtsgCookie[1];
}
if (html.includes('login_form') || html.includes('login_page')) {
throw new Error("Meta AI: Cookies expired. Please update in Settings.");
}
console.log("[Meta Video] Session tokens extracted:", {
hasLsd: !!session.lsd,
hasDtsg: !!session.fb_dtsg,
hasAccessToken: !!session.accessToken
});
return session;
}
/**
* Initiate video generation using Kadabra mutation
*/
async function initiateVideoGeneration(
prompt: string,
conversationId: string,
threadingId: string,
session: MetaSession,
cookies: string,
aspectRatio: string = 'portrait'
): Promise<void> {
console.log("[Meta Video] Initiating video generation...");
// Map aspect ratio to orientation
const orientationMap: Record<string, string> = {
'portrait': 'VERTICAL',
'landscape': 'HORIZONTAL',
'square': 'SQUARE'
};
const orientation = orientationMap[aspectRatio] || 'VERTICAL';
const variables = {
message: {
prompt_text: prompt,
external_conversation_id: conversationId,
offline_threading_id: threadingId,
imagineClientOptions: { orientation: orientation },
selectedAgentType: "PLANNER"
},
__relay_internal__pv__AbraArtifactsEnabledrelayprovider: true
};
const body = new URLSearchParams({
fb_api_caller_class: "RelayModern",
fb_api_req_friendly_name: "useKadabraSendMessageMutation",
variables: JSON.stringify(variables),
doc_id: VIDEO_INITIATE_DOC_ID,
...(session.lsd && { lsd: session.lsd }),
...(session.fb_dtsg && { fb_dtsg: session.fb_dtsg })
});
const response = await fetch(GRAPHQL_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": cookies,
"Origin": META_AI_BASE,
"Referer": `${META_AI_BASE}/`,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
...(session.accessToken && { "Authorization": `OAuth ${session.accessToken}` })
},
body: body.toString()
});
if (!response.ok) {
const errorText = await response.text();
console.error("[Meta Video] Initiation failed:", response.status, errorText);
throw new Error(`Meta AI Error: ${response.status}`);
}
const data = await response.json();
console.log("[Meta Video] Initiation response:", JSON.stringify(data).substring(0, 200));
// Check for errors
if (data.errors) {
throw new Error(data.errors[0]?.message || "Video initiation failed");
}
}
/**
* Poll for video result using KadabraPromptRootQuery
*/
async function pollForVideoResult(
conversationId: string,
session: MetaSession,
cookies: string
): Promise<{ url: string }[]> {
const maxAttempts = 60; // 2 minutes max
const pollInterval = 2000;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
console.log(`[Meta Video] Polling attempt ${attempt + 1}/${maxAttempts}...`);
await new Promise(resolve => setTimeout(resolve, pollInterval));
const variables = {
external_conversation_id: conversationId
};
const body = new URLSearchParams({
fb_api_caller_class: "RelayModern",
fb_api_req_friendly_name: "KadabraPromptRootQuery",
variables: JSON.stringify(variables),
doc_id: VIDEO_POLL_DOC_ID,
...(session.lsd && { lsd: session.lsd }),
...(session.fb_dtsg && { fb_dtsg: session.fb_dtsg })
});
try {
const response = await fetch(GRAPHQL_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": cookies,
"Origin": META_AI_BASE,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
...(session.accessToken && { "Authorization": `OAuth ${session.accessToken}` })
},
body: body.toString()
});
const data = await response.json();
// Extract video URLs from response
const videos = extractVideosFromResponse(data);
if (videos.length > 0) {
console.log(`[Meta Video] Got ${videos.length} video(s)!`);
return videos;
}
// Check for error status
const status = data?.data?.kadabra_prompt?.status;
if (status === "FAILED" || status === "ERROR") {
throw new Error("Meta AI video generation failed");
}
} catch (e: any) {
console.error("[Meta Video] Poll error:", e.message);
if (attempt === maxAttempts - 1) throw e;
}
}
throw new Error("Meta AI: Video generation timed out");
}
/**
* Extract video URLs from GraphQL response
*/
function extractVideosFromResponse(response: any): { url: string }[] {
const videos: { url: string }[] = [];
try {
// Navigate through possible response structures
const prompt = response?.data?.kadabra_prompt;
const messages = prompt?.messages?.edges || [];
for (const edge of messages) {
const node = edge?.node;
const attachments = node?.attachments || [];
for (const attachment of attachments) {
// Check for video media
const media = attachment?.media;
if (media?.video_uri) {
videos.push({ url: media.video_uri });
}
if (media?.playable_url) {
videos.push({ url: media.playable_url });
}
if (media?.browser_native_sd_url) {
videos.push({ url: media.browser_native_sd_url });
}
}
// Check imagine_card for video results
const imagineCard = node?.imagine_card;
if (imagineCard?.session?.media_sets) {
for (const mediaSet of imagineCard.session.media_sets) {
for (const media of mediaSet?.imagine_media || []) {
if (media?.video_uri) {
videos.push({ url: media.video_uri });
}
}
}
}
}
} catch (e) {
console.error("[Meta Video] Error extracting videos:", e);
}
return videos;
}

View file

@ -7,6 +7,10 @@ const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "kv-pix | AI Image Generator", title: "kv-pix | AI Image Generator",
description: "Generate images with Google ImageFX (Whisk)", description: "Generate images with Google ImageFX (Whisk)",
robots: {
index: false,
follow: false,
},
}; };
export default function RootLayout({ export default function RootLayout({

View file

@ -10,6 +10,9 @@ import { Settings } from "@/components/Settings";
import { PromptLibrary } from "@/components/PromptLibrary"; import { PromptLibrary } from "@/components/PromptLibrary";
import { UploadHistory } from "@/components/UploadHistory"; import { UploadHistory } from "@/components/UploadHistory";
import { CookieExpiredDialog } from "@/components/CookieExpiredDialog";
export default function Home() { export default function Home() {
const { currentView, setCurrentView, loadGallery } = useStore(); const { currentView, setCurrentView, loadGallery } = useStore();
@ -48,6 +51,9 @@ export default function Home() {
</div> </div>
</div> </div>
</main> </main>
<CookieExpiredDialog />
</div> </div>
); );
} }

View file

@ -0,0 +1,77 @@
"use client";
import React from 'react';
import { useStore } from '@/lib/store';
import { AlertTriangle, Settings, X, Cookie, ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
export function CookieExpiredDialog() {
const {
showCookieExpired,
setShowCookieExpired,
setCurrentView,
settings
} = useStore();
if (!showCookieExpired) return null;
const providerName = settings.provider === 'meta' ? 'Meta AI' : 'Google Whisk';
const providerUrl = settings.provider === 'meta' ? 'https://www.meta.ai' :
'https://labs.google/fx/tools/whisk/project';
const handleFixIssues = () => {
setShowCookieExpired(false);
setCurrentView('settings');
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
<div className="relative w-full max-w-md bg-[#18181B] border border-white/10 rounded-2xl shadow-2xl animate-in zoom-in-95 duration-200 overflow-hidden">
{/* Decorative header background */}
<div className="absolute top-0 left-0 right-0 h-32 bg-gradient-to-br from-amber-500/10 to-red-500/10 pointer-events-none" />
<div className="relative p-6 px-8 flex flex-col items-center text-center">
<button
onClick={() => setShowCookieExpired(false)}
className="absolute top-4 right-4 p-2 text-white/40 hover:text-white rounded-full hover:bg-white/5 transition-colors"
>
<X className="h-4 w-4" />
</button>
<div className="h-16 w-16 mb-6 rounded-full bg-amber-500/10 flex items-center justify-center ring-1 ring-amber-500/20 shadow-lg shadow-amber-900/20">
<Cookie className="h-8 w-8 text-amber-500" />
</div>
<h2 className="text-xl font-bold text-white mb-2">Cookies Expired</h2>
<p className="text-muted-foreground text-sm mb-6 leading-relaxed">
Your <span className="text-white font-medium">{providerName}</span> session has timed out.
To continue generating images, please refresh your cookies.
</p>
<div className="w-full space-y-3">
<button
onClick={handleFixIssues}
className="w-full py-3 px-4 bg-primary text-primary-foreground font-semibold rounded-xl hover:bg-primary/90 transition-all flex items-center justify-center gap-2 shadow-lg shadow-primary/20"
>
<Settings className="h-4 w-4" />
Update Settings
</button>
<a
href={providerUrl}
target="_blank"
rel="noopener noreferrer"
className="w-full py-3 px-4 bg-white/5 hover:bg-white/10 text-white font-medium rounded-xl transition-all flex items-center justify-center gap-2 border border-white/5"
>
<ExternalLink className="h-4 w-4" />
Open {providerName}
</a>
</div>
</div>
</div>
</div>
);
}

View file

@ -7,7 +7,7 @@ import { cn } from '@/lib/utils';
interface EditPromptModalProps { interface EditPromptModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
image: { data: string; prompt: string } | null; image: { data: string; prompt: string; provider?: string } | null;
onGenerate: (prompt: string, options: { keepSubject: boolean; keepScene: boolean; keepStyle: boolean }) => Promise<void>; onGenerate: (prompt: string, options: { keepSubject: boolean; keepScene: boolean; keepStyle: boolean }) => Promise<void>;
} }
@ -18,12 +18,16 @@ export function EditPromptModal({ isOpen, onClose, image, onGenerate }: EditProm
const [keepScene, setKeepScene] = React.useState(true); const [keepScene, setKeepScene] = React.useState(true);
const [keepStyle, setKeepStyle] = React.useState(true); const [keepStyle, setKeepStyle] = React.useState(true);
const isMeta = image?.provider === 'meta';
React.useEffect(() => { React.useEffect(() => {
if (isOpen && image) { if (isOpen && image) {
setPrompt(image.prompt); setPrompt(image.prompt);
} }
}, [isOpen, image]); }, [isOpen, image]);
// ... (lines 27-130 remain unchanged, so we skip them in replace tool if possible,
if (!isOpen) return null; if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@ -122,13 +126,14 @@ export function EditPromptModal({ isOpen, onClose, image, onGenerate }: EditProm
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
className="w-full h-24 p-3 rounded-xl bg-white/5 border border-white/10 resize-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500/50 outline-none text-sm text-white placeholder:text-white/30 transition-all" className="w-full h-24 p-3 rounded-xl bg-white/5 border border-white/10 resize-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500/50 outline-none text-sm text-white placeholder:text-white/30 transition-all"
placeholder="Describe your remix... The selected consistency options will be preserved..." placeholder={isMeta ? "Modify your prompt to generate a new variation..." : "Describe your remix... The selected consistency options will be preserved..."}
autoFocus autoFocus
/> />
</div> </div>
</div> </div>
{/* Consistency Toggles */} {/* Consistency Toggles */}
{!isMeta && (
<div className="mt-4"> <div className="mt-4">
<label className="text-xs font-medium text-white/50 mb-2 block">Keep Consistent:</label> <label className="text-xs font-medium text-white/50 mb-2 block">Keep Consistent:</label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@ -152,13 +157,16 @@ export function EditPromptModal({ isOpen, onClose, image, onGenerate }: EditProm
/> />
</div> </div>
</div> </div>
)}
{/* Info about consistency */} {/* Info about consistency */}
{!isMeta && (
<div className="mt-4 p-3 bg-white/5 rounded-xl border border-white/10"> <div className="mt-4 p-3 bg-white/5 rounded-xl border border-white/10">
<p className="text-xs text-white/50"> <p className="text-xs text-white/50">
<span className="text-amber-400">💡</span> Locked elements will be used as references to maintain visual consistency across generations. <span className="text-amber-400">💡</span> Locked elements will be used as references to maintain visual consistency across generations.
</p> </p>
</div> </div>
)}
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-3 mt-6"> <div className="flex justify-end gap-3 mt-6">

View file

@ -2,21 +2,56 @@
import React from 'react'; import React from 'react';
import { useStore } from '@/lib/store'; import { useStore } from '@/lib/store';
import { cn } from "@/lib/utils";
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Download, Maximize2, Sparkles, Trash2, X, ChevronLeft, ChevronRight, Copy, Film, Wand2 } from 'lucide-react'; import { Download, Maximize2, Sparkles, Trash2, X, ChevronLeft, ChevronRight, Copy, Film, Wand2 } from 'lucide-react';
import { VideoPromptModal } from './VideoPromptModal'; import { VideoPromptModal } from './VideoPromptModal';
import { EditPromptModal } from './EditPromptModal'; import { EditPromptModal } from './EditPromptModal';
// Helper function to get proper image src (handles URLs vs base64)
const getImageSrc = (data: string): string => {
if (!data) return '';
const cleanData = data.trim();
// If it's already a URL, use it directly
if (cleanData.indexOf('http') === 0 || cleanData.indexOf('data:') === 0) {
return cleanData;
}
// Otherwise, treat as base64 (don't warn - base64 often contains 'http' as random characters)
return `data:image/png;base64,${cleanData}`;
};
export function Gallery() { export function Gallery() {
const { gallery, clearGallery, removeFromGallery, setPrompt, addVideo, addToGallery, settings, videos, removeVideo } = useStore(); const {
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null); gallery, loadGallery, addToGallery, removeFromGallery, clearGallery,
isGenerating,
settings,
videos, addVideo, removeVideo,
setPrompt
} = useStore();
const [videoModalOpen, setVideoModalOpen] = React.useState(false); const [videoModalOpen, setVideoModalOpen] = React.useState(false);
const [videoSource, setVideoSource] = React.useState<{ data: string, prompt: string } | null>(null); const [videoSource, setVideoSource] = React.useState<{ data: string, prompt: string, provider?: string } | null>(null);
const [editModalOpen, setEditModalOpen] = React.useState(false); const [editModalOpen, setEditModalOpen] = React.useState(false);
const [editSource, setEditSource] = React.useState<{ data: string, prompt: string } | null>(null); const [editSource, setEditSource] = React.useState<{ data: string, prompt: string, provider?: 'whisk' | 'meta' } | null>(null);
const [editPromptValue, setEditPromptValue] = React.useState('');
const [videoPromptValue, setVideoPromptValue] = React.useState('');
const [useSourceImage, setUseSourceImage] = React.useState(true);
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null);
const openVideoModal = (img: { data: string, prompt: string }) => { React.useEffect(() => {
if (selectedIndex !== null && gallery[selectedIndex]) {
setEditSource(gallery[selectedIndex]);
setEditPromptValue(gallery[selectedIndex].prompt || '');
setVideoPromptValue('');
setUseSourceImage(true);
}
}, [selectedIndex, gallery]);
React.useEffect(() => {
loadGallery();
}, []); // Only load on mount
const openVideoModal = (img: { data: string, prompt: string, provider?: string }) => {
setVideoSource(img); setVideoSource(img);
setVideoModalOpen(true); setVideoModalOpen(true);
}; };
@ -26,21 +61,109 @@ export function Gallery() {
setEditModalOpen(true); setEditModalOpen(true);
}; };
const handleGenerateVideo = async (prompt: string) => { const [isGeneratingMetaVideo, setIsGeneratingMetaVideo] = React.useState(false); // Kept for UI state compatibility
if (!videoSource) return; const [isGeneratingWhiskVideo, setIsGeneratingWhiskVideo] = React.useState(false);
// Handle Meta AI video generation (text-to-video via Kadabra)
const handleGenerateMetaVideo = async (img: { data: string; prompt: string }, customPrompt?: string) => {
if (!settings.metaCookies && !settings.facebookCookies) {
alert("Please set your Meta AI (or Facebook) Cookies in Settings first!");
return;
}
setIsGeneratingMetaVideo(true);
try {
console.log("[Gallery] Starting Meta AI video generation...");
// Create a descriptive prompt that includes the original image context + animation
const originalPrompt = img.prompt || "";
const animationDescription = customPrompt || "natural movement";
// Combine original image description with animation instruction
const promptText = originalPrompt
? `Create a video of: ${originalPrompt}. Animation: ${animationDescription}`
: `Create an animated video with: ${animationDescription}`;
console.log("[Gallery] Meta video prompt:", promptText);
// Merge cookies safely
let mergedCookies = settings.metaCookies;
try {
const safeParse = (str: string) => {
if (!str || str === "undefined" || str === "null") return [];
try { return JSON.parse(str); } catch { return []; }
};
const m = safeParse(settings.metaCookies);
const f = safeParse(settings.facebookCookies);
if (Array.isArray(m) || Array.isArray(f)) {
mergedCookies = [...(Array.isArray(m) ? m : []), ...(Array.isArray(f) ? f : [])] as any;
}
} catch (e) { console.error("Cookie merge failed", e); }
const res = await fetch('/api/meta/video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: promptText,
cookies: typeof mergedCookies === 'string' ? mergedCookies : JSON.stringify(mergedCookies)
})
});
const data = await res.json();
console.log("[Gallery] Meta video response:", data);
if (data.success && data.videos?.length > 0) {
for (const video of data.videos) {
addVideo({
id: crypto.randomUUID(),
url: video.url,
prompt: promptText,
thumbnail: img.data,
createdAt: Date.now()
});
}
alert('🎬 Video generation complete! Scroll up to see your video.');
setVideoModalOpen(false);
} else {
throw new Error(data.error || 'No videos generated');
}
} catch (error: any) {
console.error("[Gallery] Meta video error:", error);
let errorMessage = error.message || 'Video generation failed';
if (errorMessage.includes('401') || errorMessage.includes('cookies') || errorMessage.includes('expired')) {
errorMessage = '🔐 Your Meta AI cookies have expired. Please go to Settings and update them.';
}
alert(errorMessage);
} finally {
setIsGeneratingMetaVideo(false);
}
};
const handleGenerateVideo = async (prompt: string, sourceOverride?: { data: string; prompt: string; provider?: string; aspectRatio?: string }) => {
const activeSource = sourceOverride || videoSource;
if (!activeSource) return;
// Route to Meta AI video for meta provider
if (activeSource.provider === 'meta') {
await handleGenerateMetaVideo(activeSource, prompt);
return;
}
if (!settings.whiskCookies) { if (!settings.whiskCookies) {
alert("Please set your Whisk Cookies in Settings first!"); alert("Please set your Whisk Cookies in Settings first!");
throw new Error("Missing Whisk cookies"); throw new Error("Missing Whisk cookies");
} }
setIsGeneratingWhiskVideo(true);
try {
const res = await fetch('/api/video/generate', { const res = await fetch('/api/video/generate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
prompt: prompt, prompt: prompt,
imageBase64: videoSource.data, imageBase64: activeSource.data,
// imageGenerationId: (videoSource as any).id, // REMOVE: "id" is a local DB ID (e.g. 1), not a Whisk Media ID.
cookies: settings.whiskCookies cookies: settings.whiskCookies
}) })
}); });
@ -53,32 +176,101 @@ export function Gallery() {
id: data.id, id: data.id,
url: data.url, url: data.url,
prompt: prompt, prompt: prompt,
thumbnail: videoSource.data, // Use source image as thumb thumbnail: activeSource.data,
createdAt: Date.now() createdAt: Date.now()
}); });
// Success notification
setTimeout(() => {
alert('🎬 Video generation complete!\n\nYour video has been saved. Go to the "Uploads" page and select the "Videos" tab to view it.'); alert('🎬 Video generation complete!\n\nYour video has been saved. Go to the "Uploads" page and select the "Videos" tab to view it.');
}, 100);
} else { } else {
console.error(data.error); console.error(data.error);
// Show user-friendly error messages for Google safety policies
let errorMessage = data.error; let errorMessage = data.error;
if (data.error?.includes('NCII')) { if (data.error?.includes('NCII')) {
errorMessage = '🚫 Content Policy: Video blocked by Google\'s NCII (Non-Consensual Intimate Imagery) protection. Please try with a different source image.'; errorMessage = '🚫 Content Policy: Video blocked by Google\'s NCII protection. Please try with a different source image.';
} else if (data.error?.includes('PROMINENT_PEOPLE') || data.error?.includes('prominent')) { } else if (data.error?.includes('PROMINENT_PEOPLE') || data.error?.includes('prominent')) {
errorMessage = '🚫 Content Policy: Video blocked because the image contains a recognizable person. Try using a different image.'; errorMessage = '🚫 Content Policy: Video blocked because the image contains a recognizable person.';
} else if (data.error?.includes('safety') || data.error?.includes('SAFETY')) { } else if (data.error?.includes('safety') || data.error?.includes('SAFETY')) {
errorMessage = '⚠️ Content Policy: Video blocked by Google\'s safety filters. Try a different source image.'; errorMessage = '⚠️ Content Policy: Video blocked by Google\'s safety filters.';
} else if (data.error?.includes('401') || data.error?.includes('UNAUTHENTICATED')) {
errorMessage = '🔐 Authentication Error: Your Whisk cookies have expired. Please update in Settings.';
} else if (data.error?.includes('429') || data.error?.includes('RESOURCE_EXHAUSTED')) {
errorMessage = '⏱️ Rate Limit: Too many requests. Please wait a few minutes and try again.';
} }
alert(errorMessage); alert(errorMessage);
throw new Error(data.error); throw new Error(data.error);
} }
} finally {
setIsGeneratingWhiskVideo(false);
}
}; };
const handleRemix = async (prompt: string, options: { keepSubject: boolean; keepScene: boolean; keepStyle: boolean }) => { const handleRemix = async (prompt: string, options: { keepSubject: boolean; keepScene: boolean; keepStyle: boolean }) => {
if (!editSource) return; if (!editSource) return;
// Meta AI Remix Flow (Prompt Edit Only)
if (editSource.provider === 'meta') {
if (!settings.metaCookies && !settings.facebookCookies) {
alert("Please set your Meta AI (or Facebook) Cookies in Settings first!");
return;
}
try {
// Merge cookies safely
let mergedCookies = settings.metaCookies;
try {
const safeParse = (str: string) => {
if (!str || str === "undefined" || str === "null") return [];
try { return JSON.parse(str); } catch { return []; }
};
const m = safeParse(settings.metaCookies);
const f = safeParse(settings.facebookCookies);
if (Array.isArray(m) || Array.isArray(f)) {
mergedCookies = [...(Array.isArray(m) ? m : []), ...(Array.isArray(f) ? f : [])] as any;
}
} catch (e) { console.error("Cookie merge failed", e); }
const res = await fetch('/api/meta/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: prompt,
cookies: typeof mergedCookies === 'string' ? mergedCookies : JSON.stringify(mergedCookies),
imageCount: 4
})
});
const data = await res.json();
if (data.error) throw new Error(data.error);
if (data.success && data.images?.length > 0) {
// Add new images to gallery
const newImages = data.images.map((img: any) => ({
id: crypto.randomUUID(),
data: img.data, // Base64
prompt: prompt,
createdAt: Date.now(),
width: 1024,
height: 1024,
aspectRatio: settings.aspectRatio,
provider: 'meta'
}));
// Add to store
newImages.forEach(addToGallery);
alert('✨ Remix complete! New images added to gallery.');
setEditModalOpen(false);
} else {
throw new Error('No images generated');
}
} catch (e: any) {
console.error("Meta Remix failed", e);
alert("Remix failed: " + e.message);
}
return;
}
// Whisk Remix Flow (Reference Injection)
if (!settings.whiskCookies) { if (!settings.whiskCookies) {
alert("Please set your Whisk Cookies in Settings first!"); alert("Please set your Whisk Cookies in Settings first!");
throw new Error("Missing Whisk cookies"); throw new Error("Missing Whisk cookies");
@ -154,9 +346,41 @@ export function Gallery() {
return null; // Or return generic empty state if controlled by parent, but parent checks length usually return null; // Or return generic empty state if controlled by parent, but parent checks length usually
} }
const handleClearAll = () => { const handleClearAll = async () => {
if (window.confirm("Delete all " + gallery.length + " images?")) { const count = gallery.length;
if (!window.confirm(`Delete all ${count} images? This will reset the gallery database.`)) return;
try {
console.log("[Gallery] Hard clearing...");
// 1. Clear Zustand Store visual state immediate
clearGallery(); clearGallery();
// 2. Nuclear Option: Delete the entire database file
console.log("[Gallery] Deleting IndexedDB...");
const req = window.indexedDB.deleteDatabase('kv-pix-db');
req.onsuccess = () => {
console.log("✅ DB Deleted successfully");
// Clear localStorage persistence too just in case
localStorage.removeItem('kv-pix-storage');
window.location.reload();
};
req.onerror = (e) => {
console.error("❌ Failed to delete DB", e);
alert("Failed to delete database. Browser might be blocking it.");
window.location.reload();
};
req.onblocked = () => {
console.warn("⚠️ DB Delete blocked - reloading to free locks");
window.location.reload();
};
} catch (e) {
console.error("[Gallery] Delete error:", e);
alert("❌ Failed to delete: " + String(e));
} }
}; };
@ -219,6 +443,19 @@ export function Gallery() {
{/* Gallery Grid */} {/* Gallery Grid */}
<div className="columns-1 sm:columns-2 md:columns-3 lg:columns-4 gap-4 space-y-4"> <div className="columns-1 sm:columns-2 md:columns-3 lg:columns-4 gap-4 space-y-4">
{/* Skeleton Loading State */}
{isGenerating && (
<>
{Array.from({ length: settings.imageCount || 4 }).map((_, i) => (
<div key={`skeleton-${i}`} className="break-inside-avoid rounded-xl overflow-hidden bg-white/5 border border-white/5 shadow-sm mb-4 relative aspect-[2/3] animate-pulse">
<div className="absolute inset-0 bg-gradient-to-t from-white/10 to-transparent" />
<div className="absolute bottom-4 left-4 right-4 h-4 bg-white/20 rounded w-3/4" />
<div className="absolute top-2 left-2 w-12 h-4 bg-white/20 rounded" />
</div>
))}
</>
)}
<AnimatePresence mode='popLayout'> <AnimatePresence mode='popLayout'>
{gallery.map((img, i) => ( {gallery.map((img, i) => (
<motion.div <motion.div
@ -231,13 +468,24 @@ export function Gallery() {
className="group relative break-inside-avoid rounded-xl overflow-hidden bg-card border shadow-sm" className="group relative break-inside-avoid rounded-xl overflow-hidden bg-card border shadow-sm"
> >
<img <img
src={"data:image/png;base64," + img.data} src={getImageSrc(img.data)}
alt={img.prompt} alt={img.prompt}
className="w-full h-auto object-cover transition-transform group-hover:scale-105 cursor-pointer" className="w-full h-auto object-cover transition-transform group-hover:scale-105 cursor-pointer"
onClick={() => setSelectedIndex(i)} onClick={() => setSelectedIndex(i)}
loading="lazy" loading="lazy"
/> />
{/* Provider Tag */}
{img.provider && (
<div className={cn(
"absolute top-2 left-2 px-2 py-0.5 rounded-md text-[10px] font-bold uppercase tracking-wider text-white shadow-sm backdrop-blur-md border border-white/10 z-10",
img.provider === 'meta' ? "bg-blue-500/80" :
"bg-amber-500/80"
)}>
{img.provider}
</div>
)}
{/* Delete button - Top right */} {/* Delete button - Top right */}
<button <button
onClick={(e) => { e.stopPropagation(); if (img.id) removeFromGallery(img.id); }} onClick={(e) => { e.stopPropagation(); if (img.id) removeFromGallery(img.id); }}
@ -248,162 +496,235 @@ export function Gallery() {
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
{/* Overlay */} {/* Hover Overlay - Simplified: just show prompt */}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-3 pointer-events-none"> <div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-3 pointer-events-none">
<p className="text-white text-xs line-clamp-2 mb-2">{img.prompt}</p> <p className="text-white text-xs line-clamp-2">{img.prompt}</p>
<div className="flex gap-2 justify-end pointer-events-auto">
<button
onClick={(e) => {
e.stopPropagation();
setPrompt(img.prompt);
navigator.clipboard.writeText(img.prompt);
// Optional: Toast feedback could go here
}}
className="p-1.5 bg-white/10 hover:bg-white/20 rounded-full text-white backdrop-blur-md transition-colors"
title="Use Prompt"
>
<Copy className="h-4 w-4" />
</button>
<a
href={"data:image/png;base64," + img.data}
download={"generated-" + i + "-" + Date.now() + ".png"}
className="p-1.5 bg-white/10 hover:bg-white/20 rounded-full text-white backdrop-blur-md transition-colors"
title="Download"
onClick={(e) => e.stopPropagation()}
>
<Download className="h-4 w-4" />
</a>
<button
onClick={(e) => {
e.stopPropagation();
openEditModal(img);
}}
className="p-2 bg-gradient-to-br from-amber-500/80 to-purple-600/80 hover:from-amber-500 hover:to-purple-600 rounded-full text-white shadow-lg shadow-purple-900/20 backdrop-blur-md transition-all hover:scale-105 border border-white/10"
title="Remix this image"
>
<Wand2 className="h-4 w-4" />
</button>
{/* Video button - only for 16:9 images */}
{img.aspectRatio === '16:9' ? (
<button
onClick={(e) => {
e.stopPropagation();
openVideoModal(img);
}}
className="p-2 bg-gradient-to-br from-blue-500/80 to-purple-600/80 hover:from-blue-500 hover:to-purple-600 rounded-full text-white shadow-lg shadow-blue-900/20 backdrop-blur-md transition-all hover:scale-105 border border-white/10"
title="Generate Video"
>
<Film className="h-4 w-4" />
</button>
) : (
<button
disabled
className="p-2 bg-gray-500/30 rounded-full text-white/30 cursor-not-allowed border border-white/5"
title="Video generation requires 16:9 images"
>
<Film className="h-4 w-4" />
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); setSelectedIndex(i); }}
className="p-1.5 bg-white/10 hover:bg-white/20 rounded-full text-white backdrop-blur-md transition-colors"
title="Maximize"
>
<Maximize2 className="h-4 w-4" />
</button>
</div>
</div> </div>
</motion.div> </motion.div>
))} ))}
</AnimatePresence> </AnimatePresence>
</div> </div>
{/* Lightbox Modal */} {/* Lightbox Modal - Split Panel Design */}
<AnimatePresence> <AnimatePresence>
{selectedIndex !== null && selectedImage && ( {selectedIndex !== null && selectedImage && (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm p-4 md:p-8" className="fixed inset-0 z-50 flex items-center justify-center bg-black/95 backdrop-blur-md p-4 md:p-6"
onClick={() => setSelectedIndex(null)} onClick={() => setSelectedIndex(null)}
> >
{/* Close Button */} {/* Close Button */}
<button <button
className="absolute top-4 right-4 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50" className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50"
onClick={() => setSelectedIndex(null)} onClick={() => setSelectedIndex(null)}
> >
<X className="h-6 w-6" /> <X className="h-5 w-5" />
</button> </button>
{/* Navigation Buttons */} {/* Navigation Buttons */}
{selectedIndex > 0 && ( {selectedIndex > 0 && (
<button <button
className="absolute left-4 top-1/2 -translate-y-1/2 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50 hidden md:block" className="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 p-2 md:p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50"
onClick={(e) => { e.stopPropagation(); setSelectedIndex(prev => prev! - 1); }} onClick={(e) => { e.stopPropagation(); setSelectedIndex(prev => prev! - 1); }}
> >
<ChevronLeft className="h-8 w-8" /> <ChevronLeft className="h-6 w-6 md:h-8 md:w-8" />
</button> </button>
)} )}
{selectedIndex < gallery.length - 1 && ( {selectedIndex < gallery.length - 1 && (
<button <button
className="absolute right-4 top-1/2 -translate-y-1/2 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50 hidden md:block" className="absolute left-[calc(50%-2rem)] md:left-[calc(50%+8rem)] top-1/2 -translate-y-1/2 p-2 md:p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50"
onClick={(e) => { e.stopPropagation(); setSelectedIndex(prev => prev! + 1); }} onClick={(e) => { e.stopPropagation(); setSelectedIndex(prev => prev! + 1); }}
> >
<ChevronRight className="h-8 w-8" /> <ChevronRight className="h-6 w-6 md:h-8 md:w-8" />
</button> </button>
)} )}
{/* Image Container */} {/* Split Panel Container */}
<motion.div <motion.div
initial={{ scale: 0.9, opacity: 0 }} initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }} exit={{ scale: 0.95, opacity: 0 }}
className="relative max-w-7xl max-h-full flex flex-col items-center" className="relative w-full max-w-6xl max-h-[90vh] flex flex-col md:flex-row gap-4 md:gap-6 overflow-hidden"
onClick={(e: React.MouseEvent) => e.stopPropagation()} onClick={(e: React.MouseEvent) => e.stopPropagation()}
> >
{/* Left: Image */}
<div className="flex-1 flex items-center justify-center min-h-0">
<img <img
src={"data:image/png;base64," + selectedImage.data} src={getImageSrc(selectedImage.data)}
alt={selectedImage.prompt} alt={selectedImage.prompt}
className="max-w-full max-h-[85vh] object-contain rounded-lg shadow-2xl" className="max-w-full max-h-[50vh] md:max-h-[85vh] object-contain rounded-xl shadow-2xl"
/> />
</div>
<div className="mt-4 flex flex-col items-center gap-2 max-w-2xl text-center"> {/* Right: Controls Panel */}
<p className="text-white/90 text-sm md:text-base font-medium line-clamp-2"> <div className="w-full md:w-80 lg:w-96 flex flex-col gap-4 bg-white/5 rounded-xl p-4 md:p-5 border border-white/10 backdrop-blur-sm max-h-[40vh] md:max-h-[85vh] overflow-y-auto">
{selectedImage.prompt} {/* Provider Badge */}
</p> {selectedImage.provider && (
<div className="flex gap-3"> <div className={cn(
<a "self-start px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider",
href={"data:image/png;base64," + selectedImage.data} selectedImage.provider === 'meta' ? "bg-blue-500/20 text-blue-300 border border-blue-500/30" :
download={"generated-" + selectedIndex + "-" + Date.now() + ".png"} "bg-amber-500/20 text-amber-300 border border-amber-500/30"
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground hover:bg-primary/90 rounded-full font-medium transition-colors" )}>
{selectedImage.provider}
</div>
)}
{/* Prompt Section (Editable) */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-xs font-medium text-white/50 uppercase tracking-wider">Prompt</h3>
{editPromptValue !== selectedImage.prompt && (
<span className="text-[10px] text-amber-400 font-medium animate-pulse">Modified</span>
)}
</div>
<textarea
value={editPromptValue}
onChange={(e) => setEditPromptValue(e.target.value)}
className="w-full h-24 bg-black/20 border border-white/10 rounded-lg p-3 text-sm text-white resize-none focus:ring-1 focus:ring-amber-500/30 outline-none placeholder:text-white/20"
placeholder="Enter prompt..."
/>
<div className="flex gap-2">
{(!selectedImage.provider || selectedImage.provider === 'whisk' || selectedImage.provider === 'meta') && (
<button
onClick={() => openEditModal({ ...selectedImage, prompt: editPromptValue })}
className="flex-1 py-2 bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-500 hover:to-orange-500 rounded-lg text-xs font-medium text-white transition-all flex items-center justify-center gap-2"
> >
<Download className="h-4 w-4" /> <Wand2 className="h-3 w-3" />
Download Current <span>Remix</span>
</a> </button>
)}
<button <button
onClick={() => { onClick={() => {
if (selectedImage) openVideoModal(selectedImage); navigator.clipboard.writeText(editPromptValue);
alert("Prompt copied!");
}} }}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full font-medium transition-colors" className={cn(
"px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors",
(!selectedImage.provider || selectedImage.provider === 'whisk') ? "" : "flex-1"
)}
title="Copy Prompt"
> >
<Film className="h-4 w-4" /> <Copy className="h-4 w-4 mx-auto" />
Generate Video
</button> </button>
</div>
</div>
{/* Divider */}
<div className="border-t border-white/10" />
{/* Video Generation Section */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-xs font-medium text-white/50 uppercase tracking-wider flex items-center gap-2">
<Film className="h-3 w-3" />
Animate
</h3>
</div>
<textarea
value={videoPromptValue}
onChange={(e) => setVideoPromptValue(e.target.value)}
placeholder="Describe movement (e.g. natural movement, zoom in)..."
className="w-full h-20 bg-black/20 border border-white/10 rounded-lg p-3 text-sm text-white resize-none focus:ring-1 focus:ring-purple-500/50 outline-none placeholder:text-white/30"
/>
{(() => {
const isGenerating = isGeneratingMetaVideo || isGeneratingWhiskVideo;
const isWhisk = !selectedImage.provider || selectedImage.provider === 'whisk';
const isMeta = selectedImage.provider === 'meta';
const is16by9 = selectedImage.aspectRatio === '16:9';
// Only Whisk with 16:9 can generate video - Meta video API not available
const canGenerate = isWhisk && is16by9;
return (
<button
onClick={() => handleGenerateVideo(videoPromptValue, selectedImage)}
disabled={isGenerating || !canGenerate}
className={cn(
"relative z-10 w-full py-2 rounded-lg text-xs font-medium text-white transition-all flex items-center justify-center gap-2",
isGenerating
? "bg-gray-600 cursor-wait"
: !canGenerate
? "bg-gray-600/50 cursor-not-allowed opacity-60"
: "bg-purple-600 hover:bg-purple-500"
)}
>
{isGenerating ? (
<>
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>Generating Video...</span>
</>
) : isMeta ? (
<>
<Film className="h-3.5 w-3.5 opacity-50" />
<span>Video coming soon</span>
</>
) : !canGenerate ? (
<>
<Film className="h-3.5 w-3.5 opacity-50" />
<span>Video requires 16:9 ratio</span>
</>
) : (
<>
<Film className="h-3.5 w-3.5" />
<span>Generate Video</span>
</>
)}
</button>
);
})()}
</div>
{/* Divider */}
<div className="border-t border-white/10" />
{/* Other Actions */}
<div className="space-y-2">
<h3 className="text-xs font-medium text-white/50 uppercase tracking-wider">Other Actions</h3>
<div className="grid grid-cols-2 gap-2">
<a
href={getImageSrc(selectedImage.data)}
download={"generated-" + selectedIndex + "-" + Date.now() + ".png"}
className="flex items-center justify-center gap-2 px-3 py-2 bg-white/5 hover:bg-white/10 rounded-lg text-white/80 text-xs font-medium transition-colors"
>
<Download className="h-3.5 w-3.5" />
<span>Download</span>
</a>
<button <button
onClick={() => { onClick={() => {
setPrompt(selectedImage.prompt); setPrompt(selectedImage.prompt);
navigator.clipboard.writeText(selectedImage.prompt); setSelectedIndex(null);
setSelectedIndex(null); // Close lightbox? Or keep open? User said "reuse", likely wants to edit.
// Let's close it so they can see the input updating.
}} }}
className="flex items-center gap-2 px-4 py-2 bg-secondary text-secondary-foreground hover:bg-secondary/80 rounded-full font-medium transition-colors" className="flex items-center justify-center gap-2 px-3 py-2 bg-white/5 hover:bg-white/10 rounded-lg text-white/80 text-xs font-medium transition-colors"
> >
<Copy className="h-4 w-4" /> <Sparkles className="h-3.5 w-3.5" />
Use Prompt <span>Use Prompt</span>
</button> </button>
</div> </div>
<button
onClick={() => {
if (selectedImage.id) {
removeFromGallery(selectedImage.id);
setSelectedIndex(null);
}
}}
className="flex items-center justify-center gap-2 w-full px-3 py-2 bg-red-500/10 hover:bg-red-500/20 rounded-lg text-red-400 text-xs font-medium transition-colors border border-red-500/20"
>
<Trash2 className="h-3.5 w-3.5" />
<span>Delete Image</span>
</button>
</div>
{/* Image Info */}
<div className="mt-auto pt-3 border-t border-white/10 text-xs text-white/40 space-y-1">
{selectedImage.aspectRatio && (
<p>Aspect Ratio: {selectedImage.aspectRatio}</p>
)}
<p>Image {selectedIndex + 1} of {gallery.length}</p>
</div>
</div> </div>
</motion.div> </motion.div>
</motion.div> </motion.div>

View file

@ -12,10 +12,11 @@ export function Navbar() {
const navItems = [ const navItems = [
{ id: 'gallery', label: 'Create', icon: Sparkles }, { id: 'gallery', label: 'Create', icon: Sparkles },
{ id: 'library', label: 'Prompt Library', icon: LayoutGrid }, { id: 'library', label: 'Prompt Library', icon: LayoutGrid },
{ id: 'history', label: 'Uploads', icon: Clock }, // CORRECTED: id should match store ViewType 'history' not 'uploads' { id: 'history', label: 'Uploads', icon: Clock },
]; ];
return ( return (
<>
<div className="fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-xl border-b border-border"> <div className="fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-xl border-b border-border">
{/* Yellow Accent Line */} {/* Yellow Accent Line */}
<div className="h-1 w-full bg-primary" /> <div className="h-1 w-full bg-primary" />
@ -29,7 +30,7 @@ export function Navbar() {
<span className="text-xl font-bold text-foreground tracking-tight">kv-pix</span> <span className="text-xl font-bold text-foreground tracking-tight">kv-pix</span>
</div> </div>
{/* Center Navigation */} {/* Center Navigation (Desktop) */}
<div className="hidden md:flex items-center gap-1 bg-secondary/50 p-1 rounded-full border border-border/50"> <div className="hidden md:flex items-center gap-1 bg-secondary/50 p-1 rounded-full border border-border/50">
{navItems.map((item) => ( {navItems.map((item) => (
<button <button
@ -74,5 +75,53 @@ export function Navbar() {
</div> </div>
</div> </div>
</div> </div>
{/* Mobile Bottom Navigation */}
<div className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-[#18181B]/90 backdrop-blur-xl border-t border-white/10 safe-area-bottom">
<div className="flex items-center justify-around h-16 px-2">
{navItems.map((item) => (
<button
key={item.id}
onClick={() => {
setCurrentView(item.id as any);
if (item.id === 'history') setSelectionMode(null);
}}
className={cn(
"flex flex-col items-center justify-center gap-1 p-2 rounded-xl transition-all w-16",
currentView === item.id
? "text-primary"
: "text-white/40 hover:text-white/80"
)}
>
<div className={cn(
"p-1.5 rounded-full transition-all",
currentView === item.id ? "bg-primary/10" : "bg-transparent"
)}>
<item.icon className="h-5 w-5" />
</div>
<span className="text-[10px] font-medium">{item.label}</span>
</button>
))}
{/* Settings Item for Mobile */}
<button
onClick={() => setCurrentView('settings')}
className={cn(
"flex flex-col items-center justify-center gap-1 p-2 rounded-xl transition-all w-16",
currentView === 'settings'
? "text-primary"
: "text-white/40 hover:text-white/80"
)}
>
<div className={cn(
"p-1.5 rounded-full transition-all",
currentView === 'settings' ? "bg-primary/10" : "bg-transparent"
)}>
<Settings className="h-5 w-5" />
</div>
<span className="text-[10px] font-medium">Settings</span>
</button>
</div>
</div>
</>
); );
} }

View file

@ -3,7 +3,7 @@
import React, { useRef, useState, useEffect } from "react"; import React, { useRef, useState, useEffect } from "react";
import { useStore, ReferenceCategory } from "@/lib/store"; import { useStore, ReferenceCategory } from "@/lib/store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Sparkles, Image as ImageIcon, X, Hash, AlertTriangle, Upload, Zap, Brain } from "lucide-react"; import { Sparkles, Maximize2, X, Hash, AlertTriangle, Upload, Brain, Settings, Settings2 } from "lucide-react";
const IMAGE_COUNTS = [1, 2, 4]; const IMAGE_COUNTS = [1, 2, 4];
@ -13,14 +13,47 @@ export function PromptHero() {
settings, setSettings, settings, setSettings,
references, setReference, addReference, removeReference, clearReferences, references, setReference, addReference, removeReference, clearReferences,
setSelectionMode, setCurrentView, setSelectionMode, setCurrentView,
history, setHistory history, setHistory,
setIsGenerating, // Get global setter
setShowCookieExpired
} = useStore(); } = useStore();
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setLocalIsGenerating] = useState(false);
const { addVideo } = useStore();
const [uploadingRefs, setUploadingRefs] = useState<Record<string, boolean>>({}); const [uploadingRefs, setUploadingRefs] = useState<Record<string, boolean>>({});
const [errorNotification, setErrorNotification] = useState<{ message: string; type: 'error' | 'warning' } | null>(null); const [errorNotification, setErrorNotification] = useState<{ message: string; type: 'error' | 'warning' } | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
// CLEANUP: Remove corrupted localStorage keys that crash browser extensions
useEffect(() => {
try {
if (typeof window !== 'undefined') {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key) {
const val = localStorage.getItem(key);
// Clean up "undefined" string values which cause JSON.parse errors in extensions
if (val === "undefined" || val === "null") {
console.warn(`[Cleanup] Removing corrupted localStorage key: ${key}`);
localStorage.removeItem(key);
}
}
}
}
} catch (e) {
console.error("Storage cleanup failed", e);
}
}, []);
// Auto-enable Precise mode when references are added
useEffect(() => {
const hasReferences = Object.values(references).some(refs => refs && refs.length > 0);
if (hasReferences && !settings.preciseMode) {
setSettings({ preciseMode: true });
}
}, [references, settings.preciseMode, setSettings]);
// File input refs for each reference category // File input refs for each reference category
const fileInputRefs = { const fileInputRefs = {
subject: useRef<HTMLInputElement>(null), subject: useRef<HTMLInputElement>(null),
@ -55,33 +88,53 @@ export function PromptHero() {
} }
setIsGenerating(true); setIsGenerating(true);
setLocalIsGenerating(true); // Keep local state for button UI
try { try {
// Route to the selected provider // Route to the selected provider
const provider = settings.provider || 'whisk'; const provider = settings.provider || 'whisk';
let res: Response; let res: Response;
if (provider === 'grok') { if (provider === 'meta') {
// Grok API // Image Generation Path (Meta AI)
res = await fetch('/api/grok/generate', { // Video is now handled by handleGenerateVideo
method: 'POST',
headers: { 'Content-Type': 'application/json' }, // Prepend aspect ratio for better adherence
body: JSON.stringify({ let metaPrompt = finalPrompt;
prompt: finalPrompt, if (settings.aspectRatio === '16:9') {
apiKey: settings.grokApiKey, metaPrompt = "wide 16:9 landscape image of " + finalPrompt;
cookies: settings.grokCookies, } else if (settings.aspectRatio === '9:16') {
imageCount: settings.imageCount metaPrompt = "tall 9:16 portrait image of " + finalPrompt;
}) }
});
} else if (provider === 'meta') { // Merge cookies safely
// Meta AI let mergedCookies: string | any[] = settings.metaCookies;
try {
const safeParse = (str: string) => {
if (!str || str === "undefined" || str === "null") return [];
try { return JSON.parse(str); } catch { return []; }
};
const m = safeParse(settings.metaCookies);
const f = safeParse(settings.facebookCookies);
if (Array.isArray(m) || Array.isArray(f)) {
mergedCookies = [...(Array.isArray(m) ? m : []), ...(Array.isArray(f) ? f : [])];
}
} catch (e) { console.error("Cookie merge failed", e); }
// Meta AI always generates 4 images, hardcode this
// Extract subject reference if available (for Image-to-Image)
const subjectRef = references.subject?.[0];
const imageUrl = subjectRef ? subjectRef.thumbnail : undefined; // Use full data URI from thumbnail property
res = await fetch('/api/meta/generate', { res = await fetch('/api/meta/generate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
prompt: finalPrompt, prompt: metaPrompt,
cookies: settings.metaCookies, cookies: typeof mergedCookies === 'string' ? mergedCookies : JSON.stringify(mergedCookies),
imageCount: settings.imageCount imageCount: 4, // Meta AI always returns 4 images
useMetaFreeWrapper: settings.useMetaFreeWrapper,
metaFreeWrapperUrl: settings.metaFreeWrapperUrl
}) })
}); });
} else { } else {
@ -121,10 +174,11 @@ export function PromptHero() {
// Add images one by one with createdAt // Add images one by one with createdAt
for (const img of data.images) { for (const img of data.images) {
await addToGallery({ await addToGallery({
data: img.data, data: img.data || img.url, // Use URL as fallback (Meta AI returns URLs)
prompt: img.prompt, prompt: finalPrompt, // Use original user prompt to avoid showing engineered prompts
aspectRatio: img.aspectRatio || settings.aspectRatio, aspectRatio: img.aspectRatio || settings.aspectRatio,
createdAt: Date.now() createdAt: Date.now(),
provider: provider as 'whisk' | 'meta'
}); });
} }
} }
@ -140,6 +194,12 @@ export function PromptHero() {
message: '🚫 Content Policy: The reference image contains a recognizable person. Google blocks generating images of real/famous people. Try using a different reference image without identifiable faces.', message: '🚫 Content Policy: The reference image contains a recognizable person. Google blocks generating images of real/famous people. Try using a different reference image without identifiable faces.',
type: 'warning' type: 'warning'
}); });
} else if (errorMessage.includes("Oops! I can't generate that image") ||
errorMessage.includes("Can I help you imagine something else")) {
setErrorNotification({
message: '🛡️ Meta AI Safety: The prompt was rejected by Meta AI safety filters. Please try a different prompt.',
type: 'warning'
});
} else if (errorMessage.includes('Safety Filter') || } else if (errorMessage.includes('Safety Filter') ||
errorMessage.includes('SAFETY_FILTER') || errorMessage.includes('SAFETY_FILTER') ||
errorMessage.includes('content_policy')) { errorMessage.includes('content_policy')) {
@ -168,9 +228,15 @@ export function PromptHero() {
}); });
} else if (errorMessage.includes('401') || } else if (errorMessage.includes('401') ||
errorMessage.includes('Unauthorized') || errorMessage.includes('Unauthorized') ||
errorMessage.includes('cookies not found')) { errorMessage.includes('cookies not found') ||
errorMessage.includes('Auth failed')) {
// Trigger the new popup
setShowCookieExpired(true);
// Also show a simplified toast as backup
setErrorNotification({ setErrorNotification({
message: '🔐 Authentication Error: Your Whisk cookies may have expired. Please update them in Settings.', message: '🔐 Authentication Error: Cookies Refreshed Required',
type: 'error' type: 'error'
}); });
} else { } else {
@ -183,9 +249,12 @@ export function PromptHero() {
setTimeout(() => setErrorNotification(null), 8000); setTimeout(() => setErrorNotification(null), 8000);
} finally { } finally {
setIsGenerating(false); setIsGenerating(false);
setLocalIsGenerating(false);
} }
}; };
// Note: Meta AI Video generation was removed - use Whisk for video generation from the gallery lightbox
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault(); e.preventDefault();
@ -227,17 +296,27 @@ export function PromptHero() {
}; };
const uploadReference = async (file: File, category: ReferenceCategory) => { const uploadReference = async (file: File, category: ReferenceCategory) => {
if (!settings.whiskCookies) { // Enforce Whisk cookies ONLY if using Whisk provider
if ((!settings.provider || settings.provider === 'whisk') && !settings.whiskCookies) {
alert("Please set your Whisk Cookies in Settings first!"); alert("Please set your Whisk Cookies in Settings first!");
return; return;
} }
setUploadingRefs(prev => ({ ...prev, [category]: true })); setUploadingRefs(prev => ({ ...prev, [category]: true }));
try { try {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async (e) => { reader.onload = async (e) => {
const base64 = e.target?.result as string; const base64 = e.target?.result as string;
if (!base64) return; if (!base64) {
setUploadingRefs(prev => ({ ...prev, [category]: false }));
return;
}
let refId = '';
// If Whisk, upload to backend to get ID
if (!settings.provider || settings.provider === 'whisk') {
try {
const res = await fetch('/api/references/upload', { const res = await fetch('/api/references/upload', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -248,29 +327,40 @@ export function PromptHero() {
cookies: settings.whiskCookies cookies: settings.whiskCookies
}) })
}); });
const data = await res.json(); const data = await res.json();
if (data.id) { if (data.id) {
// Add to array (supports multiple refs per category) refId = data.id;
addReference(category, { id: data.id, thumbnail: base64 });
// Add to history
const newItem = {
id: data.id,
url: base64, // For local display history we use base64. Ideally we'd valid URL but this works for session.
category: category,
originalName: file.name
};
// exist check?
const exists = history.find(h => h.id === data.id);
if (!exists) {
setHistory([newItem, ...history]);
}
} else { } else {
console.error("Upload failed details:", JSON.stringify(data)); console.error("Upload failed details:", JSON.stringify(data));
alert(`Upload failed: ${data.error}\n\nDetails: ${JSON.stringify(data) || 'Check console'}`); alert(`Upload failed: ${data.error}\n\nDetails: ${JSON.stringify(data) || 'Check console'}`);
} }
} catch (err) {
console.error("API Upload Error", err);
alert("API Upload failed");
}
} else {
// For Meta/Grok, just use local generated ID
refId = 'loc-' + Date.now() + Math.random().toString(36).substr(2, 5);
}
if (refId) {
// Add to array (supports multiple refs per category)
// Note: Store uses 'thumbnail' property for the image data
addReference(category, { id: refId, thumbnail: base64 });
// Add to history
const newItem = {
id: refId,
url: base64,
category: category,
originalName: file.name
};
const exists = history.find(h => h.id === refId);
if (!exists) {
setHistory([newItem, ...history].slice(0, 50));
}
}
setUploadingRefs(prev => ({ ...prev, [category]: false })); setUploadingRefs(prev => ({ ...prev, [category]: false }));
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
@ -344,11 +434,11 @@ export function PromptHero() {
); );
return ( return (
<div className="w-full max-w-4xl mx-auto my-8 md:my-12 px-4"> <div className="w-full max-w-3xl mx-auto my-4 md:my-6 px-4">
{/* Error/Warning Notification Toast */} {/* Error/Warning Notification Toast */}
{errorNotification && ( {errorNotification && (
<div className={cn( <div className={cn(
"mb-4 p-4 rounded-xl border flex items-start gap-3 animate-in slide-in-from-top-4 duration-300", "mb-4 p-3 rounded-lg border flex items-start gap-3 animate-in slide-in-from-top-4 duration-300",
errorNotification.type === 'warning' errorNotification.type === 'warning'
? "bg-amber-500/10 border-amber-500/30 text-amber-200" ? "bg-amber-500/10 border-amber-500/30 text-amber-200"
: "bg-red-500/10 border-red-500/30 text-red-200" : "bg-red-500/10 border-red-500/30 text-red-200"
@ -373,79 +463,62 @@ export function PromptHero() {
)} )}
<div className={cn( <div className={cn(
"relative flex flex-col gap-4 rounded-3xl bg-[#1A1A1E]/90 bg-gradient-to-b from-white/[0.02] to-transparent p-6 shadow-2xl border border-white/5 backdrop-blur-sm transition-all", "relative flex flex-col gap-3 rounded-2xl bg-[#1A1A1E]/95 bg-gradient-to-b from-white/[0.02] to-transparent p-4 shadow-xl border border-white/5 backdrop-blur-sm transition-all",
isGenerating && "ring-1 ring-purple-500/30" isGenerating && "ring-1 ring-purple-500/30"
)}> )}>
{/* Header / Title + Provider Toggle */} {/* Header / Title + Provider Toggle */}
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-2xl bg-gradient-to-br from-amber-500/20 to-purple-600/20 border border-white/5 flex items-center justify-center"> <div className="h-8 w-8 rounded-lg bg-gradient-to-br from-amber-500/20 to-purple-600/20 border border-white/5 flex items-center justify-center">
{settings.provider === 'grok' ? ( {settings.provider === 'meta' ? (
<Zap className="h-6 w-6 text-yellow-400" /> <Brain className="h-4 w-4 text-blue-400" />
) : settings.provider === 'meta' ? (
<Brain className="h-6 w-6 text-blue-400" />
) : ( ) : (
<Sparkles className="h-6 w-6 text-amber-300" /> <Sparkles className="h-4 w-4 text-amber-300" />
)} )}
</div> </div>
<div> <div>
<h2 className="text-xl font-bold text-white tracking-tight">Create & Remix</h2> <h2 className="text-base font-bold text-white tracking-tight flex items-center gap-2">
<p className="text-xs text-white/50 font-medium"> Create
Powered by <span className={cn( <span className="text-[10px] font-medium text-white/40 border-l border-white/10 pl-2">
settings.provider === 'grok' ? "text-yellow-400" : by <span className={cn(
settings.provider === 'meta' ? "text-blue-400" : settings.provider === 'meta' ? "text-blue-400" :
"text-amber-300" "text-amber-300"
)}> )}>
{settings.provider === 'grok' ? 'Grok (xAI)' : {settings.provider === 'meta' ? 'Meta AI' :
settings.provider === 'meta' ? 'Meta AI' : 'Whisk'}
'Google Whisk'}
</span> </span>
</p> </span>
</h2>
</div> </div>
</div> </div>
{/* Provider Toggle */} {/* Provider Toggle */}
<div className="flex bg-black/40 p-1 rounded-xl border border-white/10 backdrop-blur-md"> <div className="flex bg-black/40 p-0.5 rounded-lg border border-white/10 backdrop-blur-md scale-90 origin-right">
<button <button
onClick={() => setSettings({ provider: 'whisk' })} onClick={() => setSettings({ provider: 'whisk' })}
className={cn( className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all", "flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[10px] font-medium transition-all",
settings.provider === 'whisk' || !settings.provider settings.provider === 'whisk' || !settings.provider
? "bg-white/10 text-white shadow-sm" ? "bg-white/10 text-white shadow-sm"
: "text-white/40 hover:text-white/70 hover:bg-white/5" : "text-white/40 hover:text-white/70 hover:bg-white/5"
)} )}
title="Google Whisk" title="Google Whisk"
> >
<Sparkles className="h-3.5 w-3.5" /> <Sparkles className="h-3 w-3" />
<span className="hidden sm:inline">Whisk</span> <span className="hidden sm:inline">Whisk</span>
</button> </button>
<button
onClick={() => setSettings({ provider: 'grok' })}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all",
settings.provider === 'grok'
? "bg-white/10 text-white shadow-sm"
: "text-white/40 hover:text-white/70 hover:bg-white/5"
)}
title="Grok (xAI)"
>
<Zap className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Grok</span>
</button>
<button <button
onClick={() => setSettings({ provider: 'meta' })} onClick={() => setSettings({ provider: 'meta' })}
className={cn( className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all", "flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[10px] font-medium transition-all",
settings.provider === 'meta' settings.provider === 'meta'
? "bg-white/10 text-white shadow-sm" ? "bg-white/10 text-white shadow-sm"
: "text-white/40 hover:text-white/70 hover:bg-white/5" : "text-white/40 hover:text-white/70 hover:bg-white/5"
)} )}
title="Meta AI" title="Meta AI"
> >
<Brain className="h-3.5 w-3.5" /> <Brain className="h-3 w-3" />
<span className="hidden sm:inline">Meta</span> <span className="hidden sm:inline">Meta</span>
</button> </button>
</div> </div>
@ -453,35 +526,42 @@ export function PromptHero() {
{/* Input Area */} {/* Input Area */}
<div className="relative group"> <div className="relative group">
<div className="absolute -inset-0.5 bg-gradient-to-r from-amber-500/20 to-purple-600/20 rounded-2xl blur opacity-0 group-hover:opacity-100 transition duration-500" /> <div className="absolute -inset-0.5 bg-gradient-to-r from-amber-500/20 to-purple-600/20 rounded-xl blur opacity-0 group-hover:opacity-100 transition duration-500" />
<textarea <textarea
ref={textareaRef} ref={textareaRef}
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste} onPaste={handlePaste}
placeholder="Describe your imagination... (e.g. 'A futuristic city with flying cars')" placeholder="Describe your imagination..."
className="relative w-full resize-none bg-[#0E0E10] rounded-xl p-5 text-base md:text-lg text-white placeholder:text-white/20 outline-none min-h-[120px] border border-white/10 focus:border-purple-500/50 transition-all shadow-inner" className="relative w-full resize-none bg-[#0E0E10] rounded-lg p-3 text-sm md:text-base text-white placeholder:text-white/20 outline-none min-h-[60px] border border-white/10 focus:border-purple-500/50 transition-all shadow-inner"
/> />
</div> </div>
{/* Controls Area */} {/* Controls Area */}
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-6 pt-2"> <div className="flex flex-col md:flex-row items-center justify-between gap-3 pt-1">
{/* Left Controls: References */} {/* Left Controls: References */}
{/* For Meta AI: Only Subject is enabled (for video generation), Scene/Style disabled */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{(['subject', 'scene', 'style'] as ReferenceCategory[]).map((cat) => { {((settings.provider === 'meta'
? ['subject']
: ['subject', 'scene', 'style']) as ReferenceCategory[]).map((cat) => {
const refs = references[cat] || []; const refs = references[cat] || [];
const hasRefs = refs.length > 0; const hasRefs = refs.length > 0;
const isUploading = uploadingRefs[cat]; const isUploading = uploadingRefs[cat];
return ( return (
<div key={cat} className="relative group"> <div key={cat} className="relative group">
<button <button
onClick={() => toggleReference(cat)} onClick={() => toggleReference(cat)}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, cat)} onDrop={(e) => handleDrop(e, cat)}
title={settings.provider === 'meta' && cat === 'subject'
? "Upload image to animate into video"
: undefined}
className={cn( className={cn(
"flex items-center gap-2 rounded-full px-4 py-2 text-xs font-medium transition-all border relative overflow-hidden", "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[10px] font-medium transition-all border relative overflow-hidden",
hasRefs hasRefs
? "bg-purple-500/10 text-purple-200 border-purple-500/30 hover:bg-purple-500/20" ? "bg-purple-500/10 text-purple-200 border-purple-500/30 hover:bg-purple-500/20"
: "bg-white/5 text-white/40 border-white/5 hover:bg-white/10 hover:text-white/70 hover:border-white/10", : "bg-white/5 text-white/40 border-white/5 hover:bg-white/10 hover:text-white/70 hover:border-white/10",
@ -489,40 +569,35 @@ export function PromptHero() {
)} )}
> >
{isUploading ? ( {isUploading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" /> <div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : hasRefs ? ( ) : hasRefs ? (
<div className="flex -space-x-2"> <div className="flex -space-x-1.5">
{refs.slice(0, 4).map((ref, idx) => ( {refs.slice(0, 4).map((ref, idx) => (
<img <img
key={ref.id} key={ref.id}
src={ref.thumbnail} src={ref.thumbnail}
alt="" alt=""
className="h-5 w-5 rounded-sm object-cover ring-1 ring-white/20" className="h-4 w-4 rounded-sm object-cover ring-1 ring-white/20"
style={{ zIndex: 10 - idx }} style={{ zIndex: 10 - idx }}
/> />
))} ))}
{refs.length > 4 && (
<div className="h-5 w-5 rounded-sm bg-purple-500/50 flex items-center justify-center text-[9px] font-bold ring-1 ring-white/20">
+{refs.length - 4}
</div>
)}
</div> </div>
) : ( ) : (
<Upload className="h-4 w-4" /> <Upload className="h-3 w-3" />
)} )}
<span className="capitalize tracking-wide">{cat}</span> <span className="capitalize tracking-wide">{cat}</span>
{refs.length > 0 && ( {refs.length > 0 && (
<span className="text-[10px] bg-purple-500/30 text-purple-100 rounded-full px-1.5 h-4 flex items-center">{refs.length}</span> <span className="text-[9px] bg-purple-500/30 text-purple-100 rounded-full px-1.5 h-3 flex items-center">{refs.length}</span>
)} )}
</button> </button>
{/* Clear all button */} {/* Clear all button */}
{hasRefs && !isUploading && ( {hasRefs && !isUploading && (
<button <button
className="absolute -top-1 -right-1 p-1 rounded-full bg-red-500/80 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500" className="absolute -top-1 -right-1 p-0.5 rounded-full bg-red-500/80 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500"
onClick={(e) => { e.stopPropagation(); clearReferences(cat); }} onClick={(e) => { e.stopPropagation(); clearReferences(cat); }}
title={`Clear all ${cat} references`} title={`Clear all ${cat} references`}
> >
<X className="h-2.5 w-2.5" /> <X className="h-2 w-2" />
</button> </button>
)} )}
</div> </div>
@ -556,68 +631,83 @@ export function PromptHero() {
onChange={(e) => handleFileInputChange(e, 'style')} onChange={(e) => handleFileInputChange(e, 'style')}
/> />
{/* Right Controls: Settings & Generate */} {/* Right Controls: Settings & Generate */}
<div className="flex flex-wrap items-center gap-3 w-full md:w-auto justify-end"> <div className="flex flex-wrap items-center gap-2 w-full md:w-auto justify-end">
{/* Settings Group */} {/* Settings Group */}
<div className="flex items-center gap-1 bg-[#0E0E10] p-1.5 rounded-full border border-white/10"> <div className="flex items-center gap-0.5 bg-[#0E0E10] p-1 rounded-lg border border-white/10">
{/* Image Count */} {/* Image Count */}
<button <button
onClick={cycleImageCount} onClick={settings.provider === 'meta' ? undefined : cycleImageCount}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium text-white/60 hover:text-white hover:bg-white/5 transition-colors" className={cn(
title="Number of images" "flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-medium transition-colors",
settings.provider === 'meta'
? "text-blue-200/50 cursor-not-allowed"
: "text-white/60 hover:text-white hover:bg-white/5"
)}
title={settings.provider === 'meta' ? "Meta AI always generates 4 images" : "Number of images"}
> >
<Hash className="h-3.5 w-3.5 opacity-70" /> <Hash className="h-3 w-3 opacity-70" />
<span>{settings.imageCount}</span> <span>{settings.provider === 'meta' ? 4 : settings.imageCount}</span>
</button> </button>
<div className="w-px h-3 bg-white/10" /> <div className="w-px h-3 bg-white/10 mx-1" />
{/* Aspect Ratio */} {/* Aspect Ratio */}
<button <button
onClick={nextAspectRatio} onClick={nextAspectRatio}
className="px-3 py-1.5 rounded-full text-xs font-medium text-white/60 hover:text-white hover:bg-white/5 transition-colors" className="px-2 py-1 rounded-md text-[10px] font-medium text-white/60 hover:text-white hover:bg-white/5 transition-colors"
title="Aspect Ratio" title="Aspect Ratio"
> >
<span className="opacity-70">Ratio:</span> <span className="opacity-70">Ratio:</span>
<span className="ml-1 text-white/80">{settings.aspectRatio}</span> <span className="ml-1 text-white/80">{settings.aspectRatio}</span>
</button> </button>
<div className="w-px h-3 bg-white/10" /> <div className="w-px h-3 bg-white/10 mx-1" />
{/* Precise Mode */} {/* Precise Mode */}
<button <button
onClick={() => setSettings({ preciseMode: !settings.preciseMode })} onClick={() => setSettings({ preciseMode: !settings.preciseMode })}
className={cn( className={cn(
"px-3 py-1.5 rounded-full text-xs font-medium transition-all flex items-center gap-1.5", "px-2 py-1 rounded-md text-[10px] font-medium transition-all flex items-center gap-1",
settings.preciseMode settings.preciseMode
? "text-amber-300 bg-amber-500/10 ring-1 ring-amber-500/30" ? "text-amber-300 bg-amber-500/10 ring-1 ring-amber-500/30"
: "text-white/40 hover:text-white hover:bg-white/5" : "text-white/40 hover:text-white hover:bg-white/5"
)} )}
title="Precise Mode: Uses images directly as visual reference" title="Precise Mode"
> >
<span>🍌</span> {/* <span>🍌</span> */}
<span>Precise</span> <span>Precise</span>
</button> </button>
</div> </div>
{/* Generate Button */} {/* Generate Button */}
<GradientButton <button
onClick={handleGenerate} onClick={handleGenerate}
disabled={isGenerating || !prompt.trim()} disabled={isGenerating || !prompt.trim()}
className={cn(
"relative overflow-hidden px-4 py-1.5 rounded-lg font-bold text-sm text-white shadow-lg transition-all active:scale-95 group border border-white/10",
"bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 hover:shadow-indigo-500/25"
)}
> >
<div className="relative z-10 flex items-center gap-1.5">
{isGenerating ? ( {isGenerating ? (
<> <>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" /> <div className="h-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>Creating...</span> <span className="animate-pulse">Dreaming...</span>
</> </>
) : ( ) : (
<> <>
<Sparkles className="h-4 w-4" /> <Sparkles className="h-3 w-3 group-hover:rotate-12 transition-transform" />
<span>Create</span> <span>Generate</span>
</> </>
)} )}
</GradientButton> </div>
</button>
</div> </div>
</div> </div>

View file

@ -164,8 +164,24 @@ export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => voi
return filteredPrompts; return filteredPrompts;
}; };
// Pagination State
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(24);
// Reset pagination when filters change
useEffect(() => {
setCurrentPage(1);
}, [searchTerm, selectedCategory, selectedSource, sortMode]);
const finalPrompts = displayPrompts(); const finalPrompts = displayPrompts();
// Pagination Logic
const totalPages = Math.ceil(finalPrompts.length / itemsPerPage);
const paginatedPrompts = finalPrompts.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
const uniqueCategories = ['All', ...Array.from(new Set(prompts.map(p => p.category)))].filter(Boolean); const uniqueCategories = ['All', ...Array.from(new Set(prompts.map(p => p.category)))].filter(Boolean);
const uniqueSources = ['All', ...Array.from(new Set(prompts.map(p => p.source)))].filter(Boolean); const uniqueSources = ['All', ...Array.from(new Set(prompts.map(p => p.source)))].filter(Boolean);
@ -237,23 +253,42 @@ export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => voi
))} ))}
</div> </div>
{/* Sub-Categories (only show if NOT history/foryou to keep clean? Or keep it?) */} {/* Sub-Categories */}
{sortMode === 'all' && ( {sortMode === 'all' && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2 py-4 overflow-x-auto scrollbar-hide">
{uniqueCategories.map(cat => ( {(() => {
const priority = ['NAM', 'NỮ', 'SINH NHẬT', 'HALLOWEEN', 'NOEL', 'NEW YEAR', 'TRẺ EM', 'COUPLE', 'CHA - MẸ', 'MẸ BẦU', 'ĐẶC BIỆT'];
// Sort uniqueCategories (which only contains categories that exist in data)
const sortedCategories = uniqueCategories.sort((a, b) => {
if (a === 'All') return -1;
if (b === 'All') return 1;
const idxA = priority.indexOf(a);
const idxB = priority.indexOf(b);
if (idxA !== -1 && idxB !== -1) return idxA - idxB;
if (idxA !== -1) return -1;
if (idxB !== -1) return 1;
return a.localeCompare(b);
});
return sortedCategories.map(cat => (
<button <button
key={cat} key={cat}
onClick={() => setSelectedCategory(cat)} onClick={() => setSelectedCategory(cat)}
className={cn( className={cn(
"px-4 py-2 rounded-full text-sm font-medium transition-colors", "px-4 py-2 text-sm font-bold uppercase tracking-wider transition-all duration-200 rounded-md whitespace-nowrap",
selectedCategory === cat selectedCategory === cat
? "bg-primary text-primary-foreground" ? "bg-[#8B1E1E] text-white border border-white/80 shadow-[0_0_12px_rgba(139,30,30,0.6)]" // Active: Deep Red + Glow
: "bg-card hover:bg-secondary text-muted-foreground" : "text-gray-400 hover:text-yellow-400 border border-transparent hover:bg-white/5" // Inactive: Yellow Hover
)} )}
> >
{cat} {cat}
</button> </button>
))} ));
})()}
</div> </div>
)} )}
@ -276,14 +311,38 @@ export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => voi
))} ))}
</div> </div>
{/* Top Pagination Controls */}
{!loading && totalPages > 1 && (
<div className="flex items-center justify-end gap-2 py-2">
<span className="text-sm font-medium text-muted-foreground mr-2">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-3 py-1 rounded-lg bg-secondary text-sm font-medium disabled:opacity-50 hover:bg-secondary/80 transition-colors"
>
Prev
</button>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-3 py-1 rounded-lg bg-secondary text-sm font-medium disabled:opacity-50 hover:bg-secondary/80 transition-colors"
>
Next
</button>
</div>
)}
{loading && !prompts.length ? ( {loading && !prompts.length ? (
<div className="flex justify-center py-20"> <div className="flex justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <Loader2 className="h-8 w-8 animate-spin text-primary" />
</div> </div>
) : ( ) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<AnimatePresence mode="popLayout"> <AnimatePresence mode="popLayout">
{finalPrompts.map((p) => ( {paginatedPrompts.map((p) => (
<motion.div <motion.div
key={p.id} key={p.id}
layout layout
@ -339,6 +398,50 @@ export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => voi
))} ))}
</AnimatePresence> </AnimatePresence>
</div> </div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 py-8 border-t mt-8">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Show:</span>
{[24, 48, 96].map(size => (
<button
key={size}
onClick={() => { setItemsPerPage(size); setCurrentPage(1); }}
className={cn(
"px-2 py-1 rounded text-xs font-medium transition-colors",
itemsPerPage === size
? "bg-primary text-primary-foreground"
: "bg-secondary text-muted-foreground hover:bg-secondary/80"
)}
>
{size}
</button>
))}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-4 py-2 rounded-lg bg-secondary text-sm font-medium disabled:opacity-50 hover:bg-secondary/80 transition-colors"
>
Previous
</button>
<span className="text-sm font-medium text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-4 py-2 rounded-lg bg-secondary text-sm font-medium disabled:opacity-50 hover:bg-secondary/80 transition-colors"
>
Next
</button>
</div>
</div>
)}
</>
)} )}
{!loading && finalPrompts.length === 0 && ( {!loading && finalPrompts.length === 0 && (

View file

@ -2,14 +2,13 @@
import React from 'react'; import React from 'react';
import { useStore } from '@/lib/store'; import { useStore } from '@/lib/store';
import { Save, Sparkles, Zap, Brain } from 'lucide-react'; import { Save, Sparkles, Brain, Settings2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
type Provider = 'whisk' | 'grok' | 'meta'; type Provider = 'whisk' | 'meta';
const providers: { id: Provider; name: string; icon: any; description: string }[] = [ const providers: { id: Provider; name: string; icon: any; description: string }[] = [
{ id: 'whisk', name: 'Google Whisk', icon: Sparkles, description: 'ImageFX / Imagen 3' }, { id: 'whisk', name: 'Google Whisk', icon: Sparkles, description: 'ImageFX / Imagen 3' },
{ id: 'grok', name: 'Grok (xAI)', icon: Zap, description: 'FLUX.1 model' },
{ id: 'meta', name: 'Meta AI', icon: Brain, description: 'Imagine / Emu' }, { id: 'meta', name: 'Meta AI', icon: Brain, description: 'Imagine / Emu' },
]; ];
@ -19,18 +18,20 @@ export function Settings() {
// Local state for form fields // Local state for form fields
const [provider, setProvider] = React.useState<Provider>(settings.provider || 'whisk'); const [provider, setProvider] = React.useState<Provider>(settings.provider || 'whisk');
const [whiskCookies, setWhiskCookies] = React.useState(settings.whiskCookies || ''); const [whiskCookies, setWhiskCookies] = React.useState(settings.whiskCookies || '');
const [grokApiKey, setGrokApiKey] = React.useState(settings.grokApiKey || ''); const [useMetaFreeWrapper, setUseMetaFreeWrapper] = React.useState(settings.useMetaFreeWrapper !== undefined ? settings.useMetaFreeWrapper : true);
const [grokCookies, setGrokCookies] = React.useState(settings.grokCookies || ''); const [metaFreeWrapperUrl, setMetaFreeWrapperUrl] = React.useState(settings.metaFreeWrapperUrl || 'http://localhost:8000');
const [metaCookies, setMetaCookies] = React.useState(settings.metaCookies || ''); const [metaCookies, setMetaCookies] = React.useState(settings.metaCookies || '');
const [facebookCookies, setFacebookCookies] = React.useState(settings.facebookCookies || '');
const [saved, setSaved] = React.useState(false); const [saved, setSaved] = React.useState(false);
const handleSave = () => { const handleSave = () => {
setSettings({ setSettings({
provider, provider,
whiskCookies, whiskCookies,
grokApiKey, useMetaFreeWrapper,
grokCookies, metaFreeWrapperUrl,
metaCookies metaCookies,
facebookCookies
}); });
setSaved(true); setSaved(true);
setTimeout(() => setSaved(false), 2000); setTimeout(() => setSaved(false), 2000);
@ -89,47 +90,54 @@ export function Settings() {
</div> </div>
)} )}
{provider === 'grok' && ( {provider === 'meta' && (
<div className="space-y-4"> <div className="space-y-4">
{/* Advanced Settings (Hidden by default) */}
<details className="group mb-4">
<summary className="flex items-center gap-2 cursor-pointer text-xs text-white/40 hover:text-white/60 mb-2 select-none">
<Settings2 className="h-3 w-3" />
<span>Advanced Configuration</span>
</summary>
<div className="pl-4 border-l border-white/5 space-y-4 mb-4">
<div className="flex items-center justify-between p-3 rounded-lg bg-secondary/30 border border-border/50">
<div className="space-y-0.5">
<label className="text-sm font-medium text-white/70">Use Free API Wrapper</label>
<p className="text-[10px] text-muted-foreground">Running locally via Docker</p>
</div>
<div className="flex items-center gap-2">
<span className={`text-xs ${useMetaFreeWrapper ? "text-primary font-medium" : "text-muted-foreground"}`}>{useMetaFreeWrapper ? "ON" : "OFF"}</span>
<button
onClick={() => setUseMetaFreeWrapper(!useMetaFreeWrapper)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${useMetaFreeWrapper ? "bg-primary" : "bg-input"}`}
>
<span className={`pointer-events-none block h-3.5 w-3.5 rounded-full bg-background shadow-lg ring-0 transition-transform ${useMetaFreeWrapper ? "translate-x-4" : "translate-x-0.5"}`} />
</button>
</div>
</div>
{useMetaFreeWrapper && (
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Grok API Key (Recommended)</label> <label className="text-sm font-medium text-white/70">Free Wrapper URL</label>
<input <input
type="password" type="text"
value={grokApiKey} value={metaFreeWrapperUrl}
onChange={(e) => setGrokApiKey(e.target.value)} onChange={(e) => setMetaFreeWrapperUrl(e.target.value)}
placeholder="xai-..." placeholder="http://localhost:8000"
className="w-full p-3 rounded-lg bg-secondary/50 border border-border focus:ring-2 focus:ring-primary/50 outline-none font-mono text-sm" className="w-full p-2 rounded-lg bg-secondary/30 border border-border/50 focus:ring-1 focus:ring-primary/50 outline-none font-mono text-xs text-white/60"
/> />
<p className="text-xs text-muted-foreground">
Get your API key from <a href="https://console.x.ai" target="_blank" className="underline hover:text-primary">console.x.ai</a>
</p>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border"></div>
</div>
<div className="relative flex justify-center text-xs">
<span className="bg-card px-2 text-muted-foreground">or use cookies</span>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-muted-foreground">Grok Cookies (Alternative)</label>
<textarea
value={grokCookies}
onChange={(e) => setGrokCookies(e.target.value)}
placeholder="Paste cookies from grok.com..."
className="w-full h-24 p-3 rounded-lg bg-secondary/50 border border-border focus:ring-2 focus:ring-primary/50 outline-none font-mono text-xs"
/>
<p className="text-xs text-muted-foreground">
Get from logged-in <a href="https://grok.com" target="_blank" className="underline hover:text-primary">grok.com</a> session.
</p>
</div>
</div> </div>
)} )}
</div>
</details>
{provider === 'meta' && ( <div className="pt-2 border-t border-white/5">
<div className="space-y-2"> <p className="text-sm font-medium mb-3 text-amber-400">Authentication Required</p>
<label className="text-sm font-medium">Meta AI Cookies</label>
{/* Meta AI Cookies */}
<div className="space-y-2 mb-4">
<label className="text-sm font-medium">Meta.ai Cookies</label>
<textarea <textarea
value={metaCookies} value={metaCookies}
onChange={(e) => setMetaCookies(e.target.value)} onChange={(e) => setMetaCookies(e.target.value)}
@ -137,9 +145,25 @@ export function Settings() {
className="w-full h-32 p-3 rounded-lg bg-secondary/50 border border-border focus:ring-2 focus:ring-primary/50 outline-none font-mono text-xs" className="w-full h-32 p-3 rounded-lg bg-secondary/50 border border-border focus:ring-2 focus:ring-primary/50 outline-none font-mono text-xs"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Get from logged-in <a href="https://www.meta.ai" target="_blank" className="underline hover:text-primary">meta.ai</a> session (requires Facebook login). Get from logged-in <a href="https://www.meta.ai" target="_blank" className="underline hover:text-primary">meta.ai</a> session.
</p> </p>
</div> </div>
{/* Facebook Cookies */}
<div className="space-y-2">
<label className="text-sm font-medium">Facebook.com Cookies <span className="text-red-500">*</span></label>
<textarea
value={facebookCookies}
onChange={(e) => setFacebookCookies(e.target.value)}
placeholder="Paste cookies from facebook.com (REQUIRED for authentication)..."
className="w-full h-32 p-3 rounded-lg bg-secondary/50 border border-border focus:ring-2 focus:ring-primary/50 outline-none font-mono text-xs"
/>
<p className="text-xs text-muted-foreground">
<strong>Required:</strong> Meta AI authenticates via Facebook. Get from logged-in <a href="https://www.facebook.com" target="_blank" className="underline hover:text-primary">facebook.com</a> session using Cookie-Editor.
</p>
</div>
</div>
</div>
)} )}
</div> </div>

View file

@ -72,7 +72,7 @@ export function VideoPromptModal({ isOpen, onClose, image, onGenerate }: VideoPr
{image && ( {image && (
<> <>
<img <img
src={`data:image/png;base64,${image.data}`} src={image.data.startsWith('data:') || image.data.startsWith('http') ? image.data : `data:image/png;base64,${image.data}`}
alt="Source" alt="Source"
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />

18002
data/habu_prompts.json Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -7,3 +7,10 @@ services:
- "8558:3000" - "8558:3000"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
metaai-free-api:
build: ./services/metaai-api
container_name: metaai-free-api
restart: unless-stopped
ports:
- "8000:8000"

View file

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

View file

@ -2,10 +2,11 @@ import Dexie, { Table } from 'dexie';
export interface ImageItem { export interface ImageItem {
id?: number; id?: number;
data: string; // Base64 data: string; // Base64 or URL
prompt: string; prompt: string;
aspectRatio: string; aspectRatio: string;
createdAt: number; createdAt: number;
provider?: 'whisk' | 'meta'; // Track which AI generated the image
} }
export class KeyValuePixDB extends Dexie { export class KeyValuePixDB extends Dexie {

View file

@ -1,7 +1,7 @@
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import { Prompt, PromptCache } from '@/lib/types'; import { Prompt, PromptCache } from '@/lib/types';
import { JimmyLvCrawler, YouMindCrawler, ZeroLuCrawler } from '@/lib/crawler'; import { JimmyLvCrawler, YouMindCrawler, ZeroLuCrawler, HabuCrawler } from '@/lib/crawler';
const DATA_FILE = path.join(process.cwd(), 'data', 'prompts.json'); const DATA_FILE = path.join(process.cwd(), 'data', 'prompts.json');
@ -21,15 +21,17 @@ export async function syncPromptsService(): Promise<{ success: boolean, count: n
const jimmyCrawler = new JimmyLvCrawler(); const jimmyCrawler = new JimmyLvCrawler();
const youMindCrawler = new YouMindCrawler(); const youMindCrawler = new YouMindCrawler();
const zeroLuCrawler = new ZeroLuCrawler(); const zeroLuCrawler = new ZeroLuCrawler();
const habuCrawler = new HabuCrawler();
const [jimmyPrompts, youMindPrompts, zeroLuPrompts] = await Promise.all([ const [jimmyPrompts, youMindPrompts, zeroLuPrompts, habuPrompts] = await Promise.all([
jimmyCrawler.crawl(), jimmyCrawler.crawl(),
youMindCrawler.crawl(), youMindCrawler.crawl(),
zeroLuCrawler.crawl() zeroLuCrawler.crawl(),
habuCrawler.crawl()
]); ]);
const crawledPrompts = [...jimmyPrompts, ...youMindPrompts, ...zeroLuPrompts]; const crawledPrompts = [...jimmyPrompts, ...youMindPrompts, ...zeroLuPrompts, ...habuPrompts];
console.log(`[SyncService] Total crawled ${crawledPrompts.length} prompts (Jimmy: ${jimmyPrompts.length}, YouMind: ${youMindPrompts.length}, ZeroLu: ${zeroLuPrompts.length}).`); console.log(`[SyncService] Total crawled ${crawledPrompts.length} prompts (Jimmy: ${jimmyPrompts.length}, YouMind: ${youMindPrompts.length}, ZeroLu: ${zeroLuPrompts.length}, Habu: ${habuPrompts.length}).`);
// 2. Read existing // 2. Read existing
const cache = await getPrompts(); const cache = await getPrompts();

View file

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

View file

@ -27,16 +27,32 @@ interface MetaSession {
lsd?: string; lsd?: string;
fb_dtsg?: string; fb_dtsg?: string;
accessToken?: string; accessToken?: string;
externalConversationId?: string;
} }
// Aspect ratio types for Meta AI
export type AspectRatio = 'portrait' | 'landscape' | 'square';
const ORIENTATION_MAP: Record<AspectRatio, string> = {
'portrait': 'VERTICAL', // 9:16
'landscape': 'HORIZONTAL', // 16:9
'square': 'SQUARE' // 1:1
};
export class MetaAIClient { export class MetaAIClient {
private cookies: string; private cookies: string;
private session: MetaSession = {}; private session: MetaSession = {};
private useFreeWrapper: boolean = true;
private freeWrapperUrl: string = 'http://localhost:8000';
constructor(options: MetaAIOptions) { constructor(options: MetaAIOptions & { useFreeWrapper?: boolean; freeWrapperUrl?: string }) {
this.cookies = this.normalizeCookies(options.cookies); this.cookies = this.normalizeCookies(options.cookies);
this.useFreeWrapper = options.useFreeWrapper !== undefined ? options.useFreeWrapper : true;
this.freeWrapperUrl = options.freeWrapperUrl || 'http://localhost:8000';
console.log("[Meta AI] Cookie string length:", this.cookies.length);
if (this.cookies) {
this.parseSessionFromCookies(); this.parseSessionFromCookies();
} }
}
/** /**
* Normalize cookies from string or JSON format * Normalize cookies from string or JSON format
@ -79,11 +95,32 @@ export class MetaAIClient {
} }
} }
/**
* Get the initialized session tokens (call initSession first if not done)
*/
async getSession(): Promise<MetaSession & { externalConversationId?: string }> {
if (!this.useFreeWrapper && !this.session.lsd && !this.session.fb_dtsg) {
await this.initSession();
}
return this.session;
}
/**
* Get the normalized cookie string
*/
getCookies(): string {
return this.cookies;
}
/** /**
* Generate images using Meta AI's Imagine model * Generate images using Meta AI's Imagine model
*/ */
async generate(prompt: string, numImages: number = 4): Promise<MetaImageResult[]> { async generate(prompt: string, numImages: number = 4, aspectRatio: AspectRatio = 'portrait'): Promise<MetaImageResult[]> {
console.log(`[Meta AI] Generating images for: "${prompt.substring(0, 50)}..."`); console.log(`[Meta AI] Generating images for: "${prompt.substring(0, 50)}..." (${aspectRatio})`);
if (this.useFreeWrapper) {
return this.generateWithFreeWrapper(prompt, numImages);
}
// First, get the access token and session info if not already fetched // First, get the access token and session info if not already fetched
if (!this.session.accessToken) { if (!this.session.accessToken) {
@ -95,8 +132,8 @@ export class MetaAIClient {
? prompt ? prompt
: `Imagine ${prompt}`; : `Imagine ${prompt}`;
// Send the prompt via GraphQL // Send the prompt via GraphQL with aspect ratio
const response = await this.sendPrompt(imagePrompt); const response = await this.sendPrompt(imagePrompt, aspectRatio);
// Extract image URLs from response // Extract image URLs from response
const images = this.extractImages(response, prompt); const images = this.extractImages(response, prompt);
@ -111,6 +148,92 @@ export class MetaAIClient {
return images; return images;
} }
/**
* Generate using free API wrapper (mir-ashiq/metaai-api)
* Connects to local docker service
*/
/**
* Generate using free API wrapper (mir-ashiq/metaai-api)
* Connects to local docker service
*/
private async generateWithFreeWrapper(prompt: string, numImages: number): Promise<MetaImageResult[]> {
console.log(`[Meta Wrapper] Generating image for: "${prompt.substring(0, 50)}..." via ${this.freeWrapperUrl}`);
const cookieDict = this.parseCookiesToDict(this.cookies);
const response = await fetch(`${this.freeWrapperUrl}/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
message: `Imagine ${prompt}`,
stream: false,
cookies: cookieDict
})
});
if (!response.ok) {
const errorText = await response.text();
console.error("[Meta Wrapper] Error:", response.status, errorText);
throw new Error(`Meta Wrapper Error: ${response.status} - ${errorText.substring(0, 200)}`);
}
const data = await response.json();
console.log("[Meta Wrapper] Response:", JSON.stringify(data, null, 2).substring(0, 500));
// Check for images in response
const images: MetaImageResult[] = [];
// mir-ashiq/metaai-api returns { media: [{ url: "...", ... }] }
if (data.media && Array.isArray(data.media)) {
data.media.forEach((m: any) => {
if (m.url) images.push({ url: m.url, prompt, model: "meta-wrapper" });
});
}
// Fallback checks
if (images.length === 0 && data.images && Array.isArray(data.images)) {
data.images.forEach((url: string) => {
images.push({ url, prompt, model: "meta-wrapper" });
});
}
if (images.length === 0 && data.sources && Array.isArray(data.sources)) {
data.sources.forEach((s: any) => {
if (s.url && (s.url.includes('.jpg') || s.url.includes('.png') || s.url.includes('.webp'))) {
images.push({ url: s.url, prompt, model: "meta-wrapper-source" });
}
});
}
if (images.length === 0) {
console.warn("[Meta Wrapper] No images found via /chat endpoint", data);
throw new Error("Meta Wrapper returned no images. Please check if the prompt triggered image generation.");
}
return images;
}
private parseCookiesToDict(cookieStr: string): Record<string, string> {
const dict: Record<string, string> = {};
if (!cookieStr) return dict;
// Handle basic key=value; format
cookieStr.split(';').forEach(pair => {
const [key, ...rest] = pair.trim().split('=');
if (key && rest.length > 0) {
dict[key] = rest.join('=');
}
});
return dict;
}
private async generateWithFreeWrapperFallback(prompt: string): Promise<MetaImageResult[]> {
// Fallback logic not needed if /chat works
throw new Error("Meta Wrapper endpoint /chat failed.");
}
/** /**
* Initialize session - get access token from meta.ai page * Initialize session - get access token from meta.ai page
*/ */
@ -166,28 +289,63 @@ export class MetaAIClient {
this.session.fb_dtsg = dtsgMatch[1]; this.session.fb_dtsg = dtsgMatch[1];
} }
// We no longer strictly enforce accessToken presence here // Enhanced logging for debugging
// as some requests might work with just cookies console.log("[Meta AI] Session tokens extracted:", {
hasAccessToken: !!this.session.accessToken,
hasLsd: !!this.session.lsd,
hasDtsg: !!this.session.fb_dtsg
});
if (!this.session.accessToken && !this.session.lsd) {
console.warn("[Meta AI] CRITICAL: No authentication tokens found. Check if cookies are valid.");
}
} }
/** /**
* Send prompt via GraphQL mutation * Send prompt via GraphQL mutation
*/ */
private async sendPrompt(prompt: string): Promise<any> { /**
* Send prompt via GraphQL mutation (Abra - for Image Generation)
* Using EXACT variable structure from Python Strvm/meta-ai-api library
*/
private async sendPrompt(prompt: string, aspectRatio: AspectRatio = 'portrait'): Promise<any> {
// Generate external conversation ID (UUID) and offline threading ID (Snowflake-like)
const externalConversationId = crypto.randomUUID();
const timestamp = Date.now();
const randomPart = Math.floor(Math.random() * 4194304); // 22 bits
const offlineThreadingId = ((BigInt(timestamp) << BigInt(22)) | BigInt(randomPart)).toString();
// Store for polling
this.session.externalConversationId = externalConversationId;
// Map aspect ratio to Meta AI orientation
const orientation = ORIENTATION_MAP[aspectRatio];
const variables = { const variables = {
message: { message: {
text: prompt, sensitive_string_value: prompt // Python uses sensitive_string_value, not text!
content_type: "TEXT"
}, },
source: "PDT_CHAT_INPUT", externalConversationId: externalConversationId,
external_message_id: Math.random().toString(36).substring(2) + Date.now().toString(36) offlineThreadingId: offlineThreadingId,
suggestedPromptIndex: null,
flashVideoRecapInput: { images: [] },
flashPreviewInput: null,
promptPrefix: null,
entrypoint: "ABRA__CHAT__TEXT",
icebreaker_type: "TEXT",
imagineClientOptions: { orientation: orientation }, // Aspect ratio control
__relay_internal__pv__AbraDebugDevOnlyrelayprovider: false,
__relay_internal__pv__WebPixelRatiorelayprovider: 1
}; };
console.log("[Meta AI] Sending Variables:", JSON.stringify(variables, null, 2));
const body = new URLSearchParams({ const body = new URLSearchParams({
fb_api_caller_class: "RelayModern", fb_api_caller_class: "RelayModern",
fb_api_req_friendly_name: "useAbraSendMessageMutation", fb_api_req_friendly_name: "useAbraSendMessageMutation",
variables: JSON.stringify(variables), variables: JSON.stringify(variables),
doc_id: "7783822248314888", server_timestamps: "true",
doc_id: "7783822248314888", // Abra Mutation ID
...(this.session.lsd && { lsd: this.session.lsd }), ...(this.session.lsd && { lsd: this.session.lsd }),
...(this.session.fb_dtsg && { fb_dtsg: this.session.fb_dtsg }) ...(this.session.fb_dtsg && { fb_dtsg: this.session.fb_dtsg })
}); });
@ -209,25 +367,56 @@ export class MetaAIClient {
body: body.toString() body: body.toString()
}); });
const rawText = await response.text();
console.log("[Meta AI] Response received, parsing streaming data...");
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); throw new Error(`Meta AI Error: ${response.status} - ${rawText.substring(0, 500)}`);
console.error("[Meta AI] GraphQL Error:", response.status, errorText);
throw new Error(`Meta AI Error: ${response.status} - ${errorText.substring(0, 200)}`);
} }
// Check if response is actually JSON // Meta AI returns streaming response (multiple JSON lines)
const contentType = response.headers.get("content-type"); // We need to find the final response where streaming_state === "OVERALL_DONE"
if (contentType && contentType.includes("text/html")) { let lastValidResponse: any = null;
const text = await response.text(); const lines = rawText.split('\n');
if (text.includes("login_form") || text.includes("facebook.com/login")) {
for (const line of lines) {
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line);
// Check for streaming state in both direct and nested paths
const streamingState =
parsed?.data?.xfb_abra_send_message?.bot_response_message?.streaming_state ||
parsed?.data?.node?.bot_response_message?.streaming_state;
if (streamingState === "OVERALL_DONE") {
console.log("[Meta AI] Found OVERALL_DONE response");
lastValidResponse = parsed;
break;
}
// Keep track of any valid response with imagine_card
const imagineCard =
parsed?.data?.xfb_abra_send_message?.bot_response_message?.imagine_card ||
parsed?.data?.node?.bot_response_message?.imagine_card;
if (imagineCard?.session?.media_sets) {
lastValidResponse = parsed;
}
} catch (e) {
// Skip non-JSON lines
continue;
}
}
if (!lastValidResponse) {
if (rawText.includes("login_form") || rawText.includes("facebook.com/login")) {
throw new Error("Meta AI: Session expired. Please refresh your cookies."); throw new Error("Meta AI: Session expired. Please refresh your cookies.");
} }
throw new Error(`Meta AI returned HTML error: ${text.substring(0, 100)}...`); throw new Error("Meta AI: No valid response found in streaming data");
} }
const data = await response.json(); console.log("[Meta AI] Successfully parsed streaming response");
console.log("[Meta AI] Response:", JSON.stringify(data, null, 2).substring(0, 500)); return lastValidResponse;
return data;
} }
/** /**
@ -237,24 +426,88 @@ export class MetaAIClient {
const images: MetaImageResult[] = []; const images: MetaImageResult[] = [];
// Navigate through the response structure // Navigate through the response structure
const messageData = response?.data?.node?.bot_response_message || // Abra streaming: xfb_abra_send_message.bot_response_message
// Abra direct: node.bot_response_message
// Kadabra: useKadabraSendMessageMutation.node.bot_response_message
const messageData = response?.data?.xfb_abra_send_message?.bot_response_message ||
response?.data?.useKadabraSendMessageMutation?.node?.bot_response_message ||
response?.data?.node?.bot_response_message ||
response?.data?.xabraAIPreviewMessageSendMutation?.message; response?.data?.xabraAIPreviewMessageSendMutation?.message;
if (!messageData) { // For Polling/KadabraPromptRootQuery which also contains imagine_cards
const pollMessages = response?.data?.kadabra_prompt?.messages?.edges || [];
if (pollMessages.length > 0) {
for (const edge of pollMessages) {
const nodeImages = this.extractImagesFromMessage(edge?.node, originalPrompt);
images.push(...nodeImages);
}
}
if (messageData) {
images.push(...this.extractImagesFromMessage(messageData, originalPrompt));
}
// --- STRENGTHENED FALLBACK ---
// If still no images, but we have data, do a recursive search for any CDN URLs
if (images.length === 0 && response?.data) {
console.log("[Meta AI] Structured extraction failed, attempting recursive search...");
const foundUrls = this.recursiveSearchForImages(response.data);
for (const url of foundUrls) {
images.push({
url: url,
prompt: originalPrompt,
model: "meta"
});
}
}
if (images.length === 0) {
console.log("[Meta AI] Extraction failed. Response keys:", Object.keys(response || {}));
if (response?.data) console.log("[Meta AI] Data keys:", Object.keys(response.data));
}
return images; return images;
} }
/**
* Recursive search for image-like URLs in the JSON tree
*/
private recursiveSearchForImages(obj: any, found: Set<string> = new Set()): string[] {
if (!obj || typeof obj !== 'object') return [];
for (const key in obj) {
const val = obj[key];
if (typeof val === 'string') {
if ((val.includes('fbcdn.net') || val.includes('meta.ai')) &&
(val.includes('.jpg') || val.includes('.png') || val.includes('.webp') || val.includes('image_uri=') || val.includes('/imagine/'))) {
found.add(val);
}
} else if (typeof val === 'object') {
this.recursiveSearchForImages(val, found);
}
}
return Array.from(found);
}
/**
* Helper to extract images from a single message node
*/
private extractImagesFromMessage(messageData: any, originalPrompt: string): MetaImageResult[] {
const images: MetaImageResult[] = [];
if (!messageData) return images;
// Check for imagine_card (image generation response) // Check for imagine_card (image generation response)
const imagineCard = messageData?.imagine_card; const imagineCard = messageData?.imagine_card;
if (imagineCard?.session?.media_sets) { if (imagineCard?.session?.media_sets) {
for (const mediaSet of imagineCard.session.media_sets) { for (const mediaSet of imagineCard.session.media_sets) {
if (mediaSet?.imagine_media) { if (mediaSet?.imagine_media) {
for (const media of mediaSet.imagine_media) { for (const media of mediaSet.imagine_media) {
if (media?.uri) { const url = media?.uri || media?.image_uri || media?.image?.uri;
if (url) {
images.push({ images.push({
url: media.uri, url: url,
prompt: originalPrompt, prompt: originalPrompt,
model: "imagine" model: "meta"
}); });
} }
} }
@ -262,15 +515,17 @@ export class MetaAIClient {
} }
} }
// Check for attachments // Check for attachments (alternative path)
const attachments = messageData?.attachments; const attachments = messageData?.attachments;
if (attachments) { if (attachments) {
for (const attachment of attachments) { for (const attachment of attachments) {
if (attachment?.media?.image_uri) { const media = attachment?.media;
const url = media?.image_uri || media?.uri || media?.image?.uri;
if (url) {
images.push({ images.push({
url: attachment.media.image_uri, url: url,
prompt: originalPrompt, prompt: originalPrompt,
model: "imagine" model: "meta"
}); });
} }
} }
@ -283,79 +538,72 @@ export class MetaAIClient {
* Poll for image generation completion * Poll for image generation completion
*/ */
private async pollForImages(initialResponse: any, prompt: string): Promise<MetaImageResult[]> { private async pollForImages(initialResponse: any, prompt: string): Promise<MetaImageResult[]> {
const maxAttempts = 30; // Kadabra uses external_conversation_id for polling
const pollInterval = 2000; const conversationId = initialResponse?.data?.useKadabraSendMessageMutation?.node?.external_conversation_id ||
initialResponse?.data?.node?.external_conversation_id;
// Get the fetch_id from initial response for polling if (!conversationId) {
const fetchId = initialResponse?.data?.node?.id || console.warn("[Meta AI] No conversation ID found for polling");
initialResponse?.data?.xabraAIPreviewMessageSendMutation?.message?.id;
if (!fetchId) {
console.warn("[Meta AI] No fetch ID for polling, returning empty");
return []; return [];
} }
const maxAttempts = 30;
const pollInterval = 2000;
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
console.log(`[Meta AI] Polling attempt ${attempt + 1}/${maxAttempts}...`); console.log(`[Meta AI] Polling attempt ${attempt + 1}/${maxAttempts}...`);
await new Promise(resolve => setTimeout(resolve, pollInterval)); await new Promise(resolve => setTimeout(resolve, pollInterval));
try {
// Query for the message status
const statusResponse = await this.queryMessageStatus(fetchId);
const images = this.extractImages(statusResponse, prompt);
if (images.length > 0) {
console.log(`[Meta AI] Got ${images.length} images!`);
return images;
}
// Check if generation failed
const status = statusResponse?.data?.node?.imagine_card?.session?.status;
if (status === "FAILED" || status === "ERROR") {
throw new Error("Meta AI image generation failed");
}
} catch (e) {
console.error("[Meta AI] Poll error:", e);
if (attempt === maxAttempts - 1) throw e;
}
}
throw new Error("Meta AI: Image generation timed out");
}
/**
* Query message status for polling
*/
private async queryMessageStatus(messageId: string): Promise<any> {
const variables = { const variables = {
id: messageId external_conversation_id: conversationId
}; };
const body = new URLSearchParams({ const body = new URLSearchParams({
fb_api_caller_class: "RelayModern", fb_api_caller_class: "RelayModern",
fb_api_req_friendly_name: "useAbraMessageQuery", fb_api_req_friendly_name: "KadabraPromptRootQuery",
variables: JSON.stringify(variables), variables: JSON.stringify(variables),
doc_id: "7654946557897648", doc_id: "25290569913909283", // KadabraPromptRootQuery ID
...(this.session.lsd && { lsd: this.session.lsd }), ...(this.session.lsd && { lsd: this.session.lsd }),
...(this.session.fb_dtsg && { fb_dtsg: this.session.fb_dtsg }) ...(this.session.fb_dtsg && { fb_dtsg: this.session.fb_dtsg })
}); });
try {
const response = await fetch(GRAPHQL_ENDPOINT, { const response = await fetch(GRAPHQL_ENDPOINT, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
"Cookie": this.cookies, "Cookie": this.cookies,
"Origin": META_AI_BASE, "Origin": META_AI_BASE,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
...(this.session.accessToken && { "Authorization": `OAuth ${this.session.accessToken}` }) ...(this.session.accessToken && { "Authorization": `OAuth ${this.session.accessToken}` })
}, },
body: body.toString() body: body.toString()
}); });
return response.json(); const data = await response.json();
const images = this.extractImages(data, prompt);
if (images.length > 0) {
console.log(`[Meta AI] Got ${images.length} image(s) after polling!`);
return images;
} }
// Check for failure status
const status = data?.data?.kadabra_prompt?.status;
if (status === "FAILED" || status === "ERROR") {
console.error("[Meta AI] Generation failed during polling");
break;
}
} catch (e: any) {
console.error("[Meta AI] Poll error:", e.message);
}
}
return [];
}
/** /**
* Download image from URL and convert to base64 * Download image from URL and convert to base64
*/ */

View file

@ -60,6 +60,12 @@ interface AppState {
removeFromGallery: (id: number) => Promise<void>; removeFromGallery: (id: number) => Promise<void>;
clearGallery: () => Promise<void>; clearGallery: () => Promise<void>;
isGenerating: boolean;
setIsGenerating: (isGenerating: boolean) => void;
showCookieExpired: boolean;
setShowCookieExpired: (show: boolean) => void;
// Videos // Videos
videos: VideoItem[]; videos: VideoItem[];
@ -76,14 +82,14 @@ interface AppState {
imageCount: number; imageCount: number;
theme: 'light' | 'dark'; theme: 'light' | 'dark';
// Provider selection // Provider selection
provider: 'whisk' | 'grok' | 'meta'; provider: 'whisk' | 'meta';
// Whisk (Google) // Whisk (Google)
whiskCookies: string; whiskCookies: string;
// Grok (xAI)
grokApiKey: string;
grokCookies: string;
// Meta AI // Meta AI
useMetaFreeWrapper: boolean;
metaFreeWrapperUrl: string;
metaCookies: string; metaCookies: string;
facebookCookies: string;
}; };
setSettings: (s: Partial<AppState['settings']>) => void; setSettings: (s: Partial<AppState['settings']>) => void;
} }
@ -148,10 +154,18 @@ export const useStore = create<AppState>()(
}, },
clearGallery: async () => { clearGallery: async () => {
await db.gallery.clear(); await db.gallery.clear();
set({ gallery: [] }); // Also clear persistent videos and history if desired, or just gallery?
// Assuming "Clear All" in Gallery context implies clearing the visual workspace.
set({ gallery: [], videos: [] });
}, },
isGenerating: false,
setIsGenerating: (isGenerating) => set({ isGenerating }),
showCookieExpired: false,
setShowCookieExpired: (show) => set({ showCookieExpired: show }),
// Videos // Videos
videos: [], videos: [],
addVideo: (video) => set((state) => ({ videos: [video, ...state.videos] })), addVideo: (video) => set((state) => ({ videos: [video, ...state.videos] })),
@ -164,15 +178,16 @@ export const useStore = create<AppState>()(
})), })),
settings: { settings: {
aspectRatio: '1:1', aspectRatio: '9:16',
preciseMode: false, preciseMode: false,
imageCount: 4, imageCount: 4,
theme: 'dark', theme: 'dark',
provider: 'whisk', provider: 'whisk',
whiskCookies: '', whiskCookies: '',
grokApiKey: '', useMetaFreeWrapper: true,
grokCookies: '', metaFreeWrapperUrl: 'http://localhost:8000',
metaCookies: '' metaCookies: '',
facebookCookies: ''
}, },
setSettings: (s) => set((state) => ({ settings: { ...state.settings, ...s } })) setSettings: (s) => set((state) => ({ settings: { ...state.settings, ...s } }))
}), }),
@ -180,9 +195,9 @@ export const useStore = create<AppState>()(
name: 'kv-pix-storage', name: 'kv-pix-storage',
partialize: (state) => ({ partialize: (state) => ({
settings: state.settings, settings: state.settings,
// gallery: state.gallery, // Don't persist gallery to localStorage // gallery: state.gallery, // Don't persist gallery to localStorage (too large)
history: state.history, history: state.history,
videos: state.videos // Persist videos // videos: state.videos // Don't persist videos to localStorage (too large)
}), }),
} }
) )

10
package-lock.json generated
View file

@ -1947,6 +1947,7 @@
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@ -1964,6 +1965,7 @@
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
@ -2030,6 +2032,7 @@
"integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/scope-manager": "8.51.0",
"@typescript-eslint/types": "8.51.0", "@typescript-eslint/types": "8.51.0",
@ -2619,6 +2622,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -3644,6 +3648,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -3817,6 +3822,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@ -6268,6 +6274,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@ -6280,6 +6287,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@ -7101,6 +7109,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -7280,6 +7289,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"

View file

@ -1,6 +1,6 @@
{ {
"name": "v2_temp", "name": "v2_temp",
"version": "0.1.0", "version": "2.5.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",

BIN
public/images/prompts/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Some files were not shown because too many files have changed in this diff Show more