Back to n8n Workflows

Product Photography Combination Testing

Rhys Fisher Rhys Fisher

Automatically generates combinations of product photography and creates AI variations from your original photos, then tests them with AI personas to predict CTR performance. Get instant creative assets and testing results without spending a penny on real ads

Product Photography Combination Testing n8n workflow diagram

Click to expand

Summarize in
OR

Overview

This workflow modernizes your product photography testing by automatically generating multiple product photography variations and instantly testing them with AI personas to predict performance. Starting with some raw photos of your product in one Google Drive folder, and scene photos in another, it uses Runway to create creative variations that maintain the core photos. These variations are then tested against Rally's AI audience using voting mode to simulate real user choice behavior, with results ranked by predicted click-through rates. Perfect for marketers who need to rapidly test creative concepts before committing to ad spend.

📝 Step-by-Step Instructions

 

  1. Save source material to Gdrive - User uploads source images and ads them to two folders.
  2. Random Combinations - Generate the # of combos set by user to be sent to an LLM image generator.
  3. Generate Creative Variations - Runway takes the combo source images to create new creatives that explore combine the images.
  4. Rally Audience Testing - Sends all creative variations to Rally's AI personas in voting mode, presenting them as realistic asking "Which do you prefer?"
  5. Extract Individual Votes - Splits Rally's response array to process each persona's individual voting decision for detailed analysis.
  6. Calculate Click-Through Rates - Custom code processes all votes, counts selections for each variation, calculates percentages, and marries results with original ad content.
  7. Downloadable Performance Report - Formats results into a ranked CSV report showing each variation predicted CTR sorted by performance from highest to lowest.

📋 Requirements

Required Integrations

  • Runway API - AI language model provider for generating creative ad
  • Rally API - AI persona testing service for simulating real user ad selection behavior
  • Gdrive - for storing the generated creatives

Required Credentials

  • Runway API credentials for creative generation
  • Rally API Bearer token with voting mode access
  • Gdrive for file storage

Setup Prerequisites

  • Active Rally account with configured audience personas
  • Runway AI/Gemini API access with sufficient quotas
  • Gdrive auth

🚀 n8n Workflow Template

