Compare commits
20 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bf9f6e39c | ||
|
|
072c7adf89 | ||
|
|
e8978bb086 | ||
|
|
ad19603f7c | ||
|
|
6e833b24a6 | ||
|
|
5d4413ff51 | ||
|
|
962ff4667c | ||
|
|
ccfa897ac9 | ||
|
|
d43d979e43 | ||
|
|
21abb11766 | ||
|
|
2e203dad19 | ||
|
|
bf4a56e550 | ||
|
|
2173eb1446 | ||
|
|
e69c6ba64d | ||
|
|
537b1b80e5 | ||
|
|
c2ee01b7b7 | ||
|
|
7aaa4c8166 | ||
|
|
bae4c487da | ||
|
|
0f87b8ef99 | ||
|
|
2a4bf8b58b |
|
|
@ -7,3 +7,4 @@ README.md
|
||||||
.git
|
.git
|
||||||
.env*
|
.env*
|
||||||
! .env.example
|
! .env.example
|
||||||
|
.venv
|
||||||
|
|
|
||||||
6
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
77
components/CookieExpiredDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,43 +126,47 @@ 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 */}
|
||||||
<div className="mt-4">
|
{!isMeta && (
|
||||||
<label className="text-xs font-medium text-white/50 mb-2 block">Keep Consistent:</label>
|
<div className="mt-4">
|
||||||
<div className="flex flex-wrap gap-2">
|
<label className="text-xs font-medium text-white/50 mb-2 block">Keep Consistent:</label>
|
||||||
<ConsistencyToggle
|
<div className="flex flex-wrap gap-2">
|
||||||
label="Subject"
|
<ConsistencyToggle
|
||||||
checked={keepSubject}
|
label="Subject"
|
||||||
onChange={setKeepSubject}
|
checked={keepSubject}
|
||||||
color="text-blue-400"
|
onChange={setKeepSubject}
|
||||||
/>
|
color="text-blue-400"
|
||||||
<ConsistencyToggle
|
/>
|
||||||
label="Scene"
|
<ConsistencyToggle
|
||||||
checked={keepScene}
|
label="Scene"
|
||||||
onChange={setKeepScene}
|
checked={keepScene}
|
||||||
color="text-green-400"
|
onChange={setKeepScene}
|
||||||
/>
|
color="text-green-400"
|
||||||
<ConsistencyToggle
|
/>
|
||||||
label="Style"
|
<ConsistencyToggle
|
||||||
checked={keepStyle}
|
label="Style"
|
||||||
onChange={setKeepStyle}
|
checked={keepStyle}
|
||||||
color="text-purple-400"
|
onChange={setKeepStyle}
|
||||||
/>
|
color="text-purple-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Info about consistency */}
|
{/* Info about consistency */}
|
||||||
<div className="mt-4 p-3 bg-white/5 rounded-xl border border-white/10">
|
{!isMeta && (
|
||||||
<p className="text-xs text-white/50">
|
<div className="mt-4 p-3 bg-white/5 rounded-xl border border-white/10">
|
||||||
<span className="text-amber-400">💡</span> Locked elements will be used as references to maintain visual consistency across generations.
|
<p className="text-xs text-white/50">
|
||||||
</p>
|
<span className="text-amber-400">💡</span> Locked elements will be used as references to maintain visual consistency across generations.
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex justify-end gap-3 mt-6">
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
|
|
||||||
|
|
@ -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,59 +61,216 @@ 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");
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch('/api/video/generate', {
|
setIsGeneratingWhiskVideo(true);
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
prompt: prompt,
|
|
||||||
imageBase64: videoSource.data,
|
|
||||||
// imageGenerationId: (videoSource as any).id, // REMOVE: "id" is a local DB ID (e.g. 1), not a Whisk Media ID.
|
|
||||||
cookies: settings.whiskCookies
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
try {
|
||||||
console.log("[Gallery] Video API response:", data);
|
const res = await fetch('/api/video/generate', {
|
||||||
if (data.success) {
|
method: 'POST',
|
||||||
console.log("[Gallery] Adding video to store:", { id: data.id, url: data.url?.substring(0, 50) });
|
headers: { 'Content-Type': 'application/json' },
|
||||||
addVideo({
|
body: JSON.stringify({
|
||||||
id: data.id,
|
prompt: prompt,
|
||||||
url: data.url,
|
imageBase64: activeSource.data,
|
||||||
prompt: prompt,
|
cookies: settings.whiskCookies
|
||||||
thumbnail: videoSource.data, // Use source image as thumb
|
})
|
||||||
createdAt: Date.now()
|
|
||||||
});
|
});
|
||||||
// Success notification
|
|
||||||
setTimeout(() => {
|
const data = await res.json();
|
||||||
|
console.log("[Gallery] Video API response:", data);
|
||||||
|
if (data.success) {
|
||||||
|
console.log("[Gallery] Adding video to store:", { id: data.id, url: data.url?.substring(0, 50) });
|
||||||
|
addVideo({
|
||||||
|
id: data.id,
|
||||||
|
url: data.url,
|
||||||
|
prompt: prompt,
|
||||||
|
thumbnail: activeSource.data,
|
||||||
|
createdAt: Date.now()
|
||||||
|
});
|
||||||
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);
|
let errorMessage = data.error;
|
||||||
// Show user-friendly error messages for Google safety policies
|
if (data.error?.includes('NCII')) {
|
||||||
let errorMessage = data.error;
|
errorMessage = '🚫 Content Policy: Video blocked by Google\'s NCII protection. Please try with a different source image.';
|
||||||
if (data.error?.includes('NCII')) {
|
} else if (data.error?.includes('PROMINENT_PEOPLE') || data.error?.includes('prominent')) {
|
||||||
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 because the image contains a recognizable person.';
|
||||||
} else if (data.error?.includes('PROMINENT_PEOPLE') || data.error?.includes('prominent')) {
|
} else if (data.error?.includes('safety') || data.error?.includes('SAFETY')) {
|
||||||
errorMessage = '🚫 Content Policy: Video blocked because the image contains a recognizable person. Try using a different image.';
|
errorMessage = '⚠️ Content Policy: Video blocked by Google\'s safety filters.';
|
||||||
} else if (data.error?.includes('safety') || data.error?.includes('SAFETY')) {
|
} else if (data.error?.includes('401') || data.error?.includes('UNAUTHENTICATED')) {
|
||||||
errorMessage = '⚠️ Content Policy: Video blocked by Google\'s safety filters. Try a different source image.';
|
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);
|
||||||
|
throw new Error(data.error);
|
||||||
}
|
}
|
||||||
alert(errorMessage);
|
} finally {
|
||||||
throw new Error(data.error);
|
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
|
||||||
|
src={getImageSrc(selectedImage.data)}
|
||||||
|
alt={selectedImage.prompt}
|
||||||
|
className="max-w-full max-h-[50vh] md:max-h-[85vh] object-contain rounded-xl shadow-2xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<img
|
{/* Right: Controls Panel */}
|
||||||
src={"data:image/png;base64," + selectedImage.data}
|
<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">
|
||||||
alt={selectedImage.prompt}
|
{/* Provider Badge */}
|
||||||
className="max-w-full max-h-[85vh] object-contain rounded-lg shadow-2xl"
|
{selectedImage.provider && (
|
||||||
/>
|
<div className={cn(
|
||||||
|
"self-start px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider",
|
||||||
|
selectedImage.provider === 'meta' ? "bg-blue-500/20 text-blue-300 border border-blue-500/30" :
|
||||||
|
"bg-amber-500/20 text-amber-300 border border-amber-500/30"
|
||||||
|
)}>
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
<Wand2 className="h-3 w-3" />
|
||||||
|
<span>Remix</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(editPromptValue);
|
||||||
|
alert("Prompt copied!");
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4 mx-auto" />
|
||||||
|
</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
|
||||||
|
onClick={() => {
|
||||||
|
setPrompt(selectedImage.prompt);
|
||||||
|
setSelectedIndex(null);
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
<span>Use Prompt</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-col items-center gap-2 max-w-2xl text-center">
|
|
||||||
<p className="text-white/90 text-sm md:text-base font-medium line-clamp-2">
|
|
||||||
{selectedImage.prompt}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<a
|
|
||||||
href={"data:image/png;base64," + selectedImage.data}
|
|
||||||
download={"generated-" + selectedIndex + "-" + Date.now() + ".png"}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground hover:bg-primary/90 rounded-full font-medium transition-colors"
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
Download Current
|
|
||||||
</a>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (selectedImage) openVideoModal(selectedImage);
|
if (selectedImage.id) {
|
||||||
|
removeFromGallery(selectedImage.id);
|
||||||
|
setSelectedIndex(null);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
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="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"
|
||||||
>
|
>
|
||||||
<Film className="h-4 w-4" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
Generate Video
|
<span>Delete Image</span>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setPrompt(selectedImage.prompt);
|
|
||||||
navigator.clipboard.writeText(selectedImage.prompt);
|
|
||||||
setSelectedIndex(null); // Close lightbox? Or keep open? User said "reuse", likely wants to edit.
|
|
||||||
// Let's close it so they can see the input updating.
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-secondary text-secondary-foreground hover:bg-secondary/80 rounded-full font-medium transition-colors"
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
Use Prompt
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -12,25 +12,73 @@ 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">
|
<>
|
||||||
{/* Yellow Accent Line */}
|
<div className="fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-xl border-b border-border">
|
||||||
<div className="h-1 w-full bg-primary" />
|
{/* Yellow Accent Line */}
|
||||||
|
<div className="h-1 w-full bg-primary" />
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-4 h-16 max-w-7xl mx-auto">
|
<div className="flex items-center justify-between px-4 h-16 max-w-7xl mx-auto">
|
||||||
{/* Logo Area */}
|
{/* Logo Area */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-10 w-10 rounded-full bg-primary/20 flex items-center justify-center text-primary">
|
<div className="h-10 w-10 rounded-full bg-primary/20 flex items-center justify-center text-primary">
|
||||||
<Sparkles className="h-6 w-6" />
|
<Sparkles className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold text-foreground tracking-tight">kv-pix</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xl font-bold text-foreground tracking-tight">kv-pix</span>
|
|
||||||
</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) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentView(item.id as any);
|
||||||
|
if (item.id === 'history') setSelectionMode(null);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all",
|
||||||
|
currentView === item.id
|
||||||
|
? "bg-primary text-primary-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-secondary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentView('settings')}
|
||||||
|
className={cn(
|
||||||
|
"p-2 transition-colors",
|
||||||
|
currentView === 'settings'
|
||||||
|
? "text-primary bg-primary/10 rounded-full"
|
||||||
|
: "text-muted-foreground hover:text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Settings className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<div className="h-8 w-px bg-border mx-1" />
|
||||||
|
<button className="flex items-center gap-2 pl-1 pr-3 py-1 bg-card hover:bg-secondary border border-border rounded-full transition-colors">
|
||||||
|
<div className="h-7 w-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-bold">
|
||||||
|
KV
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium hidden sm:block">Khoa Vo</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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) => (
|
{navItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
|
@ -39,40 +87,41 @@ export function Navbar() {
|
||||||
if (item.id === 'history') setSelectionMode(null);
|
if (item.id === 'history') setSelectionMode(null);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all",
|
"flex flex-col items-center justify-center gap-1 p-2 rounded-xl transition-all w-16",
|
||||||
currentView === item.id
|
currentView === item.id
|
||||||
? "bg-primary text-primary-foreground shadow-sm"
|
? "text-primary"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-secondary"
|
: "text-white/40 hover:text-white/80"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="h-4 w-4" />
|
<div className={cn(
|
||||||
<span>{item.label}</span>
|
"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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
{/* Settings Item for Mobile */}
|
||||||
|
|
||||||
{/* Right Actions */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentView('settings')}
|
onClick={() => setCurrentView('settings')}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-2 transition-colors",
|
"flex flex-col items-center justify-center gap-1 p-2 rounded-xl transition-all w-16",
|
||||||
currentView === 'settings'
|
currentView === 'settings'
|
||||||
? "text-primary bg-primary/10 rounded-full"
|
? "text-primary"
|
||||||
: "text-muted-foreground hover:text-primary"
|
: "text-white/40 hover:text-white/80"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Settings className="h-5 w-5" />
|
<div className={cn(
|
||||||
</button>
|
"p-1.5 rounded-full transition-all",
|
||||||
<div className="h-8 w-px bg-border mx-1" />
|
currentView === 'settings' ? "bg-primary/10" : "bg-transparent"
|
||||||
<button className="flex items-center gap-2 pl-1 pr-3 py-1 bg-card hover:bg-secondary border border-border rounded-full transition-colors">
|
)}>
|
||||||
<div className="h-7 w-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-bold">
|
<Settings className="h-5 w-5" />
|
||||||
KV
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium hidden sm:block">Khoa Vo</span>
|
<span className="text-[10px] font-medium">Settings</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,49 +296,70 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch('/api/references/upload', {
|
let refId = '';
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
imageBase64: base64,
|
|
||||||
mimeType: file.type,
|
|
||||||
category: category,
|
|
||||||
cookies: settings.whiskCookies
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
// If Whisk, upload to backend to get ID
|
||||||
if (data.id) {
|
if (!settings.provider || settings.provider === 'whisk') {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/references/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
imageBase64: base64,
|
||||||
|
mimeType: file.type,
|
||||||
|
category: category,
|
||||||
|
cookies: settings.whiskCookies
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.id) {
|
||||||
|
refId = data.id;
|
||||||
|
} else {
|
||||||
|
console.error("Upload failed details:", JSON.stringify(data));
|
||||||
|
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)
|
// Add to array (supports multiple refs per category)
|
||||||
addReference(category, { id: data.id, thumbnail: base64 });
|
// Note: Store uses 'thumbnail' property for the image data
|
||||||
|
addReference(category, { id: refId, thumbnail: base64 });
|
||||||
|
|
||||||
// Add to history
|
// Add to history
|
||||||
const newItem = {
|
const newItem = {
|
||||||
id: data.id,
|
id: refId,
|
||||||
url: base64, // For local display history we use base64. Ideally we'd valid URL but this works for session.
|
url: base64,
|
||||||
category: category,
|
category: category,
|
||||||
originalName: file.name
|
originalName: file.name
|
||||||
};
|
};
|
||||||
// exist check?
|
|
||||||
const exists = history.find(h => h.id === data.id);
|
|
||||||
if (!exists) {
|
|
||||||
setHistory([newItem, ...history]);
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
const exists = history.find(h => h.id === refId);
|
||||||
console.error("Upload failed details:", JSON.stringify(data));
|
if (!exists) {
|
||||||
alert(`Upload failed: ${data.error}\n\nDetails: ${JSON.stringify(data) || 'Check console'}`);
|
setHistory([newItem, ...history].slice(0, 50));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setUploadingRefs(prev => ({ ...prev, [category]: false }));
|
setUploadingRefs(prev => ({ ...prev, [category]: false }));
|
||||||
};
|
};
|
||||||
|
|
@ -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>
|
</span>
|
||||||
</p>
|
</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,81 +526,83 @@ 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'
|
||||||
const refs = references[cat] || [];
|
? ['subject']
|
||||||
const hasRefs = refs.length > 0;
|
: ['subject', 'scene', 'style']) as ReferenceCategory[]).map((cat) => {
|
||||||
const isUploading = uploadingRefs[cat];
|
const refs = references[cat] || [];
|
||||||
return (
|
const hasRefs = refs.length > 0;
|
||||||
<div key={cat} className="relative group">
|
const isUploading = uploadingRefs[cat];
|
||||||
<button
|
|
||||||
onClick={() => toggleReference(cat)}
|
return (
|
||||||
onDragOver={handleDragOver}
|
<div key={cat} className="relative group">
|
||||||
onDrop={(e) => handleDrop(e, cat)}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 rounded-full px-4 py-2 text-xs font-medium transition-all border relative overflow-hidden",
|
|
||||||
hasRefs
|
|
||||||
? "bg-purple-500/10 text-purple-200 border-purple-500/30 hover:bg-purple-500/20"
|
|
||||||
: "bg-white/5 text-white/40 border-white/5 hover:bg-white/10 hover:text-white/70 hover:border-white/10",
|
|
||||||
isUploading && "animate-pulse cursor-wait"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isUploading ? (
|
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
|
||||||
) : hasRefs ? (
|
|
||||||
<div className="flex -space-x-2">
|
|
||||||
{refs.slice(0, 4).map((ref, idx) => (
|
|
||||||
<img
|
|
||||||
key={ref.id}
|
|
||||||
src={ref.thumbnail}
|
|
||||||
alt=""
|
|
||||||
className="h-5 w-5 rounded-sm object-cover ring-1 ring-white/20"
|
|
||||||
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>
|
|
||||||
) : (
|
|
||||||
<Upload className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span className="capitalize tracking-wide">{cat}</span>
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{/* Clear all button */}
|
|
||||||
{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"
|
onClick={() => toggleReference(cat)}
|
||||||
onClick={(e) => { e.stopPropagation(); clearReferences(cat); }}
|
onDragOver={handleDragOver}
|
||||||
title={`Clear all ${cat} references`}
|
onDrop={(e) => handleDrop(e, cat)}
|
||||||
|
title={settings.provider === 'meta' && cat === 'subject'
|
||||||
|
? "Upload image to animate into video"
|
||||||
|
: undefined}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[10px] font-medium transition-all border relative overflow-hidden",
|
||||||
|
hasRefs
|
||||||
|
? "bg-purple-500/10 text-purple-200 border-purple-500/30 hover:bg-purple-500/20"
|
||||||
|
: "bg-white/5 text-white/40 border-white/5 hover:bg-white/10 hover:text-white/70 hover:border-white/10",
|
||||||
|
isUploading && "animate-pulse cursor-wait"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<X className="h-2.5 w-2.5" />
|
{isUploading ? (
|
||||||
|
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
) : hasRefs ? (
|
||||||
|
<div className="flex -space-x-1.5">
|
||||||
|
{refs.slice(0, 4).map((ref, idx) => (
|
||||||
|
<img
|
||||||
|
key={ref.id}
|
||||||
|
src={ref.thumbnail}
|
||||||
|
alt=""
|
||||||
|
className="h-4 w-4 rounded-sm object-cover ring-1 ring-white/20"
|
||||||
|
style={{ zIndex: 10 - idx }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Upload className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
<span className="capitalize tracking-wide">{cat}</span>
|
||||||
|
{refs.length > 0 && (
|
||||||
|
<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 */}
|
||||||
</div>
|
{hasRefs && !isUploading && (
|
||||||
);
|
<button
|
||||||
})}
|
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); }}
|
||||||
|
title={`Clear all ${cat} references`}
|
||||||
|
>
|
||||||
|
<X className="h-2 w-2" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hidden file inputs for upload */}
|
{/* Hidden file inputs for upload */}
|
||||||
|
|
@ -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(
|
||||||
{isGenerating ? (
|
"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="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
|
||||||
<span>Creating...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Sparkles className="h-4 w-4" />
|
|
||||||
<span>Create</span>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</GradientButton>
|
>
|
||||||
|
<div className="relative z-10 flex items-center gap-1.5">
|
||||||
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||||
|
<span className="animate-pulse">Dreaming...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles className="h-3 w-3 group-hover:rotate-12 transition-transform" />
|
||||||
|
<span>Generate</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 => (
|
{(() => {
|
||||||
<button
|
const priority = ['NAM', 'NỮ', 'SINH NHẬT', 'HALLOWEEN', 'NOEL', 'NEW YEAR', 'TRẺ EM', 'COUPLE', 'CHA - MẸ', 'MẸ BẦU', 'ĐẶC BIỆT'];
|
||||||
key={cat}
|
|
||||||
onClick={() => setSelectedCategory(cat)}
|
// Sort uniqueCategories (which only contains categories that exist in data)
|
||||||
className={cn(
|
const sortedCategories = uniqueCategories.sort((a, b) => {
|
||||||
"px-4 py-2 rounded-full text-sm font-medium transition-colors",
|
if (a === 'All') return -1;
|
||||||
selectedCategory === cat
|
if (b === 'All') return 1;
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-card hover:bg-secondary text-muted-foreground"
|
const idxA = priority.indexOf(a);
|
||||||
)}
|
const idxB = priority.indexOf(b);
|
||||||
>
|
|
||||||
{cat}
|
if (idxA !== -1 && idxB !== -1) return idxA - idxB;
|
||||||
</button>
|
if (idxA !== -1) return -1;
|
||||||
))}
|
if (idxB !== -1) return 1;
|
||||||
|
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
return sortedCategories.map(cat => (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
onClick={() => setSelectedCategory(cat)}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 text-sm font-bold uppercase tracking-wider transition-all duration-200 rounded-md whitespace-nowrap",
|
||||||
|
selectedCategory === cat
|
||||||
|
? "bg-[#8B1E1E] text-white border border-white/80 shadow-[0_0_12px_rgba(139,30,30,0.6)]" // Active: Deep Red + Glow
|
||||||
|
: "text-gray-400 hover:text-yellow-400 border border-transparent hover:bg-white/5" // Inactive: Yellow Hover
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</button>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -276,69 +311,137 @@ 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">
|
<>
|
||||||
<AnimatePresence mode="popLayout">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{finalPrompts.map((p) => (
|
<AnimatePresence mode="popLayout">
|
||||||
<motion.div
|
{paginatedPrompts.map((p) => (
|
||||||
key={p.id}
|
<motion.div
|
||||||
layout
|
key={p.id}
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
layout
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
exit={{ opacity: 0, scale: 0.9 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
className="group relative flex flex-col bg-card border rounded-xl overflow-hidden hover:border-primary/50 transition-all hover:shadow-lg"
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
>
|
className="group relative flex flex-col bg-card border rounded-xl overflow-hidden hover:border-primary/50 transition-all hover:shadow-lg"
|
||||||
{p.images && p.images.length > 0 ? (
|
>
|
||||||
<div className="aspect-video relative overflow-hidden bg-secondary/50">
|
{p.images && p.images.length > 0 ? (
|
||||||
<img
|
<div className="aspect-video relative overflow-hidden bg-secondary/50">
|
||||||
src={p.images[0]}
|
<img
|
||||||
alt={p.title}
|
src={p.images[0]}
|
||||||
className="object-cover w-full h-full transition-transform group-hover:scale-105"
|
alt={p.title}
|
||||||
loading="lazy"
|
className="object-cover w-full h-full transition-transform group-hover:scale-105"
|
||||||
/>
|
loading="lazy"
|
||||||
</div>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<div className="aspect-video bg-gradient-to-br from-secondary to-background p-4 flex items-center justify-center text-muted-foreground/20">
|
) : (
|
||||||
<Sparkles className="h-12 w-12" />
|
<div className="aspect-video bg-gradient-to-br from-secondary to-background p-4 flex items-center justify-center text-muted-foreground/20">
|
||||||
</div>
|
<Sparkles className="h-12 w-12" />
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="p-4 flex flex-col flex-1 gap-3">
|
<div className="p-4 flex flex-col flex-1 gap-3">
|
||||||
<div className="flex justify-between items-start gap-2">
|
<div className="flex justify-between items-start gap-2">
|
||||||
<h3 className="font-semibold line-clamp-1" title={p.title}>{p.title}</h3>
|
<h3 className="font-semibold line-clamp-1" title={p.title}>{p.title}</h3>
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary text-muted-foreground whitespace-nowrap">
|
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary text-muted-foreground whitespace-nowrap">
|
||||||
{p.source}
|
{p.source}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground line-clamp-3 flex-1 font-mono bg-secondary/30 p-2 rounded">
|
<p className="text-sm text-muted-foreground line-clamp-3 flex-1 font-mono bg-secondary/30 p-2 rounded">
|
||||||
{p.prompt}
|
{p.prompt}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-2 border-t mt-auto">
|
<div className="flex items-center justify-between pt-2 border-t mt-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSelect(p)}
|
onClick={() => handleSelect(p)}
|
||||||
className="text-xs font-medium text-primary hover:underline flex items-center gap-1"
|
className="text-xs font-medium text-primary hover:underline flex items-center gap-1"
|
||||||
>
|
>
|
||||||
Use Prompt
|
Use Prompt
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigator.clipboard.writeText(p.prompt)}
|
onClick={() => navigator.clipboard.writeText(p.prompt)}
|
||||||
className="p-1.5 text-muted-foreground hover:text-primary transition-colors"
|
className="p-1.5 text-muted-foreground hover:text-primary transition-colors"
|
||||||
title="Copy to clipboard"
|
title="Copy to clipboard"
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</motion.div>
|
))}
|
||||||
))}
|
</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 && (
|
||||||
|
|
|
||||||
|
|
@ -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,56 +90,79 @@ export function Settings() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{provider === 'grok' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">Grok API Key (Recommended)</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={grokApiKey}
|
|
||||||
onChange={(e) => setGrokApiKey(e.target.value)}
|
|
||||||
placeholder="xai-..."
|
|
||||||
className="w-full p-3 rounded-lg bg-secondary/50 border border-border focus:ring-2 focus:ring-primary/50 outline-none font-mono text-sm"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Get your API key from <a href="https://console.x.ai" target="_blank" className="underline hover:text-primary">console.x.ai</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<div className="w-full border-t border-border"></div>
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs">
|
|
||||||
<span className="bg-card px-2 text-muted-foreground">or use cookies</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-muted-foreground">Grok Cookies (Alternative)</label>
|
|
||||||
<textarea
|
|
||||||
value={grokCookies}
|
|
||||||
onChange={(e) => setGrokCookies(e.target.value)}
|
|
||||||
placeholder="Paste cookies from grok.com..."
|
|
||||||
className="w-full h-24 p-3 rounded-lg bg-secondary/50 border border-border focus:ring-2 focus:ring-primary/50 outline-none font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Get from logged-in <a href="https://grok.com" target="_blank" className="underline hover:text-primary">grok.com</a> session.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{provider === 'meta' && (
|
{provider === 'meta' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
<label className="text-sm font-medium">Meta AI Cookies</label>
|
|
||||||
<textarea
|
{/* Advanced Settings (Hidden by default) */}
|
||||||
value={metaCookies}
|
<details className="group mb-4">
|
||||||
onChange={(e) => setMetaCookies(e.target.value)}
|
<summary className="flex items-center gap-2 cursor-pointer text-xs text-white/40 hover:text-white/60 mb-2 select-none">
|
||||||
placeholder="Paste cookies from meta.ai..."
|
<Settings2 className="h-3 w-3" />
|
||||||
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"
|
<span>Advanced Configuration</span>
|
||||||
/>
|
</summary>
|
||||||
<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).
|
<div className="pl-4 border-l border-white/5 space-y-4 mb-4">
|
||||||
</p>
|
<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">
|
||||||
|
<label className="text-sm font-medium text-white/70">Free Wrapper URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={metaFreeWrapperUrl}
|
||||||
|
onChange={(e) => setMetaFreeWrapperUrl(e.target.value)}
|
||||||
|
placeholder="http://localhost:8000"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-white/5">
|
||||||
|
<p className="text-sm font-medium mb-3 text-amber-400">Authentication Required</p>
|
||||||
|
|
||||||
|
{/* Meta AI Cookies */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<label className="text-sm font-medium">Meta.ai Cookies</label>
|
||||||
|
<textarea
|
||||||
|
value={metaCookies}
|
||||||
|
onChange={(e) => setMetaCookies(e.target.value)}
|
||||||
|
placeholder="Paste cookies from meta.ai..."
|
||||||
|
className="w-full h-32 p-3 rounded-lg bg-secondary/50 border border-border focus:ring-2 focus:ring-primary/50 outline-none font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Get from logged-in <a href="https://www.meta.ai" target="_blank" className="underline hover:text-primary">meta.ai</a> session.
|
||||||
|
</p>
|
||||||
|
</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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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
36969
data/prompts.json
|
|
@ -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"
|
||||||
|
|
|
||||||
322
lib/crawler.ts
|
|
@ -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:  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";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -27,15 +27,31 @@ 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.parseSessionFromCookies();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -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
|
||||||
return images;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,78 +538,71 @@ 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));
|
||||||
|
|
||||||
|
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: "25290569913909283", // KadabraPromptRootQuery ID
|
||||||
|
...(this.session.lsd && { lsd: this.session.lsd }),
|
||||||
|
...(this.session.fb_dtsg && { fb_dtsg: this.session.fb_dtsg })
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Query for the message status
|
const response = await fetch(GRAPHQL_ENDPOINT, {
|
||||||
const statusResponse = await this.queryMessageStatus(fetchId);
|
method: "POST",
|
||||||
const images = this.extractImages(statusResponse, prompt);
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Cookie": this.cookies,
|
||||||
|
"Origin": META_AI_BASE,
|
||||||
|
...(this.session.accessToken && { "Authorization": `OAuth ${this.session.accessToken}` })
|
||||||
|
},
|
||||||
|
body: body.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const images = this.extractImages(data, prompt);
|
||||||
|
|
||||||
if (images.length > 0) {
|
if (images.length > 0) {
|
||||||
console.log(`[Meta AI] Got ${images.length} images!`);
|
console.log(`[Meta AI] Got ${images.length} image(s) after polling!`);
|
||||||
return images;
|
return images;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if generation failed
|
// Check for failure status
|
||||||
const status = statusResponse?.data?.node?.imagine_card?.session?.status;
|
const status = data?.data?.kadabra_prompt?.status;
|
||||||
if (status === "FAILED" || status === "ERROR") {
|
if (status === "FAILED" || status === "ERROR") {
|
||||||
throw new Error("Meta AI image generation failed");
|
console.error("[Meta AI] Generation failed during polling");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
console.error("[Meta AI] Poll error:", e);
|
console.error("[Meta AI] Poll error:", e.message);
|
||||||
if (attempt === maxAttempts - 1) throw e;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("Meta AI: Image generation timed out");
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Query message status for polling
|
|
||||||
*/
|
|
||||||
private async queryMessageStatus(messageId: string): Promise<any> {
|
|
||||||
const variables = {
|
|
||||||
id: messageId
|
|
||||||
};
|
|
||||||
|
|
||||||
const body = new URLSearchParams({
|
|
||||||
fb_api_caller_class: "RelayModern",
|
|
||||||
fb_api_req_friendly_name: "useAbraMessageQuery",
|
|
||||||
variables: JSON.stringify(variables),
|
|
||||||
doc_id: "7654946557897648",
|
|
||||||
...(this.session.lsd && { lsd: this.session.lsd }),
|
|
||||||
...(this.session.fb_dtsg && { fb_dtsg: this.session.fb_dtsg })
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(GRAPHQL_ENDPOINT, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
"Cookie": this.cookies,
|
|
||||||
"Origin": META_AI_BASE,
|
|
||||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
||||||
...(this.session.accessToken && { "Authorization": `OAuth ${this.session.accessToken}` })
|
|
||||||
},
|
|
||||||
body: body.toString()
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download image from URL and convert to base64
|
* Download image from URL and convert to base64
|
||||||
|
|
|
||||||
37
lib/store.ts
|
|
@ -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
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
@ -33,4 +33,4 @@
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^1.0.0"
|
"vitest": "^1.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BIN
public/images/prompts/1.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
public/images/prompts/10.png
Normal file
|
After Width: | Height: | Size: 381 KiB |
BIN
public/images/prompts/100.png
Normal file
|
After Width: | Height: | Size: 287 KiB |
BIN
public/images/prompts/1000.png
Normal file
|
After Width: | Height: | Size: 349 KiB |
BIN
public/images/prompts/1001.png
Normal file
|
After Width: | Height: | Size: 386 KiB |
BIN
public/images/prompts/1002.png
Normal file
|
After Width: | Height: | Size: 298 KiB |
BIN
public/images/prompts/1003.png
Normal file
|
After Width: | Height: | Size: 454 KiB |
BIN
public/images/prompts/1004.png
Normal file
|
After Width: | Height: | Size: 405 KiB |
BIN
public/images/prompts/1005.png
Normal file
|
After Width: | Height: | Size: 432 KiB |
BIN
public/images/prompts/1006.png
Normal file
|
After Width: | Height: | Size: 323 KiB |
BIN
public/images/prompts/1007.png
Normal file
|
After Width: | Height: | Size: 341 KiB |
BIN
public/images/prompts/1008.png
Normal file
|
After Width: | Height: | Size: 413 KiB |
BIN
public/images/prompts/1009.png
Normal file
|
After Width: | Height: | Size: 504 KiB |
BIN
public/images/prompts/101.png
Normal file
|
After Width: | Height: | Size: 398 KiB |
BIN
public/images/prompts/1010.png
Normal file
|
After Width: | Height: | Size: 483 KiB |
BIN
public/images/prompts/1011.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
public/images/prompts/1012.png
Normal file
|
After Width: | Height: | Size: 412 KiB |
BIN
public/images/prompts/1013.png
Normal file
|
After Width: | Height: | Size: 348 KiB |
BIN
public/images/prompts/1014.png
Normal file
|
After Width: | Height: | Size: 337 KiB |
BIN
public/images/prompts/1015.png
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
public/images/prompts/1016.png
Normal file
|
After Width: | Height: | Size: 432 KiB |
BIN
public/images/prompts/1017.png
Normal file
|
After Width: | Height: | Size: 373 KiB |
BIN
public/images/prompts/1018.png
Normal file
|
After Width: | Height: | Size: 443 KiB |
BIN
public/images/prompts/1019.png
Normal file
|
After Width: | Height: | Size: 361 KiB |
BIN
public/images/prompts/102.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
public/images/prompts/1020.png
Normal file
|
After Width: | Height: | Size: 368 KiB |
BIN
public/images/prompts/1021.png
Normal file
|
After Width: | Height: | Size: 350 KiB |
BIN
public/images/prompts/1022.png
Normal file
|
After Width: | Height: | Size: 284 KiB |
BIN
public/images/prompts/1023.png
Normal file
|
After Width: | Height: | Size: 419 KiB |
BIN
public/images/prompts/1024.png
Normal file
|
After Width: | Height: | Size: 366 KiB |
BIN
public/images/prompts/1025.png
Normal file
|
After Width: | Height: | Size: 377 KiB |
BIN
public/images/prompts/1026.png
Normal file
|
After Width: | Height: | Size: 379 KiB |
BIN
public/images/prompts/1027.jpg
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
public/images/prompts/1028.png
Normal file
|
After Width: | Height: | Size: 452 KiB |
BIN
public/images/prompts/1029.png
Normal file
|
After Width: | Height: | Size: 369 KiB |
BIN
public/images/prompts/103.png
Normal file
|
After Width: | Height: | Size: 422 KiB |
BIN
public/images/prompts/1030.png
Normal file
|
After Width: | Height: | Size: 383 KiB |
BIN
public/images/prompts/1031.png
Normal file
|
After Width: | Height: | Size: 457 KiB |
BIN
public/images/prompts/1032.png
Normal file
|
After Width: | Height: | Size: 379 KiB |
BIN
public/images/prompts/1033.png
Normal file
|
After Width: | Height: | Size: 462 KiB |
BIN
public/images/prompts/1034.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
public/images/prompts/1035.png
Normal file
|
After Width: | Height: | Size: 456 KiB |
BIN
public/images/prompts/1036.png
Normal file
|
After Width: | Height: | Size: 407 KiB |
BIN
public/images/prompts/1037.png
Normal file
|
After Width: | Height: | Size: 429 KiB |
BIN
public/images/prompts/1038.png
Normal file
|
After Width: | Height: | Size: 398 KiB |
BIN
public/images/prompts/1039.png
Normal file
|
After Width: | Height: | Size: 453 KiB |
BIN
public/images/prompts/104.png
Normal file
|
After Width: | Height: | Size: 478 KiB |
BIN
public/images/prompts/1040.png
Normal file
|
After Width: | Height: | Size: 363 KiB |
BIN
public/images/prompts/1041.png
Normal file
|
After Width: | Height: | Size: 293 KiB |
BIN
public/images/prompts/1042.png
Normal file
|
After Width: | Height: | Size: 365 KiB |
BIN
public/images/prompts/1043.jpg
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
public/images/prompts/1044.png
Normal file
|
After Width: | Height: | Size: 450 KiB |
BIN
public/images/prompts/1045.png
Normal file
|
After Width: | Height: | Size: 408 KiB |
BIN
public/images/prompts/1046.png
Normal file
|
After Width: | Height: | Size: 330 KiB |
BIN
public/images/prompts/1047.png
Normal file
|
After Width: | Height: | Size: 398 KiB |
BIN
public/images/prompts/1048.png
Normal file
|
After Width: | Height: | Size: 403 KiB |
BIN
public/images/prompts/1049.png
Normal file
|
After Width: | Height: | Size: 361 KiB |
BIN
public/images/prompts/105.png
Normal file
|
After Width: | Height: | Size: 383 KiB |
BIN
public/images/prompts/1050.png
Normal file
|
After Width: | Height: | Size: 367 KiB |
BIN
public/images/prompts/1051.png
Normal file
|
After Width: | Height: | Size: 393 KiB |
BIN
public/images/prompts/1052.png
Normal file
|
After Width: | Height: | Size: 377 KiB |
BIN
public/images/prompts/1053.png
Normal file
|
After Width: | Height: | Size: 463 KiB |
BIN
public/images/prompts/1054.png
Normal file
|
After Width: | Height: | Size: 460 KiB |
BIN
public/images/prompts/1055.png
Normal file
|
After Width: | Height: | Size: 264 KiB |
BIN
public/images/prompts/1056.png
Normal file
|
After Width: | Height: | Size: 373 KiB |
BIN
public/images/prompts/1057.png
Normal file
|
After Width: | Height: | Size: 255 KiB |
BIN
public/images/prompts/1058.png
Normal file
|
After Width: | Height: | Size: 375 KiB |
BIN
public/images/prompts/1059.png
Normal file
|
After Width: | Height: | Size: 427 KiB |
BIN
public/images/prompts/106.png
Normal file
|
After Width: | Height: | Size: 482 KiB |
BIN
public/images/prompts/1060.png
Normal file
|
After Width: | Height: | Size: 522 KiB |
BIN
public/images/prompts/1061.png
Normal file
|
After Width: | Height: | Size: 418 KiB |
BIN
public/images/prompts/1062.png
Normal file
|
After Width: | Height: | Size: 428 KiB |
BIN
public/images/prompts/1063.jpg
Normal file
|
After Width: | Height: | Size: 172 KiB |