AutomationFlowsAI & RAG › Batch Ad Banner Generator with Gpt-image-2 and Sharp

Batch Ad Banner Generator with Gpt-image-2 and Sharp

ByLeo Wood @leo-wood on n8n.io

This workflow automates batch ad banner production from existing source images. It is designed for teams that already store campaign source images in Google Drive and want to generate multiple ad formats such as 300x250, 728x90, 160x600, 320x100, 300x50, and 970x250. The…

Event trigger★★★★☆ complexityAI-powered13 nodesGoogle SheetsGoogle DriveOpenAI
AI & RAG Trigger: Event Nodes: 13 Complexity: ★★★★☆ AI nodes: yes Added:

This workflow corresponds to n8n.io template #15837 — we link there as the canonical source.

This workflow follows the Google Drive → Google Sheets recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "nodes": [
    {
      "id": "b2e36298-1403-4867-9111-dacb7fed0765",
      "name": "Manual Trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        2816,
        1344
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "07a14a83-a942-4108-aa92-b8a018929632",
      "name": "Sticky Note - Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2448,
        512
      ],
      "parameters": {
        "color": 7,
        "width": 512,
        "height": 400,
        "content": "# Batch Ad Banner Generator\n\nFlow:\n\nGoogle Sheets - Read Jobs  \n\u2192 Google Drive - Download Source Image  \n\u2192 OpenAI - Edit image  \n\u2192 Code - Build Banner Sizes With Sharp  \n\u2192 Google Drive - Upload Banner  \n\u2192 Google Sheets - Update Result\n\nThis version assumes the source image already exists in Google Drive.  \nThe Google Sheet row should contain the source image file ID."
      },
      "typeVersion": 1
    },
    {
      "id": "1879dfc7-dfbd-4223-b16e-334e3fdc9e31",
      "name": "Sticky Note - Sheet Format",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2976,
        512
      ],
      "parameters": {
        "width": 480,
        "height": 1008,
        "content": "# Google Sheets columns\n\nRequired input columns:\n\n`id`  \nUnique job ID.\n\n`status`  \nUse `pending`, `todo`, or `new`.\n\n`source_file_id`  \nGoogle Drive file ID of the source image.\n\n`prompt`  \nGeneral campaign prompt or edit direction.\n\n`headline`  \nOptional overlay text for larger banners.\n\n`subheadline`  \nOptional secondary text.\n\n`cta`  \nOptional call-to-action.\n\n`target_sizes`  \nExample:\n`300x250,728x90,160x600,320x100,300x50,970x250`\n\n`output_folder_id`  \nGoogle Drive folder ID for final banners.\n\n`edit_instruction`  \nPrompt for OpenAI Edit image.\n\nOutput columns:\n\n`generated_at`  \n`output_links`  \n`output_files`  \n`status`"
      },
      "typeVersion": 1
    },
    {
      "id": "de0bef11-36ed-41a4-bc1e-7c4ea1590ad4",
      "name": "Sticky Note - Sharp Setup",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4208,
        944
      ],
      "parameters": {
        "color": 4,
        "width": 704,
        "height": 576,
        "content": "# sharp setup\n\nThe Code node uses `sharp` to crop and resize banners exactly.\n\nFor self-hosted n8n:\n\n```bash\nNODE_FUNCTION_ALLOW_EXTERNAL=sharp\nnpm install sharp\n```\n\nFor Docker, install `sharp` inside the n8n container or build a custom image.\n\nIf you use n8n Cloud and external packages are not available, replace the sharp Code node with an external image-processing API."
      },
      "typeVersion": 1
    },
    {
      "id": "9564d473-bd00-48bc-83df-6f224aed5ec9",
      "name": "Google Sheets - Read Jobs",
      "type": "n8n-nodes-base.googleSheets",
      "notes": "Expected columns: id, status, source_file_id, prompt, headline, subheadline, cta, target_sizes, output_folder_id, edit_instruction",
      "position": [
        3056,
        1344
      ],
      "parameters": {
        "options": {},
        "sheetName": "={{$env.GOOGLE_SHEET_NAME || 'Sheet1'}}",
        "documentId": "={{$env.GOOGLE_SHEET_ID || 'PASTE_YOUR_GOOGLE_SHEET_ID_HERE'}}"
      },
      "typeVersion": 4.5
    },
    {
      "id": "5848a4e5-2015-4b8c-bd8b-8c7c77e6c4d2",
      "name": "Code - Normalize Pending Rows",
      "type": "n8n-nodes-base.code",
      "position": [
        3296,
        1344
      ],
      "parameters": {
        "jsCode": "\nconst rows = $input.all();\n\nreturn rows\n  .map((item, index) => {\n    const j = item.json;\n\n    const status = String(j.status || '').trim().toLowerCase();\n    if (status && !['pending', 'todo', 'new'].includes(status)) return null;\n\n    const sourceFileId = j.source_file_id || j.sourceFileId || j.file_id || j.fileId;\n    if (!sourceFileId) {\n      throw new Error(`Missing source_file_id on row ${index + 2}`);\n    }\n\n    const rawSizes = String(j.target_sizes || '300x250,728x90,160x600,320x100,300x50,970x250');\n    const sizes = rawSizes\n      .split(',')\n      .map(s => s.trim())\n      .filter(Boolean)\n      .map(s => {\n        const m = s.match(/(\\d+)\\s*x\\s*(\\d+)/i);\n        if (!m) return null;\n        return { label: `${m[1]}x${m[2]}`, width: Number(m[1]), height: Number(m[2]) };\n      })\n      .filter(Boolean);\n\n    if (!sizes.length) {\n      throw new Error(`No valid target_sizes on row ${index + 2}`);\n    }\n\n    const editInstruction = j.edit_instruction || j.editInstruction || j.prompt || \n      'Improve this ad image while keeping the original product, brand feeling, and commercial layout.';\n\n    return {\n      json: {\n        rowNumber: j.row_number || j.rowNumber || index + 2,\n        id: j.id || `job-${Date.now()}-${index}`,\n        sourceFileId,\n        prompt: j.prompt || '',\n        editInstruction,\n        headline: j.headline || '',\n        subheadline: j.subheadline || '',\n        cta: j.cta || '',\n        sizes,\n        outputFolderId: j.output_folder_id || j.outputFolderId || $env.GOOGLE_DRIVE_OUTPUT_FOLDER_ID || 'PASTE_OUTPUT_FOLDER_ID_HERE'\n      }\n    };\n  })\n  .filter(Boolean);\n"
      },
      "typeVersion": 2
    },
    {
      "id": "847a0482-a9f9-4dc2-845b-1531a0b07f98",
      "name": "Split In Batches",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        3536,
        1344
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "7003c3b6-bb8a-453d-acf7-7ee5e387673e",
      "name": "Google Drive - Download Source Image",
      "type": "n8n-nodes-base.googleDrive",
      "notes": "Downloads source image from Google Drive into binary field named image.",
      "position": [
        3776,
        1344
      ],
      "parameters": {
        "fileId": "={{$json.sourceFileId}}",
        "options": {
          "binaryPropertyName": "image"
        },
        "operation": "download"
      },
      "typeVersion": 3
    },
    {
      "id": "aa578baf-24e6-491e-8992-95a37e9ea145",
      "name": "Edit image",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "notes": "Native n8n OpenAI image edit node. Input binary property should be image.",
      "position": [
        4016,
        1344
      ],
      "parameters": {
        "images": {
          "values": [
            {
              "binaryPropertyName": "image"
            }
          ]
        },
        "prompt": "={{$json.editInstruction}}",
        "options": {},
        "resource": "image",
        "operation": "edit"
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "f707521c-3af3-49fa-bafc-6b33f930b9d8",
      "name": "Code - Build Banner Sizes With Sharp",
      "type": "n8n-nodes-base.code",
      "notes": "Uses edited image binary if available; otherwise falls back to downloaded source image. Outputs each banner as binary field `banner`.",
      "position": [
        4272,
        1344
      ],
      "parameters": {
        "jsCode": "\nconst sharp = require('sharp');\n\n// Some OpenAI image-edit node versions return binary directly.\n// This node normalizes the binary field to `image` and preserves the job JSON.\nconst current = $input.first();\nconst sourceJob = $('Split In Batches').first().json;\n\nlet imageBinary = null;\n\n// Prefer edited image from OpenAI node.\nif (current.binary) {\n  if (current.binary.image) imageBinary = current.binary.image;\n  else {\n    const firstKey = Object.keys(current.binary)[0];\n    if (firstKey) imageBinary = current.binary[firstKey];\n  }\n}\n\n// Fallback: use original downloaded source image if OpenAI node output format is different.\nif (!imageBinary) {\n  const downloaded = $('Google Drive - Download Source Image').first();\n  if (downloaded.binary?.image) imageBinary = downloaded.binary.image;\n}\n\nif (!imageBinary?.data) {\n  throw new Error('No image binary found. Check Google Drive download binary property and OpenAI Edit image output.');\n}\n\nconst sourceBuffer = Buffer.from(imageBinary.data, 'base64');\nconst meta = await sharp(sourceBuffer).metadata();\n\nconst sizes = sourceJob.sizes || [];\nconst outputItems = [];\n\nfunction escapeXml(str) {\n  return String(str || '').replace(/[<>&\\\"']/g, c => ({\n    '<': '&lt;',\n    '>': '&gt;',\n    '&': '&amp;',\n    '\"': '&quot;',\n    \"'\": '&apos;'\n  }[c]));\n}\n\nfunction truncateText(str, maxChars) {\n  const s = String(str || '').trim();\n  if (s.length <= maxChars) return s;\n  return s.slice(0, Math.max(0, maxChars - 1)) + '\u2026';\n}\n\nfor (const size of sizes) {\n  const w = Number(size.width);\n  const h = Number(size.height);\n  const aspect = w / h;\n  const sourceAspect = meta.width / meta.height;\n\n  let cropWidth = meta.width;\n  let cropHeight = meta.height;\n\n  if (sourceAspect > aspect) {\n    cropWidth = Math.round(meta.height * aspect);\n  } else {\n    cropHeight = Math.round(meta.width / aspect);\n  }\n\n  const left = Math.max(0, Math.round((meta.width - cropWidth) / 2));\n  const top = Math.max(0, Math.round((meta.height - cropHeight) / 2));\n\n  let img = sharp(sourceBuffer)\n    .extract({ left, top, width: cropWidth, height: cropHeight })\n    .resize(w, h, { fit: 'cover', position: 'centre' });\n\n  const headline = escapeXml(truncateText(sourceJob.headline, Math.max(12, Math.floor(w / 18))));\n  const subheadline = escapeXml(truncateText(sourceJob.subheadline, Math.max(18, Math.floor(w / 12))));\n  const cta = escapeXml(truncateText(sourceJob.cta, Math.max(10, Math.floor(w / 24))));\n\n  // Only add overlay text when there is enough room.\n  if ((headline || cta) && w >= 250 && h >= 90) {\n    const headlineSize = Math.max(18, Math.min(52, Math.round(w / 14)));\n    const subSize = Math.max(12, Math.min(28, Math.round(w / 28)));\n    const ctaSize = Math.max(12, Math.min(24, Math.round(w / 30)));\n\n    const overlay = Buffer.from(`\n      <svg width=\"${w}\" height=\"${h}\" xmlns=\"http://www.w3.org/2000/svg\">\n        <rect x=\"0\" y=\"${Math.round(h * 0.58)}\" width=\"${w}\" height=\"${Math.round(h * 0.42)}\" fill=\"rgba(0,0,0,0.44)\"/>\n        ${headline ? `<text x=\"${Math.round(w * 0.05)}\" y=\"${Math.round(h * 0.74)}\" font-family=\"Arial, Helvetica, sans-serif\" font-size=\"${headlineSize}\" font-weight=\"700\" fill=\"white\">${headline}</text>` : ''}\n        ${subheadline && h >= 180 ? `<text x=\"${Math.round(w * 0.05)}\" y=\"${Math.round(h * 0.84)}\" font-family=\"Arial, Helvetica, sans-serif\" font-size=\"${subSize}\" font-weight=\"400\" fill=\"white\">${subheadline}</text>` : ''}\n        ${cta ? `<text x=\"${Math.round(w * 0.05)}\" y=\"${Math.round(h * 0.93)}\" font-family=\"Arial, Helvetica, sans-serif\" font-size=\"${ctaSize}\" font-weight=\"700\" fill=\"white\">${cta}</text>` : ''}\n      </svg>\n    `);\n\n    img = img.composite([{ input: overlay, top: 0, left: 0 }]);\n  }\n\n  const out = await img.png().toBuffer();\n  const fileName = `${sourceJob.id}-${size.label}.png`;\n\n  outputItems.push({\n    json: {\n      ...sourceJob,\n      bannerSize: size.label,\n      width: w,\n      height: h,\n      fileName,\n      mimeType: 'image/png'\n    },\n    binary: {\n      banner: {\n        data: out.toString('base64'),\n        mimeType: 'image/png',\n        fileName\n      }\n    }\n  });\n}\n\nreturn outputItems;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "238e00f6-5a1b-4bcc-9054-72ddcdf6bf79",
      "name": "Google Drive - Upload Banner",
      "type": "n8n-nodes-base.googleDrive",
      "position": [
        4528,
        1344
      ],
      "parameters": {
        "name": "={{$json.fileName}}",
        "driveId": "myDrive",
        "options": {},
        "folderId": "={{$json.outputFolderId}}"
      },
      "typeVersion": 3
    },
    {
      "id": "654e2454-77df-48b6-8ee1-f2fae1d9cea6",
      "name": "Code - Collect Upload Links",
      "type": "n8n-nodes-base.code",
      "position": [
        4768,
        1344
      ],
      "parameters": {
        "jsCode": "\nconst uploads = $input.all();\n\nconst byJob = {};\nfor (const item of uploads) {\n  const j = item.json;\n  const id = j.id;\n\n  if (!byJob[id]) {\n    byJob[id] = {\n      id,\n      status: 'done',\n      generated_at: new Date().toISOString(),\n      output_links: [],\n      output_files: []\n    };\n  }\n\n  const link = j.webViewLink || j.webContentLink || j.url || j.fileUrl || j.id || '';\n  byJob[id].output_links.push(`${j.bannerSize}: ${link}`);\n  byJob[id].output_files.push(j.fileName);\n}\n\nreturn Object.values(byJob).map(j => ({\n  json: {\n    id: j.id,\n    status: j.status,\n    generated_at: j.generated_at,\n    output_links: j.output_links.join('\\n'),\n    output_files: j.output_files.join(', ')\n  }\n}));\n"
      },
      "typeVersion": 2
    },
    {
      "id": "fe2380df-7151-4d16-8f54-aadeb34cf4d1",
      "name": "Google Sheets - Update Result",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        5008,
        1344
      ],
      "parameters": {
        "columns": {
          "value": {
            "id": "={{$json.id}}",
            "status": "={{$json.status}}",
            "generated_at": "={{$json.generated_at}}",
            "output_files": "={{$json.output_files}}",
            "output_links": "={{$json.output_links}}"
          },
          "schema": [],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "id"
          ]
        },
        "options": {},
        "operation": "appendOrUpdate",
        "sheetName": "={{$env.GOOGLE_SHEET_NAME || 'Sheet1'}}",
        "documentId": "={{$env.GOOGLE_SHEET_ID || 'PASTE_YOUR_GOOGLE_SHEET_ID_HERE'}}"
      },
      "typeVersion": 4.5
    }
  ],
  "connections": {
    "Edit image": {
      "main": [
        [
          {
            "node": "Code - Build Banner Sizes With Sharp",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Manual Trigger": {
      "main": [
        [
          {
            "node": "Google Sheets - Read Jobs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split In Batches": {
      "main": [
        [
          {
            "node": "Google Drive - Download Source Image",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Google Sheets - Read Jobs": {
      "main": [
        [
          {
            "node": "Code - Normalize Pending Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code - Collect Upload Links": {
      "main": [
        [
          {
            "node": "Google Sheets - Update Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Google Drive - Upload Banner": {
      "main": [
        [
          {
            "node": "Code - Collect Upload Links",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code - Normalize Pending Rows": {
      "main": [
        [
          {
            "node": "Split In Batches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code - Build Banner Sizes With Sharp": {
      "main": [
        [
          {
            "node": "Google Drive - Upload Banner",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Google Drive - Download Source Image": {
      "main": [
        [
          {
            "node": "Edit image",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

This workflow automates batch ad banner production from existing source images. It is designed for teams that already store campaign source images in Google Drive and want to generate multiple ad formats such as 300x250, 728x90, 160x600, 320x100, 300x50, and 970x250. The…

Source: https://n8n.io/workflows/15837/ — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

The Problem That it Solves

Google Drive Trigger, OpenAI, Google Drive +5
AI & RAG

Scrape ads – Pulls Facebook Ad Library data for "ai automation" keywords using Apify Filter & sort – Filters ads by page likes (&gt;1,000) and separates into videos, images, and text ads Analyze creat

HTTP Request, Google Drive, OpenAI +3
AI & RAG

This workflow converts emailed timesheets into structured invoice rows in Google Sheets and stores them in the correct Google Drive folder structure.

Gmail Trigger, OpenAI, Google Sheets +2
AI & RAG

Content creators, YouTubers, and social media managers who want to repurpose long form videos into short clips without doing it manually. Works on self hosted n8n instances.

Google Drive Trigger, Google Drive, N8N Nodes Renderio +3
AI & RAG

[Template] Viral Video Factory - Fal.ai + GPT-4. Uses googleDrive, httpRequest, openAi, googleSheets. Event-driven trigger; 39 nodes.

Google Drive, HTTP Request, OpenAI +1