{
  "id": "SHYB21Rqgvpck1Tz",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Automated UGC Video Generator from Google Sheets",
  "tags": [
    {
      "id": "RWoirf4893qm4Odj",
      "name": "Error Handler",
      "createdAt": "2026-03-23T00:52:39.664Z",
      "updatedAt": "2026-03-23T00:52:39.664Z"
    },
    {
      "id": "TxZURGobldQjD44j",
      "name": "Video Generation",
      "createdAt": "2026-03-23T00:52:24.378Z",
      "updatedAt": "2026-03-23T00:52:24.378Z"
    },
    {
      "id": "Vi4cvKWoEWH4wp1M",
      "name": "UGC",
      "createdAt": "2026-03-23T00:52:24.404Z",
      "updatedAt": "2026-03-23T00:52:24.404Z"
    },
    {
      "id": "hZxLr6tZp8JB3yH9",
      "name": "AI Automation",
      "createdAt": "2026-03-23T00:52:24.341Z",
      "updatedAt": "2026-03-23T00:52:24.341Z"
    }
  ],
  "nodes": [
    {
      "id": "15ba86f1-e3d3-46cd-a1d7-0005287509d6",
      "name": "Error Trigger",
      "type": "n8n-nodes-base.errorTrigger",
      "position": [
        -2896,
        848
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "c431d2a6-b0e7-4b21-81ef-b9ec1efe379a",
      "name": "Parse Error Details",
      "type": "n8n-nodes-base.code",
      "position": [
        -2672,
        848
      ],
      "parameters": {
        "jsCode": "// Extract error details and the row number from the failed execution\nconst errorData = $input.first().json;\n\n// The execution data contains the workflow data from when it failed\n// We need to find the sheetRowNumber that was being processed\nlet sheetRowNumber = null;\nlet productName = 'Unknown';\nlet errorMessage = 'Unknown error';\n\n// Extract error message\nif (errorData.execution?.error?.message) {\n  errorMessage = errorData.execution.error.message;\n} else if (errorData.execution?.lastNodeExecuted) {\n  errorMessage = `Failed at node: ${errorData.execution.lastNodeExecuted}`;\n}\n\n// Try to extract the row number from the execution data\n// Walk through the execution's result data to find sheetRowNumber\ntry {\n  const resultData = errorData.execution?.data?.resultData;\n  if (resultData?.runData) {\n    // Check nodes in reverse order to find the most recent data with sheetRowNumber\n    const nodeNames = Object.keys(resultData.runData);\n    for (const nodeName of nodeNames.reverse()) {\n      const nodeRuns = resultData.runData[nodeName];\n      if (nodeRuns && nodeRuns.length > 0) {\n        const lastRun = nodeRuns[nodeRuns.length - 1];\n        const outputData = lastRun?.data?.main?.[0];\n        if (outputData && outputData.length > 0) {\n          const jsonData = outputData[0]?.json;\n          if (jsonData?.sheetRowNumber) {\n            sheetRowNumber = jsonData.sheetRowNumber;\n            productName = jsonData.productName || productName;\n            break;\n          }\n        }\n      }\n    }\n  }\n} catch (e) {\n  // If we can't extract row data, we'll log what we can\n  errorMessage += ` (Could not determine sheet row: ${e.message})`;\n}\n\nreturn [{\n  json: {\n    sheetRowNumber: sheetRowNumber,\n    productName: productName,\n    errorMessage: errorMessage.substring(0, 500),\n    failedNode: errorData.execution?.lastNodeExecuted || 'Unknown',\n    executionId: errorData.execution?.id || 'Unknown',\n    timestamp: new Date().toISOString()\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "4746134f-3714-45e5-9cca-a5751b070dd8",
      "name": "Row Number Known?",
      "type": "n8n-nodes-base.if",
      "position": [
        -2448,
        848
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "check-row-exists",
              "operator": {
                "name": "filter.operator.exists",
                "type": "number",
                "operation": "exists"
              },
              "leftValue": "={{ $json.sheetRowNumber }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "3b2456ed-3507-48a0-b4c5-b92d5f26e271",
      "name": "Update Sheet \u2014 Error",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -2224,
        752
      ],
      "parameters": {
        "columns": {
          "value": {
            "Status": "Error",
            "Error Message": "={{ $json.errorMessage + ' | Node: ' + $json.failedNode + ' | Execution: ' + $json.executionId }}"
          },
          "schema": [
            {
              "id": "Status",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Status",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Error Message",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Error Message",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "row_number"
          ]
        },
        "options": {
          "cellFormat": "USER_ENTERED"
        },
        "operation": "update",
        "sheetName": {
          "__rl": true,
          "mode": "byName",
          "value": "Products"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1SaqB-z6WD5QDudhkZfcVmKpvu0-fteQcwE02_YK0ufs",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1SaqB-z6WD5QDudhkZfcVmKpvu0-fteQcwE02_YK0ufs/edit?usp=drivesdk",
          "cachedResultName": "UGC Automation"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "35d1f8da-c092-4eab-aa3d-980767a288ef",
      "name": "Log Error (Unknown Row)",
      "type": "n8n-nodes-base.code",
      "position": [
        -2224,
        944
      ],
      "parameters": {
        "jsCode": "// Log the error for cases where we couldn't determine the row\nconst item = $input.first().json;\nconsole.warn('UGC Pipeline Error (unknown row):', JSON.stringify(item));\n\nreturn [{\n  json: {\n    logged: true,\n    ...item\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "3f5dc41d-654e-4a6c-afe8-5bff56034a60",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2944,
        624
      ],
      "parameters": {
        "color": 2,
        "width": 920,
        "height": 536,
        "content": "## ERROR HANDLER WORKFLOW\nThis workflow is triggered when the main UGC pipeline fails.\nIt extracts the error details and row number, then updates\nthe Google Sheet with Status = \"Error\" and the error message.\n\nSETUP: In the main workflow settings, set this workflow\nas the \"Error Workflow\"."
      },
      "typeVersion": 1
    },
    {
      "id": "14d935a8-8970-4289-9a6b-79922ea48d7d",
      "name": "Sticky Note \u2014 Complete1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -896,
        -80
      ],
      "parameters": {
        "color": 5,
        "width": 1248,
        "height": 492,
        "content": "## 5. GOOGLE DRIVE UPLOAD & SHEET UPDATE\nFetches the video binary from Sora API.\nUploads MP4 to Google Drive.\nSets file to public (anyone with link).\nBuilds shareable Drive URL.\nWrites Image URL + Video URL back to the\ncorrect row. Sets Status = \"Done\".\nLoops back for next pending product.\nOn error, the Error Handler workflow sets\nStatus = \"Error\" + error message."
      },
      "typeVersion": 1
    },
    {
      "id": "91e74ed5-93b8-4060-b6b1-a55d20ed26d3",
      "name": "Sticky Note \u2014 Sora Poll Loop1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1840,
        -80
      ],
      "parameters": {
        "width": 916,
        "height": 492,
        "content": "## 4b. SORA POLLING LOOP\nPolls GET /v1/videos/{videoId} every 30s. Check Sora Status merges poll response with\ncarried product data from Extract Sora Job ID.\n\nIF node checks isDone === true:\n  TRUE  -> Fetch video content + upload to Drive\n  FALSE -> Loop back to Wait 30 Seconds\nMax 40 polls = 20 minute timeout safety."
      },
      "typeVersion": 1
    },
    {
      "id": "028d4623-9249-49b1-ab7d-a87d5effba36",
      "name": "Sticky Note \u2014 Sora Async Polling1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2464,
        -80
      ],
      "parameters": {
        "width": 592,
        "height": 492,
        "content": "## 4. SORA VIDEO GENERATION (Async + Polling)\nWaits 60s for rate limiting, then POSTs to Sora API. Sora returns a queued job ID (not a video URL).\n\nExtracts the job ID, then enters a polling loop:\n  - Wait 30s between polls\n  - GET /v1/videos/{id} to check status\n  - If status != \"completed\", loop back to wait\n  - Max 40 polls (20 min timeout)\nOnce completed, fetches video content URL."
      },
      "typeVersion": 1
    },
    {
      "id": "41f09387-5c2b-46be-91e7-905f9d99b17f",
      "name": "Sticky Note \u2014 Vision & Script1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -3168,
        32
      ],
      "parameters": {
        "color": 3,
        "width": 680,
        "height": 376,
        "content": "## 3. VISION ANALYSIS & VIDEO SCRIPT\nGPT-4 Vision analyzes the generated image for mood, colors, composition. This enriches the video script with visual continuity. Script optimized for Sora's prompt format."
      },
      "typeVersion": 1
    },
    {
      "id": "a858e198-91f1-459e-a02c-dafff58d809e",
      "name": "Sticky Note \u2014 Image Gen1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -3824,
        32
      ],
      "parameters": {
        "color": 6,
        "width": 632,
        "height": 376,
        "content": "## 2. IMAGE GENERATION\n\nFor each pending product, a code node builds a detailed DALL\u00b7E 3 prompt with UGC aesthetic direction. The prompt is sent to the OpenAI API to generate an HD image, and the returned image URL is extracted for downstream use."
      },
      "typeVersion": 1
    },
    {
      "id": "f3480891-ef64-4a0e-a223-980b307496e5",
      "name": "Sticky Note \u2014 Trigger1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -4704,
        32
      ],
      "parameters": {
        "color": 4,
        "width": 648,
        "height": 328,
        "content": "## Stage 1 \u2014 Trigger & data fetch. \n\nA schedule trigger fires on a set interval. It reads all rows from a Google Sheets \"Products\" tab, then a code node filters down to only rows where Status = \"Pending\", tracking each row number for later updates."
      },
      "typeVersion": 1
    },
    {
      "id": "b0db93d6-486b-4c91-8e65-357c07d58a32",
      "name": "Loop Complete1",
      "type": "n8n-nodes-base.noOp",
      "position": [
        512,
        176
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "4a816411-f497-4d10-90fd-bef7e15792bd",
      "name": "Update Sheet \u2014 Done2",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        144,
        176
      ],
      "parameters": {
        "columns": {
          "value": {
            "Status": "Done",
            "Image URL": "={{ $json.imageUrl }}",
            "Video URL": "={{ $json.videoUrl }}",
            "row_number": "={{ $json.sheetRowNumber }}",
            "Description": "={{ $json.description }}",
            "Product Name": "={{ $json.productName }}",
            "Target Audience": "={{ $json.targetAudience }}"
          },
          "schema": [
            {
              "id": "Product Name",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Product Name",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Description",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Description",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Target Audience",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Target Audience",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Status",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Status",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Image URL",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Image URL",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Video URL",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Video URL",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Error",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Error",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "row_number",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": true,
              "required": false,
              "displayName": "row_number",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "row_number"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {
          "cellFormat": "USER_ENTERED"
        },
        "operation": "update",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1SaqB-z6WD5QDudhkZfcVmKpvu0-fteQcwE02_YK0ufs/edit#gid=0",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1SaqB-z6WD5QDudhkZfcVmKpvu0-fteQcwE02_YK0ufs",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1SaqB-z6WD5QDudhkZfcVmKpvu0-fteQcwE02_YK0ufs/edit?usp=drivesdk",
          "cachedResultName": "UGC Automation"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "4b2ef789-11da-4f36-aeda-9c8fb0b03c3f",
      "name": "Build Public Drive URL1",
      "type": "n8n-nodes-base.code",
      "position": [
        -176,
        176
      ],
      "parameters": {
        "jsCode": "const driveFile = $('Upload Video to Google Drive1').first().json;\nconst carried = $('Check Sora Status1').first().json;\n\nconst fileId = driveFile.id;\n// Direct shareable link format\nconst videoUrl = `https://drive.google.com/file/d/${fileId}/view?usp=sharing`;\n\nreturn [{\n  json: {\n    productName: carried.productName,\n    description: carried.description,\n    targetAudience: carried.targetAudience,\n    sheetRowNumber: carried.sheetRowNumber,\n    imageUrl: carried.imageUrl,\n    videoUrl: videoUrl,\n    videoId: carried.videoId,\n    driveFileId: fileId\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "1b8ed01a-0222-4890-a4f5-49a26f8cf35b",
      "name": "Make File Public1",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -400,
        176
      ],
      "parameters": {
        "url": "=https://www.googleapis.com/drive/v3/files/{{ $json.id }}/permissions",
        "method": "POST",
        "options": {
          "timeout": 30000
        },
        "jsonBody": "{\n  \"role\": \"reader\",\n  \"type\": \"anyone\"\n}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleDriveOAuth2Api"
      },
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "e6da9b30-d9bb-47e7-99a4-64899060732a",
      "name": "Upload Video to Google Drive1",
      "type": "n8n-nodes-base.googleDrive",
      "position": [
        -624,
        176
      ],
      "parameters": {
        "name": "={{ $('Check Sora Status1').first().json.productName || 'ugc-video' }}_{{ $('Check Sora Status1').first().json.videoId }}.mp4",
        "driveId": {
          "__rl": true,
          "mode": "list",
          "value": "My Drive",
          "cachedResultUrl": "https://drive.google.com/drive/my-drive",
          "cachedResultName": "My Drive"
        },
        "options": {},
        "folderId": {
          "__rl": true,
          "mode": "list",
          "value": "140oet5FuceF8z56hI8u41JcJQbPuTtR7",
          "cachedResultUrl": "https://drive.google.com/drive/folders/140oet5FuceF8z56hI8u41JcJQbPuTtR7",
          "cachedResultName": "UGC Videos"
        }
      },
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 3
    },
    {
      "id": "53d8ace4-c965-48f2-97fd-755cdef262c1",
      "name": "Fetch Sora Video Content1",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -848,
        176
      ],
      "parameters": {
        "url": "=https://api.openai.com/v1/videos/{{ $json.videoId }}/content",
        "options": {
          "timeout": 120000,
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        },
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi"
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "b541ed0b-1437-4146-a64e-7cee7436a0a8",
      "name": "Video Ready?1",
      "type": "n8n-nodes-base.if",
      "position": [
        -1072,
        176
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "sora-video-ready-condition",
              "operator": {
                "name": "filter.operator.equals",
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json.isDone }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "77c8f941-9d0e-49d9-b54d-a57dcc83d712",
      "name": "Check Sora Status1",
      "type": "n8n-nodes-base.code",
      "position": [
        -1296,
        112
      ],
      "parameters": {
        "jsCode": "const raw = $input.first().json;\nconst response = Array.isArray(raw) ? raw[0] : raw;\n\n// Get carried data from the job ID node\nconst carried = $('Extract Sora Job ID1').first().json;\n\nconst status = response.status || '';\nconst progress = response.progress || 0;\nconst pollCount = (carried.pollCount || 0) + 1;\nconst maxPolls = 40; // 20 minutes max\n\nif (response.error) {\n  throw new Error('Sora video generation failed: ' + JSON.stringify(response.error));\n}\n\nif (pollCount >= maxPolls) {\n  throw new Error('Sora video generation timed out after ' + maxPolls + ' polls (20 minutes)');\n}\n\nreturn [{\n  json: {\n    productName: carried.productName,\n    description: carried.description,\n    targetAudience: carried.targetAudience,\n    sheetRowNumber: carried.sheetRowNumber,\n    imageUrl: carried.imageUrl,\n    videoId: carried.videoId,\n    videoStatus: status,\n    progress: progress,\n    pollCount: pollCount,\n    isDone: status === 'completed'\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "4f0a27cf-59c0-4e20-9ddc-6494695133af",
      "name": "Poll Sora Status1",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -1520,
        112
      ],
      "parameters": {
        "url": "=https://api.openai.com/v1/videos/{{ $json.videoId }}",
        "options": {
          "timeout": 30000
        },
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi"
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "7e2196bd-3f07-46eb-a2d5-58d9c2429712",
      "name": "Wait 30 Seconds1",
      "type": "n8n-nodes-base.wait",
      "position": [
        -1744,
        176
      ],
      "parameters": {
        "amount": 30
      },
      "typeVersion": 1.1
    },
    {
      "id": "a264934c-b7d4-4b9f-aa12-c08c8ccd46d8",
      "name": "Extract Sora Job ID1",
      "type": "n8n-nodes-base.code",
      "position": [
        -1968,
        176
      ],
      "parameters": {
        "jsCode": "const raw = $input.first().json;\nconst prevData = $('Video Script Builder1').first().json;\nconst response = Array.isArray(raw) ? raw[0] : raw;\n\nif (!response || !response.id) {\n  throw new Error('Could not extract video job ID from Sora response: ' + JSON.stringify(raw).substring(0, 500));\n}\n\nreturn [{\n  json: {\n    productName: prevData.productName,\n    description: prevData.description,\n    targetAudience: prevData.targetAudience,\n    sheetRowNumber: prevData.sheetRowNumber,\n    imageUrl: prevData.imageUrl,\n    videoId: response.id,\n    videoStatus: response.status,\n    progress: response.progress || 0,\n    pollCount: 0\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "5438f0c9-dd1c-4e1d-97c4-37fdb13446c1",
      "name": "Sora Generate Video1",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -2192,
        176
      ],
      "parameters": {
        "url": "https://api.openai.com/v1/videos",
        "method": "POST",
        "options": {
          "timeout": 300000
        },
        "jsonBody": "={{ JSON.stringify({\n  model: \"sora-2\",\n  prompt: $json.videoScript,\n  seconds: '8',\n  size: \"720x1280\"\n}) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi"
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "1b949498-28e8-4be0-b6d8-bc7af87fc8a2",
      "name": "Wait for Sora Rate Limit1",
      "type": "n8n-nodes-base.wait",
      "position": [
        -2416,
        176
      ],
      "parameters": {
        "amount": 60
      },
      "typeVersion": 1.1
    },
    {
      "id": "339acd53-88b0-412b-9d3f-e3df4415d62c",
      "name": "Video Script Builder1",
      "type": "n8n-nodes-base.code",
      "position": [
        -2640,
        176
      ],
      "parameters": {
        "jsCode": "// Build a structured UGC video script for OpenAI Sora\nconst item = $input.first().json;\n\nconst product = item.productName || 'this product';\nconst description = item.description || '';\nconst audience = item.targetAudience || 'young adults';\nconst vision = item.visionAnalysis || {};\n\nconst mood = vision.mood || 'warm and authentic';\nconst colors = (vision.dominantColors || ['warm tones']).join(', ');\nconst lighting = vision.lighting || 'natural lighting';\nconst setting = vision.settingDetails || 'casual everyday setting';\nconst emotion = vision.emotionalTone || 'relatable';\nconst cameraMovements = (vision.suggestedCameraMovements || ['slow zoom in']).join(', ');\n\n// Build a concise video prompt optimized for Sora\n// Sora works best with clear, descriptive scene prompts rather than screenplay-style scripts\nconst videoScript = [\n  `UGC-style smartphone video of ${audience} authentically using ${product} in ${setting}.`,\n  description ? `The product is ${description}.` : '',\n  `Visual style: ${mood} mood, ${colors} color palette, ${lighting}.`,\n  `Camera: ${cameraMovements}, handheld smartphone feel.`,\n  `The person picks up ${product} with genuine curiosity, uses it naturally, and reacts with authentic satisfaction.`,\n  `Emotional tone: ${emotion}, relatable, candid.`,\n  'Natural ambient lighting, shallow depth of field, no text overlays or logos.'\n].filter(Boolean).join(' ');\n\nreturn [{\n  json: {\n    ...item,\n    videoScript: videoScript\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "718f66b9-df1d-4ffa-98c4-d8da3135e29c",
      "name": "Parse Vision Analysis1",
      "type": "n8n-nodes-base.code",
      "position": [
        -2864,
        176
      ],
      "parameters": {
        "jsCode": "// Parse GPT-4 Vision analysis and carry forward\nconst input = $input.first().json;\nconst prevData = $('Extract DALL-E URL1').first().json;\n\nconst content = input?.choices?.[0]?.message?.content || '{}';\n\nlet visionAnalysis;\ntry {\n  // Strip markdown code fences if present\n  const cleaned = content.replace(/```json\\n?/g, '').replace(/```\\n?/g, '').trim();\n  visionAnalysis = JSON.parse(cleaned);\n} catch (e) {\n  // If JSON parsing fails, use raw text\n  visionAnalysis = {\n    mood: 'warm and authentic',\n    dominantColors: ['warm tones'],\n    composition: content,\n    lighting: 'natural',\n    subjectExpression: 'genuine',\n    settingDetails: 'casual setting',\n    suggestedCameraMovements: ['slow zoom in', 'pan across product'],\n    emotionalTone: 'relatable'\n  };\n}\n\nreturn [{\n  json: {\n    ...prevData,\n    visionAnalysis: visionAnalysis\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "76b6c96b-ec5d-4a19-92f9-4f2616353a45",
      "name": "GPT-4 Vision Analysis1",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -3088,
        176
      ],
      "parameters": {
        "url": "https://api.openai.com/v1/chat/completions",
        "method": "POST",
        "options": {
          "timeout": 60000
        },
        "jsonBody": "={{ JSON.stringify({\n  model: \"gpt-4o\",\n  messages: [\n    {\n      role: \"system\",\n      content: \"You are a visual analyst for UGC video production. Analyze the image and return a JSON object with these fields: mood (string), dominantColors (array of strings), composition (string describing layout), lighting (string), subjectExpression (string), settingDetails (string), suggestedCameraMovements (array of strings for video), emotionalTone (string). Be specific and cinematic in your descriptions.\"\n    },\n    {\n      role: \"user\",\n      content: [\n        {\n          type: \"text\",\n          text: \"Analyze this UGC product image for video production planning. Product: \" + $json.productName + \". Return structured JSON only, no markdown.\"\n        },\n        {\n          type: \"image_url\",\n          image_url: {\n            url: $json.imageUrl,\n            detail: \"high\"\n          }\n        }\n      ]\n    }\n  ],\n  max_tokens: 1000,\n  temperature: 0.3\n}) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi"
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "4a84b896-8b39-45a6-aee5-f42ab91e5269",
      "name": "Extract DALL-E URL1",
      "type": "n8n-nodes-base.code",
      "position": [
        -3312,
        176
      ],
      "parameters": {
        "jsCode": "// Extract the DALL-E image URL directly and carry forward all product data\nconst response = $input.first().json;\nconst prevData = $('AI Prompt Builder1').first().json;\n\nconst imageUrl = response.data?.[0]?.url || response.data?.[0]?.revised_url || '';\n\nif (!imageUrl) {\n  throw new Error('No image URL returned from DALL-E 3: ' + JSON.stringify(response));\n}\n\nreturn [{\n  json: {\n    ...prevData,\n    imageUrl: imageUrl,\n    dalleRevisedPrompt: response.data?.[0]?.revised_prompt || ''\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "168cbb8e-8baa-4e77-8101-4aaa8ba88cfc",
      "name": "DALL-E 3 Generate Image1",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -3536,
        176
      ],
      "parameters": {
        "url": "https://api.openai.com/v1/images/generations",
        "method": "POST",
        "options": {
          "timeout": 120000
        },
        "jsonBody": "={{ JSON.stringify({\n  model: \"dall-e-3\",\n  prompt: $json.imagePrompt,\n  n: 1,\n  size: \"1024x1024\",\n  quality: \"hd\",\n  style: \"natural\"\n}) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi"
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "d98ee411-296b-40fb-b480-8463f1902ba7",
      "name": "AI Prompt Builder1",
      "type": "n8n-nodes-base.code",
      "position": [
        -3760,
        176
      ],
      "parameters": {
        "jsCode": "// Build a UGC-style image generation prompt from product data\nconst item = $input.first().json;\n\nconst productName = item.productName || 'product';\nconst description = item.description || '';\nconst targetAudience = item.targetAudience || 'young adults';\n\n// Construct a detailed DALL-E 3 prompt with UGC aesthetic\nconst prompt = [\n  `A lifestyle photograph of ${productName} being used by ${targetAudience}.`,\n  description ? `The product is ${description}.` : '',\n  'Shot on iPhone, natural window lighting, casual home or cafe setting.',\n  'UGC aesthetic: authentic, unposed, relatable feel.',\n  'Warm color grading, shallow depth of field, genuine emotion.',\n  'No text overlays, no logos, no watermarks.',\n  'Photorealistic, editorial quality, candid moment.'\n].filter(Boolean).join(' ');\n\nreturn [{\n  json: {\n    ...item,\n    imagePrompt: prompt\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "708a0f76-2a80-44dc-aeb1-43f19074864b",
      "name": "Loop Over Items1",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        -3984,
        176
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "ae1ccc94-f9d6-4144-b940-2fc5ede0b2bd",
      "name": "Filter Pending Rows1",
      "type": "n8n-nodes-base.code",
      "position": [
        -4208,
        176
      ],
      "parameters": {
        "jsCode": "// Filter to only rows with Status = \"Pending\"\n// n8n Google Sheets node returns rows with 0-based row_number\n// but the actual sheet row is row_number + 2 (header row + 0-index offset)\n\nconst items = $input.all();\nconst pendingItems = [];\n\nfor (let i = 0; i < items.length; i++) {\n  const item = items[i];\n  const status = (item.json['Status'] || '').toString().trim();\n  \n  if (status === 'Pending') {\n    pendingItems.push({\n      json: {\n        productName: item.json['Product Name'] || '',\n        description: item.json['Description'] || '',\n        targetAudience: item.json['Target Audience'] || '',\n        status: item.json['Status'] || '',\n        imageUrl: item.json['Image URL'] || '',\n        videoUrl: item.json['Video URL'] || '',\n        errorMessage: item.json['Error Message'] || '',\n        // row_number is the index in the sheet (0-based from data rows)\n        // Actual sheet row = index + 2 (1 for header, 1 for 0-index)\n        sheetRowNumber: i + 2\n      }\n    });\n  }\n}\n\nif (pendingItems.length === 0) {\n  // Return empty array \u2014 workflow stops naturally\n  return [];\n}\n\nreturn pendingItems;"
      },
      "typeVersion": 2
    },
    {
      "id": "ee9d2866-355d-4c73-aaf3-bf46c09b365e",
      "name": "Read Product Sheet1",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -4432,
        176
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1SaqB-z6WD5QDudhkZfcVmKpvu0-fteQcwE02_YK0ufs/edit#gid=0",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1SaqB-z6WD5QDudhkZfcVmKpvu0-fteQcwE02_YK0ufs",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1SaqB-z6WD5QDudhkZfcVmKpvu0-fteQcwE02_YK0ufs/edit?usp=drivesdk",
          "cachedResultName": "UGC Automation"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "5969cd10-3860-488e-a9b1-8bf89383bd73",
      "name": "Schedule Trigger1",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -4656,
        176
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "4b0a9306-c78e-4f47-99ce-e11b7650c14b",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -5488,
        -96
      ],
      "parameters": {
        "width": 496,
        "height": 352,
        "content": "## Workflow overview\n\nThis is an n8n automation workflow that turns product listings in a Google Sheet into UGC-style videos, fully hands-off.\n\nIt runs on a schedule, picks up any row marked \"Pending\" in the sheet, and for each product it: generates a product image with DALL\u00b7E 3, analyzes that image with GPT-4 Vision to inform the video style, sends a tailored prompt to Sora to generate a short video, uploads the finished video to Google Drive, and writes the public links back to the sheet. The row's status gets updated to \"Done\" when complete, or \"Error\" with details if something fails along the way.\n\nIn short: you fill in product info in a spreadsheet, and the workflow automatically produces and delivers AI-generated marketing videos for each one."
      },
      "typeVersion": 1
    },
    {
      "id": "5178b44c-46aa-47a8-88f5-a5c9a70ee9ed",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -5488,
        304
      ],
      "parameters": {
        "width": 496,
        "height": 560,
        "content": "## Setup instructions\n\nBefore running this workflow, configure the following:\n\n1. **Google Sheets OAuth** \u2014 Connect your Google account so the workflow can read product rows and write back status updates, image URLs, and video URLs.\n2. **Google Drive credentials** \u2014 Needed for uploading finished MP4 videos and setting them to public access.\n3. **OpenAI API key** \u2014 Used for both DALL\u00b7E 3 image generation and GPT-4 Vision analysis. Set this in the HTTP Request nodes for those calls.\n4. **Sora API key** \u2014 Used for video generation. Configure in the Sora Generate Video and Poll Sora Status HTTP Request nodes.\n5. **Google Sheet setup** \u2014 Create a sheet named \"Products\" with columns for product info, Status, Image URL, Video URL, and Error Message. Mark rows you want processed with Status = \"Pending\".\n6. **Google Drive folder** \u2014 Set the target folder ID in the Upload Video node where finished videos will be stored.\n7. **Schedule interval** \u2014 Adjust the Schedule Trigger to your preferred frequency (e.g. every 15 minutes, hourly).\n8. **Error handler workflow** \u2014 Import the error handler as a separate workflow and link it in this workflow's settings under \"Error Workflow\" so failed rows get logged back to the sheet."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "c4835390-8a09-402e-b364-c945f5d9474f",
  "connections": {
    "Error Trigger": {
      "main": [
        [
          {
            "node": "Parse Error Details",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Video Ready?1": {
      "main": [
        [
          {
            "node": "Fetch Sora Video Content1",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Wait 30 Seconds1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Over Items1": {
      "main": [
        [
          {
            "node": "Loop Complete1",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "AI Prompt Builder1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait 30 Seconds1": {
      "main": [
        [
          {
            "node": "Poll Sora Status1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Make File Public1": {
      "main": [
        [
          {
            "node": "Build Public Drive URL1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Poll Sora Status1": {
      "main": [
        [
          {
            "node": "Check Sora Status1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Row Number Known?": {
      "main": [
        [
          {
            "node": "Update Sheet \u2014 Error",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Error (Unknown Row)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule Trigger1": {
      "main": [
        [
          {
            "node": "Read Product Sheet1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Prompt Builder1": {
      "main": [
        [
          {
            "node": "DALL-E 3 Generate Image1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Sora Status1": {
      "main": [
        [
          {
            "node": "Video Ready?1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract DALL-E URL1": {
      "main": [
        [
          {
            "node": "GPT-4 Vision Analysis1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Error Details": {
      "main": [
        [
          {
            "node": "Row Number Known?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Product Sheet1": {
      "main": [
        [
          {
            "node": "Filter Pending Rows1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Sora Job ID1": {
      "main": [
        [
          {
            "node": "Wait 30 Seconds1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Pending Rows1": {
      "main": [
        [
          {
            "node": "Loop Over Items1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sora Generate Video1": {
      "main": [
        [
          {
            "node": "Extract Sora Job ID1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Video Script Builder1": {
      "main": [
        [
          {
            "node": "Wait for Sora Rate Limit1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GPT-4 Vision Analysis1": {
      "main": [
        [
          {
            "node": "Parse Vision Analysis1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Vision Analysis1": {
      "main": [
        [
          {
            "node": "Video Script Builder1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update Sheet \u2014 Done2": {
      "main": [
        [
          {
            "node": "Loop Over Items1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Public Drive URL1": {
      "main": [
        [
          {
            "node": "Update Sheet \u2014 Done2",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "DALL-E 3 Generate Image1": {
      "main": [
        [
          {
            "node": "Extract DALL-E URL1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Sora Video Content1": {
      "main": [
        [
          {
            "node": "Upload Video to Google Drive1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait for Sora Rate Limit1": {
      "main": [
        [
          {
            "node": "Sora Generate Video1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload Video to Google Drive1": {
      "main": [
        [
          {
            "node": "Make File Public1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}