DEV Community

Kate Vu
Kate Vu

Posted on • Originally published at Medium

Build Your Own Private AI Image Generator on AWS with AWS Bedrock with Multiple Models and AWS CDK

There are many excellent image generation tools available today. Many of them are free and easy to use. However, your prompts and images are processed outside your environment on platforms that you do not control. In this project, we will build a fully private image generator app running entirely inside AWS. The images remain in your S3 bucket, and you can switch between Bedrock foundation models such as Titan and Stable Diffusion.

What this app can do

  • Text-to-Image Enter a prompt such as "an orange cat sitting next to the door looking at the rain outside" and the app will generate an image
  • Image-to-Image Upload an image and a new prompt, for example:
    • Image of Leo the orange cat
    • With prompt: convert to Leo the knight cat The model will transform the image based on the prompt
  • Multiple Bedrock Image Models Simply switch between two models on website hosted in S3 bucket:
    • Amazon Titan Image Generator v2 (default)
    • Stable Diffusion 3.5 Large Future models can be added easily. AWS Lambda works as back end will detect which model is selected by user and send the request with model id to AWS Bedrock

Architecture

  1. The user sends a request via website hosted on AWS S3 bucket with the following inputs:
    • Text-to-Image or Image-to-Image
    • Model to use
    • Prompt and uploaded image for image to image
  2. The request goes to API gateway
  3. API Gateway invokes Lambda function
  4. Lambda function:
    • Validate input
    • Retrieve the selected model
    • Build the Bedrock request
    • Send the request to Bedrock via InvokeModel API
  5. Bedrock generates the image and returns it as base64 string format
  6. Lambda function:
    • Receive the generated image as base64 string format, and save it to S3
    • Generate a pre-signed URL for downloading
    • Publish performance metric to CloudWatch
    • Return the image and URL to frontend
  7. The frontend displays the image along with the download link.

AWS Resources:

  • AWS S3 Buckets
  • Frontend bucket
  • Image bucket
  • API Gateway
  • AWS Lambda
  • Amazon Bedrock
  • IAM
  • CloudWatch

Prerequisites:

  • AWS Account: you will need it to deploy S3, lambda, API Gateway, Bedrock, CloudWatch, and IAM
  • Environment setup: Make sure these are set installed and working
    • Note.js
    • Typescript
    • AWS CDK Toolkit
    • Docker: up and running, we will use this to bundle our lambda function
    • AWS Credentials: keep them handy so you can deploy the stacks

Deploy

1. Get the modelIDs

Go to the AWS Bedrock console and get the exact model IDs. For this app, we are using

  • Amazon.titan-image-generator-v2:0
  • stability.sd3-large-v1:0 in us-west-2.

Make sure these models are available in your selected region. For information about models and region, refer to Model lifecycle - Amazon Bedrock

2. Create the resources

2.1 Setup frontend

