{
  "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
          }
        ]
      ]
    }
  }
}