Leonardo AI: Generative Art in Engineering Workflows
Automated asset generation, style consistency, and API patterns that saved our design team 20 hours per week — integrating Leonardo AI into our pipeline.
The Problem
Imagine you need 47 unique hero images for a product launch. Each one needs specific branding, colors, and layout. Your design team says it’ll take 3 weeks. The launch is in 10 days.
That’s the kind of crunch that makes you look for a better way.
Before Leonardo AI, generating a single image took 45 to 90 minutes. A designer would open Figma, create the layout, tweak colors, export, and repeat. For every locale, every campaign, every variant. The team was spending 22 hours per week on repetitive asset work — not creative strategy, just grinding through the same steps over and over.
Why this matters: If you’ve ever needed 10, 50, or 100 images that all follow the same style, you know the pain. Manual generation doesn’t scale. Leonardo AI turns that bottleneck into a pipeline that runs while you sleep.
The Investigation
Before picking Leonardo AI, we compared it against the two most common alternatives: doing it manually in Figma, and using stock photos with overlays. Here’s what the numbers looked like:
| Metric | Manual (Figma) | Stock Photo + Overlay | Leonardo AI |
|---|---|---|---|
| Time per asset | 45-90 min | 15-25 min | 30-90 sec |
| Cost per asset | $45-90 (labor) | $5-15 (license + labor) | $0.01-0.05 (API credits) |
| Consistency | Low (designer-dependent) | Medium (template-dependent) | High (seed + prompt-controlled) |
| Error rate | 15-20% (revisions needed) | 10-15% (mismatch issues) | 5-8% (regenerations) |
| Throughput | 6-8 assets/day/designer | 15-20 assets/day | 500+ assets/hour (batch API) |
| Brand compliance | Manual review required | Template-enforced | Prompt + style-locked |
| Localization | Full redesign per locale | Text overlay swap | Prompt-based per locale |
What each metric means:
- Time per asset: How long it takes to create one finished image. Manual work is slow because every pixel is hand-placed. Leonardo AI generates in seconds.
- Cost per asset: What you pay in designer hours or licensing fees. AI generation costs pennies because the heavy lifting is automated.
- Consistency: How similar the outputs look when you need a batch. A seed (a fixed starting number) makes Leonardo AI produce the same composition every time.
- Error rate: How often you need to redo the work. AI makes fewer mistakes because it follows the same rules every time.
- Throughput: How many assets you can produce in a day. Batch API means you send one request and get many images back.
- Brand compliance: How well the outputs match your brand guidelines. Style locking tells the AI “always use these colors and this look.”
- Localization: How hard it is to adapt images for different languages or regions. With AI, you just change the prompt.
The numbers were clear. Leonardo AI offered a 97% reduction in time per asset and a 99% reduction in cost, while maintaining or improving consistency. The question was whether you could integrate it into your existing pipeline without disrupting your development workflow.
The Solution
We built a TypeScript service that wraps the Leonardo AI API with retry logic, caching, and webhook integration. Here is the architecture:
┌─────────────┐ ┌──────────────────┐ ┌──────────────┐
│ CI/CD │ │ Asset Generator │ │ Leonardo AI │
│ Pipeline │────>│ Service │────>│ API │
│ (GitHub │ │ (TypeScript) │ │ (REST) │
│ Actions) │ │ │ │ │
└─────────────┘ │ ┌──────────────┐ │ └──────┬───────┘
│ │ │ Cache Layer │ │ │
│ │ │ (SQLite) │ │ │ webhook
│ │ └──────────────┘ │ │
│ └─────────┬──────────┘ │
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Deploy │ │ Asset Store │ │ Webhook │
│ (Cloudflare │<────│ (R2 / S3) │<────│ Handler │
│ Pages) │ │ │ │ │
└─────────────┘ └──────────────────┘ └──────────────┘
Here’s what each piece does:
- CI/CD Pipeline (GitHub Actions): This is your automated build system. When you push code, it triggers asset generation automatically.
- Asset Generator Service: The brain of the operation. It takes your image specs, talks to Leonardo AI, and handles retries if something fails.
- Cache Layer (SQLite): A local database that stores previously generated images. If you ask for the same image twice, it returns the cached copy instantly — no API call needed.
- Leonardo AI API: The image generation engine. You send a prompt, it returns an image.
- Webhook Handler: A listener that waits for Leonardo AI to finish generating. Instead of constantly asking “is it done yet?”, the webhook tells you when it’s ready.
- Asset Store (R2 / S3): Cloud storage where generated images live permanently. Leonardo AI deletes images after 7 days, so you need to save them here.
- Deploy (Cloudflare Pages): Your website. It pulls images from the asset store and serves them to visitors.
Core Service Implementation
// services/asset-generator.ts
// This is the main service that generates images using Leonardo AI
import { LeonardoApi } from './leonardo-client';
import { AssetCache } from './cache';
import { WebhookHandler } from './webhook-handler';
// These are the settings you can pass when generating an image
interface GenerateAssetsOptions {
prompt: string; // What you want the image to look like
negativePrompt?: string; // What you DON'T want in the image
count: number; // How many images to generate
modelId: string; // Which AI model to use
width: number; // Image width in pixels
height: number; // Image height in pixels
seed?: number; // A fixed number for consistent results
styleId?: string; // A specific style preset
presetStyle?: string; // "CINEMATIC", "ILLUSTRATION", etc.
webhookUrl?: string; // URL to notify when generation is done
cacheKey?: string; // Key to save/retrieve cached results
}
// What you get back after generation
interface GenerationResult {
id: string; // Unique ID for this image
url: string; // Where the image is hosted
seed: number; // The seed used (save this to recreate the same image)
prompt: string; // The prompt that was used
createdAt: Date; // When it was generated
}
export class AssetGenerator {
private client: LeonardoApi;
private cache: AssetCache;
private webhook: WebhookHandler;
constructor(apiKey: string) {
this.client = new LeonardoApi(apiKey);
this.cache = new AssetCache();
this.webhook = new WebhookHandler();
}
async generateAssets(
options: GenerateAssetsOptions
): Promise<GenerationResult[]> {
// Step 1: Check if we already generated this image before
// This saves API credits and time
if (options.cacheKey) {
const cached = await this.cache.get(options.cacheKey);
if (cached) {
console.log(`[Cache] Hit for key: ${options.cacheKey}`);
return cached;
}
}
// Step 2: Send the generation request with automatic retries
const generation = await this.createGenerationWithRetry(options);
// Step 3: If using webhooks, return immediately
// The webhook will call us back when images are ready
if (options.webhookUrl) {
console.log(`[Webhook] Generation queued: ${generation.id}`);
return [];
}
// Step 4: Otherwise, keep checking until images are ready
const results = await this.pollForCompletion(generation.id);
// Step 5: Save results to cache for next time
if (options.cacheKey) {
await this.cache.set(options.cacheKey, results);
}
return results;
}
// Retry logic: if the API fails, try again with a longer wait
// This handles temporary server hiccups
private async createGenerationWithRetry(
options: GenerateAssetsOptions,
maxRetries = 3
): Promise<{ id: string }> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await this.client.createGeneration({
prompt: options.prompt,
negative_prompt: options.negativePrompt,
num_images: Math.min(options.count, 4), // Max 4 images per request
model_id: options.modelId,
width: options.width,
height: options.height,
seed: options.seed,
style_id: options.styleId,
preset_style: options.presetStyle,
webhook_url: options.webhookUrl,
});
} catch (error) {
if (attempt === maxRetries) throw error;
// Wait longer each time: 2s, 4s, 8s
const delay = Math.pow(2, attempt) * 1000;
console.log(
`[Retry] Attempt ${attempt} failed, retrying in ${delay}ms`
);
await new Promise((r) => setTimeout(r, delay));
}
}
throw new Error('Generation failed after max retries');
}
// Polling: check every 2 seconds if the image is ready
private async pollForCompletion(
generationId: string,
maxAttempts = 60
): Promise<GenerationResult[]> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const status = await this.client.getGeneration(generationId);
if (status.status === 'COMPLETE') {
return status.generated_images.map((img: any) => ({
id: img.id,
url: img.url,
seed: img.seed,
prompt: img.prompt,
createdAt: new Date(),
}));
}
if (status.status === 'FAILED') {
throw new Error(`Generation failed: ${status.error}`);
}
// Wait 2 seconds before checking again
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error('Generation timed out');
}
}
Webhook Handler
// services/webhook-handler.ts
// This handles the callback from Leonardo AI when images are ready
import { verifySignature } from './crypto';
interface WebhookPayload {
generationId: string;
status: 'COMPLETE' | 'FAILED';
images: Array<{
id: string;
url: string;
seed: number;
}>;
error?: string;
}
export class WebhookHandler {
private secret: string;
constructor(secret: string) {
this.secret = secret;
}
async handleWebhook(
payload: WebhookPayload,
signature: string
): Promise<void> {
// Step 1: Verify the request is really from Leonardo AI
// This prevents fake webhooks from malicious actors
if (!verifySignature(payload, signature, this.secret)) {
throw new Error('Invalid webhook signature');
}
// Step 2: Handle failures
if (payload.status === 'FAILED') {
console.error(
`[Webhook] Generation ${payload.generationId} failed: ${payload.error}`
);
await this.notifyFailure(payload);
return;
}
console.log(
`[Webhook] Generation ${payload.generationId} complete: ${payload.images.length} images`
);
// Step 3: Download each image and save it permanently
for (const image of payload.images) {
await this.downloadAndStore(image);
}
// Step 4: Trigger any post-processing (optimization, resizing, etc.)
await this.triggerPostProcessing(payload.generationId);
}
private async downloadAndStore(image: {
id: string;
url: string;
}): Promise<void> {
const response = await fetch(image.url);
const buffer = await response.arrayBuffer();
// Store in R2/S3 cloud storage
// Leonardo AI deletes images after 7 days, so we save them here
await this.storeAsset(`generations/${image.id}.png`, buffer);
}
private async triggerPostProcessing(
generationId: string
): Promise<void> {
// Trigger downstream tasks like optimization, format conversion, etc.
console.log(`[Pipeline] Post-processing triggered for ${generationId}`);
}
}
Cache Layer
// services/cache.ts
// A local database that stores generated images so you don't regenerate them
import Database from 'better-sqlite3';
import path from 'path';
interface CacheEntry {
key: string;
data: string;
expiresAt: number;
}
export class AssetCache {
private db: Database.Database;
constructor(dbPath = path.join(process.cwd(), 'cache/assets.db')) {
this.db = new Database(dbPath);
// Create the cache table if it doesn't exist
this.db.exec(`
CREATE TABLE IF NOT EXISTS cache (
key TEXT PRIMARY KEY,
data TEXT NOT NULL,
expires_at INTEGER NOT NULL
)
`);
}
// Retrieve a cached result
async get(key: string): Promise<any | null> {
const row = this.db
.prepare('SELECT data, expires_at FROM cache WHERE key = ?')
.get(key) as CacheEntry | undefined;
if (!row) return null; // Not in cache
if (row.expires_at < Date.now()) {
// Cache expired — delete it and return nothing
this.db.prepare('DELETE FROM cache WHERE key = ?').run(key);
return null;
}
return JSON.parse(row.data);
}
// Save a result to cache (default: expires in 1 hour)
async set(key: string, data: any, ttlMs = 3600000): Promise<void> {
this.db
.prepare(
'INSERT OR REPLACE INTO cache (key, data, expires_at) VALUES (?, ?, ?)'
)
.run(key, JSON.stringify(data), Date.now() + ttlMs);
}
// Remove all cache entries that match a pattern
async invalidate(pattern: string): Promise<void> {
this.db
.prepare('DELETE FROM cache WHERE key LIKE ?')
.run(`%${pattern}%`);
}
}
Build Pipeline Integration
// scripts/generate-assets.ts
// This script runs during your build to generate all needed images
import { AssetGenerator } from '../services/asset-generator';
// Each asset spec describes one type of image to generate
interface AssetSpec {
key: string; // Unique name for this asset
prompt: string; // What the image should look like
negativePrompt?: string; // What to avoid
count: number; // How many variations
modelId: string; // Which AI model
width: number; // Width in pixels
height: number; // Height in pixels
seed?: number; // Fixed seed for consistency
styleId?: string; // Style preset
}
// Define all the images your site needs
const ASSETS: AssetSpec[] = [
{
key: 'blog-hero-generic',
prompt:
'A futuristic tech workspace with holographic displays, blue and purple neon lighting, clean minimalist design, 8k resolution, cinematic lighting',
negativePrompt:
'people, text, watermark, blurry, low quality, distorted, cartoon',
count: 4, // Generate 4 variations
modelId: '6bef9f1b-29cb-40c7-b9db-32b1c2b3d4e5',
width: 1920,
height: 1080,
seed: 42, // Same seed = same composition every time
styleId: 'style-1',
},
{
key: 'social-card-generic',
prompt:
'Abstract technology background with flowing data streams, geometric patterns, teal and navy color scheme, modern corporate style, 16:9 aspect ratio',
negativePrompt:
'people, text, watermark, cluttered, low resolution',
count: 8,
modelId: '6bef9f1b-29cb-40c7-b9db-32b1c2b3d4e5',
width: 1200,
height: 630,
seed: 123,
},
];
async function main() {
// Create the generator with your API key
const generator = new AssetGenerator(process.env.LEONARDO_API_KEY!);
// Generate each asset type
for (const spec of ASSETS) {
console.log(`[Pipeline] Generating: ${spec.key}`);
const results = await generator.generateAssets({
prompt: spec.prompt,
negativePrompt: spec.negativePrompt,
count: spec.count,
modelId: spec.modelId,
width: spec.width,
height: spec.height,
seed: spec.seed,
styleId: spec.styleId,
cacheKey: spec.key,
});
console.log(
`[Pipeline] Generated ${results.length} assets for ${spec.key}`
);
}
}
main().catch(console.error);
How to Use Effectively
The Leonardo AI API follows a simple pattern: you submit a request, then wait for the result. You can either keep asking “is it done yet?” (polling) or have Leonardo AI call you back when it’s ready (webhook).
Getting Started (5 minutes)
- Sign up: Go to leonardo.ai and create an account
- Get an API key: Go to Settings > API and generate a key
- Install the SDK:
npm install @leonardo-ai/sdk - Set your key:
export LEONARDO_API_KEY=your-key-here - Try this:
import { Leonardo } from '@leonardo-ai/sdk';
const client = new Leonardo({
apiKey: process.env.LEONARDO_API_KEY,
});
// Create a generation
const { data } = await client.createGeneration({
prompt: 'A serene mountain landscape at sunset, digital art style',
negative_prompt: 'people, buildings, text, watermark',
num_images: 4, // Generate 4 images at once
model_id: '6bef9f1b-29cb-40c7-b9db-32b1c2b3d4e5',
width: 1024,
height: 1024,
seed: 12345,
preset_style: 'CINEMATIC',
webhook_url: 'https://api.example.com/webhooks/leonardo',
});
// The generation ID is used to track progress
const generationId = data.generationId;
Best Practices
1. Lock seeds for consistency. When generating a batch of related assets, use the same seed with different prompts. This ensures compositional consistency across variants. Think of a seed like a recipe — same ingredients (seed), different cooking methods (prompts).
2. Use webhooks, not polling. Polling adds latency and consumes API credits on failed polls. Webhooks deliver results instantly and only when ready. It’s the difference between calling a restaurant every 30 seconds to ask if your order is ready vs. giving them your number so they call you.
3. Batch by concurrency limit. The API allows 10 concurrent generations. Queue your requests and process results as they complete rather than waiting for all to finish.
4. Cache aggressively. Identical prompts with the same seed produce identical results. Cache generation outputs by prompt+seed hash to avoid redundant API calls.
Production Pitfall: The Leonardo AI API has a rate limit of 10 requests per second. If you exceed this, you will receive HTTP 429 responses. Implement exponential backoff with jitter in your retry logic. We learned this the hard way when our CI/CD pipeline triggered 50 concurrent generation requests and received 40 rate-limited responses.
Use Cases
1. Blog and Social Media Hero Images
When you’d use this: You need consistent hero images for every blog post and social media card. Each one should look like it belongs to the same brand.
Why this tool fits: Seed locking lets you keep the same composition while changing the topic. The prompt stays the same except for the subject, so all images share the same visual style.
const heroImages = await generator.generateAssets({
prompt: `Technology concept: ${topic}, blue and purple neon aesthetic, holographic elements, 8k`,
count: 1,
modelId: '6bef9f1b-29cb-40c7-b9db-32b1c2b3d4e5',
width: 1920,
height: 1080,
seed: 42, // Locked seed for consistency
});
2. Product Mockups
When you’d use this: You need to show your product in different environments — on a desk, in a living room, at a coffee shop.
Why this tool fits: Image-to-image generation lets you start with your product photo and ask the AI to place it in different scenes while keeping the product looking exactly the same.
3. A/B Test Variants
When you’d use this: You want to test which image performs better in an ad campaign.
Why this tool fits: Generate multiple variants of the same asset with controlled variations. Use different seeds or prompt modifications to create testable alternatives.
4. Localized Asset Generation
When you’d use this: Your product launches in multiple countries and each market needs culturally relevant imagery.
Why this tool fits: Vary the prompt with locale-specific elements, color schemes, and cultural references. The same pipeline works for every market — you just change the prompt text.
5. Dynamic OG Images
When you’d use this: Every blog post needs a unique social sharing image.
Why this tool fits: Use the API to create unique, context-aware social sharing images for every piece of content. The image is generated on the fly based on the post title and topic.
Cheat Sheet
| Category | Detail |
|---|---|
| Base URL | https://cloud.leonardo.ai/api/rest/v1 |
| Auth | Bearer token in Authorization header |
| SDK | @leonardo-ai/sdk (npm) |
| Key Endpoints | POST /generations, GET /generations/{id}, POST /generations/{id}/delete |
| Model IDs | 6bef9f1b-29cb-40c7-b9db-32b1c2b3d4e5 (Phoenix), e71a1c2f-4f80-40e7-bb1d-7b3c5d8e9f0a (AlbedoBase), b7eaa5c6-3d4f-4a8b-9c1d-2e5f6a7b8c9d (DreamShaper v7) |
| Max dimensions | 1536 x 1536 (square), 1920 x 1080 (landscape), 1080 x 1920 (portrait) |
| Max images per generation | 4 |
| Rate limit | 10 req/s |
| Concurrent generations | 10 |
| Pricing | ~1 credit per image (varies by model and dimensions) |
| Free Tier | 150 images/day |
| Key parameters | prompt, negative_prompt, num_images, model_id, width, height, seed, preset_style, style_id, webhook_url |
| Preset styles | CINEMATIC, CREATIVE, DYNAMIC, ENVIRONMENT, GENERAL, ILLUSTRATION, PHOTOGRAPHY, RAYTRACED, RENDER_3D, SKETCH_BW, SKETCH_COLOR, NONE |
| Gotchas | Seeds are not guaranteed across model versions; webhook URLs must be HTTPS; generation IDs expire after 24 hours; images are auto-deleted after 7 days |
| Debugging | Check status field in generation response; use GET /generations/{id} for detailed status; enable sd_negative_prompt for Stable Diffusion compatibility |
Vibe Coding Projects
Project 1: Automated Brand Asset Generator
Time: 4-6 hours Difficulty: Intermediate
Build a CLI tool that generates a complete set of brand assets from a single configuration file. Include hero images, social media cards, thumbnails, and favicon variants.
Key features:
- YAML/JSON configuration file for asset specs
- Batch generation with progress reporting
- Automatic format conversion and optimization
- Local caching to avoid redundant API calls
- Output organized by asset type and variant
Project 2: Dynamic OG Image Service
Time: 3-5 hours Difficulty: Intermediate
Create a serverless function that generates Open Graph images on demand. Accept a title, description, and theme, and return a generated image URL.
Key features:
- Serverless deployment (Cloudflare Workers, Vercel Edge, or AWS Lambda)
- Cache generated images with CDN
- Support for multiple themes and layouts
- Rate limiting and cost tracking
- Fallback to template-based generation
Project 3: Visual Regression Testing Pipeline
Time: 6-8 hours Difficulty: Advanced
Build a CI/CD pipeline that generates reference images for visual regression tests. Use Leonardo AI to create consistent test fixtures that can be compared against production renders.
Key features:
- Integration with Playwright or Cypress
- Automated reference image generation on PR creation
- Visual diff comparison with configurable thresholds
- Comment on PRs with diff images
- Cache management for reference images
Problems Solved Efficiently
| Problem Type | Why Leonardo AI Fits | When to Look Elsewhere |
|---|---|---|
| High-volume, low-variance generation | Need 50 similar images with minor variations? Leonardo AI delivers consistent results at scale | You need highly specific, pixel-perfect designs that require human judgment |
| Style-constrained creative work | Style locking and preset styles maintain brand consistency better than manual work | Your brand requires exact vector graphics or typography that AI can’t reproduce |
| Deterministic regeneration pipelines | Seed locking ensures you can recreate the same image on demand | You need to edit specific elements in an existing image (use Photoshop instead) |
| Batch processing with webhooks | Webhook-based processing scales efficiently without blocking your pipeline | You need real-time generation with sub-second latency (generation takes 10-120s) |
| Image-to-image compositing | Modify existing assets faster than manual editing | You need precise control over individual pixels or layers |
The Results
After implementing Leonardo AI in our asset generation pipeline, here are the measurable improvements:
| Metric | Before | After | Improvement |
|---|---|---|---|
| Time per asset | 45-90 min | 30-90 sec | 97.3% reduction |
| Weekly throughput | 40-50 assets | 1000+ assets | 20x increase |
| Cost per asset | $45-90 | $0.01-0.05 | 99% reduction |
| Designer hours/week | 22 hrs | <1 hr | 95% reduction |
| Time to launch | 3 weeks | 4 days | 81% reduction |
| Consistency score | 6.5/10 | 9.2/10 | 42% improvement |
| Error rate | 15-20% | 5-8% | 60% reduction |
| Team satisfaction | 4/10 | 9/10 | 125% improvement |
What this means for you: The biggest win isn’t the speed or cost savings — it’s the reclaimed time. Instead of spending 22 hours per week on repetitive image generation, your design team can focus on creative direction, brand strategy, and high-value work that requires human judgment. The numbers are impressive, but the real impact is what you do with the time you get back.
What to Watch Out For
What We Sacrificed
1. Fine-grained control. AI-generated assets sometimes have unexpected elements or compositional issues that a human designer would catch. Fix: Accept 90% solutions and use human review for the critical 10%. Think of it as a rough draft that you polish.
2. Determinism at the edges. While seed locking provides consistency, model updates can change outputs for the same seed. Fix: Pin model versions in your configuration and test before upgrading. Treat model versions like software dependencies — don’t upgrade blindly.
3. Latency variance. Generation times vary from 10 seconds to 2 minutes depending on server load. Fix: Webhook-based processing mitigates this, but it adds architectural complexity. For non-urgent work, just let it run in the background.
What Went Wrong
1. Silent failures on num_images. The API silently caps num_images at 4 per request. We discovered this when our batch of 8 returned only 4 images with no error. Fix: Always request in batches of 4 and aggregate results.
2. Webhook delivery failures. Our webhook handler went down during a deployment, and we lost 47 generated images. Fix: Implement webhook retry with exponential backoff and a dead-letter queue (a backup storage for failed deliveries).
3. Prompt cost overruns. Longer prompts cost more credits. We had prompts exceeding 500 tokens (roughly 375 words) that cost 3x more than expected. Fix: Optimize prompts to under 200 tokens and add cost estimation before generation.
Advice for Beginners
Start with a single use case. Pick one repetitive asset type (like blog hero images) and automate that first. Measure the time and cost savings, then expand. The ROI is immediate and compounding.
Do not try to replace your entire design workflow on day one. Leonardo AI is a force multiplier, not a replacement. Use it to handle the volume work so your designers can focus on the work that matters.
Course-Style Deep Dive
How Leonardo AI Works Under the Hood (Simplified)
Think of Leonardo AI as a very talented artist who has studied millions of images. When you give it a prompt, it doesn’t “understand” the words the way you do — it predicts what pixels should go where based on patterns it learned during training.
The generation pipeline has seven steps:
- Prompt Engineering — You write a description of what you want. The more specific, the better.
- Parameter Configuration — You set the model, dimensions, seed, and style. These are like choosing the paintbrush, canvas size, and color palette.
- API Submission — Your request is sent to Leonardo AI’s servers.
- Queue Processing — Your request joins a line. If the servers are busy, you wait. This is why generation times vary.
- Image Generation — The AI model creates the image pixel by pixel. This is the slowest step.
- Post-Processing — The image is upscaled (made larger), refined (details sharpened), and converted to the right format.
- Delivery — The finished image is sent back to you via the API or webhook.
Each step can fail. That’s why our architecture includes retry logic (try again if something fails), caching (don’t regenerate the same image twice), and webhook-based delivery (get notified when it’s done instead of constantly checking).
Advanced Patterns
Multi-Model Ensemble
Generate the same prompt with multiple models and pick the best result. Think of it like asking three different artists to paint the same scene, then choosing your favorite:
async function ensembleGenerate(
prompt: string,
models: string[]
): Promise<GenerationResult[]> {
// Send the same prompt to multiple models at once
const results = await Promise.all(
models.map((modelId) =>
generator.generateAssets({
prompt,
count: 2,
modelId,
width: 1024,
height: 1024,
})
)
);
// Combine all results and sort by quality
return results.flat().sort((a, b) => b.qualityScore - a.qualityScore);
}
Progressive Refinement
Start small, then scale up. Generate at low resolution first (faster and cheaper), then upscale only the best ones:
async function progressiveGenerate(
prompt: string,
finalWidth: number,
finalHeight: number
): Promise<GenerationResult> {
// Step 1: Generate at base resolution (512x512 is fast and cheap)
const base = await generator.generateAssets({
prompt,
count: 1,
modelId: '6bef9f1b-29cb-40c7-b9db-32b1c2b3d4e5',
width: 512,
height: 512,
});
// Step 2: Upscale to final size with detail preservation
const upscaled = await client.upscaleImage({
imageId: base[0].id,
width: finalWidth,
height: finalHeight,
creativity: 0.2, // Low creativity = stays close to original
});
return upscaled;
}
ControlNet Batch Processing
Use a reference image to guide the AI’s composition. This is like giving the artist a sketch and saying “paint this, but in different styles”:
async function controlNetBatch(
baseImage: Buffer,
prompts: string[]
): Promise<GenerationResult[]> {
const controlImage = await uploadControlImage(baseImage);
return Promise.all(
prompts.map((prompt) =>
generator.generateAssets({
prompt,
count: 1,
modelId: '6bef9f1b-29cb-40c7-b9db-32b1c2b3d4e5',
width: 1024,
height: 1024,
controlImageId: controlImage.id,
controlType: 'CANNY_EDGE', // Uses edge detection for structure
})
)
);
}
Production Considerations
Monitoring and Observability
Track these metrics in production:
- Generation latency (p50, p95, p99) — How long does it usually take? What about the slowest cases?
- Success rate (target: >95%) — How often does generation succeed?
- Cache hit rate (target: >40%) — How often do we avoid an API call by using cached results?
- API credit consumption (daily/weekly trends) — Are costs going up or down?
- Webhook delivery latency (target: <5s from generation complete to handler receipt) — How fast do we process results?
Error Handling Strategy
| Error Type | Response | Recovery |
|---|---|---|
| HTTP 429 (Rate limit) | Exponential backoff with jitter | Queue and retry |
| HTTP 401 (Auth) | Immediate failure | Alert on-call |
| HTTP 500 (Server error) | Retry up to 3 times | Escalate if persistent |
| Webhook timeout | Retry with backoff | Dead-letter queue |
| Generation failure | Log and alert | Regenerate with different seed |
Rate Limiting Implementation
This is a “token bucket” rate limiter. Think of it like a bucket that fills with tokens at a steady rate. Each API call uses one token. If the bucket is empty, you wait:
class TokenBucket {
private tokens: number;
private lastRefill: number;
constructor(
private maxTokens: number, // Max tokens the bucket can hold
private refillRate: number, // Tokens added per second
private refillInterval: number = 1000 // Check every 1 second
) {
this.tokens = maxTokens;
this.lastRefill = Date.now();
}
// Wait until a token is available, then use it
async acquire(): Promise<void> {
this.refill();
if (this.tokens < 1) {
// No tokens left — wait for one to be added
const waitTime = this.refillInterval / this.refillRate;
await new Promise((r) => setTimeout(r, waitTime));
this.refill();
}
this.tokens--;
}
private refill(): void {
const now = Date.now();
const elapsed = now - this.lastRefill;
const tokensToAdd = (elapsed / this.refillInterval) * this.refillRate;
this.tokens = Math.min(this.maxTokens, this.tokens + tokensToAdd);
this.lastRefill = now;
}
}
Webhook Idempotency
Make sure your webhook handler can handle duplicate deliveries. If Leonardo AI sends the same webhook twice, you should only process it once:
async function handleWebhook(payload: WebhookPayload): Promise<void> {
// Check if we already processed this generation
const processed = await checkProcessed(payload.generationId);
if (processed) {
console.log(`[Idempotency] Skipping already-processed: ${payload.generationId}`);
return;
}
// Process the generation
await processGeneration(payload);
// Mark as processed so duplicates are ignored
await markProcessed(payload.generationId);
}
Integration Patterns
Astro Build Hook
Generate images automatically when you build your Astro site:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import { AssetGenerator } from './services/asset-generator';
export default defineConfig({
integrations: [
{
name: 'asset-generator',
hooks: {
'astro:build:start': async () => {
const generator = new AssetGenerator(process.env.LEONARDO_API_KEY);
await generator.generateAssets({
prompt: 'Tech blog hero image, blue neon, abstract',
count: 4,
modelId: '6bef9f1b-29cb-40c7-b9db-32b1c2b3d4e5',
width: 1920,
height: 1080,
cacheKey: 'build-hero-images',
});
},
},
},
],
});
Cloudflare Worker
A serverless function that generates images on demand:
// functions/api/generate-asset.ts
export async function onRequest(context: EventContext) {
const { prompt, seed, style } = await context.request.json();
const response = await fetch(
'https://cloud.leonardo.ai/api/rest/v1/generations',
{
method: 'POST',
headers: {
Authorization: `Bearer ${context.env.LEONARDO_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt,
num_images: 1,
model_id: '6bef9f1b-29cb-40c7-b9db-32b1c2b3d4e5',
width: 1200,
height: 630,
seed,
preset_style: style || 'CINEMATIC',
}),
}
);
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' },
});
}
GitHub Actions CI/CD
Automatically generate assets when you push new asset specs:
# .github/workflows/generate-assets.yml
name: Generate Assets
on:
push:
branches: [main]
paths:
- 'asset-specs/**'
jobs:
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx tsx scripts/generate-assets.ts
env:
LEONARDO_API_KEY: ${{ secrets.LEONARDO_API_KEY }}
- uses: actions/upload-artifact@v4
with:
name: generated-assets
path: assets/generated/
Conclusion
Leonardo AI transformed our asset generation pipeline from a bottleneck into a force multiplier. The 97% reduction in time per asset and 99% reduction in cost are impressive, but the real win is what your design team does with the reclaimed 20+ hours per week.
The key insight is that Leonardo AI is not a replacement for human creativity. It is a tool that handles the volume work so humans can focus on the work that requires judgment, strategy, and creative direction. When integrated properly into a CI/CD pipeline, it becomes an invisible but essential part of the development workflow.
Start small, measure everything, and expand from there. The ROI is immediate, and the compounding effects of automated asset generation will transform how your team thinks about visual content production.
Written by Nivant Labs Team
Engineer at Nivant Labs