Create two HTML files:
Index.html: main interface allowed user to

  • Switch between Text-to-Image or Image-to-Image
  • Switch between models: Amazon Titan image generator (default) or Stable Diffusion
  • Submit prompts and images Error.html: a simple page displayed if something goes wrong index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>AI Image Generator</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
            background: linear-gradient(135deg, #10b981 0%, #059669 100%);
            min-height: 100vh;
            padding: 20px;
        }

        .container {
            max-width: 1200px;
            margin: 0 auto;
        }

        h1 {
            text-align: center;
            color: white;
            margin-bottom: 30px;
            font-size: 2.5rem;
        }

        .card {
            background: white;
            border-radius: 12px;
            padding: 30px;
            box-shadow: 0 10px 40px rgba(0,0,0,0.1);
            margin-bottom: 20px;
        }

        .tabs {
            display: flex;
            gap: 10px;
            margin-bottom: 30px;
        }

        .tab {
            flex: 1;
            padding: 15px;
            background: #f5f5f5;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            font-size: 16px;
            font-weight: 600;
            transition: all 0.3s;
        }

        .tab.active {
            background: #10b981;
            color: white;
        }

        .tab-content {
            display: none;
        }

        .tab-content.active {
            display: block;
        }

        .form-group {
            margin-bottom: 20px;
        }

        label {
            display: block;
            margin-bottom: 8px;
            font-weight: 600;
            color: #333;
        }

        input[type="text"],
        textarea {
            width: 100%;
            padding: 12px;
            border: 2px solid #e0e0e0;
            border-radius: 8px;
            font-size: 16px;
            transition: border-color 0.3s;
        }

        input[type="text"]:focus,
        textarea:focus {
            outline: none;
            border-color: #10b981;
        }

        textarea {
            resize: vertical;
            min-height: 100px;
        }

        .size-inputs {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 15px;
        }

        input[type="number"] {
            width: 100%;
            padding: 12px;
            border: 2px solid #e0e0e0;
            border-radius: 8px;
            font-size: 16px;
        }

        .file-upload {
            border: 2px dashed #e0e0e0;
            border-radius: 8px;
            padding: 30px;
            text-align: center;
            cursor: pointer;
            transition: all 0.3s;
        }

        .file-upload:hover {
            border-color: #10b981;
            background: #f0fdf4;
        }

        .file-upload.dragover {
            border-color: #10b981;
            background: #d1fae5;
            border-style: solid;
        }

        .file-upload input {
            display: none;
        }

        .preview-image {
            max-width: 100%;
            max-height: 300px;
            margin-top: 15px;
            border-radius: 8px;
        }

        button {
            width: 100%;
            padding: 15px;
            background: #10b981;
            color: white;
            border: none;
            border-radius: 8px;
            font-size: 18px;
            font-weight: 600;
            cursor: pointer;
            transition: all 0.3s;
        }

        button:hover {
            background: #059669;
            transform: translateY(-2px);
            box-shadow: 0 5px 15px rgba(16, 185, 129, 0.4);
        }

        button:disabled {
            background: #ccc;
            cursor: not-allowed;
            transform: none;
        }

        .result {
            margin-top: 30px;
        }

        .result-image {
            width: 100%;
            border-radius: 8px;
            margin-top: 15px;
        }

        .status {
            padding: 15px;
            border-radius: 8px;
            margin-top: 15px;
            font-weight: 600;
        }

        .status.success {
            background: #d4edda;
            color: #155724;
        }

        .status.error {
            background: #f8d7da;
            color: #721c24;
        }

        .status.loading {
            background: #d1ecf1;
            color: #0c5460;
        }

        .loader {
            border: 4px solid #f3f3f3;
            border-top: 4px solid #10b981;
            border-radius: 50%;
            width: 40px;
            height: 40px;
            animation: spin 1s linear infinite;
            margin: 20px auto;
        }

        @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }

        @keyframes slideIn {
            from {
                opacity: 0;
                transform: translateY(-10px);
            }
            to {
                opacity: 1;
                transform: translateY(0);
            }
        }

        .privacy-note {
            background: #e8f4f8;
            padding: 15px;
            border-radius: 8px;
            margin-top: 20px;
            font-size: 14px;
            color: #0c5460;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>🎨 AI Image Generator</h1>

        <div class="card">
            <div class="tabs">
                <button class="tab active" onclick="switchTab('text-to-image')">Text to Image</button>
                <button class="tab" onclick="switchTab('image-to-image')">Image to Image</button>
            </div>

            <!-- Text to Image Tab -->
            <div id="text-to-image" class="tab-content active">
                <form onsubmit="generateTextToImage(event)">
                    <div class="form-group">
                        <label>API Endpoint</label>
                        <input type="text" id="api-endpoint-text" placeholder="https://your-api-gateway-url/generate" required>
                    </div>

                    <div class="form-group">
                        <label>API Key (Required)</label>
                        <input type="password" id="api-key-text" placeholder="Your API key" required>
                        <small style="color: #666; font-size: 12px; margin-top: 5px; display: block;">Get your API key from CDK output or AWS Console</small>
                    </div>

                    <div class="form-group">
                        <label>Model</label>
                        <select id="model-text" style="width: 100%; padding: 12px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 16px;">
                            <option value="amazon.titan-image-generator-v2:0">Amazon Titan Image Generator v2 (Recommended)</option>
                            <option value="stability.sd3-5-large-v1:0">Stable Diffusion 3.5 Large (Higher Quality)</option>
                        </select>
                        <small style="color: #666; font-size: 12px;">Titan: $0.008-$0.010/image | SD 3.5: $0.065/image</small>
                    </div>

                    <div class="form-group">
                        <label>Prompt</label>
                        <textarea id="prompt-text" placeholder="Describe the image you want to generate..." required></textarea>
                        <div style="margin-top: 8px; display: flex; gap: 8px;">
                            <button type="button" onclick="showPromptTemplates()" 
                                    style="width: auto; padding: 8px 15px; font-size: 14px; background: #6c757d;">
                                💡 Example Prompts
                            </button>
                            <button type="button" onclick="showStylePresets('text')" 
                                    style="width: auto; padding: 8px 15px; font-size: 14px; background: #8b5cf6;">
                                🎨 Style Presets
                            </button>
                        </div>
                    </div>

                    <div class="form-group">
                        <label>Negative Prompt (Optional - what to avoid)</label>
                        <textarea id="negative-prompt-text" placeholder="e.g., blurry, low quality, distorted, ugly..." rows="2"></textarea>
                        <small style="color: #666; font-size: 12px; margin-top: 5px; display: block;">Specify what you DON'T want in the image</small>
                    </div>

                    <div class="form-group">
                        <label>Image Size</label>
                        <div class="size-inputs">
                            <div>
                                <label>Width</label>
                                <input type="number" id="width-text" value="1024" min="512" max="2048" step="64">
                            </div>
                            <div>
                                <label>Height</label>
                                <input type="number" id="height-text" value="1024" min="512" max="2048" step="64">
                            </div>
                        </div>
                        <div style="margin-top: 10px; display: flex; gap: 5px; flex-wrap: wrap;">
                            <button type="button" onclick="setDimensions(1024, 1024, 'text')" style="width: auto; padding: 5px 10px; font-size: 12px;">1:1 (1024x1024)</button>
                            <button type="button" onclick="setDimensions(1344, 768, 'text')" style="width: auto; padding: 5px 10px; font-size: 12px;">16:9 (1344x768)</button>
                            <button type="button" onclick="setDimensions(768, 1344, 'text')" style="width: auto; padding: 5px 10px; font-size: 12px;">9:16 (768x1344)</button>
                            <button type="button" onclick="setDimensions(1216, 832, 'text')" style="width: auto; padding: 5px 10px; font-size: 12px;">3:2 (1216x832)</button>
                        </div>
                        <small style="color: #666; font-size: 12px; margin-top: 5px; display: block;">Quick presets for common sizes (SD 3.5 compatible)</small>
                    </div>

                    <button type="submit" id="btn-text">Generate Image</button>
                </form>

                <div id="result-text" class="result"></div>
            </div>

            <!-- Image to Image Tab -->
            <div id="image-to-image" class="tab-content">
                <form onsubmit="generateImageToImage(event)">
                    <div class="form-group">
                        <label>API Endpoint</label>
                        <input type="text" id="api-endpoint-image" placeholder="https://your-api-gateway-url/generate" required>
                    </div>

                    <div class="form-group">
                        <label>API Key (Required)</label>
                        <input type="password" id="api-key-image" placeholder="Your API key" required>
                        <small style="color: #666; font-size: 12px; margin-top: 5px; display: block;">Get your API key from CDK output or AWS Console</small>
                    </div>

                    <div class="form-group">
                        <label>Model</label>
                        <select id="model-image" style="width: 100%; padding: 12px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 16px;">
                            <option value="amazon.titan-image-generator-v2:0">Amazon Titan Image Generator v2 (Recommended)</option>
                            <option value="stability.sd3-5-large-v1:0">Stable Diffusion 3.5 Large (Higher Quality)</option>
                        </select>
                        <small style="color: #666; font-size: 12px;">Both models support image-to-image transformation</small>
                    </div>

                    <div class="form-group">
                        <label>Upload Image</label>
                        <div class="file-upload" id="drop-zone" onclick="document.getElementById('image-input').click()">
                            <input type="file" id="image-input" accept="image/*" onchange="previewImage(event)">
                            <p id="upload-text">Click to upload or drag and drop</p>
                            <p style="font-size: 14px; color: #666; margin-top: 5px;">PNG, JPEG, WebP (max 5 MB)</p>
                            <img id="preview" class="preview-image" style="display: none;">
                        </div>
                    </div>

                    <div class="form-group">
                        <label>Prompt</label>
                        <textarea id="prompt-image" placeholder="Describe how you want to transform the image..." required></textarea>
                        <div style="margin-top: 8px; display: flex; gap: 8px;">
                            <button type="button" onclick="showPromptTemplates()" 
                                    style="width: auto; padding: 8px 15px; font-size: 14px; background: #6c757d;">
                                💡 Example Prompts
                            </button>
                            <button type="button" onclick="showStylePresets('image')" 
                                    style="width: auto; padding: 8px 15px; font-size: 14px; background: #8b5cf6;">
                                🎨 Style Presets
                            </button>
                        </div>
                    </div>

                    <div class="form-group">
                        <label>Negative Prompt (Optional - what to avoid)</label>
                        <textarea id="negative-prompt-image" placeholder="e.g., blurry, low quality, distorted, ugly..." rows="2"></textarea>
                        <small style="color: #666; font-size: 12px; margin-top: 5px; display: block;">Specify what you DON'T want in the image</small>
                    </div>

                    <div class="form-group">
                        <label>Image Size</label>
                        <div class="size-inputs">
                            <div>
                                <label>Width</label>
                                <input type="number" id="width-image" value="1024" min="320" max="2048" step="64">
                            </div>
                            <div>
                                <label>Height</label>
                                <input type="number" id="height-image" value="1024" min="320" max="2048" step="64">
                            </div>
                        </div>
                        <div style="margin-top: 10px; display: flex; gap: 5px; flex-wrap: wrap;">
                            <button type="button" onclick="setDimensions(1024, 1024, 'image')" style="width: auto; padding: 5px 10px; font-size: 12px;">1:1 (1024x1024)</button>
                            <button type="button" onclick="setDimensions(1344, 768, 'image')" style="width: auto; padding: 5px 10px; font-size: 12px;">16:9 (1344x768)</button>
                            <button type="button" onclick="setDimensions(768, 1344, 'image')" style="width: auto; padding: 5px 10px; font-size: 12px;">9:16 (768x1344)</button>
                            <button type="button" onclick="setDimensions(512, 512, 'image')" style="width: auto; padding: 5px 10px; font-size: 12px;">Small (512x512)</button>
                        </div>
                        <small style="color: #666; font-size: 12px; margin-top: 5px; display: block;">Quick presets (Titan v2 only)</small>
                    </div>

                    <button type="submit" id="btn-image">Transform Image</button>
                </form>

                <div id="result-image" class="result"></div>
            </div>

            <div class="privacy-note">
                🔒 <strong>Privacy:</strong> All images are generated using AWS Bedrock in your AWS account. 
                No data is sent to third parties. Images are stored privately in your S3 bucket with encryption.
            </div>
        </div>
    </div>

    <script>
        let uploadedImageBase64 = null;

        // Load saved API endpoint and show recent images on page load
        document.addEventListener('DOMContentLoaded', function() {
            // Load saved API endpoint
            const savedEndpoint = localStorage.getItem('apiEndpoint');
            if (savedEndpoint) {
                document.getElementById('api-endpoint-text').value = savedEndpoint;
                document.getElementById('api-endpoint-image').value = savedEndpoint;
            }

            // Load saved API key
            const savedApiKey = localStorage.getItem('apiKey');
            if (savedApiKey) {
                document.getElementById('api-key-text').value = savedApiKey;
                document.getElementById('api-key-image').value = savedApiKey;
            }

            // Show recent images
            showRecentImages();

            // Load example prompts
            loadPromptTemplates();
        });

        function switchTab(tabName) {
            document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
            document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));

            event.target.classList.add('active');
            document.getElementById(tabName).classList.add('active');
        }

        function saveApiEndpoint(endpoint) {
            localStorage.setItem('apiEndpoint', endpoint);
        }

        function saveApiKey(apiKey) {
            if (apiKey) {
                localStorage.setItem('apiKey', apiKey);
            }
        }

        function saveToHistory(imageUrl, filename, prompt, modelId) {
            const history = JSON.parse(localStorage.getItem('imageHistory') || '[]');
            history.unshift({
                url: imageUrl,
                filename: filename,
                prompt: prompt,
                modelId: modelId,
                timestamp: new Date().toISOString()
            });
            // Keep only last 10 images
            if (history.length > 10) history.pop();
            localStorage.setItem('imageHistory', JSON.stringify(history));
        }

        function showRecentImages() {
            const history = JSON.parse(localStorage.getItem('imageHistory') || '[]');
            if (history.length === 0) return;

            const historyHtml = `
                <div style="margin-top: 30px; padding: 20px; background: #f9f9f9; border-radius: 8px;">
                    <h3 style="margin-bottom: 15px; color: #333;">Recent Generations</h3>
                    <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px;">
                        ${history.slice(0, 5).map(item => `
                            <div style="text-align: center;">
                                <img src="${item.url}" style="width: 100%; border-radius: 4px; cursor: pointer;" 
                                     onclick="window.open('${item.url}', '_blank')" 
                                     title="${item.prompt.substring(0, 50)}...">
                                <small style="display: block; margin-top: 5px; color: #666; font-size: 11px;">
                                    ${new Date(item.timestamp).toLocaleDateString()}
                                </small>
                            </div>
                        `).join('')}
                    </div>
                    <button type="button" onclick="clearHistory()" 
                            style="margin-top: 15px; width: auto; padding: 8px 15px; font-size: 14px; background: #dc3545;">
                        Clear History
                    </button>
                </div>
            `;

            // Add to both result divs if they're empty
            const resultText = document.getElementById('result-text');
            const resultImage = document.getElementById('result-image');
            if (!resultText.innerHTML) resultText.innerHTML = historyHtml;
            if (!resultImage.innerHTML) resultImage.innerHTML = historyHtml;
        }

        function clearHistory() {
            if (confirm('Clear all image history?')) {
                localStorage.removeItem('imageHistory');
                document.getElementById('result-text').innerHTML = '';
                document.getElementById('result-image').innerHTML = '';
            }
        }

        function loadPromptTemplates() {
            const templates = [
                "A serene mountain landscape at sunset with vibrant colors",
                "A futuristic city with flying cars and neon lights",
                "A cozy coffee shop interior with warm lighting",
                "An astronaut floating in space with Earth in background",
                "A magical forest with glowing mushrooms and fireflies",
                "A steampunk airship flying through clouds",
                "A minimalist modern living room with large windows",
                "A cyberpunk street market at night with rain",
                "A peaceful zen garden with cherry blossoms",
                "An underwater scene with colorful coral and fish"
            ];

            window.promptTemplates = templates;

            // Style presets with prompt modifiers
            window.stylePresets = {
                "Photorealistic": {
                    suffix: ", photorealistic, highly detailed, 8k, professional photography",
                    negative: "cartoon, anime, painting, drawing, illustration, low quality"
                },
                "Digital Art": {
                    suffix: ", digital art, artstation, concept art, smooth, sharp focus",
                    negative: "photo, photograph, realistic, low quality, blurry"
                },
                "Oil Painting": {
                    suffix: ", oil painting, canvas, brushstrokes, artistic, masterpiece",
                    negative: "photo, digital, 3d render, low quality"
                },
                "Anime": {
                    suffix: ", anime style, manga, cel shaded, vibrant colors",
                    negative: "realistic, photo, 3d, western cartoon, low quality"
                },
                "Watercolor": {
                    suffix: ", watercolor painting, soft colors, artistic, flowing",
                    negative: "photo, digital, harsh lines, low quality"
                },
                "3D Render": {
                    suffix: ", 3d render, octane render, unreal engine, highly detailed",
                    negative: "2d, flat, painting, sketch, low quality"
                },
                "Sketch": {
                    suffix: ", pencil sketch, hand drawn, artistic, detailed linework",
                    negative: "photo, color, painted, low quality"
                },
                "Cyberpunk": {
                    suffix: ", cyberpunk style, neon lights, futuristic, dystopian, high tech",
                    negative: "medieval, natural, rustic, low quality"
                },
                "Fantasy": {
                    suffix: ", fantasy art, magical, ethereal, epic, detailed",
                    negative: "modern, realistic, mundane, low quality"
                },
                "Minimalist": {
                    suffix: ", minimalist, clean, simple, modern, elegant",
                    negative: "cluttered, busy, complex, ornate, low quality"
                }
            };
        }

        function applyStylePreset(style, tabType) {
            const preset = window.stylePresets[style];
            if (!preset) return;

            const promptField = tabType === 'text' 
                ? document.getElementById('prompt-text')
                : document.getElementById('prompt-image');

            const negativeField = tabType === 'text'
                ? document.getElementById('negative-prompt-text')
                : document.getElementById('negative-prompt-image');

            // Add style suffix to prompt if not already there
            let currentPrompt = promptField.value.trim();
            if (currentPrompt && !currentPrompt.includes(preset.suffix)) {
                promptField.value = currentPrompt + preset.suffix;
            }

            // Set negative prompt
            if (negativeField && !negativeField.value.trim()) {
                negativeField.value = preset.negative;
            }

            // Show selected style indicator
            showStyleIndicator(style, tabType);
        }

        function showStyleIndicator(style, tabType) {
            const indicatorId = `style-indicator-${tabType}`;

            // Remove existing indicator
            const existing = document.getElementById(indicatorId);
            if (existing) existing.remove();

            // Create new indicator
            const indicator = document.createElement('div');
            indicator.id = indicatorId;
            indicator.style.cssText = `
                margin-top: 10px;
                padding: 10px 15px;
                background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
                color: white;
                border-radius: 8px;
                display: flex;
                align-items: center;
                justify-content: space-between;
                font-size: 14px;
                animation: slideIn 0.3s ease-out;
            `;

            indicator.innerHTML = `
                <span>🎨 <strong>Style:</strong> ${style}</span>
                <button onclick="resetStylePreset('${tabType}')" 
                        style="
                            background: rgba(255,255,255,0.2);
                            color: white;
                            border: none;
                            padding: 5px 12px;
                            border-radius: 5px;
                            cursor: pointer;
                            font-size: 12px;
                            transition: background 0.2s;
                        "
                        onmouseover="this.style.background='rgba(255,255,255,0.3)'"
                        onmouseout="this.style.background='rgba(255,255,255,0.2)'">
                    ✕ Reset
                </button>
            `;

            // Insert after the style presets button
            const buttonContainer = tabType === 'text'
                ? document.getElementById('prompt-text').nextElementSibling
                : document.getElementById('prompt-image').nextElementSibling;

            buttonContainer.parentNode.insertBefore(indicator, buttonContainer.nextSibling);
        }

        function resetStylePreset(tabType) {
            const promptField = tabType === 'text' 
                ? document.getElementById('prompt-text')
                : document.getElementById('prompt-image');

            const negativeField = tabType === 'text'
                ? document.getElementById('negative-prompt-text')
                : document.getElementById('negative-prompt-image');

            // Remove style suffixes from prompt
            let currentPrompt = promptField.value;
            Object.values(window.stylePresets || {}).forEach(preset => {
                currentPrompt = currentPrompt.replace(preset.suffix, '').trim();
            });
            promptField.value = currentPrompt;

            // Clear negative prompt if it matches a preset
            const currentNegative = negativeField.value;
            const isPresetNegative = Object.values(window.stylePresets || {})
                .some(preset => preset.negative === currentNegative);
            if (isPresetNegative) {
                negativeField.value = '';
            }

            // Remove indicator
            const indicator = document.getElementById(`style-indicator-${tabType}`);
            if (indicator) indicator.remove();
        }

        function showStylePresets(tabType) {
            const styles = Object.keys(window.stylePresets || {});

            // Create modal HTML
            const modalHtml = `
                <div id="style-modal" style="
                    position: fixed;
                    top: 0;
                    left: 0;
                    width: 100%;
                    height: 100%;
                    background: rgba(0,0,0,0.7);
                    display: flex;
                    align-items: center;
                    justify-content: center;
                    z-index: 1000;
                " onclick="if(event.target.id === 'style-modal') this.remove()">
                    <div style="
                        background: white;
                        border-radius: 12px;
                        padding: 30px;
                        max-width: 600px;
                        max-height: 80vh;
                        overflow-y: auto;
                        box-shadow: 0 20px 60px rgba(0,0,0,0.3);
                    " onclick="event.stopPropagation()">
                        <h2 style="margin: 0 0 20px 0; color: #333;">🎨 Choose a Style Preset</h2>
                        <div style="display: grid; gap: 10px;">
                            ${styles.map((style, i) => {
                                const preset = window.stylePresets[style];
                                return `
                                    <button onclick="applyStylePreset('${style}', '${tabType}'); document.getElementById('style-modal').remove();" 
                                            style="
                                                padding: 15px;
                                                background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
                                                color: white;
                                                border: none;
                                                border-radius: 8px;
                                                cursor: pointer;
                                                text-align: left;
                                                transition: transform 0.2s;
                                                font-size: 14px;
                                            "
                                            onmouseover="this.style.transform='translateY(-2px)'"
                                            onmouseout="this.style.transform='translateY(0)'">
                                        <strong style="font-size: 16px; display: block; margin-bottom: 5px;">${style}</strong>
                                        <small style="opacity: 0.9; display: block;">Adds: ${preset.suffix.substring(0, 60)}...</small>
                                    </button>
                                `;
                            }).join('')}
                        </div>
                        <button onclick="document.getElementById('style-modal').remove()" 
                                style="
                                    margin-top: 20px;
                                    width: 100%;
                                    padding: 12px;
                                    background: #6c757d;
                                    color: white;
                                    border: none;
                                    border-radius: 8px;
                                    cursor: pointer;
                                    font-size: 14px;
                                ">
                            Cancel
                        </button>
                    </div>
                </div>
            `;

            // Add modal to page
            document.body.insertAdjacentHTML('beforeend', modalHtml);
        }

        function showPromptTemplates() {
            const templates = window.promptTemplates || [];
            const templateList = templates.map((t, i) => `${i + 1}. ${t}`).join('\n');
            const selected = prompt(`Choose a prompt template (enter number 1-${templates.length}):\n\n${templateList}`);

            if (selected && !isNaN(selected)) {
                const index = parseInt(selected) - 1;
                if (index >= 0 && index < templates.length) {
                    const activeTab = document.querySelector('.tab-content.active');
                    const promptField = activeTab.querySelector('textarea');
                    if (promptField) {
                        promptField.value = templates[index];
                    }
                }
            }
        }

        // Initialize drag and drop after DOM is loaded
        document.addEventListener('DOMContentLoaded', function() {
            const dropZone = document.getElementById('drop-zone');

            if (dropZone) {
                ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
                    dropZone.addEventListener(eventName, preventDefaults, false);
                });

                ['dragenter', 'dragover'].forEach(eventName => {
                    dropZone.addEventListener(eventName, highlight, false);
                });

                ['dragleave', 'drop'].forEach(eventName => {
                    dropZone.addEventListener(eventName, unhighlight, false);
                });

                dropZone.addEventListener('drop', handleDrop, false);
            }
        });

        function preventDefaults(e) {
            e.preventDefault();
            e.stopPropagation();
        }

        function highlight(e) {
            e.currentTarget.classList.add('dragover');
        }

        function unhighlight(e) {
            e.currentTarget.classList.remove('dragover');
        }

        function handleDrop(e) {
            const dt = e.dataTransfer;
            const files = dt.files;

            if (files.length > 0) {
                const file = files[0];

                // Validate file size (5 MB limit)
                const maxSize = 5 * 1024 * 1024;
                if (file.size > maxSize) {
                    alert('Image size must be less than 5 MB. Please resize or compress your image.');
                    return;
                }

                // Validate file type
                const validTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
                if (!validTypes.includes(file.type)) {
                    alert('Only PNG, JPEG, and WebP formats are supported.');
                    return;
                }

                const reader = new FileReader();
                reader.onload = function(readerEvent) {
                    const img = new Image();
                    img.onload = function() {
                        // Validate dimensions
                        const minDim = 320;
                        const maxDim = 2048;

                        if (img.width < minDim || img.height < minDim) {
                            alert(`Image dimensions must be at least ${minDim}x${minDim} pixels. Current: ${img.width}x${img.height}`);
                            return;
                        }

                        if (img.width > maxDim || img.height > maxDim) {
                            alert(`Image dimensions must not exceed ${maxDim}x${maxDim} pixels. Current: ${img.width}x${img.height}`);
                            return;
                        }

                        // All validations passed
                        const preview = document.getElementById('preview');
                        preview.src = readerEvent.target.result;
                        preview.style.display = 'block';
                        uploadedImageBase64 = readerEvent.target.result.split(',')[1];
                    };
                    img.src = readerEvent.target.result;
                };
                reader.readAsDataURL(file);
            }
        }

        function previewImage(event) {
            const file = event.target.files[0];
            if (file) {
                // Validate file size (5 MB limit)
                const maxSize = 5 * 1024 * 1024; // 5 MB
                if (file.size > maxSize) {
                    alert('Image size must be less than 5 MB. Please resize or compress your image.');
                    event.target.value = '';
                    return;
                }

                // Validate file type
                const validTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
                if (!validTypes.includes(file.type)) {
                    alert('Only PNG, JPEG, and WebP formats are supported.');
                    event.target.value = '';
                    return;
                }

                const reader = new FileReader();
                reader.onload = function(e) {
                    const img = new Image();
                    img.onload = function() {
                        // Validate dimensions
                        const minDim = 320;
                        const maxDim = 2048;

                        if (img.width < minDim || img.height < minDim) {
                            alert(`Image dimensions must be at least ${minDim}x${minDim} pixels. Current: ${img.width}x${img.height}`);
                            event.target.value = '';
                            return;
                        }

                        if (img.width > maxDim || img.height > maxDim) {
                            alert(`Image dimensions must not exceed ${maxDim}x${maxDim} pixels. Current: ${img.width}x${img.height}`);
                            event.target.value = '';
                            return;
                        }

                        // All validations passed
                        const preview = document.getElementById('preview');
                        preview.src = e.target.result;
                        preview.style.display = 'block';
                        uploadedImageBase64 = e.target.result.split(',')[1];
                    };
                    img.src = e.target.result;
                };
                reader.readAsDataURL(file);
            }
        }

        function validateDimensions(width, height, modelId) {
            // Basic validation
            if (width < 512 || height < 512) {
                return `Image dimensions too small: ${width}x${height}. Minimum size is 512x512 pixels.`;
            }
            if (width > 2048 || height > 2048) {
                return `Image dimensions too large: ${width}x${height}. Maximum size is 2048x2048 pixels.`;
            }

            // SDXL supports any dimensions, no aspect ratio validation needed
            if (false && modelId.includes('stable-diffusion')) {
                const ratio = (width / height).toFixed(2);
                const validRatios = {
                    '1.00': '1:1 (e.g., 1024x1024)',
                    '1.78': '16:9 (e.g., 1344x768)',
                    '2.40': '21:9 (e.g., 1536x640)',
                    '0.67': '2:3 (e.g., 832x1216)',
                    '1.46': '3:2 (e.g., 1216x832)',
                    '0.80': '4:5 (e.g., 896x1120)',
                    '1.25': '5:4 (e.g., 1120x896)',
                    '0.56': '9:16 (e.g., 768x1344)',
                    '0.42': '9:21 (e.g., 640x1536)'
                };

                const isValid = Object.keys(validRatios).some(validRatio => 
                    Math.abs(parseFloat(ratio) - parseFloat(validRatio)) < 0.05
                );

                if (!isValid) {
                    const supported = Object.values(validRatios).join(', ');
                    return `Stable Diffusion 3.5 requires standard aspect ratios.\n\nYour dimensions: ${width}x${height} (ratio ${ratio})\n\nSupported ratios:\n${supported}`;
                }
            }

            return null; // Valid
        }

        async function generateTextToImage(event) {
            event.preventDefault();

            const apiEndpoint = document.getElementById('api-endpoint-text').value;
            const apiKey = document.getElementById('api-key-text').value;
            const modelId = document.getElementById('model-text').value;
            const prompt = document.getElementById('prompt-text').value;
            const negativePrompt = document.getElementById('negative-prompt-text').value;
            const width = parseInt(document.getElementById('width-text').value);
            const height = parseInt(document.getElementById('height-text').value);
            const resultDiv = document.getElementById('result-text');
            const button = document.getElementById('btn-text');

            // Client-side validation
            const validationError = validateDimensions(width, height, modelId);
            if (validationError) {
                alert(validationError);
                return;
            }

            // Save API endpoint and key for future use
            saveApiEndpoint(apiEndpoint);
            saveApiKey(apiKey);

            button.disabled = true;
            resultDiv.innerHTML = '<div class="status loading">Generating image... This may take up to 60 seconds.</div><div class="loader"></div>';

            try {
                // Create abort controller for timeout
                const controller = new AbortController();
                const timeoutId = setTimeout(() => controller.abort(), 120000); // 2 minute timeout

                // Build headers
                const headers = {
                    'Content-Type': 'application/json',
                };
                if (apiKey) {
                    headers['X-API-Key'] = apiKey;
                }

                const response = await fetch(apiEndpoint, {
                    method: 'POST',
                    headers: headers,
                    body: JSON.stringify({
                        model_id: modelId,
                        prompt: prompt,
                        negative_prompt: negativePrompt,
                        width: width,
                        height: height
                    }),
                    signal: controller.signal
                });

                clearTimeout(timeoutId);

                if (!response.ok) {
                    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
                }

                const data = await response.json();

                if (data.success) {
                    // Save to history
                    saveToHistory(data.image_url, data.filename, prompt, modelId);

                    resultDiv.innerHTML = `
                        <div class="status success">${data.message}</div>
                        ${data.filename ? `<p style="font-size: 14px; color: #666; margin-top: 10px;"><strong>Filename:</strong> ${data.filename}</p>` : ''}
                        <img src="${data.image_url}" class="result-image" alt="Generated image">
                        <p style="margin-top: 10px; font-size: 14px; color: #666;">
                            <a href="${data.image_url}" download="${data.filename || 'generated-image.png'}" style="color: #10b981;">Download Image</a>
                        </p>
                    `;
                } else {
                    resultDiv.innerHTML = `<div class="status error">Error: ${data.error || 'Unknown error'}</div>`;
                }
            } catch (error) {
                if (error.name === 'AbortError') {
                    resultDiv.innerHTML = `<div class="status error">⏱️ Request timeout. Image generation took too long. Please try again.</div>`;
                } else {
                    resultDiv.innerHTML = `
                        <div class="status error">
                            ❌ <strong>Error:</strong> ${error.message}
                            <br><small style="display: block; margin-top: 10px;">
                                💡 <strong>Troubleshooting:</strong><br>
                                • Check that your API endpoint is correct<br>
                                • Ensure CORS is enabled in API Gateway<br>
                                • Verify Lambda has Bedrock permissions<br>
                                • Check CloudWatch logs for details
                            </small>
                        </div>
                    `;
                }
            } finally {
                button.disabled = false;
            }
        }

        async function generateImageToImage(event) {
            event.preventDefault();

            if (!uploadedImageBase64) {
                alert('Please upload an image first');
                return;
            }

            const apiEndpoint = document.getElementById('api-endpoint-image').value;
            const apiKey = document.getElementById('api-key-image').value;
            const modelId = document.getElementById('model-image').value;
            const prompt = document.getElementById('prompt-image').value;
            const negativePrompt = document.getElementById('negative-prompt-image').value;
            const width = parseInt(document.getElementById('width-image').value);
            const height = parseInt(document.getElementById('height-image').value);
            const resultDiv = document.getElementById('result-image');
            const button = document.getElementById('btn-image');

            // Client-side validation (image-to-image has min 320x320)
            if (width < 320 || height < 320) {
                alert(`Image-to-image dimensions too small: ${width}x${height}. Minimum size is 320x320 pixels.`);
                return;
            }
            if (width > 2048 || height > 2048) {
                alert(`Image dimensions too large: ${width}x${height}. Maximum size is 2048x2048 pixels.`);
                return;
            }

            // Save API endpoint and key for future use
            saveApiEndpoint(apiEndpoint);
            saveApiKey(apiKey);

            button.disabled = true;
            resultDiv.innerHTML = '<div class="status loading">Transforming image... This may take up to 60 seconds.</div><div class="loader"></div>';

            try {
                // Create abort controller for timeout
                const controller = new AbortController();
                const timeoutId = setTimeout(() => controller.abort(), 120000); // 2 minute timeout

                // Build headers
                const headers = {
                    'Content-Type': 'application/json',
                };
                if (apiKey) {
                    headers['X-API-Key'] = apiKey;
                }

                const response = await fetch(apiEndpoint, {
                    method: 'POST',
                    headers: headers,
                    body: JSON.stringify({
                        model_id: modelId,
                        prompt: prompt,
                        negative_prompt: negativePrompt,
                        input_image: uploadedImageBase64,
                        width: width,
                        height: height
                    }),
                    signal: controller.signal
                });

                clearTimeout(timeoutId);

                if (!response.ok) {
                    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
                }

                const data = await response.json();

                if (data.success) {
                    resultDiv.innerHTML = `
                        <div class="status success">${data.message}</div>
                        ${data.filename ? `<p style="font-size: 14px; color: #666; margin-top: 10px;"><strong>Filename:</strong> ${data.filename}</p>` : ''}
                        <img src="${data.image_url}" class="result-image" alt="Generated image">
                        <p style="margin-top: 10px; font-size: 14px; color: #666;">
                            <a href="${data.image_url}" download="${data.filename || 'generated-image.png'}" style="color: #10b981;">Download Image</a>
                        </p>
                    `;
                } else {
                    resultDiv.innerHTML = `<div class="status error">Error: ${data.error || 'Unknown error'}</div>`;
                }
            } catch (error) {
                if (error.name === 'AbortError') {
                    resultDiv.innerHTML = `<div class="status error">Request timeout. Image generation took too long. Please try again.</div>`;
                } else {
                    resultDiv.innerHTML = `<div class="status error">Error: ${error.message}<br><small>Check that your API endpoint is correct and CORS is enabled.</small></div>`;
                }
            } finally {
                button.disabled = false;
            }
        }
    </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

