/** * Generate images using Gemini with reference images for style matching. * * Usage: * pnpm action generate-image --prompt "description" * pnpm action generate-image --prompt "description" --slide-content "
...
" * pnpm action generate-image --prompt "description" --deck-id "vkkvhkbJ_Q" --slide-id "sko-21" * pnpm action generate-image --prompt "description" --count 3 --output public/assets/generated/img * * Options: * --prompt Image description (required) * --slide-content HTML content of the current slide (primary context) * --deck-id Deck ID to load full deck text as secondary context * --slide-id Slide ID within the deck (used with --deck-id to highlight current slide) * --model Provider: 'gemini', 'openai', or 'auto' (default: auto) * --reference-image-urls Comma-separated URLs of extra reference images * --count Number of variations to generate (default: 1) * --output Output file path prefix (e.g. public/assets/generated/slide21) * Files will be named {prefix}-v1.png, {prefix}-v2.png, etc. * --help Show this help */ const config = async () => { try { const m = await import("dotenv"); m.config(); } catch {} }; import { readFileSync, writeFileSync, mkdirSync } from "fs"; import { dirname, join } from "path"; import { isBlockedExtensionUrlWithDns } from "@agent-native/core/extensions/url-safety"; import pLimit from "p-limit"; import { DEFAULT_STYLE_REFERENCE_URLS } from "../shared/api.js"; function parseArgs(args: string[]): Record { const result: Record = {}; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg.startsWith("--")) { const key = arg.slice(2); const next = args[i + 1]; if (next && !next.startsWith("--")) { result[key] = next; i++; } else { result[key] = "true"; } } } return result; } /** Strip HTML tags to extract plain text from slide content */ function stripHtml(html: string): string { return html .replace(//gi, "\n") .replace(/<[^>]+>/g, " ") .replace(/&[a-z]+;/gi, " ") .replace(/&#x[0-9a-f]+;/gi, "") .replace(/\s+/g, " ") .trim(); } /** Load a deck JSON and extract text context */ function loadDeckContext( deckId: string, slideId?: string, ): { slideContent?: string; deckText: string } { // Try to find the deck file const deckPath = join("data", "decks", `${deckId}.json`); try { const raw = readFileSync(deckPath, "utf-8"); const deck = JSON.parse(raw); const slides = deck.slides || []; let slideContent: string | undefined; const textParts: string[] = [`Deck: ${deck.title || deckId}`]; for (const slide of slides) { const text = stripHtml(slide.content || ""); const isCurrent = slideId && slide.id === slideId; if (isCurrent) { slideContent = slide.content; textParts.push(`[CURRENT SLIDE ${slide.id}]: ${text}`); } else { textParts.push(`Slide ${slide.id}: ${text}`); } } return { slideContent, deckText: textParts.join("\n"), }; } catch (err: any) { console.warn(`Could not load deck ${deckId}: ${err.message}`); return { deckText: "" }; } } export default async function main(args: string[]) { await config(); const opts = parseArgs(args); if (opts["help"]) { console.log(`Usage: pnpm action generate-image --prompt "description" [options] Options: --prompt Image description (required) --slide-content HTML content of the current slide (primary context) --deck-id Deck ID to load full deck text as secondary context --slide-id Slide ID within the deck (highlights current slide) --model Provider: 'gemini', 'openai', or 'auto' (default: auto) --reference-image-urls Comma-separated URLs of extra reference images --count Number of variations (default: 1) --output Output file path prefix (files: {prefix}-v1.png, etc.) --help Show this help`); return; } const prompt = opts["prompt"]; if (!prompt) { console.error("Error: --prompt is required"); throw new Error("Script failed"); } // ── A2A delegation to the Images app ──────────────────────────────────── // If the workspace has the images app deployed and IMAGES_A2A_URL + // IMAGES_A2A_KEY are set (typically via per-user/org credentials), prefer // delegating to it so the generated image is grounded in the user's brand // library rather than the slides app's generic style references. We pick up // the A2A endpoint via env so this works in single-tenant deploys; for // multi-tenant we resolve credentials per-request. // // On any failure (network error, A2A timeout, no library matched, etc.) we // fall through to the existing direct-Gemini path so slides keeps working // standalone. See the `image-generation-via-a2a` skill for the contract. const imagesA2AUrl = ( process.env.IMAGES_A2A_URL || process.env.AGENT_NATIVE_IMAGES_URL || "" ).trim(); const imagesA2AKey = ( process.env.IMAGES_A2A_KEY || process.env.AGENT_NATIVE_IMAGES_KEY || process.env.A2A_SECRET || "" ).trim(); if (imagesA2AUrl) { try { const { callAgent } = await import("@agent-native/core/a2a"); const slideHints: string[] = []; if (opts["deck-id"]) slideHints.push(`deckId: ${opts["deck-id"]}`); if (opts["slide-id"]) slideHints.push(`slideId: ${opts["slide-id"]}`); if (opts["slide-content"]) { slideHints.push( `slideContent: ${stripHtml(opts["slide-content"]).slice(0, 280)}`, ); } const message = `Generate ${opts["count"] ?? "1"} brand-consistent image candidate(s) ` + `for an agent-native slides deck.\n\n` + `Prompt: ${prompt}\n` + `Aspect ratio: 16:9\n` + (slideHints.length ? `Slide context: ${slideHints.join(", ")}\n` : "") + `\nPick the best matching library via match-library if no libraryId is ` + `obvious. Return previewUrl + downloadUrl in the response so the slides ` + `agent can drop them into the slide HTML. When calling Images actions, ` + `set source: "a2a" and callerAppId: "slides" for audit logging.`; const replyText = await callAgent(imagesA2AUrl, message, { apiKey: imagesA2AKey || undefined, timeoutMs: 240_000, }); // Successful A2A response — print it verbatim so the calling agent can // parse the URLs out of the reply. console.log(replyText); return; } catch (err: any) { console.warn( `[slides/generate-image] A2A delegation to ${imagesA2AUrl} failed; ` + `falling back to direct provider. Error: ${err?.message ?? err}`, ); // Fall through to the direct provider path below. } } // Validate that at least one provider is configured const { getProvider } = await import("../server/handlers/image-providers/index.js"); const modelChoice = opts["model"] || "auto"; let provider: Awaited>; try { provider = await getProvider(modelChoice); } catch { console.error( "Error: No image generation provider configured. Save GEMINI_API_KEY or OPENAI_API_KEY in settings.", ); throw new Error("Script failed"); } const count = parseInt(opts["count"] || "1", 10); const outputPrefix = opts["output"]; const extraReferenceUrls = opts["reference-image-urls"] ? opts["reference-image-urls"].split(",").map((u) => u.trim()) : []; // Build context from slide content and/or deck let slideContent = opts["slide-content"]; let deckText = ""; if (opts["deck-id"]) { const deckCtx = loadDeckContext(opts["deck-id"], opts["slide-id"]); if (!slideContent && deckCtx.slideContent) { slideContent = deckCtx.slideContent; } deckText = deckCtx.deckText; console.log(`Loaded deck context: ${deckCtx.deckText.length} chars`); } const context = slideContent || deckText ? { slideContent, deckText } : undefined; // Always include default style references + any extra ones const referenceUrls = [ ...DEFAULT_STYLE_REFERENCE_URLS, ...extraReferenceUrls, ]; // Load reference images from URLs in parallel (capped concurrency to avoid // overwhelming the network and to keep the agent within its run budget). const refFetchLimit = pLimit(4); const refImages = ( await Promise.all( referenceUrls.map((url) => refFetchLimit(async () => { try { console.log(`Loading reference image: ${url}`); // SSRF guard: extra reference URLs are agent-supplied. Block // private/internal targets and do not follow redirects into them. if (await isBlockedExtensionUrlWithDns(url)) { console.warn(`Blocked private/internal reference image: ${url}`); return null; } const res = await fetch(url, { signal: AbortSignal.timeout(8000), redirect: "manual", }); if (res.status >= 300 && res.status < 400) { console.warn(`Refusing redirected reference image: ${url}`); return null; } if (!res.ok) { console.warn(`Failed to load reference image: ${url}`); return null; } const contentType = res.headers.get("content-type") || "image/png"; const buffer = Buffer.from(await res.arrayBuffer()); return { data: buffer.toString("base64"), mimeType: contentType.split(";")[0].trim(), }; } catch (err: any) { console.warn( `Error loading reference image ${url}: ${err.message}`, ); return null; } }), ), ) ).filter((r): r is { data: string; mimeType: string } => r !== null); console.log(`\nGenerating ${count} image(s) with prompt: "${prompt}"`); console.log( `Using ${refImages.length} reference image(s) for style matching`, ); if (context) { console.log( `With context: slide content=${!!slideContent}, deck text=${deckText.length > 0}`, ); } // Ensure output directory exists if (outputPrefix) { mkdirSync(dirname(outputPrefix), { recursive: true }); } // Generate variations concurrently. Default to 2 in flight to stay under the // image-provider rate limits (Gemini and OpenAI both have low TPM/RPM caps); // tunable via IMAGE_GEN_CONCURRENCY without redeploying. const genLimit = pLimit( Math.max(1, Number(process.env.IMAGE_GEN_CONCURRENCY) || 2), ); const variantResults = await Promise.allSettled( Array.from({ length: count }, (_, i) => genLimit(async () => { console.log(`\nGenerating variation ${i + 1}/${count}...`); const result = await provider.generate(prompt, refImages, context); return { i, result }; }), ), ); const generatedFiles: string[] = []; for (const settled of variantResults) { if (settled.status === "rejected") { const err = settled.reason as { message?: string } | undefined; console.error(`Failed to generate variation: ${err?.message ?? err}`); continue; } const { i, result } = settled.value; if (outputPrefix) { const filePath = `${outputPrefix}-v${i + 1}.png`; writeFileSync(filePath, result.imageData); generatedFiles.push(filePath); console.log( `Saved: ${filePath} (${Math.round(result.imageData.length / 1024)}KB)`, ); } else { const dataUrl = `data:${result.mimeType};base64,${result.imageData.toString("base64")}`; console.log(`\nGenerated image ${i + 1}:`); console.log(` MIME type: ${result.mimeType}`); console.log(` Size: ${Math.round(result.imageData.length / 1024)}KB`); console.log( ` Data URL (first 100 chars): ${dataUrl.substring(0, 100)}...`, ); } } if (generatedFiles.length > 0) { console.log(`\n✓ Generated ${generatedFiles.length} image(s):`); for (const f of generatedFiles) { console.log(` ${f}`); } } console.log("\nDone!"); }