·11 min read

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)

  1. Sign up: Go to leonardo.ai and create an account
  2. Get an API key: Go to Settings > API and generate a key
  3. Install the SDK: npm install @leonardo-ai/sdk
  4. Set your key: export LEONARDO_API_KEY=your-key-here
  5. 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:

  1. Prompt Engineering — You write a description of what you want. The more specific, the better.
  2. Parameter Configuration — You set the model, dimensions, seed, and style. These are like choosing the paintbrush, canvas size, and color palette.
  3. API Submission — Your request is sent to Leonardo AI’s servers.
  4. Queue Processing — Your request joins a line. If the servers are busy, you wait. This is why generation times vary.
  5. Image Generation — The AI model creates the image pixel by pixel. This is the slowest step.
  6. Post-Processing — The image is upscaled (made larger), refined (details sharpened), and converted to the right format.
  7. 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.

NL

Written by Nivant Labs Team

Engineer at Nivant Labs

Share this post