// server/handlers/image-gen.ts — Multi-provider image generation with batch/parallel support
// Providers: OpenAI (DALL-E 3), Google (Imagen 3), Gemini (native image generation)
// Auto-uploads to Supabase storage and inserts into store_media table
import type { SupabaseClient } from "@supabase/supabase-js";
import OpenAI from "openai";
import { GoogleGenAI } from "@google/genai";
// ============================================================================
// TYPES
// ============================================================================
type ImageProvider = "openai" | "imagen" | "gemini" | "gemini3";
interface GeneratedImage {
id: string;
provider: ImageProvider;
model: string;
prompt: string;
file_url: string;
file_name: string;
file_size: number;
media_id: string;
}
// ============================================================================
// CREDENTIAL RESOLUTION
// ============================================================================
interface ImageCredentials {
openai?: string;
google?: string;
}
async function getImageCredentials(sb: SupabaseClient, storeId: string): Promise<ImageCredentials> {
const creds: ImageCredentials = {};
const keys = ["OPENAI_API_KEY", "GOOGLE_AI_API_KEY"] as const;
const results = await Promise.all(
keys.map(async (k) => {
try {
const r = await sb.rpc("decrypt_secret", { p_name: k, p_store_id: storeId });
return r.data as string | null;
} catch { return null; }
}),
);
if (results[0]) creds.openai = results[0];
if (results[1]) creds.google = results[1];
return creds;
}
// ============================================================================
// PROVIDER IMPLEMENTATIONS
// ============================================================================
async function generateWithDalle(
apiKey: string,
prompt: string,
size: string,
quality: string,
style: string,
): Promise<{ base64: string; revisedPrompt: string }> {
const client = new OpenAI({ apiKey });
const validSizes = ["1024x1024", "1024x1792", "1792x1024"];
const selectedSize = validSizes.includes(size) ? size as "1024x1024" | "1024x1792" | "1792x1024" : "1024x1024";
const resp = await client.images.generate({
model: "dall-e-3",
prompt,
n: 1,
size: selectedSize,
quality: quality === "hd" ? "hd" : "standard",
style: style === "natural" ? "natural" : "vivid",
response_format: "b64_json",
});
const first = resp.data?.[0];
if (!first?.b64_json) throw new Error("DALL-E returned no image data");
return {
base64: first.b64_json,
revisedPrompt: first.revised_prompt || prompt,
};
}
async function generateWithImagen(
apiKey: string,
prompt: string,
aspectRatio: string,
): Promise<{ base64: string }> {
const client = new GoogleGenAI({ apiKey });
const validRatios = ["1:1", "3:4", "4:3", "9:16", "16:9"];
const selectedRatio = validRatios.includes(aspectRatio) ? aspectRatio : "1:1";
const response = await client.models.generateImages({
model: "imagen-4.0-generate-001",
prompt,
config: {
numberOfImages: 1,
aspectRatio: selectedRatio,
},
});
const image = response.generatedImages?.[0];
if (!image?.image?.imageBytes) {
throw new Error("Imagen returned no image data");
}
return { base64: image.image.imageBytes };
}
async function generateWithGemini(
apiKey: string,
prompt: string,
model: string = "gemini-2.5-flash-image",
): Promise<{ base64: string; mimeType: string }> {
const client = new GoogleGenAI({ apiKey });
const response = await client.models.generateContent({
model,
contents: prompt,
config: {
responseModalities: ["TEXT", "IMAGE"],
},
});
// Find image part in response
const parts = response.candidates?.[0]?.content?.parts;
if (!parts) throw new Error("Gemini returned no content parts");
for (const part of parts) {
if (part.inlineData?.data) {
return {
base64: part.inlineData.data,
mimeType: part.inlineData.mimeType || "image/png",
};
}
}
throw new Error("Gemini response contained no image data");
}
// ============================================================================
// STORAGE + MEDIA HELPERS
// ============================================================================
async function uploadAndRecord(
sb: SupabaseClient,
storeId: string,
base64Data: string,
prompt: string,
provider: ImageProvider,
model: string,
mimeType: string = "image/png",
): Promise<GeneratedImage> {
const id = crypto.randomUUID();
const ext = mimeType.includes("jpeg") || mimeType.includes("jpg") ? "jpg" : "png";
const fileName = `${id}.${ext}`;
const storeIdUpper = storeId.toUpperCase();
const storagePath = `ai-generated/${storeIdUpper}/standalone/${fileName}`;
// Decode base64 to buffer
const buffer = Buffer.from(base64Data, "base64");
// Upload to Supabase storage
const { error: uploadErr } = await sb.storage
.from("product-images")
.upload(storagePath, buffer, {
contentType: mimeType,
upsert: true,
});
if (uploadErr) {
throw new Error(`Storage upload failed: ${uploadErr.message}`);
}
// Get public URL
const { data: urlData } = sb.storage.from("product-images").getPublicUrl(storagePath);
const fileUrl = urlData.publicUrl;
// Determine AI tags based on provider
const aiTags = [
provider === "openai" ? "DALL-E 3" : provider === "imagen" ? "Imagen 3" : "Gemini",
"AI Generated",
];
// Insert into store_media
const { data: mediaRow, error: mediaErr } = await sb
.from("store_media")
.insert({
store_id: storeId,
file_name: fileName,
file_path: storagePath,
file_url: fileUrl,
file_size: buffer.length,
file_type: mimeType,
category: "ai_generated",
ai_tags: aiTags,
ai_description: prompt.substring(0, 500),
source: `mcp-${provider}`,
folder: "ai-generated",
})
.select("id")
.single();
if (mediaErr) {
console.error("[image-gen] store_media insert error:", mediaErr.message);
}
return {
id,
provider,
model,
prompt,
file_url: fileUrl,
file_name: fileName,
file_size: buffer.length,
media_id: mediaRow?.id || id,
};
}
// ============================================================================
// HANDLER
// ============================================================================
export async function handleImageGen(
sb: SupabaseClient,
args: Record<string, unknown>,
storeId?: string,
): Promise<{ success: boolean; data?: unknown; error?: string }> {
const action = args.action as string;
if (!storeId) {
return { success: false, error: "store_id is required" };
}
switch (action) {
// ================================================================
// GENERATE — single image generation
// ================================================================
case "generate": {
const prompt = args.prompt as string;
if (!prompt) return { success: false, error: "prompt is required" };
const provider = (args.provider as ImageProvider) || "openai";
const size = (args.size as string) || "1024x1024";
const quality = (args.quality as string) || "standard";
const style = (args.style as string) || "vivid";
const aspectRatio = (args.aspect_ratio as string) || "1:1";
const creds = await getImageCredentials(sb, storeId);
try {
let base64: string;
let model: string;
let mimeType = "image/png";
switch (provider) {
case "openai": {
if (!creds.openai) return { success: false, error: "OpenAI API key not configured" };
const result = await generateWithDalle(creds.openai, prompt, size, quality, style);
base64 = result.base64;
model = "dall-e-3";
break;
}
case "imagen": {
if (!creds.google) return { success: false, error: "Google AI API key not configured" };
const result = await generateWithImagen(creds.google, prompt, aspectRatio);
base64 = result.base64;
model = "imagen-4.0-generate-001";
break;
}
case "gemini": {
if (!creds.google) return { success: false, error: "Google AI API key not configured" };
const result = await generateWithGemini(creds.google, prompt);
base64 = result.base64;
model = "gemini-2.5-flash-image";
mimeType = result.mimeType;
break;
}
case "gemini3": {
if (!creds.google) return { success: false, error: "Google AI API key not configured" };
const result = await generateWithGemini(creds.google, prompt, "gemini-3-pro-image-preview");
base64 = result.base64;
model = "gemini-3-pro-image-preview";
mimeType = result.mimeType;
break;
}
default:
return { success: false, error: `Unknown provider: ${provider}. Use openai, imagen, gemini, or gemini3` };
}
const image = await uploadAndRecord(sb, storeId, base64, prompt, provider, model, mimeType);
return { success: true, data: image };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, error: `Image generation failed: ${msg}` };
}
}
// ================================================================
// BATCH — parallel batch generation (multiple prompts and/or providers)
// ================================================================
case "batch": {
const items = args.items as Array<{
prompt: string;
provider?: ImageProvider;
size?: string;
quality?: string;
style?: string;
aspect_ratio?: string;
}>;
if (!items || !Array.isArray(items) || items.length === 0) {
return { success: false, error: "items array is required with at least one {prompt} entry" };
}
if (items.length > 10) {
return { success: false, error: "Maximum 10 images per batch" };
}
const creds = await getImageCredentials(sb, storeId);
// Run all generations in parallel
const results = await Promise.allSettled(
items.map(async (item) => {
const provider = item.provider || "openai";
const size = item.size || "1024x1024";
const quality = item.quality || "standard";
const style = item.style || "vivid";
const aspectRatio = item.aspect_ratio || "1:1";
let base64: string;
let model: string;
let mimeType = "image/png";
switch (provider) {
case "openai": {
if (!creds.openai) throw new Error("OpenAI API key not configured");
const result = await generateWithDalle(creds.openai, item.prompt, size, quality, style);
base64 = result.base64;
model = "dall-e-3";
break;
}
case "imagen": {
if (!creds.google) throw new Error("Google AI API key not configured");
const result = await generateWithImagen(creds.google, item.prompt, aspectRatio);
base64 = result.base64;
model = "imagen-4.0-generate-001";
break;
}
case "gemini": {
if (!creds.google) throw new Error("Google AI API key not configured");
const result = await generateWithGemini(creds.google, item.prompt);
base64 = result.base64;
model = "gemini-2.5-flash-image";
mimeType = result.mimeType;
break;
}
case "gemini3": {
if (!creds.google) throw new Error("Google AI API key not configured");
const result = await generateWithGemini(creds.google, item.prompt, "gemini-3-pro-image-preview");
base64 = result.base64;
model = "gemini-3-pro-image-preview";
mimeType = result.mimeType;
break;
}
default:
throw new Error(`Unknown provider: ${provider}`);
}
return uploadAndRecord(sb, storeId, base64, item.prompt, provider, model, mimeType);
}),
);
const images: GeneratedImage[] = [];
const errors: Array<{ index: number; prompt: string; error: string }> = [];
results.forEach((r, i) => {
if (r.status === "fulfilled") {
images.push(r.value);
} else {
errors.push({
index: i,
prompt: items[i].prompt,
error: r.reason instanceof Error ? r.reason.message : String(r.reason),
});
}
});
return {
success: true,
data: {
generated: images.length,
failed: errors.length,
total: items.length,
images,
...(errors.length > 0 ? { errors } : {}),
},
};
}
// ================================================================
// LIST — list generated images from store_media
// ================================================================
case "list": {
const limit = Math.min((args.limit as number) || 25, 100);
const category = (args.category as string) || "ai_generated";
const folder = args.folder as string | undefined;
let query = sb
.from("store_media")
.select("id, file_name, file_url, file_size, file_type, category, ai_tags, ai_description, source, folder, created_at")
.eq("store_id", storeId)
.eq("status", "active")
.order("created_at", { ascending: false })
.limit(limit);
if (category !== "all") {
query = query.eq("category", category);
}
if (folder) {
query = query.eq("folder", folder);
}
const { data, error } = await query;
if (error) return { success: false, error: error.message };
return { success: true, data: { images: data, count: data?.length || 0 } };
}
// ================================================================
// GET — get a specific image by media_id
// ================================================================
case "get": {
const mediaId = args.media_id as string;
if (!mediaId) return { success: false, error: "media_id is required" };
const { data, error } = await sb
.from("store_media")
.select("*")
.eq("id", mediaId)
.eq("store_id", storeId)
.single();
if (error) return { success: false, error: error.message };
return { success: true, data };
}
// ================================================================
// DELETE — soft-delete an image (set status to archived)
// ================================================================
case "delete": {
const mediaId = args.media_id as string;
if (!mediaId) return { success: false, error: "media_id is required" };
const { error } = await sb
.from("store_media")
.update({ status: "archived", updated_at: new Date().toISOString() })
.eq("id", mediaId)
.eq("store_id", storeId);
if (error) return { success: false, error: error.message };
return { success: true, data: { deleted: mediaId } };
}
// ================================================================
// PROVIDERS — list available image generation providers
// ================================================================
case "providers": {
const creds = await getImageCredentials(sb, storeId);
return {
success: true,
data: {
providers: [
{
provider: "openai",
model: "dall-e-3",
configured: !!creds.openai,
sizes: ["1024x1024", "1024x1792", "1792x1024"],
quality_options: ["standard", "hd"],
style_options: ["vivid", "natural"],
},
{
provider: "imagen",
model: "imagen-4.0-generate-001",
configured: !!creds.google,
aspect_ratios: ["1:1", "3:4", "4:3", "9:16", "16:9"],
},
{
provider: "gemini",
model: "gemini-2.5-flash-image",
configured: !!creds.google,
note: "Gemini 2.5 Flash native image generation (Nano Banana)",
},
{
provider: "gemini3",
model: "gemini-3-pro-image-preview",
configured: !!creds.google,
note: "Gemini 3 Pro image generation — highest quality Google model",
},
],
},
};
}
default:
return { success: false, error: `Unknown action: ${action}. Valid: generate, batch, list, get, delete, providers` };
}
}