{
  "id": "ZvidBulkPersonal",
  "name": "Bulk personalized videos from CSV (Zvid)",
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "nodes": [
    {
      "id": "note-overview",
      "name": "Sticky Note - Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -260,
        -400
      ],
      "parameters": {
        "width": 620,
        "height": 340,
        "content": "## Bulk personalized videos from a CSV\nn8n port of [Zvid-io/bulk-personalized-videos](https://github.com/Zvid-io/bulk-personalized-videos): one designed template + one CSV row per customer = one branded 1080x1920 video each, via `POST /api/render/bulk/api-key`.\n\n**Setup**\n1. Create an API key at app.zvid.io -> API Keys.\n2. Create a **Header Auth** credential: name `x-api-key`, value = your key. Attach it to *Validate row (free)*, *Submit bulk render* and *Get batch status*.\n3. Tweak **Campaign Config**: point `csvUrl` / `templateUrl` / `campaignUrl` at your own files, set `rowLimit` (0 = all rows), or `dryRun: true` for a free per-row validation + credit estimate.\n\nAlso available: a Google Sheets variant and an event-driven webhook variant - see the `n8n/` folder of the repo. Prefer native nodes? The official [`@zvid/n8n-nodes-zvid`](https://www.npmjs.com/package/@zvid/n8n-nodes-zvid) package adds a Zvid node (bulk render, free validate, wait-for-completion) and an HMAC-verified Zvid Trigger."
      }
    },
    {
      "id": "note-poll",
      "name": "Sticky Note - Polling",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1420,
        520
      ],
      "parameters": {
        "width": 760,
        "height": 240,
        "color": 4,
        "content": "### Batch + submit + poll\nRows are submitted in batches of `batchSize` (the API accepts up to 500 items per request; plan limits may be lower). Each batch returns a `bulkId`; the loop's done-output collects every 202 response, then we poll `GET /api/render/bulk/:id` for **all** batches every `pollSeconds` until no job is pending, with a `timeoutMinutes` guard. API calls retry 3x on transient failures. Credits for failed jobs are refunded automatically.\n\nJob `name` is **null until completed** - results are mapped back to CSV rows by `jobId`, never by name."
      }
    },
    {
      "id": "note-output",
      "name": "Sticky Note - Output",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2900,
        -80
      ],
      "parameters": {
        "width": 660,
        "height": 250,
        "color": 5,
        "content": "### Outputs\n*Summary* ends the run with every video URL, credits reserved and problem rows - videos live on Zvid's CDN, so nothing needs to touch disk. That keeps the workflow n8n-Cloud-safe by default.\n\nSelf-hosting and want local files? Set `saveToDisk: true` to also write `results.csv` + the MP4s into `outDir`. n8n only allows writes under `~/.n8n-files` (`N8N_RESTRICT_FILE_ACCESS_TO`); create the folder once: `docker exec n8n mkdir -p /home/node/.n8n-files/zvid-out`, fetch with `docker cp n8n:/home/node/.n8n-files/zvid-out .`\n\nTo ship results elsewhere, replace the *Save* nodes with Google Drive / S3 / email nodes."
      }
    },
    {
      "id": "n-manual",
      "name": "When clicking 'Execute workflow'",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        -180,
        96
      ],
      "parameters": {}
    },
    {
      "id": "n-config",
      "name": "Campaign Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        20,
        96
      ],
      "parameters": {
        "mode": "raw",
        "jsonOutput": "{\n  \"apiUrl\": \"https://api.zvid.io\",\n  \"templateUrl\": \"https://raw.githubusercontent.com/Zvid-io/bulk-personalized-videos/master/template.json\",\n  \"campaignUrl\": \"https://raw.githubusercontent.com/Zvid-io/bulk-personalized-videos/master/campaign.json\",\n  \"csvUrl\": \"https://raw.githubusercontent.com/Zvid-io/bulk-personalized-videos/master/data/customers.csv\",\n  \"batchName\": \"personalized-offers (n8n)\",\n  \"rowLimit\": 0,\n  \"dryRun\": false,\n  \"batchSize\": 100,\n  \"pollSeconds\": 10,\n  \"timeoutMinutes\": 30,\n  \"saveToDisk\": false,\n  \"outDir\": \"/home/node/.n8n-files/zvid-out\"\n}",
        "options": {}
      }
    },
    {
      "id": "n-fetch-template",
      "name": "Fetch template.json",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        220,
        96
      ],
      "parameters": {
        "url": "={{ $json.templateUrl }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        }
      }
    },
    {
      "id": "n-fetch-campaign",
      "name": "Fetch campaign.json",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        420,
        96
      ],
      "parameters": {
        "url": "={{ $('Campaign Config').first().json.campaignUrl }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        }
      }
    },
    {
      "id": "n-fetch-csv",
      "name": "Fetch customers.csv",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        620,
        96
      ],
      "parameters": {
        "url": "={{ $('Campaign Config').first().json.csvUrl }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        }
      }
    },
    {
      "id": "n-parse-csv",
      "name": "Parse CSV rows",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        820,
        96
      ],
      "parameters": {
        "operation": "csv",
        "options": {}
      }
    },
    {
      "id": "n-build-items",
      "name": "Build bulk items",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1020,
        96
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Mirrors src/csv.js of the CLI example: required-column checks,\n// per-row validation, slugified job names, optional row limit.\nconst REQUIRED = ['first_name','product_name','product_tagline','product_image','discount_label','coupon_code','cta_url'];\nconst cfg = $('Campaign Config').first().json;\nconst rows = $input.all().map((i) => i.json);\nif (rows.length === 0) throw new Error('CSV contains no data rows');\n\nconst missing = REQUIRED.filter((c) => !(c in rows[0]));\nif (missing.length) {\n  throw new Error(`CSV is missing required column(s): ${missing.join(', ')}. Expected header: ${REQUIRED.join(',')}`);\n}\n\nconst slugify = (v) => String(v).toLowerCase().normalize('NFKD').replace(/[\\u0300-\\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'row';\n// note: the Code sandbox has no URL global - keep this a regex\nconst isHttpUrl = (v) => /^https?:\\/\\//i.test(String(v).trim());\n\nconst items = [];\nconst rowErrors = [];\nrows.forEach((record, index) => {\n  const row = index + 1; // 1-based CSV data row (header excluded)\n  const problems = [];\n  for (const c of REQUIRED) if (!record[c]) problems.push(`\"${c}\" is empty`);\n  if (record.product_image && !isHttpUrl(record.product_image)) problems.push('\"product_image\" must be an http(s) URL');\n  if (problems.length) { rowErrors.push({ row, error: problems.join('; ') }); return; }\n  items.push({\n    row,\n    name: `offer-${slugify(record.first_name)}-r${row}`,\n    variables: {\n      firstName: record.first_name,\n      productName: record.product_name,\n      productMeta: record.product_tagline,\n      productImage: record.product_image,\n      discountLabel: record.discount_label,\n      couponCode: record.coupon_code,\n      ctaUrl: record.cta_url,\n    },\n  });\n});\n\nif (items.length === 0) throw new Error('No valid rows in the CSV - nothing to render. Reasons: ' + JSON.stringify(rowErrors.slice(0, 5)));\nconst limit = Number(cfg.rowLimit) || 0;\nconst limited = limit > 0 ? items.slice(0, limit) : items;\n// rowErrors ride on the first item so the manifest can report skipped rows\nreturn limited.map((it, i) => ({ json: i === 0 ? { ...it, rowErrors } : it }));"
      }
    },
    {
      "id": "n-if-dryrun",
      "name": "Dry run?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1220,
        96
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c-dryrun",
              "leftValue": "={{ $('Campaign Config').first().json.dryRun }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ]
        },
        "looseTypeValidation": true,
        "options": {}
      }
    },
    {
      "id": "n-validate",
      "name": "Validate row (free)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1460,
        -64
      ],
      "onError": "continueRegularOutput",
      "parameters": {
        "method": "POST",
        "url": "={{ $('Campaign Config').first().json.apiUrl }}/api/render/validate/api-key",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ payload: $('Fetch template.json').first().json, variables: Object.assign({}, $('Fetch campaign.json').first().json, $json.variables) }) }}",
        "options": {}
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "n-estimate",
      "name": "Credit estimate",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1660,
        -64
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Per-row validation report + total credit estimate. No credits spent.\nconst items = $('Build bulk items').all().map((i) => i.json);\nconst results = $input.all().map((i) => i.json);\nlet creditsRequired = 0;\nlet invalid = 0;\nconst rows = results.map((r, i) => {\n  const item = items[i] || {};\n  if (r.error || r.valid === false) {\n    invalid += 1;\n    const message = typeof r.error === 'string' ? r.error : (r.error && r.error.message) || r.message || 'invalid';\n    return { csv_row: item.row, name: item.name, valid: false, error: message };\n  }\n  creditsRequired += r.creditsRequired || 0;\n  return { csv_row: item.row, name: item.name, valid: true, credits: r.creditsRequired, layoutWarnings: (r.warnings || []).length };\n});\nreturn [{ json: {\n  dryRun: true,\n  videos: results.length - invalid,\n  invalidRows: invalid,\n  creditsRequired,\n  note: 'No credits were spent. Set dryRun=false in Campaign Config to render.',\n  rows,\n} }];"
      }
    },
    {
      "id": "n-split-batches",
      "name": "Split into batches",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        1460,
        256
      ],
      "parameters": {
        "batchSize": "={{ $('Campaign Config').first().json.batchSize }}",
        "options": {}
      }
    },
    {
      "id": "n-build-request",
      "name": "Build bulk request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1660,
        400
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// One envelope per batch (up to batchSize items) - same merge semantics as\n// the CLI: item variables > batch variables > template defaults.\nconst cfg = $('Campaign Config').first().json;\nconst items = $input.all().map((i) => i.json);\nreturn [{ json: {\n  request: {\n    payload: $('Fetch template.json').first().json,\n    variables: $('Fetch campaign.json').first().json,\n    items: items.map(({ variables, name }) => ({ variables, name })),\n    name: cfg.batchName,\n  },\n} }];"
      }
    },
    {
      "id": "n-submit",
      "name": "Submit bulk render",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1860,
        400
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "parameters": {
        "method": "POST",
        "url": "={{ $('Campaign Config').first().json.apiUrl }}/api/render/bulk/api-key",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.request) }}",
        "options": {}
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "n-collect",
      "name": "Collect submissions",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1700,
        192
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// The loop's done-output delivers one 202 response per batch, in batch order.\n// Re-derive each batch's rows by slicing the same item list the loop consumed,\n// then map every queued job to its CSV row via the batch-relative jobs[].index.\nconst cfg = $('Campaign Config').first().json;\nconst batchSize = Math.max(1, Number(cfg.batchSize) || 100);\nconst allItems = $('Build bulk items').all().map((i) => i.json);\nconst submits = $input.all().map((i) => i.json);\n\nconst jobsMeta = [];\nconst rejected = [];\nconst bulks = [];\nsubmits.forEach((submit, b) => {\n  const batch = allItems.slice(b * batchSize, (b + 1) * batchSize);\n  for (const job of submit.jobs || []) {\n    const item = batch[job.index] || {};\n    jobsMeta.push({ jobId: job.jobId, row: item.row, name: item.name });\n  }\n  for (const e of submit.errors || submit.itemErrors || []) {\n    const idx = e.item != null ? e.item : e.index;\n    const item = typeof idx === 'number' ? batch[idx] : undefined;\n    const details = (e.errors || e.details || []).map((d) => `${d.field}: ${d.message}`).join('; ');\n    rejected.push({ csv_row: item ? item.row : null, name: item ? item.name : `item-${idx}`, error: details || e.error || e.message || 'invalid' });\n  }\n  bulks.push({ bulkId: submit.bulkId, creditsReserved: submit.creditsReserved || 0, totalJobs: submit.totalJobs || 0 });\n});\n\nconst aggregate = {\n  jobsMeta,\n  rejected,\n  rowErrors: $('Build bulk items').first().json.rowErrors || [],\n  creditsReservedTotal: bulks.reduce((a, x) => a + x.creditsReserved, 0),\n  totalJobs: bulks.reduce((a, x) => a + x.totalJobs, 0),\n  bulkIds: bulks.map((x) => x.bulkId),\n};\n// One output item per bulkId (the poll loop fetches each); aggregate rides on the first.\nreturn bulks.map((x, i) => ({ json: i === 0 ? { ...x, aggregate } : x }));"
      }
    },
    {
      "id": "n-wait",
      "name": "Wait before poll",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        1900,
        192
      ],
      "parameters": {
        "amount": "={{ $('Campaign Config').first().json.pollSeconds }}"
      }
    },
    {
      "id": "n-get-status",
      "name": "Get batch status",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2100,
        192
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "parameters": {
        "url": "={{ $('Campaign Config').first().json.apiUrl }}/api/render/bulk/{{ $json.bulkId }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {}
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "n-check-done",
      "name": "Check all done",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2300,
        192
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Aggregate progress across every batch. Input order always mirrors Collect\n// submissions' output order, so bulkIds can be re-attached by position.\nconst bulkIds = $('Collect submissions').all().map((i) => i.json.bulkId);\nconst responses = $input.all().map((i) => i.json);\nconst counts = { completed: 0, failed: 0, pending: 0 };\nfor (const r of responses) {\n  counts.completed += r.bulk.counts.completed;\n  counts.failed += r.bulk.counts.failed;\n  counts.pending += r.bulk.counts.pending;\n}\nconst done = counts.pending === 0;\nreturn responses.map((r, i) => ({ json: { bulkId: bulkIds[i], done, counts, response: r } }));"
      }
    },
    {
      "id": "n-if-done",
      "name": "All jobs finished?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2500,
        192
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c-done",
              "leftValue": "={{ $json.done }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ]
        },
        "looseTypeValidation": true,
        "options": {}
      }
    },
    {
      "id": "n-timeout",
      "name": "Poll timeout guard",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2300,
        384
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Loop back to Wait unless we have polled for longer than timeoutMinutes.\nconst cfg = $('Campaign Config').first().json;\nconst maxPolls = Math.ceil((Number(cfg.timeoutMinutes || 30) * 60) / Math.max(2, Number(cfg.pollSeconds || 10)));\nif ($runIndex >= maxPolls) {\n  const first = $input.first().json;\n  throw new Error(`Timed out after ${cfg.timeoutMinutes} min with ${first.counts.pending} job(s) still rendering. Check later: GET ${cfg.apiUrl}/api/render/bulk/${first.bulkId}`);\n}\nreturn $input.all();"
      }
    },
    {
      "id": "n-manifest",
      "name": "Build manifest",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2720,
        192
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Join poll results to CSV rows BY JOB ID (job name is null until completed),\n// plus items the API rejected at submit and rows skipped locally.\nconst meta = $('Collect submissions').first().json.aggregate;\nconst byJobId = new Map(meta.jobsMeta.map((m) => [m.jobId, m]));\nconst rows = [];\nfor (const item of $input.all()) {\n  for (const job of item.json.response.jobs || []) {\n    const m = byJobId.get(job.id) || {};\n    rows.push({\n      csv_row: m.row != null ? m.row : null,\n      name: job.name || m.name || job.id,\n      status: job.status,\n      video_url: job.outputUrl || '',\n      thumbnail_url: job.thumbnailUrl || '',\n      credits: job.creditsConsumed != null ? job.creditsConsumed : null,\n      error: job.errorMessage || '',\n    });\n  }\n}\nfor (const e of meta.rejected || []) {\n  rows.push({ csv_row: e.csv_row, name: e.name, status: 'rejected', video_url: '', thumbnail_url: '', credits: null, error: e.error });\n}\nfor (const e of meta.rowErrors || []) {\n  rows.push({ csv_row: e.row, name: '', status: 'skipped', video_url: '', thumbnail_url: '', credits: null, error: e.error });\n}\nrows.sort((a, b) => ((a.csv_row != null ? a.csv_row : 1e9) - (b.csv_row != null ? b.csv_row : 1e9)));\nreturn rows.map((r) => ({ json: r }));"
      }
    },
    {
      "id": "n-summary",
      "name": "Summary",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2940,
        64
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const manifest = $('Build manifest').all().map((i) => i.json);\nconst meta = $('Collect submissions').first().json.aggregate;\nconst cfg = $('Campaign Config').first().json;\nconst rendered = manifest.filter((r) => r.status === 'completed');\nconst problems = manifest.filter((r) => r.status !== 'completed');\nreturn [{ json: {\n  rendered: rendered.length,\n  failedOrSkipped: problems.length,\n  creditsReserved: meta.creditsReservedTotal,\n  bulkIds: meta.bulkIds,\n  savedTo: cfg.saveToDisk ? cfg.outDir : '(saveToDisk=false - videos stay on the CDN, URLs below)',\n  videos: rendered.map((r) => ({ name: r.name, url: r.video_url })),\n  problems,\n} }];"
      }
    },
    {
      "id": "n-if-save",
      "name": "Save to disk?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2940,
        288
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c-save",
              "leftValue": "={{ $('Campaign Config').first().json.saveToDisk }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ]
        },
        "looseTypeValidation": true,
        "options": {}
      }
    },
    {
      "id": "n-tocsv",
      "name": "Manifest to CSV",
      "type": "n8n-nodes-base.convertToFile",
      "typeVersion": 1.1,
      "position": [
        3160,
        192
      ],
      "parameters": {
        "operation": "csv",
        "options": {
          "fileName": "results.csv"
        }
      }
    },
    {
      "id": "n-save-csv",
      "name": "Save results.csv",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [
        3360,
        192
      ],
      "parameters": {
        "operation": "write",
        "fileName": "={{ $('Campaign Config').first().json.outDir }}/results.csv",
        "dataPropertyName": "data",
        "options": {}
      }
    },
    {
      "id": "n-filter-completed",
      "name": "Filter completed videos",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2.2,
      "position": [
        3160,
        384
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c-completed",
              "leftValue": "={{ $json.status }}",
              "rightValue": "completed",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ]
        },
        "looseTypeValidation": true,
        "options": {}
      }
    },
    {
      "id": "n-download",
      "name": "Download video",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3360,
        384
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "parameters": {
        "url": "={{ $json.video_url }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        }
      }
    },
    {
      "id": "n-save-mp4",
      "name": "Save MP4",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [
        3560,
        384
      ],
      "parameters": {
        "operation": "write",
        "fileName": "={{ $('Campaign Config').first().json.outDir }}/{{ $('Filter completed videos').item.json.name }}.mp4",
        "dataPropertyName": "data",
        "options": {}
      }
    },
    {
      "id": "note-package",
      "name": "Sticky Note - Official nodes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -260,
        480
      ],
      "parameters": {
        "width": 640,
        "height": 430,
        "color": 6,
        "content": "### Optional: official Zvid nodes (`@zvid/n8n-nodes-zvid`)\nThis workflow runs on n8n **core nodes only** - nothing extra to install. If you self-host n8n, you can install Zvid's official community package and swap the raw HTTP pieces for native nodes:\n\n**Install** (self-hosted n8n)\n1. **Settings \u2192 Community Nodes \u2192 Install** and enter `@zvid/n8n-nodes-zvid`  \n   (manual alternative: `cd ~/.n8n/nodes && npm install @zvid/n8n-nodes-zvid`, then restart n8n).\n2. Create its **Zvid API** credential with the same API key (`zvid_...` from app.zvid.io \u2192 API Keys).\n\n**What it replaces here**\n- *Zvid node \u2192 Render \u2192 Create Bulk* (+ \"Wait for Completion\") = *Build bulk request* + *Submit bulk render* + the whole poll loop\n- *Zvid node \u2192 Render \u2192 Validate* (free) = the *Validate row (free)* dry-run branch\n- *Zvid Trigger* = signed `render.completed` / `render.failed` events instead of polling\n\nn8n **Cloud** can only install verified community nodes - until `@zvid/n8n-nodes-zvid` is verified there, use this workflow as-is (that's exactly why it sticks to core nodes)."
      }
    }
  ],
  "connections": {
    "When clicking 'Execute workflow'": {
      "main": [
        [
          {
            "node": "Campaign Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Campaign Config": {
      "main": [
        [
          {
            "node": "Fetch template.json",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch template.json": {
      "main": [
        [
          {
            "node": "Fetch campaign.json",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch campaign.json": {
      "main": [
        [
          {
            "node": "Fetch customers.csv",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch customers.csv": {
      "main": [
        [
          {
            "node": "Parse CSV rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse CSV rows": {
      "main": [
        [
          {
            "node": "Build bulk items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build bulk items": {
      "main": [
        [
          {
            "node": "Dry run?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dry run?": {
      "main": [
        [
          {
            "node": "Validate row (free)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Split into batches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate row (free)": {
      "main": [
        [
          {
            "node": "Credit estimate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split into batches": {
      "main": [
        [
          {
            "node": "Collect submissions",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build bulk request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build bulk request": {
      "main": [
        [
          {
            "node": "Submit bulk render",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Submit bulk render": {
      "main": [
        [
          {
            "node": "Split into batches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Collect submissions": {
      "main": [
        [
          {
            "node": "Wait before poll",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait before poll": {
      "main": [
        [
          {
            "node": "Get batch status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get batch status": {
      "main": [
        [
          {
            "node": "Check all done",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check all done": {
      "main": [
        [
          {
            "node": "All jobs finished?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "All jobs finished?": {
      "main": [
        [
          {
            "node": "Build manifest",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Poll timeout guard",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Poll timeout guard": {
      "main": [
        [
          {
            "node": "Wait before poll",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build manifest": {
      "main": [
        [
          {
            "node": "Summary",
            "type": "main",
            "index": 0
          },
          {
            "node": "Save to disk?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save to disk?": {
      "main": [
        [
          {
            "node": "Manifest to CSV",
            "type": "main",
            "index": 0
          },
          {
            "node": "Filter completed videos",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    },
    "Manifest to CSV": {
      "main": [
        [
          {
            "node": "Save results.csv",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter completed videos": {
      "main": [
        [
          {
            "node": "Download video",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download video": {
      "main": [
        [
          {
            "node": "Save MP4",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}