error.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Error - AI Image Generator</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
            background: linear-gradient(135deg, #10b981 0%, #059669 100%);
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 20px;
        }

        .error-container {
            background: white;
            border-radius: 12px;
            padding: 60px 40px;
            box-shadow: 0 10px 40px rgba(0,0,0,0.1);
            text-align: center;
            max-width: 600px;
            width: 100%;
        }

        .error-icon {
            font-size: 80px;
            margin-bottom: 20px;
        }

        h1 {
            color: #333;
            font-size: 2.5rem;
            margin-bottom: 15px;
        }

        .error-code {
            color: #10b981;
            font-size: 1.2rem;
            font-weight: 600;
            margin-bottom: 20px;
        }

        p {
            color: #666;
            font-size: 1.1rem;
            line-height: 1.6;
            margin-bottom: 30px;
        }

        .button-group {
            display: flex;
            gap: 15px;
            justify-content: center;
            flex-wrap: wrap;
        }

        .btn {
            padding: 15px 30px;
            border: none;
            border-radius: 8px;
            font-size: 16px;
            font-weight: 600;
            cursor: pointer;
            text-decoration: none;
            transition: all 0.3s;
            display: inline-block;
        }

        .btn-primary {
            background: #10b981;
            color: white;
        }

        .btn-primary:hover {
            background: #059669;
            transform: translateY(-2px);
            box-shadow: 0 5px 15px rgba(16, 185, 129, 0.4);
        }

        .btn-secondary {
            background: #f5f5f5;
            color: #333;
        }

        .btn-secondary:hover {
            background: #e0e0e0;
        }

        .error-details {
            margin-top: 30px;
            padding: 20px;
            background: #f9f9f9;
            border-radius: 8px;
            text-align: left;
        }

        .error-details h3 {
            color: #333;
            font-size: 1rem;
            margin-bottom: 10px;
        }

        .error-details ul {
            list-style: none;
            padding: 0;
        }

        .error-details li {
            color: #666;
            font-size: 0.9rem;
            padding: 5px 0;
            padding-left: 20px;
            position: relative;
        }

        .error-details li:before {
            content: "•";
            position: absolute;
            left: 0;
            color: #10b981;
            font-weight: bold;
        }

        @media (max-width: 600px) {
            .error-container {
                padding: 40px 20px;
            }

            h1 {
                font-size: 2rem;
            }

            .error-icon {
                font-size: 60px;
            }

            .button-group {
                flex-direction: column;
            }

            .btn {
                width: 100%;
            }
        }
    </style>