{
  "active": false,
  "connections": {
    "Build Rally Payload1": {
      "main": [
        [
          {
            "index": 0,
            "node": "Call Rally2",
            "type": "main"
          }
        ]
      ]
    },
    "Call Rally2": {
      "main": [
        [
          {
            "index": 0,
            "node": "Code2",
            "type": "main"
          }
        ]
      ]
    },
    "Check Generation Status": {
      "main": [
        [
          {
            "index": 0,
            "node": "Switch",
            "type": "main"
          }
        ]
      ]
    },
    "Code": {
      "main": [
        [
          {
            "index": 0,
            "node": "Save Generated Image1",
            "type": "main"
          }
        ]
      ]
    },
    "Code2": {
      "main": [
        [
          {
            "index": 0,
            "node": "Convert to File2",
            "type": "main"
          }
        ]
      ]
    },
    "Create All Combinations1": {
      "main": [
        [
          {
            "index": 0,
            "node": "Loop Over Combinations",
            "type": "main"
          }
        ]
      ]
    },
    "Download Product Image": {
      "main": [
        [
          {
            "index": 0,
            "node": "Prepare Image Data",
            "type": "main"
          }
        ]
      ]
    },
    "Download Reference Image": {
      "main": [
        [
          {
            "index": 0,
            "node": "Download Product Image",
            "type": "main"
          }
        ]
      ]
    },
    "Download Runway Result2": {
      "main": [
        [
          {
            "index": 0,
            "node": "Prepare Save Data",
            "type": "main"
          }
        ]
      ]
    },
    "Extract from File": {
      "main": [
        [
          {
            "index": 0,
            "node": "Merge Photos4",
            "type": "main"
          }
        ]
      ]
    },
    "Extract from File4": {
      "main": [
        [
          {
            "index": 1,
            "node": "Merge Photos4",
            "type": "main"
          }
        ]
      ]
    },
    "List Product Photos1": {
      "main": [
        [
          {
            "index": 1,
            "node": "Merge Photos",
            "type": "main"
          }
        ]
      ]
    },
    "List Reference Images": {
      "main": [
        [
          {
            "index": 0,
            "node": "Merge Photos",
            "type": "main"
          }
        ]
      ]
    },
    "Log Completion": {
      "main": [
        [
          {
            "index": 0,
            "node": "Loop Over Combinations",
            "type": "main"
          }
        ]
      ]
    },
    "Loop Over Combinations": {
      "main": [
        [
          {
            "index": 0,
            "node": "Build Rally Payload1",
            "type": "main"
          }
        ],
        [
          {
            "index": 0,
            "node": "Download Reference Image",
            "type": "main"
          }
        ]
      ]
    },
    "Merge": {
      "main": [
        [
          {
            "index": 0,
            "node": "Code",
            "type": "main"
          }
        ]
      ]
    },
    "Merge Photos": {
      "main": [
        [
          {
            "index": 0,
            "node": "Create All Combinations1",
            "type": "main"
          }
        ]
      ]
    },
    "Merge Photos4": {
      "main": [
        [
          {
            "index": 0,
            "node": "RunWay Image Gen2",
            "type": "main"
          }
        ]
      ]
    },
    "Prepare Image Data": {
      "main": [
        [
          {
            "index": 0,
            "node": "Extract from File",
            "type": "main"
          },
          {
            "index": 0,
            "node": "Extract from File4",
            "type": "main"
          }
        ]
      ]
    },
    "Prepare Save Data": {
      "main": [
        [
          {
            "index": 0,
            "node": "upload to rally",
            "type": "main"
          },
          {
            "index": 0,
            "node": "Merge",
            "type": "main"
          }
        ]
      ]
    },
    "RunWay Image Gen2": {
      "main": [
        [
          {
            "index": 0,
            "node": "Wait for Runway (40 Seconds)2",
            "type": "main"
          }
        ]
      ]
    },
    "Save Generated Image1": {
      "main": [
        [
          {
            "index": 0,
            "node": "Log Completion",
            "type": "main"
          }
        ]
      ]
    },
    "Schedule Trigger": {
      "main": [
        [
          {
            "index": 0,
            "node": "List Reference Images",
            "type": "main"
          },
          {
            "index": 0,
            "node": "List Product Photos1",
            "type": "main"
          }
        ]
      ]
    },
    "Switch": {
      "main": [
        [],
        [
          {
            "index": 0,
            "node": "Download Runway Result2",
            "type": "main"
          }
        ],
        [
          {
            "index": 0,
            "node": "Wait for Runway (40 Seconds)2",
            "type": "main"
          }
        ]
      ]
    },
    "Wait for Runway (40 Seconds)2": {
      "main": [
        [
          {
            "index": 0,
            "node": "Check Generation Status",
            "type": "main"
          }
        ]
      ]
    },
    "upload to rally": {
      "main": [
        [
          {
            "index": 1,
            "node": "Merge",
            "type": "main"
          }
        ]
      ]
    }
  },
  "id": "2iDcXnw7x2mQUNpQ",
  "meta": {
    "instanceId": "7921b3cd29c1121b3ec4f2177acf06fe1f1325838297f593db7db4e9563eb98d",
    "templateCredsSetupCompleted": true
  },
  "name": "Product Photography Combination Testing",
  "nodes": [
    {
      "id": "003d0046-d97a-4d89-be21-4382bbab1f43",
      "name": "Create All Combinations1",
      "parameters": {
        "jsCode": "// Get all photos\nconst allItems = $input.all();\nconst referenceImages = []; // Lifestyle photos with existing labubus\nconst products = [];        // Our labubu products to replace with\n\n// Separate reference images and products\n// First input contains reference images, second contains products\nlet foundReferenceEnd = false;\nfor (const item of allItems) {\n  // Check if this is a product by looking at parent folder\n  if (item.json.parents \u0026\u0026 item.json.parents.includes(\u00271yxVQyDsafpJuT7N2gx8O6Vbm1uKgb8zw\u0027)) {\n    products.push(item.json);\n    foundReferenceEnd = true;\n  } else if (!foundReferenceEnd || (item.json.parents \u0026\u0026 item.json.parents.includes(\u00271EErMLWL2z2IKKo_30UtpTkZyMBoA1ikW\u0027))) {\n    referenceImages.push(item.json);\n  }\n}\n\n// If separation didn\u0027t work, use position-based split\nif (referenceImages.length === 0 || products.length === 0) {\n  referenceImages.length = 0;\n  products.length = 0;\n  const referenceItems = $(\u0027List Reference Images\u0027).all();\n  const productItems = $(\u0027List Product Photos1\u0027).all();\n  \n  referenceImages.push(...referenceItems.map(item =\u003e item.json));\n  products.push(...productItems.map(item =\u003e item.json));\n}\n\nconsole.log(`Found ${referenceImages.length} reference lifestyle images and ${products.length} products`);\n\n// Create exactly 15 combinations\nconst combinations = [];\nlet combinationCount = 0;\nconst targetCombinations = 2;\n\n// Generate combinations\nfor (let i = 0; i \u003c referenceImages.length \u0026\u0026 combinationCount \u003c targetCombinations; i++) {\n  for (let j = 0; j \u003c products.length \u0026\u0026 combinationCount \u003c targetCombinations; j++) {\n    const reference = referenceImages[i];\n    const product = products[j];\n    \n    combinations.push({\n      combinationId: `combo_${String(combinationCount + 1).padStart(2, \u00270\u0027)}`,\n      combinationIndex: combinationCount,\n      totalCombinations: targetCombinations,\n      referenceImageId: reference.id,\n      referenceImageName: reference.name,\n      productId: product.id,\n      productName: product.name,\n      imageType: \u0027lifestyle_replacement\u0027,\n      createdAt: new Date().toISOString()\n    });\n    \n    combinationCount++;\n  }\n}\n\nconsole.log(`Created ${combinations.length} combinations`);\n\n// Return all combinations as separate items\nreturn combinations.map(combo =\u003e ({ json: combo }));"
      },
      "position": [
        260,
        6360
      ],
      "type": "n8n-nodes-base.code",
      "typeVersion": 2
    },
    {
      "id": "6d6bdc4f-0fb9-4593-97ab-9bb858a35b97",
      "name": "Schedule Trigger",
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "hoursInterval": 12
            }
          ]
        }
      },
      "position": [
        -540,
        6340
      ],
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2
    },
    {
      "id": "545f1ede-d2f0-420d-b3b1-d5cdb14928a0",
      "name": "Merge Photos",
      "parameters": {},
      "position": [
        40,
        6360
      ],
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2
    },
    {
      "id": "2c2f4db1-e7cf-4065-921f-3ac8934c40ee",
      "name": "Loop Over Combinations",
      "parameters": {
        "batchSize": 2,
        "options": {}
      },
      "position": [
        580,
        6360
      ],
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3
    },
    {
      "credentials": {
        "googleDriveOAuth2Api": {
          "id": "lolQfCBJjJ6XjXJ3",
          "name": "Google Drive account"
        }
      },
      "id": "d0ff46e7-e490-4795-93fc-9b24da1beb35",
      "name": "Download Product Image",
      "onError": "continueErrorOutput",
      "parameters": {
        "fileId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $json.productId }}"
        },
        "operation": "download",
        "options": {
          "binaryPropertyName": "productImage"
        }
      },
      "position": [
        1520,
        6380
      ],
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3
    },
    {
      "id": "18102bd0-33db-4744-95f7-fa27c3b6c459",
      "name": "Prepare Image Data",
      "parameters": {
        "jsCode": "// Get the combination data and downloaded images\nconst combinationData = $(\u0027Loop Over Combinations\u0027).item.json;\nconst referenceDownload = $(\u0027Download Reference Image\u0027).item;\nconst productDownload = $(\u0027Download Product Image\u0027).item;\n\n// Check if downloads were successful\nlet referenceBinary = null;\nlet productBinary = null;\nlet downloadStatus = \u0027success\u0027;\nlet downloadErrors = [];\n\n// Handle reference image (lifestyle photo with existing labubu)\nif (referenceDownload.binary \u0026\u0026 referenceDownload.binary.referenceImage) {\n  referenceBinary = referenceDownload.binary.referenceImage;\n} else {\n  downloadErrors.push(\u0027Failed to download reference lifestyle image\u0027);\n  downloadStatus = \u0027partial_failure\u0027;\n}\n\n// Handle product image (our labubu to replace with)\nif (productDownload.binary \u0026\u0026 productDownload.binary.productImage) {\n  productBinary = productDownload.binary.productImage;\n} else {\n  downloadErrors.push(\u0027Failed to download product image\u0027);\n  downloadStatus = \u0027partial_failure\u0027;\n}\n\n// If both failed, mark as complete failure\nif (!referenceBinary \u0026\u0026 !productBinary) {\n  downloadStatus = \u0027failed\u0027;\n}\n\n// Prepare the complete data package\nconst preparedData = {\n  // Combination info\n  combinationData: {\n    ...combinationData,\n    downloadStatus,\n    downloadErrors\n  },\n  \n  // Processing metadata\n  processingMetadata: {\n    combinationId: combinationData.combinationId,\n    referenceImageName: combinationData.referenceImageName,\n    productName: combinationData.productName,\n    imageType: combinationData.imageType,\n    downloadedAt: new Date().toISOString(),\n    readyForAI: downloadStatus === \u0027success\u0027,\n    currentIndex: combinationData.combinationIndex + 1,\n    totalCombinations: combinationData.totalCombinations\n  }\n};\n\n// Log progress\nconsole.log(`Processing ${preparedData.processingMetadata.currentIndex} of ${preparedData.processingMetadata.totalCombinations}`);\nconsole.log(`Reference: ${preparedData.processingMetadata.referenceImageName}, Product: ${preparedData.processingMetadata.productName}`);\n\n// Add binary data to output if available\nconst output = { json: preparedData };\n\nif (referenceBinary || productBinary) {\n  output.binary = {};\n  \n  if (referenceBinary) {\n    output.binary.referenceImage = referenceBinary;\n  }\n  \n  if (productBinary) {\n    output.binary.productImage = productBinary;\n  }\n}\n\nreturn output;"
      },
      "position": [
        40,
        6740
      ],
      "type": "n8n-nodes-base.code",
      "typeVersion": 2
    },
    {
      "credentials": {
        "googleDriveOAuth2Api": {
          "id": "lolQfCBJjJ6XjXJ3",
          "name": "Google Drive account"
        }
      },
      "id": "0a2ebb26-fe93-4596-9db4-7518b7930548",
      "name": "List Product Photos1",
      "parameters": {
        "filter": {
          "folderId": {
            "__rl": true,
            "mode": "url",
            "value": "https://drive.google.com/drive/folders/1kgjqBH8me4V_KUFardRceiFX2zUYAo7E?usp=sharing"
          }
        },
        "options": {},
        "resource": "fileFolder",
        "returnAll": true
      },
      "position": [
        -280,
        6460
      ],
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3
    },
    {
      "id": "5ccddffd-ffe1-43f0-aa21-936ae1fd97a8",
      "name": "Check Generation Status",
      "parameters": {
        "headerParameters": {
          "parameters": [
            {
              "name": "X-Runway-Version",
              "value": "2024-11-06"
            },
            {
              "name": "Authorization",
              "value": "Bearer key_33c34ec814906db343979538fd85bfd4b152716fa93e0cacefca64d48b05eac462e337b88f97cdf87862f0f201b07237a444036b762c057f7c925213e7bf8fc4"
            }
          ]
        },
        "options": {},
        "sendHeaders": true,
        "url": "={{ \u0027https://api.dev.runwayml.com/v1/tasks/\u0027 + $json.id}}"
      },
      "position": [
        1220,
        6900
      ],
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2
    },
    {
      "id": "a4376b79-be73-45c0-800c-91f5f58f13ba",
      "name": "Download Runway Result2",
      "parameters": {
        "options": {
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        },
        "url": "={{ $(\u0027Check Generation Status\u0027).item.json.output[0] }}"
      },
      "position": [
        1940,
        6480
      ],
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2
    },
    {
      "id": "fffba48b-f6a7-47a8-b2d1-829914ea1b16",
      "name": "Merge Photos4",
      "parameters": {},
      "position": [
        640,
        6740
      ],
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2
    },
    {
      "id": "57d6b8c4-a056-4352-9751-4cf9436358f9",
      "name": "Switch",
      "parameters": {
        "options": {},
        "rules": {
          "values": [
            {
              "conditions": {
                "combinator": "and",
                "conditions": [
                  {
                    "id": "1837f1d6-e4d4-4fd7-aa48-e7aa63514945",
                    "leftValue": "={{ $json.status }}",
                    "operator": {
                      "name": "filter.operator.equals",
                      "operation": "equals",
                      "type": "string"
                    },
                    "rightValue": "FAILED"
                  }
                ],
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                }
              }
            },
            {
              "conditions": {
                "combinator": "and",
                "conditions": [
                  {
                    "id": "bff9e000-a0d3-436f-8308-be4f95968aed",
                    "leftValue": "={{ $json.status }}",
                    "operator": {
                      "operation": "equals",
                      "type": "string"
                    },
                    "rightValue": "SUCCEEDED"
                  }
                ],
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                }
              }
            },
            {
              "conditions": {
                "combinator": "and",
                "conditions": [
                  {
                    "id": "22533ab5-0459-40d6-86be-d2399e89af2d",
                    "leftValue": "={{ $json.status }}",
                    "operator": {
                      "name": "filter.operator.equals",
                      "operation": "equals",
                      "type": "string"
                    },
                    "rightValue": "RUNNING"
                  }
                ],
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                }
              }
            }
          ]
        }
      },
      "position": [
        1420,
        6680
      ],
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2
    },
    {
      "id": "aeaf2ab9-68eb-4e88-907d-87904d03bcdc",
      "name": "RunWay Image Gen2",
      "parameters": {
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "Bearer key_33c34ec814906db343979538fd85bfd4b152716fa93e0cacefca64d48b05eac462e337b88f97cdf87862f0f201b07237a444036b762c057f7c925213e7bf8fc4"
            },
            {
              "name": "X-Runway-Version",
              "value": "2024-11-06"
            }
          ]
        },
        "jsonBody": "={{ {\n  \"promptText\": \"Photo of different products with different backdrops.\",\n  \"ratio\": \"1024:1024\",\n  \"seed\": Math.floor(Math.random() * 4294967295),\n  \"model\": \"gen4_image\",\n  \"referenceImages\": [\n    {\n      \"uri\": \"data:image/jpeg;base64,\" + $(\u0027Extract from File4\u0027).first().json.reference,\n      \"tag\": \"gun\"\n    },\n    {\n      \"uri\": \"data:image/jpeg;base64,\" + $(\u0027Extract from File\u0027).first().json.product,\n      \"tag\": \"flag\"\n    }\n  ],\n  \"contentModeration\": {\n    \"publicFigureThreshold\": \"auto\"\n  }\n} }}",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "url": "https://api.dev.runwayml.com/v1/text_to_image"
      },
      "position": [
        820,
        6880
      ],
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2
    },
    {
      "id": "3ff6d504-1b96-44be-8ee2-e25a40c27088",
      "name": "Prepare Save Data",
      "parameters": {
        "jsCode": "return items\n  .map((item, index) =\u003e {\n    const combinationData    = $(\u0027Loop Over Combinations\u0027).all()[index].json;\n    const generatedImage      = $(\u0027Download Runway Result2\u0027).all()[index]?.binary?.data;\n    const runwayResponse      = $(\u0027Check Generation Status\u0027).all()[index].json;\n\n    // Skip any iteration where no image binary was returned\n    if (!generatedImage) return null;\n\n    const saveData = {\n      combinationId:    combinationData.combinationId,\n      combinationIndex: combinationData.combinationIndex,\n      totalCombinations: combinationData.totalCombinations,\n      referenceImageName: combinationData.referenceImageName,\n      productName:        combinationData.productName,\n      imageType:          combinationData.imageType || \u0027gun_to_flag_replacement\u0027,\n      runwayTaskId:       runwayResponse.id,\n      generatedAt:        new Date().toISOString(),\n      fileName:           `gun_to_flag_${combinationData.combinationId}_` +\n                          `${combinationData.referenceImageName.replace(/[^a-z0-9]/gi, \u0027_\u0027)}_` +\n                          `${combinationData.productName.replace(/[^a-z0-9]/gi, \u0027_\u0027)}.png`\n    };\n\n    console.log(\n      `Saving image ${combinationData.combinationIndex + 1}` +\n      ` of ${combinationData.totalCombinations}`\n    );\n    console.log(\n      `Replaced guns in: ${saveData.referenceImageName}` +\n      ` with: ${saveData.productName}`\n    );\n\n    return {\n      json: saveData,\n      binary: {\n        imageToSave: generatedImage\n      },\n      pairedItem: [\n        { node: \"Loop Over Combinations\",     item: index },\n        { node: \"Download Runway Result2\",    item: index },\n        { node: \"Check Generation Status\",    item: index }\n      ]\n    };\n  })\n  .filter(Boolean);  // drop any null entries so downstream sees only real images\n"
      },
      "position": [
        2140,
        6320
      ],
      "type": "n8n-nodes-base.code",
      "typeVersion": 2
    },
    {
      "credentials": {
        "googleDriveOAuth2Api": {
          "id": "lolQfCBJjJ6XjXJ3",
          "name": "Google Drive account"
        }
      },
      "id": "03009cc0-ba3c-4c62-af5e-43605fb592d4",
      "name": "Save Generated Image1",
      "parameters": {
        "driveId": {
          "__rl": true,
          "mode": "list",
          "value": "My Drive"
        },
        "folderId": {
          "__rl": true,
          "mode": "url",
          "value": "https://drive.google.com/drive/folders/1UPZtwLjHonz5NKcWQW52VUq1Qvq413ve"
        },
        "inputDataFieldName": "imageToSave",
        "name": "={{ $(\u0027Prepare Save Data\u0027).item.json.fileName }}",
        "options": {}
      },
      "position": [
        2840,
        7040
      ],
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3
    },
    {
      "id": "61745062-becf-4e12-a77c-dd2a212c64b7",
      "name": "Wait for Runway (40 Seconds)2",
      "parameters": {
        "amount": 30
      },
      "position": [
        1000,
        6680
      ],
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "webhookId": "5e6bd561-7e1f-4d26-a71b-24252607e274"
    },
    {
      "credentials": {
        "googleDriveOAuth2Api": {
          "id": "lolQfCBJjJ6XjXJ3",
          "name": "Google Drive account"
        }
      },
      "id": "c7fd0ffd-a2ba-4f16-b648-6d28df013c4d",
      "name": "List Reference Images",
      "parameters": {
        "filter": {
          "folderId": {
            "__rl": true,
            "mode": "url",
            "value": "https://drive.google.com/drive/folders/1L9hQ4mM_Z4XKEUKIHK3yg4IiLme9-osk?usp=sharing"
          }
        },
        "options": {},
        "resource": "fileFolder",
        "returnAll": true
      },
      "position": [
        -280,
        6280
      ],
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3
    },
    {
      "credentials": {
        "googleDriveOAuth2Api": {
          "id": "lolQfCBJjJ6XjXJ3",
          "name": "Google Drive account"
        }
      },
      "id": "b8d2a0ca-af7c-4a23-8478-5fbe497bfa97",
      "name": "Download Reference Image",
      "onError": "continueErrorOutput",
      "parameters": {
        "fileId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $json.referenceImageId }}"
        },
        "operation": "download",
        "options": {
          "binaryPropertyName": "referenceImage"
        }
      },
      "position": [
        1120,
        6300
      ],
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3
    },
    {
      "id": "042238d0-ed83-4fa7-bba2-c0b9f3346fdf",
      "name": "Extract from File",
      "parameters": {
        "binaryPropertyName": "productImage",
        "destinationKey": "product",
        "operation": "binaryToPropery",
        "options": {}
      },
      "position": [
        340,
        6620
      ],
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1
    },
    {
      "id": "3557fdbb-260d-4fdd-ba93-42a12e357e78",
      "name": "Extract from File4",
      "parameters": {
        "binaryPropertyName": "referenceImage",
        "destinationKey": "reference",
        "operation": "binaryToPropery",
        "options": {}
      },
      "position": [
        340,
        6860
      ],
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1
    },
    {
      "id": "af6930e4-220b-42af-b269-c6e345448c50",
      "name": "Sticky Note5",
      "parameters": {
        "color": 3,
        "content": "## Changed to desired Save Location\n",
        "height": 260,
        "width": 260
      },
      "position": [
        2760,
        6940
      ],
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1
    },
    {
      "id": "ef2db916-8792-4236-8cb8-cf69d30971aa",
      "name": "Log Completion",
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "log-success",
              "name": "successLog",
              "type": "object",
              "value": "={\n  status: \u0027completed\u0027,\n  combinationId: $json.combinationId,\n  combinationIndex: $json.combinationIndex,\n  totalCombinations: $json.totalCombinations,\n  fileName: $json.fileName,\n  googleDriveId: $json.id,\n  savedAt: $now.toISO()\n}"
            }
          ]
        },
        "options": {}
      },
      "position": [
        3100,
        7040
      ],
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4
    },
    {
      "credentials": {
        "httpBearerAuth": {
          "id": "wSoUK2sXm0c8MCMq",
          "name": "Bearer Auth account 2"
        }
      },
      "id": "1528f362-b814-4df3-a099-9c652de1251d",
      "name": "Call Rally2",
      "parameters": {
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBearerAuth",
        "jsonBody": "={{ $json }}",
        "jsonHeaders": "={\n  \"Content-Type\": \"application/json\",\n  \"Accept\":       \"application/json\"\n}",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "specifyHeaders": "json",
        "url": "https://api.askrally.com/api/v1/chat"
      },
      "position": [
        1180,
        5940
      ],
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2
    },
    {
      "id": "751c3af9-2553-4eb3-9fdd-7763a036f64f",
      "name": "Sticky Note8",
      "parameters": {
        "color": 3,
        "content": "## Set # of combs",
        "height": 240,
        "width": 260
      },
      "position": [
        200,
        6280
      ],
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1
    },
    {
      "credentials": {
        "httpBearerAuth": {
          "id": "wSoUK2sXm0c8MCMq",
          "name": "Bearer Auth account 2"
        },
        "httpCustomAuth": {
          "id": "00aggangtNPs0N7C",
          "name": "Custom Auth account"
        }
      },
      "id": "0f2d2e25-bbee-42db-8f3c-c31061a7e8ac",
      "name": "upload to rally",
      "parameters": {
        "authentication": "genericCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "inputDataFieldName": "imageToSave",
              "name": "file",
              "parameterType": "formBinaryData"
            }
          ]
        },
        "contentType": "multipart-form-data",
        "genericAuthType": "httpBearerAuth",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "url": "https://api.askrally.com/api/v1/files/upload"
      },
      "position": [
        2340,
        6600
      ],
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2
    },
    {
      "id": "65111daa-b033-40a8-8d65-2aa01a1b455c",
      "name": "Merge",
      "parameters": {
        "combineBy": "combineByPosition",
        "mode": "combine",
        "options": {}
      },
      "position": [
        2500,
        6340
      ],
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2
    },
    {
      "id": "77bdf8fc-c260-478a-b4b5-8c9e6bf17dab",
      "name": "Sticky Note7",
      "parameters": {
        "color": 5,
        "content": "# Synthetic testing\n",
        "height": 260,
        "width": 960
      },
      "position": [
        860,
        5840
      ],
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1
    },
    {
      "id": "bbdff101-34e8-47bf-a0b4-a5c169668fe5",
      "name": "Sticky Note9",
      "parameters": {
        "color": 4,
        "content": "# Creative generation",
        "height": 1180,
        "width": 3880
      },
      "position": [
        -600,
        6120
      ],
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1
    },
    {
      "id": "c61da02f-21f5-4f2a-ba37-b53251813deb",
      "name": "Build Rally Payload1",
      "parameters": {
        "jsCode": "// 0) get \u0026 sanity-check the bag of uploads -------------------------------\nconst bag = $getWorkflowStaticData(\u0027global\u0027);\nconst uploads = bag.rallyUploads ?? [];\nif (!uploads.length) {\n  throw new Error(\u0027No uploads found in global.rallyUploads \u2013 nothing to send.\u0027);\n}\n\n// 1) sort by numeric combinationIndex (01, 02, 03\u2026)\nuploads.sort((a, b) =\u003e a.combinationIndex - b.combinationIndex);\n\n// 2) build the files[] array AskRally expects ----------------------------\nconst files = uploads.map(u =\u003e ({\n  type: \u0027image/png\u0027,\n  url:  u.url,\n}));\n\n// 3) build the \u201cA) fileName \u2026\u201d part --------------------------------------\nconst letters = \u0027ABCDEFGHIJKLMNOPQRSTUVWXYZ\u0027;\nconst optionsText = uploads\n  .map((u, i) =\u003e `${letters[i]}) ${u.combinationId}`)\n  .join(\u0027 \u0027);\n\n// 4) final payload --------------------------------------------------------\nreturn [\n  {\n    json: {\n      smart:        false,\n      provider:     \u0027openai\u0027,\n      audience_id:  \u0027r7ea561c4448543\u0027,\n      query:        `Which image do you prefer? ${optionsText}`,\n      files,\n      mode:         \u0027normal\u0027,\n      voting_mode:  true,\n    },\n  },\n];\n"
      },
      "position": [
        940,
        5940
      ],
      "type": "n8n-nodes-base.code",
      "typeVersion": 2
    },
    {
      "id": "c45f6723-f1ce-4163-beb2-03ad556ce857",
      "name": "Code",
      "parameters": {
        "jsCode": "// 0) Pull or initialize the workflow-wide array\nconst bag = $getWorkflowStaticData(\u0027global\u0027);\nbag.rallyUploads = bag.rallyUploads ?? [];\n\n// 1) Add this image\u2019s metadata to the shared bag\nbag.rallyUploads.push({\n  fileName: $json.fileName,\n  url: $json.url,\n  combinationId: $json.combinationId,\n});\n\n// 2) Let this item keep flowing downstream\nreturn $input.item;\n",
        "mode": "runOnceForEachItem"
      },
      "position": [
        2680,
        6600
      ],
      "type": "n8n-nodes-base.code",
      "typeVersion": 2
    },
    {
      "id": "4cb1d128-b2b5-4aaf-9ed4-1c5bc20408ef",
      "name": "Code2",
      "parameters": {
        "jsCode": "/****************************************************************\n *  Crunch Rally responses  \u279c  output one item per option\n *\n *  Works directly on the raw Call-Rally item:\n *    $input.first().json.responses[]  \u2190 array of objects\n *                                       { persona_id, response:\"{\u2026}\" }\n *  Needs the upload metadata we stashed earlier in\n *    global.rallyUploads[]\n ****************************************************************/\n\n// ------------------------------------------------------------------\n// helpers\n// ------------------------------------------------------------------\nconst letters = \u0027ABCDEFGHIJKLMNOPQRSTUVWXYZ\u0027;\nconst bag     = $getWorkflowStaticData(\u0027global\u0027);\nconst uploads = (bag.rallyUploads ?? [])\n                  .sort((a, b) =\u003e a.combinationIndex - b.combinationIndex);\n\n// ------------------------------------------------------------------\n// 1) tally the votes\n// ------------------------------------------------------------------\nconst counts = {};   // { A: 23, B: 9, \u2026 }\nlet   total  = 0;\n\nfor (const obj of ($input.first().json.responses || [])) {\n  const raw = obj.response;             // \u003c\u2500 string like \u0027{\"thinking\":\u2026,\"option\":\"C\"}\u0027\n  if (!raw) continue;\n\n  let opt;\n  try { opt = JSON.parse(raw).option?.trim().toUpperCase(); }\n  catch { continue; }                   // bad JSON \u2192 skip\n\n  if (!opt) continue;\n  counts[opt] = (counts[opt] ?? 0) + 1;\n  total += 1;\n}\n\nif (total === 0) throw new Error(\u0027No valid votes found.\u0027);\n\n// ------------------------------------------------------------------\n// 2) build CSV-friendly rows\n// ------------------------------------------------------------------\nconst rows = Object.entries(counts)      // [ [\u0027A\u0027,23], \u2026 ]\n  .map(([ltr, votes]) =\u003e {\n    const idx    = letters.indexOf(ltr);   // A\u21920, B\u21921 \u2026\n    const upload = uploads[idx] || {};\n\n    return {\n      Option        : ltr,\n      Votes         : votes,\n      Percent       : +(100 * votes / total).toFixed(1),\n      CombinationID : upload.combinationId ?? \u0027\u2013\u0027,\n      URL           : upload.url           ?? \u0027missing\u0027,\n      FileName      : upload.fileName      ?? \u0027unknown\u0027\n    };\n  })\n  .sort((a, b) =\u003e b.Votes - a.Votes);     // winner first\n\n// ------------------------------------------------------------------\n// 3) emit one item per option (perfect for Convert-to-CSV)\n// ------------------------------------------------------------------\nreturn rows.map(r =\u003e ({ json: r }));\n"
      },
      "position": [
        1400,
        5940
      ],
      "type": "n8n-nodes-base.code",
      "typeVersion": 2
    },
    {
      "id": "c30caefd-8de2-4eba-8324-8d003d00f7fb",
      "name": "Convert to File2",
      "parameters": {
        "binaryPropertyName": "breakdown",
        "options": {
          "fileName": "ctr_results.csv",
          "headerRow": true
        }
      },
      "position": [
        1600,
        5940
      ],
      "type": "n8n-nodes-base.convertToFile",
      "typeVersion": 1.1
    },
    {
      "id": "8ce8e3b2-2a7e-42dd-90ec-6acaad5fbb8a",
      "name": "Sticky Note",
      "parameters": {
        "color": 5,
        "content": "### Synthetic testing\n",
        "height": 220,
        "width": 260
      },
      "position": [
        2260,
        6560
      ],
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1
    }
  ],
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  },
  "tags": [],
  "versionId": "881e838d-b380-4529-9f0d-e13ece00e7e4"
}

About the Author

Rhys Fisher

Rhys Fisher

Rhys Fisher is the COO & Co-Founder of Rally. He previously co-founded a boutique analytics agency called Unvanity, crossed the Pyrenees coast-to-coast via paraglider, and now watches virtual crowds respond to memes. Follow him on Twitter @virtual_rf