</head>
<body>
    <div class="error-container">
        <div class="error-icon">⚠️</div>
        <h1>Oops! Something went wrong</h1>
        <p class="error-code">Error 404 - Page Not Found</p>
        <p>The page you're looking for doesn't exist or has been moved.</p>

        <div class="button-group">
            <a href="/" class="btn btn-primary">Go to Home</a>
            <button onclick="history.back()" class="btn btn-secondary">Go Back</button>
        </div>

        <div class="error-details">
            <h3>Common Issues:</h3>
            <ul>
                <li>The URL might be mistyped</li>
                <li>The page may have been removed or renamed</li>
                <li>Your session might have expired</li>
                <li>The resource you're looking for is not available</li>
            </ul>
        </div>
    </div>

    <script>
        // Get error code from URL if available
        const urlParams = new URLSearchParams(window.location.search);
        const errorCode = urlParams.get('code');

        if (errorCode) {
            const errorCodeElement = document.querySelector('.error-code');
            const errorMessages = {
                '403': 'Error 403 - Access Forbidden',
                '404': 'Error 404 - Page Not Found',
                '500': 'Error 500 - Internal Server Error',
                '503': 'Error 503 - Service Unavailable'
            };

            if (errorMessages[errorCode]) {
                errorCodeElement.textContent = errorMessages[errorCode];
            }
        }
    </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

2.2. S3 buckets

Create two buckets:

  • Frontend bucket: hosts the website
  • Image Storage bucket: stores generated images
    • Private bucket
    • Create lifecycle policies to delete images after 7 days to save cost
import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import { Construct } from 'constructs';
import * as path from 'path';

export interface S3BucketsStackProps extends cdk.StackProps {
  stackName: string;
  region: string;
  accountId: string;
  envName: string;
  imagesBucketName: string;
  frontendBucketName: string;
  imageExpiration: number;
}

export class S3BucketsStack extends cdk.Stack {
  public readonly imagesBucket: s3.Bucket;
  public readonly frontendBucket: s3.Bucket;

  constructor(scope: Construct, id: string, props: S3BucketsStackProps) {
    const { region, accountId, envName } = props;
    const updatedProps = {
      env: {
        region: region,
        account: accountId,
      },
      ...props,
    };
    super(scope, id, updatedProps);

    // Determine removal policy based on environment
    const isProduction = envName.toLowerCase() === 'prod';
    const imagesRemovalPolicy = isProduction
      ? cdk.RemovalPolicy.RETAIN
      : cdk.RemovalPolicy.DESTROY;
    const autoDeleteImages = !isProduction;

    // Private S3 bucket for generated images
    this.imagesBucket = new s3.Bucket(this, 'ImagesBucket', {
      bucketName: props.imagesBucketName,
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      versioned: true,
      enforceSSL: true,
      removalPolicy: imagesRemovalPolicy,
      autoDeleteObjects: autoDeleteImages,
      lifecycleRules: [
        {
          // Expire current versions
          expiration: cdk.Duration.days(props.imageExpiration),
          noncurrentVersionExpiration: cdk.Duration.days(props.imageExpiration),
          enabled: true,
        },
        {
          // Remove expired object delete markers
          expiredObjectDeleteMarker: true,
          enabled: true,
        },
      ],
    });

    // Frontend S3 bucket with website hosting
    this.frontendBucket = new s3.Bucket(this, 'FrontendBucket', {
      bucketName: props.frontendBucketName,
      websiteIndexDocument: 'index.html',
      websiteErrorDocument: 'error.html',
      publicReadAccess: true,
      blockPublicAccess: new s3.BlockPublicAccess({
        blockPublicAcls: false,
        blockPublicPolicy: false,
        ignorePublicAcls: false,
        restrictPublicBuckets: false,
      }),
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    // Deploy frontend files
    new s3deploy.BucketDeployment(this, 'DeployFrontend', {
      sources: [s3deploy.Source.asset(path.join(__dirname, '../frontend'))],
      destinationBucket: this.frontendBucket,
    });

    // Outputs
    new cdk.CfnOutput(this, 'ImagesBucketName', {
      value: this.imagesBucket.bucketName,
      description: 'S3 bucket for generated images',
      exportName: `ImagesBucketName-${envName}`,
    });

    new cdk.CfnOutput(this, 'ImagesBucketArn', {
      value: this.imagesBucket.bucketArn,
      description: 'S3 bucket ARN for generated images',
      exportName: `ImagesBucketArn-${envName}`,
    });

    new cdk.CfnOutput(this, 'FrontendBucketName', {
      value: this.frontendBucket.bucketName,
      description: 'S3 bucket for frontend',
      exportName: `FrontendBucketName-${envName}`,
    });

    new cdk.CfnOutput(this, 'FrontendUrl', {
      value: this.frontendBucket.bucketWebsiteUrl,
      description: 'Frontend website URL',
      exportName: `FrontendUrl-${envName}`,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

2.3 Lambda function

  • Create lambda handler:
"""
AWS Lambda function for AI image generation using Amazon Bedrock.

This function handles both text-to-image and image-to-image generation
using Amazon Titan Image Generator v2 and Stable Diffusion 3.5 Large models.

Features:
- Text-to-image generation from prompts
- Image-to-image transformation
- Negative prompts support
- Style presets
- Automatic dimension validation
- CloudWatch metrics and structured logging
- S3 storage with presigned URLs

Environment Variables:
- BUCKET_NAME: S3 bucket for storing generated images
- MODEL_ID: Default Bedrock model ID (fallback)
- ENVIRONMENT: Deployment environment (dev/prod)

Author: KateVu
Repository: https://github.com/KateVu/aws-cdk-genai-image
"""

import json
import boto3
import os
import base64
import uuid
import logging
from datetime import datetime
from time import time

# Configure structured logging for CloudWatch
logger = logging.getLogger()
logger.setLevel(logging.INFO)

# Initialize AWS service clients
bedrock_runtime = boto3.client('bedrock-runtime')  # For AI image generation
s3_client = boto3.client('s3')  # For image storage
cloudwatch = boto3.client('cloudwatch')  # For custom metrics

# Load environment variables
BUCKET_NAME = os.environ['BUCKET_NAME']
MODEL_ID = os.environ['MODEL_ID']
ENVIRONMENT = os.environ.get('ENVIRONMENT', 'dev')

def handler(event, context):
    """
    Main Lambda handler for image generation requests.

    Processes API Gateway requests to generate images using AWS Bedrock models.
    Supports both text-to-image and image-to-image generation with optional
    negative prompts and style presets.

    Args:
        event: API Gateway event containing request body with:
            - prompt (str, required): Text description of desired image
            - negative_prompt (str, optional): What to avoid in the image
            - model_id (str, optional): Bedrock model ID to use
            - width (int, optional): Image width (512-2048, default 1024)
            - height (int, optional): Image height (512-2048, default 1024)
            - input_image (str, optional): Base64 encoded image for transformation
        context: Lambda context object with request metadata

    Returns:
        dict: API Gateway response with:
            - statusCode: HTTP status code (200, 400, or 500)
            - headers: CORS headers
            - body: JSON with success status, image_url, filename, or error

    Raises:
        ValueError: For validation errors (returns 400)
        Exception: For server errors (returns 500)
    """
    start_time = time()
    request_id = context.aws_request_id

    try:
        # Parse request body
        body = json.loads(event.get('body', '{}'))

        prompt = body.get('prompt', '')
        negative_prompt = body.get('negative_prompt', '')
        input_image = body.get('input_image')
        width = body.get('width', 1024)
        height = body.get('height', 1024)
        model_id = body.get('model_id', MODEL_ID)
        generation_type = 'image-to-image' if input_image else 'text-to-image'

        logger.info(
            "Processing image generation request",
            extra={
                'request_id': request_id,
                'model_id': model_id,
                'generation_type': generation_type,
                'dimensions': f"{width}x{height}",
                'prompt_length': len(prompt)
            }
        )

        if not prompt:
            logger.warning("Request rejected: missing prompt",
                           extra={'request_id': request_id})
            publish_metric('ValidationError', 1, model_id, generation_type)
            return response(400, {'error': 'Prompt is required'})

        # Validate dimensions
        try:
            validate_dimensions(width, height, model_id, generation_type)
        except ValueError as e:
            logger.warning(
                f"Dimension validation failed: {str(e)}",
                extra={'request_id': request_id}
            )
            publish_metric('ValidationError', 1, model_id, generation_type)
            return response(400, {'error': str(e)})

        # Generate image
        if input_image:
            image_data = generate_image_to_image(
                input_image, prompt, negative_prompt, width, height, model_id
            )
        else:
            image_data = generate_text_to_image(
                prompt, negative_prompt, width, height, model_id
            )

        # Save to S3
        image_url, filename = save_to_s3(image_data, prompt)

        # Calculate duration and publish metrics
        duration = time() - start_time
        logger.info(
            "Image generated successfully",
            extra={
                'request_id': request_id,
                'model_id': model_id,
                'generation_type': generation_type,
                'image_filename': filename,
                'duration_seconds': round(duration, 2)
            }
        )

        publish_metric('GenerationSuccess', 1, model_id, generation_type)
        publish_metric('GenerationDuration', duration, model_id,
                       generation_type, unit='Seconds')

        return response(200, {
            'success': True,
            'image_url': image_url,
            'filename': filename,
            'message': 'Image generated successfully'
        })

    except ValueError as e:
        # Validation errors - return 400
        duration = time() - start_time
        logger.warning(
            f"Validation error: {str(e)}",
            extra={
                'request_id': request_id,
                'duration_seconds': round(duration, 2)
            }
        )
        publish_metric('ValidationError', 1,
                       body.get('model_id', MODEL_ID),
                       'image-to-image' if body.get('input_image')
                       else 'text-to-image')
        return response(400, {'error': str(e)})

    except Exception as e:
        # Server errors - return 500
        duration = time() - start_time
        error_type = type(e).__name__

        logger.error(
            "Image generation failed",
            extra={
                'request_id': request_id,
                'error_type': error_type,
                'error_message': str(e),
                'duration_seconds': round(duration, 2)
            },
            exc_info=True
        )

        publish_metric('GenerationError', 1,
                       body.get('model_id', MODEL_ID),
                       'image-to-image' if body.get('input_image')
                       else 'text-to-image')

        return response(500, {'error': str(e)})

def validate_dimensions(width, height, model_id, generation_type):
    """
    Validate image dimensions before calling Bedrock API.

    Ensures dimensions meet model requirements and prevents API errors.

    Args:
        width (int): Desired image width in pixels
        height (int): Desired image height in pixels
        model_id (str): Bedrock model identifier
        generation_type (str): 'text-to-image' or 'image-to-image'

    Raises:
        ValueError: If dimensions are invalid with descriptive error message

    Validation Rules:
        - Both dimensions must be integers
        - Minimum: 512x512 pixels (320x320 for Titan image-to-image)
        - Maximum: 2048x2048 pixels
        - SD3 requires specific aspect ratios (handled by get_aspect_ratio)
    """
    # Basic dimension validation
    if not isinstance(width, int) or not isinstance(height, int):
        raise ValueError(
            "Width and height must be integers. "
            f"Received: width={width}, height={height}"
        )

    if width < 512 or height < 512:
        raise ValueError(
            f"Image dimensions too small: {width}x{height}. "
            "Minimum size is 512x512 pixels."
        )

    if width > 2048 or height > 2048:
        raise ValueError(
            f"Image dimensions too large: {width}x{height}. "
            "Maximum size is 2048x2048 pixels."
        )

    # Stable Diffusion XL supports flexible dimensions (no aspect ratio restrictions)

    # Image-to-image specific validation for Titan
    if generation_type == 'image-to-image' and 'titan' in model_id.lower():
        if width < 320 or height < 320:
            raise ValueError(
                f"Image-to-image dimensions too small: {width}x{height}. "
                "Minimum size for image-to-image is 320x320 pixels."
            )

    logger.info(f"Dimension validation passed: {width}x{height}")

def publish_metric(metric_name, value, model_id, generation_type,
                   unit='Count'):
    """
    Publish custom CloudWatch metric for monitoring.

    Tracks generation success/failure rates, duration, and validation errors
    with dimensions for filtering by environment, model, and generation type.

    Args:
        metric_name (str): Name of the metric (e.g., 'GenerationSuccess')
        value (float): Metric value to publish
        model_id (str): Bedrock model identifier
        generation_type (str): 'text-to-image' or 'image-to-image'
        unit (str): CloudWatch unit (default: 'Count', also 'Seconds')

    Metrics Published:
        - GenerationSuccess: Count of successful generations
        - GenerationError: Count of failed generations
        - GenerationDuration: Time taken in seconds
        - ValidationError: Count of validation failures
    """
    try:
        cloudwatch.put_metric_data(
            Namespace='ImageGenerator',
            MetricData=[
                {
                    'MetricName': metric_name,
                    'Value': value,
                    'Unit': unit,
                    'Dimensions': [
                        {'Name': 'Environment', 'Value': ENVIRONMENT},
                        {'Name': 'ModelId', 'Value': model_id},
                        {'Name': 'GenerationType', 'Value': generation_type}
                    ]
                }
            ]
        )
    except Exception as e:
        logger.warning(f"Failed to publish metric: {str(e)}")

def generate_text_to_image(prompt, negative_prompt, width, height, model_id):
    """
    Generate image from text prompt using AWS Bedrock.

    Supports multiple models with automatic API format detection:
    - Amazon Titan Image Generator v2: Uses taskType and imageGenerationConfig
    - Stable Diffusion 3.5 Large: Uses prompt and aspect_ratio
    - Legacy SDXL: Uses text_prompts array

    Args:
        prompt (str): Text description of desired image
        negative_prompt (str): What to avoid in the image (optional)
        width (int): Image width in pixels
        height (int): Image height in pixels
        model_id (str): Bedrock model identifier

    Returns:
        bytes: Generated image data in PNG format

    Raises:
        Exception: If Bedrock API call fails or image generation fails
    """
    logger.info(f"Generating text-to-image with model: {model_id}")

    # Determine model type and format request accordingly
    if 'titan' in model_id.lower():
        # Amazon Titan Image Generator format
        text_params = {"text": prompt}
        if negative_prompt:
            text_params["negativeText"] = negative_prompt

        request_body = {
            "taskType": "TEXT_IMAGE",
            "textToImageParams": text_params,
            "imageGenerationConfig": {
                "numberOfImages": 1,
                "width": width,
                "height": height,
                "cfgScale": 8.0
            }
        }
    elif 'sd3' in model_id.lower():
        # Stable Diffusion 3.5 Large format
        aspect_ratio = get_aspect_ratio(width, height)
        request_body = {
            "prompt": prompt,
            "aspect_ratio": aspect_ratio,
            "seed": 0,
            "output_format": "png"
        }
        if negative_prompt:
            request_body["negative_prompt"] = negative_prompt
    else:
        # Legacy Stable Diffusion format (SDXL)
        request_body = {
            "text_prompts": [{"text": prompt}],
            "cfg_scale": 10,
            "seed": 0,
            "steps": 50,
            "width": width,
            "height": height
        }

    try:
        bedrock_response = bedrock_runtime.invoke_model(
            modelId=model_id,
            body=json.dumps(request_body)
        )
        response_body = json.loads(bedrock_response['body'].read())
        logger.info(f"Bedrock text-to-image API call successful: {model_id}")
    except Exception as e:
        logger.error(
            f"Bedrock text-to-image failed for {model_id}: {str(e)}",
            exc_info=True
        )
        raise

    # Extract image based on model type
    if 'titan' in model_id.lower():
        image_b64 = response_body['images'][0]
    elif 'sd3' in model_id.lower():
        image_b64 = response_body['images'][0]
    else:
        # Legacy SDXL format
        if response_body.get('result') == 'success':
            image_b64 = response_body['artifacts'][0]['base64']
        else:
            raise Exception("Image generation failed")

    return base64.b64decode(image_b64)

def get_aspect_ratio(width, height):
    """
    Convert width/height to closest supported aspect ratio for SD3.

    SD3 models require specific aspect ratios. This function finds the
    closest supported ratio to the requested dimensions.

    Args:
        width (int): Desired image width
        height (int): Desired image height

    Returns:
        str: Aspect ratio string (e.g., "1:1", "16:9", "9:16")

    Supported Ratios:
        1:1 (1024x1024), 16:9 (1344x768), 21:9 (1536x640),
        2:3 (832x1216), 3:2 (1216x832), 4:5 (896x1120),
        5:4 (1120x896), 9:16 (768x1344), 9:21 (640x1536)
    """
    ratio = width / height

    # Map to closest supported aspect ratio
    aspect_ratios = {
        1.0: "1:1",      # 1024x1024
        1.75: "16:9",    # 1344x768
        2.4: "21:9",     # 1536x640
        0.67: "2:3",     # 832x1216
        1.46: "3:2",     # 1216x832
        0.8: "4:5",      # 896x1120
        1.25: "5:4",     # 1120x896
        0.57: "9:16",    # 768x1344
        0.42: "9:21"     # 640x1536
    }

    # Find closest ratio
    closest_ratio = min(aspect_ratios.keys(), key=lambda x: abs(x - ratio))
    return aspect_ratios[closest_ratio]

def generate_image_to_image(input_image_b64, prompt, negative_prompt,
                            width, height, model_id):
    """
    Transform an existing image using AWS Bedrock (image-to-image).

    Takes an input image and transforms it according to the prompt while
    preserving some of the original image structure.

    Supported Models:
    - Amazon Titan v2: Uses IMAGE_VARIATION task with strength control
    - Stable Diffusion 3.5 Large: Uses image and strength parameters
    - Legacy SDXL: Uses init_image with image_strength

    Args:
        input_image_b64 (str): Base64 encoded input image
        prompt (str): Text description of desired transformation
        negative_prompt (str): What to avoid in the transformation (optional)
        width (int): Output image width in pixels
        height (int): Output image height in pixels
        model_id (str): Bedrock model identifier

    Returns:
        bytes: Transformed image data in PNG format

    Raises:
        Exception: If Bedrock API call fails or transformation fails

    Note:
        Strength parameter (0.0-1.0) controls how much to transform:
        - 0.0: Keep original image
        - 0.7: Balanced transformation (default for SD3)
        - 1.0: Maximum transformation
    """
    logger.info(f"Generating image-to-image with model: {model_id}")

    # Determine model type and format request accordingly
    if 'sd3' in model_id.lower():
        # Stable Diffusion 3.5 Large format for image-to-image
        request_body = {
            "prompt": prompt,
            "image": input_image_b64,
            "strength": 0.7,  # How much to transform (0.0-1.0)
            "output_format": "png"
        }
        if negative_prompt:
            request_body["negative_prompt"] = negative_prompt
    elif 'titan' in model_id.lower():
        # Amazon Titan Image Generator format
        variation_params = {
            "text": prompt,
            "images": [input_image_b64]
        }
        if negative_prompt:
            variation_params["negativeText"] = negative_prompt

        request_body = {
            "taskType": "IMAGE_VARIATION",
            "imageVariationParams": variation_params,
            "imageGenerationConfig": {
                "numberOfImages": 1,
                "width": width,
                "height": height,
                "cfgScale": 8.0
            }
        }
    else:
        # Legacy Stable Diffusion format (SDXL)
        request_body = {
            "text_prompts": [{"text": prompt}],
            "init_image": input_image_b64,
            "cfg_scale": 10,
            "image_strength": 0.5,
            "seed": 0,
            "steps": 50,
            "width": width,
            "height": height
        }

    try:
        bedrock_response = bedrock_runtime.invoke_model(
            modelId=model_id,
            body=json.dumps(request_body)
        )
        response_body = json.loads(bedrock_response['body'].read())
        logger.info(
            f"Bedrock image-to-image API call successful: {model_id}"
        )
    except Exception as e:
        logger.error(
            f"Bedrock image-to-image failed for {model_id}: {str(e)}",
            exc_info=True
        )
        raise

    # Extract image based on model type
    if 'titan' in model_id.lower():
        image_b64 = response_body['images'][0]
    elif 'sd3' in model_id.lower():
        image_b64 = response_body['images'][0]
    else:
        # Legacy SDXL format
        if response_body.get('result') == 'success':
            image_b64 = response_body['artifacts'][0]['base64']
        else:
            raise Exception("Image generation failed")

    return base64.b64decode(image_b64)

def save_to_s3(image_data, prompt):
    """
    Save generated image to private S3 bucket with encryption.

    Creates a descriptive filename from the prompt and timestamp,
    stores the image with server-side encryption, and generates
    a presigned URL for temporary access.

    Args:
        image_data (bytes): PNG image data to save
        prompt (str): Original prompt used for generation

    Returns:
        tuple: (presigned_url, filename)
            - presigned_url (str): Temporary URL valid for 1 hour
            - filename (str): Generated filename with timestamp and prompt

    Raises:
        Exception: If S3 upload or presigned URL generation fails

    Filename Format:
        YYYYMMDD-HHMMSS_sanitized-prompt_uuid.png
        Example: 20241119-143022_mountain-landscape_a1b2c3d4.png

    S3 Configuration:
        - Server-side encryption: AES256
        - Metadata: prompt (first 100 chars) and generation timestamp
        - Presigned URL expiration: 1 hour (3600 seconds)
    """
    logger.info(f"Saving image to S3 bucket: {BUCKET_NAME}")

    # Generate filename with date and UUID
    now = datetime.utcnow()
    date_str = now.strftime('%Y%m%d-%H%M%S')
    unique_id = str(uuid.uuid4())[:8]

    # Sanitize prompt for filename (first 30 chars, alphanumeric only)
    safe_prompt = ''.join(
        c for c in prompt[:30] if c.isalnum() or c in (' ', '-', '_'))
    safe_prompt = safe_prompt.replace(' ', '-').lower()

    # Create filename: YYYYMMDD-HHMMSS_prompt_uuid.png
    if safe_prompt:
        filename = f"{date_str}_{safe_prompt}_{unique_id}.png"
    else:
        filename = f"{date_str}_{unique_id}.png"

    key = f"generated-images/{filename}"

    try:
        s3_client.put_object(
            Bucket=BUCKET_NAME,
            Key=key,
            Body=image_data,
            ContentType='image/png',
            ServerSideEncryption='AES256',
            Metadata={
                'prompt': prompt[:100],
                'generated_at': now.isoformat()
            }
        )
        logger.info(f"Image saved successfully: {key}")
    except Exception as e:
        logger.error(f"Failed to save image to S3: {str(e)}", exc_info=True)
        raise

    # Generate presigned URL (expires in 1 hour)
    try:
        presigned_url = s3_client.generate_presigned_url(
            'get_object',
            Params={'Bucket': BUCKET_NAME, 'Key': key},
            ExpiresIn=3600
        )
        logger.info("Presigned URL generated successfully")
    except Exception as e:
        logger.error(
            f"Failed to generate presigned URL: {str(e)}",
            exc_info=True
        )
        raise

    return presigned_url, filename

def response(status_code, body):
    """
    Format API Gateway response with CORS headers.

    Args:
        status_code (int): HTTP status code (200, 400, 500)
        body (dict): Response body to be JSON serialized

    Returns:
        dict: API Gateway response format with:
            - statusCode: HTTP status code
            - headers: CORS headers for cross-origin requests
            - body: JSON stringified response body

    CORS Configuration:
        - Access-Control-Allow-Origin: * (all origins)
        - Access-Control-Allow-Methods: POST, OPTIONS
        - Access-Control-Allow-Headers: Content-Type
    """
    return {
        'statusCode': status_code,
        'headers': {
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Headers': 'Content-Type',
            'Access-Control-Allow-Methods': 'POST, OPTIONS'
        },
        'body': json.dumps(body)
Enter fullscreen mode Exit fullscreen mode
  • Provision lambda in AWS
// Create IAM role for Lambda function with Bedrock permissions
    const lambdaRole = new iam.Role(this, 'ImageGeneratorLambdaRole', {
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
      description:
        'Role for image generator Lambda function to access Bedrock and S3',
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          'service-role/AWSLambdaBasicExecutionRole'
        ),
      ],
    });

    // Add Bedrock invoke permissions for all models
    lambdaRole.addToPolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: ['bedrock:InvokeModel'],
        resources: [
          `arn:aws:bedrock:${region}::foundation-model/amazon.titan-image-generator-v2:0`,
          `arn:aws:bedrock:${region}::foundation-model/stability.sd3-5-large-v1:0`,
        ],
      })
    );

    // Add AWS Marketplace permissions for first-time model enablement
    lambdaRole.addToPolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: [
          'aws-marketplace:ViewSubscriptions',
          'aws-marketplace:Subscribe',
        ],
        resources: ['*'],
      })
    );

    // Grant S3 permissions
    props.imagesBucket.grantReadWrite(lambdaRole);

    // Grant CloudWatch metrics permissions
    lambdaRole.addToPolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: ['cloudwatch:PutMetricData'],
        resources: ['*'],
      })
    );

    // Create CloudWatch Log Group for Lambda
    const logGroup = new logs.LogGroup(this, 'ImageGeneratorLogGroup', {
      logGroupName: `/aws/lambda/${envName}-image-generator`,
      retention: props.logRetentionDays as logs.RetentionDays,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    // Lambda function for image generation with automatic dependency bundling
    this.lambdaFunction = new lambdaPython.PythonFunction(
      this,
      'ImageGeneratorFunction',
      {
        functionName: `${envName}-image-generator`,
        runtime: lambda.Runtime.PYTHON_3_11,
        entry: path.join(__dirname, '../lambda'),
        index: 'index.py',
        handler: 'handler',
        role: lambdaRole,
        timeout: cdk.Duration.seconds(props.lambdaTimeout),
        memorySize: props.lambdaMemorySize,
        environment: {
          BUCKET_NAME: props.imagesBucket.bucketName,
          MODEL_ID: 'amazon.titan-image-generator-v2:0', // Default fallback
          ENVIRONMENT: envName,
        },
        logGroup: logGroup,
        description:
          'Lambda function to generate images using Amazon Bedrock',
      }
    );
Enter fullscreen mode Exit fullscreen mode

2.4 Api gateway

Provision API gateway with:

  • POST endpoint
  • CORS allowed
  • API key for security
  • Set up throttling to prevent overspending
// Create API Gateway REST API
    this.api = new apigateway.RestApi(this, 'ImageGeneratorApi', {
      restApiName: `${envName}-image-generator-API`,
      description: 'API for AI image generation using AWS Bedrock',
      defaultCorsPreflightOptions: {
        allowOrigins: props.corsAllowOrigins,
        allowMethods: apigateway.Cors.ALL_METHODS,
        allowHeaders: [
          'Content-Type',
          'X-Amz-Date',
          'Authorization',
          'X-Api-Key',
        ],
      },
    });

    // Create Lambda integration
    const imageGeneratorIntegration = new apigateway.LambdaIntegration(
      this.lambdaFunction
    );

    // Add POST method to the API with API key requirement
    const generateResource = this.api.root.addResource('generate');
    generateResource.addMethod('POST', imageGeneratorIntegration, {
      apiKeyRequired: true,
    });

    // Create API Key
    const apiKey = this.api.addApiKey('ImageGeneratorApiKey', {
      apiKeyName: `${envName}-image-generator-key`,
      description: `API key for ${envName} image generator`,
    });

    // Create Usage Plan with rate limiting and quotas
    const usagePlan = this.api.addUsagePlan('ImageGeneratorUsagePlan', {
      name: `${envName}-image-generator-usage-plan`,
      description: 'Usage plan with rate limiting for image generation',
      throttle: {
        rateLimit: 10, // 10 requests per second
        burstLimit: 20, // Allow bursts up to 20 requests
      },
      quota: {
        limit: 10000, // 10,000 requests per month
        period: apigateway.Period.MONTH,
      },
    });

    // Associate API key with usage plan
    usagePlan.addApiKey(apiKey);
    usagePlan.addApiStage({
      stage: this.api.deploymentStage,
    });
Enter fullscreen mode Exit fullscreen mode

2.5 Connect everything in app.ts

import * as cdk from 'aws-cdk-lib';
import { getAccountId, loadConfig } from '../lib/utils';
import { ImageGeneratorStack } from '../lib/image-generator-stack';
import { S3BucketsStack } from '../lib/s3-buckets-stack';

const configFolder = '../config/';
const accountFileName = 'aws_account.yaml';

// Define common tags
const commonTags = {
  createdby: 'KateVu',
  createdvia: 'AWS-CDK',
};

// Function to apply tags to a stack
function applyTags(stack: cdk.Stack, tags: Record<string, string>): void {
  Object.entries(tags).forEach(([key, value]) => {
    cdk.Tags.of(stack).add(key, value);
  });
}

// Set up default value
const envName = process.env.ENVIRONMENT_NAME || 'kate';
const accountName = process.env.ACCOUNT_NAME || 'sandpit2';
const region = process.env.REGION || 'us-west-2'; // us-west-2 has both Titan v2 and SD 3.5 Large available
const aws_account_id = process.env.AWS_ACCOUNT_ID || 'none';

// Get aws account id
let accountId = aws_account_id;
if (aws_account_id == 'none') {
  accountId = getAccountId(accountName, configFolder, accountFileName);
}

// Load configuration
const config = loadConfig(envName);

const app = new cdk.App();

// Define bucket names with region
const imagesBucketName = `${envName}-image-generator-images-${region}`;
const frontendBucketName = `${envName}-image-generator-frontend-${region}`;

const s3BucketsStack = new S3BucketsStack(app, 'S3BucketsStack', {
  stackName: `aws-cdk-image-generator-s3-${envName}`,
  region: region,
  accountId: accountId,
  envName: envName,
  imagesBucketName: imagesBucketName,
  frontendBucketName: frontendBucketName,
  imageExpiration: config.imageExpiration,
});

const imageGeneratorStack = new ImageGeneratorStack(
  app,
  'ImageGeneratorStack',
  {
    stackName: `aws-cdk-image-generator-${envName}`,
    region: region,
    accountId: accountId,
    accountName: accountName,
    envName: envName,
    imagesBucket: s3BucketsStack.imagesBucket,
    lambdaMemorySize: config.lambdaMemorySize,
    lambdaTimeout: config.lambdaTimeout,
    logRetentionDays: config.logRetentionDays,
    corsAllowOrigins: config.corsAllowOrigins,

    /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */
  }
);

imageGeneratorStack.addDependency(s3BucketsStack);

// Apply tags to both stacks
applyTags(s3BucketsStack, {
  ...commonTags,
  environment: envName,
});

applyTags(imageGeneratorStack, {
  ...commonTags,
  environment: envName,
});
Enter fullscreen mode Exit fullscreen mode

2.6 Deploy the app

  • Ensure valid credentials for the target AWS account
  • Export environment variable or the app will use the ones have been set in app.ts
  • Run cdk deploy --all to deploy both stacks

From the output we can get

  • Frontend link
  • API Key ID and API Endpoint

Or we can go to CloudFormation console to get them from Output tab

Test your app

  • Get the frontend link from the output when deploying the app or in CloudFormation console or Go to S3 bucket, check tab Properties to get the bucket S3 endpoint
  • Update API Endpoint and API Key. To get API Key grab the API Key ID then run the command
aws apigateway get-api-key --api-key <API Key ID> --include-value --query 'value' --output text
Enter fullscreen mode Exit fullscreen mode
  • Open the link:
    • Update API Endpoint and API Key
    • Submit prompts, images and verify the outputs

Final Thoughts

So that's it, now you have your very own Image Generator app that you have total control over infrastructure. Other than S3 storage costs, you only pay for the other resource you use.
With some simple tweaks, you can experiment with other models in the future. While we have the option to send negative prompts from the website to Bedrock, for enhanced security and content moderation, you can consider integrating Bedrock Guardrails. For details, refer to Building a Summarizer app using Amazon Bedrock and Bedrock Guardrails using AWS CDK
When done, clean up your resources cdk destroy --all
If you want to retain any S3 bucket, update these parameters when creating the bucket

     removalPolicy: imagesRemovalPolicy,
     autoDeleteObjects: autoDeleteImages,
Enter fullscreen mode Exit fullscreen mode

Reference:

Top comments (0)