AutomationFlowsWeb Scraping › Create Shopify Product Video Ads From Your Catalog with Zvid Bulk Rendering

Create Shopify Product Video Ads From Your Catalog with Zvid Bulk Rendering

ByZvid @mina on n8n.io

Create Shopify product video ads at scale with n8n and Zvid. This workflow selects eligible products, generates product-first, benefit-first, and offer-first variants, validates costs, bulk-renders MP4s, and returns review links or sends a tracking-ready manifest to your asset…

Event trigger★★★★★ complexity45 nodesHTTP Request@Zvid/N8N Nodes Zvid
Web Scraping Trigger: Event Nodes: 45 Complexity: ★★★★★ Added:

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

The workflow JSON

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

Download .json
{
  "id": "ImjQyzjoIjFc1VQv",
  "name": "n8n shopify ads variants",
  "tags": [],
  "nodes": [
    {
      "id": "2c5af3a4-44d5-4baf-860e-75323ee522ef",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        560,
        32
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 512,
        "content": "## Start and configure\n\nProvides manual and scheduled entry points, then centralizes workflow settings such as Shopify, render, dry-run, and delivery options."
      },
      "typeVersion": 1
    },
    {
      "id": "a0509bf4-34a7-40f5-a934-4f4f76f8d30f",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1008,
        80
      ],
      "parameters": {
        "color": 7,
        "height": 368,
        "content": "## Fetch Shopify catalog\n\nRetrieves product catalog data from Shopify using the configuration prepared at the start of the workflow."
      },
      "typeVersion": 1
    },
    {
      "id": "5536f2bf-a0f0-41b3-bed5-0b06c70692f0",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1280,
        96
      ],
      "parameters": {
        "color": 7,
        "width": 656,
        "height": 480,
        "content": "## Create ad candidates\n\nTurns catalog data into ad concepts, checks whether any ads were produced, and either builds the ad template or ends with a no-work summary."
      },
      "typeVersion": 1
    },
    {
      "id": "e917db28-72a9-479a-9fdc-dfc21f14d047",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1968,
        96
      ],
      "parameters": {
        "color": 7,
        "width": 656,
        "height": 304,
        "content": "## Validate render project\n\nValidates the generated Zvid project, checks the validation result, and decides whether to continue through dry-run or real rendering."
      },
      "typeVersion": 1
    },
    {
      "id": "023a2ad2-82fe-4e51-bdb7-dfd20f99f85a",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2656,
        48
      ],
      "parameters": {
        "color": 7,
        "width": 400,
        "height": 320,
        "content": "## Save dry run\n\nFor dry-run executions, saves the generated draft to the editor and produces a dry-run summary without submitting renders."
      },
      "typeVersion": 1
    },
    {
      "id": "fe8a84f3-f066-41bb-b921-67734f96f921",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2656,
        400
      ],
      "parameters": {
        "color": 7,
        "width": 400,
        "height": 320,
        "content": "## Submit render batch\n\nSubmits the generated ads as a bulk render job and verifies that the batch was accepted before polling begins."
      },
      "typeVersion": 1
    },
    {
      "id": "1e64869c-266c-4785-b886-8617b45e31a9",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3104,
        32
      ],
      "parameters": {
        "color": 7,
        "width": 672,
        "height": 496,
        "content": "## Poll render status\n\nWaits between checks, requests the latest batch status, branches when rendering is complete, and loops while the batch is still in progress or fails fast when needed."
      },
      "typeVersion": 1
    },
    {
      "id": "d0b9bc09-10f8-4acd-bc6a-f0d0e92a60ca",
      "name": "Sticky Note8",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3824,
        16
      ],
      "parameters": {
        "color": 7,
        "width": 432,
        "height": 528,
        "content": "## Collect and preview\n\nCollects completed rendered ad outputs and provides a nearby direct video request path for watching an individual rendered video."
      },
      "typeVersion": 1
    },
    {
      "id": "d8a9e416-d215-4934-b1d0-2efa4ba3fd04",
      "name": "Sticky Note9",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4288,
        16
      ],
      "parameters": {
        "color": 7,
        "width": 432,
        "height": 320,
        "content": "## Prepare delivery manifest\n\nBuilds the delivery manifest from rendered outputs and checks whether an external delivery destination has been configured."
      },
      "typeVersion": 1
    },
    {
      "id": "66e1d98d-0ad6-4f34-bda9-da2d5dec5299",
      "name": "Sticky Note10",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4752,
        0
      ],
      "parameters": {
        "color": 7,
        "width": 432,
        "height": 544,
        "content": "## Deliver or review\n\nIf delivery is configured, posts the manifest to an external asset store or scheduler and summarizes the result; otherwise returns a review-only summary for the rendered assets."
      },
      "typeVersion": 1
    },
    {
      "id": "6a6a1fff-9d00-449c-b987-4506c1839f6c",
      "name": "Manual Trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        608,
        192
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "bcdfcd87-b74c-4466-a3de-5dc665a6e275",
      "name": "Trigger Every Monday at 9am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        608,
        384
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 9 * * 1"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "fd1a43f9-8a20-455f-9e7f-436aae12b106",
      "name": "Configure Workflow Parameters",
      "type": "n8n-nodes-base.set",
      "position": [
        832,
        288
      ],
      "parameters": {
        "mode": "raw",
        "options": {},
        "jsonOutput": "{\n  \"editorUrl\": \"https://editor.zvid.io\",\n  \"shopDomain\": \"zvid-2\",\n  \"shopifyApiVersion\": \"2026-07\",\n  \"shopifyProductQuery\": \"status:active\",\n  \"scanLimit\": 10,\n  \"maxProducts\": 1,\n  \"variantsPerProduct\": 1,\n  \"brandName\": \"Zvid Studio\",\n  \"website\": \"zvid-2.myshopify.com\",\n  \"ctaText\": \"Shop now\",\n  \"accentColor\": \"#C96F4A\",\n  \"paleAccentColor\": \"#F1C7B3\",\n  \"backgroundColor\": \"#2B1D18\",\n  \"creamColor\": \"#FAFAF7\",\n  \"resolution\": \"instagram-post\",\n  \"musicUrl\": \"\",\n  \"musicVolume\": 0.16,\n  \"hookTemplates\": [\n    \"Meet your next everyday favorite.\",\n    \"A fresh look at {{product}}.\",\n    \"{{product}} belongs in the rotation.\"\n  ],\n  \"creativeFormats\": [\n    \"product-first\",\n    \"benefit-first\"\n  ],\n  \"dryRun\": false,\n  \"deliveryWebhookUrl\": \"\",\n  \"bulkName\": \"Shopify dynamic product ads\",\n  \"pollSeconds\": 10,\n  \"timeoutMinutes\": 20,\n  \"maxTotalAds\": 5\n}"
      },
      "typeVersion": 3.4
    },
    {
      "id": "6b489278-e066-4939-90c8-83b34f5fc5d7",
      "name": "Post Shopify Catalog Request",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1056,
        288
      ],
      "parameters": {
        "url": "={{ 'https://' + $('Configure Workflow Parameters').first().json.shopDomain + '.myshopify.com/admin/api/' + $('Configure Workflow Parameters').first().json.shopifyApiVersion + '/graphql.json' }}",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "fullResponse": true
            }
          }
        },
        "jsonBody": "={{ JSON.stringify({ query: `query AdCandidates($first: Int!, $query: String!) { shop { name currencyCode primaryDomain { url } } products(first: $first, query: $query, sortKey: UPDATED_AT, reverse: true) { nodes { id title handle description vendor productType tags status featuredImage { url(transform: { maxWidth: 1600, maxHeight: 1600, preferredContentType: WEBP }) altText } images(first: 3) { nodes { url(transform: { maxWidth: 1600, maxHeight: 1600, preferredContentType: WEBP }) altText } } variants(first: 1) { nodes { title price compareAtPrice } } } } }`, variables: { first: Math.min(250, Math.max(1, Number($('Configure Workflow Parameters').first().json.scanLimit || 50))), query: String($('Configure Workflow Parameters').first().json.shopifyProductQuery || 'status:active') } }) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "5c323091-74f3-48af-a7e3-2af56d3bb231",
      "name": "Generate Ad Concepts Code",
      "type": "n8n-nodes-base.code",
      "position": [
        1328,
        288
      ],
      "parameters": {
        "jsCode": "const cfg = $('Configure Workflow Parameters').first().json;\nconst res = $input.first().json || {};\nconst status = Number(res.statusCode || 200);\nconst body = res.body === undefined ? res : res.body;\nif (status !== 200) throw new Error('Shopify catalog query failed (HTTP ' + status + '): ' + JSON.stringify(body).slice(0, 700));\nif (Array.isArray(body.errors) && body.errors.length) throw new Error('Shopify GraphQL error: ' + body.errors.map((e) => e.message).join('; '));\nconst data = body.data || {};\nconst products = data.products && Array.isArray(data.products.nodes) ? data.products.nodes : [];\nconst clean = (value) => String(value == null ? '' : value).replace(/\\s+/g, ' ').trim();\nconst trim = (value, max) => { const text = clean(value); if (text.length <= max) return text; const cut = text.slice(0, max - 1); const at = cut.lastIndexOf(' '); return (at > max * 0.55 ? cut.slice(0, at) : cut).replace(/[\\s,;:.\u2014-]+$/, '') + '\u2026'; };\nconst currency = clean(data.shop && data.shop.currencyCode) || 'USD';\nconst money = (value) => { const amount = Number(value); if (!Number.isFinite(amount)) return clean(value); try { return new Intl.NumberFormat('en', { style: 'currency', currency, maximumFractionDigits: 2 }).format(amount); } catch { return '$' + amount.toFixed(2); } };\nconst storefront = clean(data.shop && data.shop.primaryDomain && data.shop.primaryDomain.url).replace(/\\/+$/, '') || ('https://' + cfg.shopDomain + '.myshopify.com');\nconst hookTemplates = Array.isArray(cfg.hookTemplates) && cfg.hookTemplates.length ? cfg.hookTemplates : ['Meet your next everyday favorite.'];\nconst creativeFormats = Array.isArray(cfg.creativeFormats) && cfg.creativeFormats.length ? cfg.creativeFormats : ['product-first'];\nconst maxProducts = Math.max(1, Math.floor(Number(cfg.maxProducts || 3)));\nconst variantsPerProduct = Math.max(1, Math.floor(Number(cfg.variantsPerProduct || 3)));\nconst maxTotalAds = Math.max(1, Math.floor(Number(cfg.maxTotalAds ?? cfg.maxAdVariants ?? 5)));\nconst selected = [];\nconst skipped = [];\nfor (const product of products) {\n  if (selected.length >= maxProducts) break;\n  const title = clean(product.title);\n  const variant = product.variants && Array.isArray(product.variants.nodes) ? product.variants.nodes[0] : null;\n  const images = [\n    clean(product.featuredImage && product.featuredImage.url),\n    ...((product.images && Array.isArray(product.images.nodes) ? product.images.nodes : []).map((entry) => clean(entry && entry.url))),\n  ].filter((url, index, all) => /^https?:\\/\\//i.test(url) && all.indexOf(url) === index);\n  if (!title || !variant || clean(variant.price) === '' || !images.length) {\n    skipped.push({ productId: product.id, title: title || '(untitled)', reason: !images.length ? 'missing public image' : !variant ? 'missing priced variant' : 'missing title' });\n    continue;\n  }\n  selected.push({ product, title, variant, images });\n}\nconst ads = [];\nfor (let variantIndex = 0; variantIndex < variantsPerProduct && ads.length < maxTotalAds; variantIndex += 1) {\n  for (const entry of selected) {\n    if (ads.length >= maxTotalAds) break;\n    const { product, title, variant, images } = entry;\n    const price = Number(variant.price);\n    const compareAt = Number(variant.compareAtPrice);\n    const hasDiscount = Number.isFinite(compareAt) && compareAt > price;\n    const savingPercent = hasDiscount ? Math.round((1 - price / compareAt) * 100) : 0;\n    const description = trim(product.description, 92) || 'Designed to earn a place in your everyday rotation.';\n    const offerOptions = [\n      hasDiscount ? ('Save ' + savingPercent + '% \u2014 now ' + money(price)) : (money(price) + ' \u2014 available now'),\n      'Discover ' + title + ' for ' + money(price),\n      hasDiscount ? ('Was ' + money(compareAt) + '. Yours for ' + money(price)) : ('A standout pick at ' + money(price)),\n    ];\n    const format = creativeFormats[variantIndex % creativeFormats.length];\n    let hook = clean(hookTemplates[variantIndex % hookTemplates.length]).replace(/\\{\\{product\\}\\}/g, title);\n    const offer = offerOptions[variantIndex % offerOptions.length];\n    if (format === 'benefit-first') hook = description;\n    if (format === 'offer-first') hook = hasDiscount ? ('Save ' + savingPercent + '% on ' + title) : (title + ' for ' + money(price));\n    const angleLabel = format === 'benefit-first' ? 'WHY YOU WILL LOVE IT' : format === 'offer-first' ? 'THE OFFER' : 'THE NEW EDIT';\n    const openerHeadline = format === 'benefit-first'\n      ? 'Why you will love it'\n      : format === 'offer-first'\n        ? (hasDiscount ? ('Save ' + savingPercent + '% today') : ('Available for ' + money(price)))\n        : title;\n    const openerSubheadline = format === 'benefit-first'\n      ? description\n      : format === 'offer-first'\n        ? title\n        : hook;\n    const storyKicker = format === 'benefit-first' ? 'WHY IT STANDS OUT' : format === 'offer-first' ? 'SHOP THE OFFER' : 'THE PRODUCT';\n    const storyCopy = format === 'offer-first' ? offer : description;\n    const benefits = format === 'benefit-first'\n      ? [\n          { title: 'Why it stands out', detail: description },\n          { title: 'Ready when you are', detail: offer },\n        ]\n      : format === 'offer-first'\n        ? [\n            { title: hasDiscount ? ('Save ' + savingPercent + '%') : money(price), detail: offer },\n            { title: 'Worth a closer look', detail: description },\n          ]\n        : [\n            { title: 'Made for the rotation', detail: description },\n            { title: 'Available now', detail: offer },\n          ];\n    const conceptId = String(product.id).split('/').pop() + '-' + String(variantIndex + 1).padStart(2, '0');\n    ads.push({\n      conceptId,\n      trackingKey: 'zvid-ad-' + conceptId,\n      shopifyProductId: product.id,\n      product: title,\n      handle: product.handle,\n      productUrl: storefront + '/products/' + product.handle,\n      creativeFormat: format,\n      audienceHook: trim(hook, 72),\n      openerHeadline: trim(openerHeadline, 62),\n      openerSubheadline: trim(openerSubheadline, 84),\n      storyKicker,\n      storyCopy: trim(storyCopy, 92),\n      offer: trim(offer, 72),\n      price: money(price),\n      compareAtPrice: hasDiscount ? money(compareAt) : '',\n      ctaText: cfg.ctaText || 'Shop now',\n      productImage: images[0],\n      productImage2: images[1] || images[0],\n      productImage3: images[2] || images[1] || images[0],\n      angleLabel,\n      description,\n      benefits,\n      variantLabel: format + ' \u00b7 ' + conceptId,\n    });\n  }\n}\nconst variantDistribution = selected.map((entry) => ({\n  productId: entry.product.id,\n  product: entry.title,\n  variants: ads.filter((ad) => ad.shopifyProductId === entry.product.id).length,\n  formats: ads.filter((ad) => ad.shopifyProductId === entry.product.id).map((ad) => ad.creativeFormat),\n}));\nreturn [{ json: {\n  ready: ads.length > 0,\n  ads,\n  skipped,\n  productsScanned: products.length,\n  productsSelected: selected.length,\n  requested: { maxProducts, variantsPerProduct, maxTotalAds },\n  variantDistribution,\n  allocation: 'round-robin-by-variant',\n} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "1ab142f1-bf13-446c-b6df-20a003b8803c",
      "name": "Check Ads Found",
      "type": "n8n-nodes-base.if",
      "position": [
        1552,
        288
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "shopify-products-found",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.ready }}",
              "rightValue": ""
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.2
    },
    {
      "id": "4430cff8-fe03-4333-b3a0-428646b9af77",
      "name": "Handle No Ads Code",
      "type": "n8n-nodes-base.code",
      "position": [
        1792,
        400
      ],
      "parameters": {
        "jsCode": "const data = $input.first().json; return [{ json: { status: 'nothing_to_render', productsScanned: data.productsScanned, skipped: data.skipped, message: 'No eligible Shopify products were found. Products need a title, priced variant, and public image.' } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "ad0f60a6-0c79-41e5-b75e-607b06b5d4f3",
      "name": "Create Ad Template Code",
      "type": "n8n-nodes-base.code",
      "position": [
        1792,
        224
      ],
      "parameters": {
        "jsCode": "const cfg = $('Configure Workflow Parameters').first().json;\nconst source = $('Generate Ad Concepts Code').first().json;\nconst ads = source.ads || [];\nif (!ads.length) throw new Error('No ad concepts to build.');\nconst first = ads[0];\nconst safeJobName = (value) => String(value || '').normalize('NFKD').replace(/[^a-zA-Z0-9_ -]+/g, '-').replace(/\\s+/g, ' ').replace(/-+/g, '-').trim().slice(0, 120) || 'Shopify product ad';\nconst background = cfg.backgroundColor || '#2B1D18';\nconst accent = cfg.accentColor || '#C96F4A';\nconst paleAccent = cfg.paleAccentColor || '#F1C7B3';\nconst cream = cfg.creamColor || '#FAFAF7';\nconst payload = {\n  name: 'shopify-atelier-editorial-ad',\n  resolution: cfg.resolution || 'instagram-post',\n  frameRate: 30,\n  backgroundColor: cream,\n  outputFormat: 'mp4',\n  variables: {\n    brandName: cfg.brandName,\n    productName: first.product,\n    angleLabel: first.angleLabel,\n    openerHeadline: first.openerHeadline,\n    openerSubheadline: first.openerSubheadline,\n    storyKicker: first.storyKicker,\n    storyCopy: first.storyCopy,\n    ctaText: first.ctaText,\n    price: first.price,\n    offer: first.offer,\n    productImage: first.productImage,\n    detailImage: first.productImage2,\n    textureImage: first.productImage3,\n    benefits: first.benefits,\n    website: cfg.website,\n    variantLabel: first.variantLabel,\n    musicUrl: cfg.musicUrl || '',\n  },\n  scenes: [\n    { id: 'opener', duration: 3.6, backgroundColor: cream, transition: 'fade', transitionId: 'about', transitionDuration: 0.5, visuals: [\n      { type: 'IMAGE', src: '{{productImage}}', width: 1080, height: 1080, position: 'center-center', resize: 'cover', zoom: true, track: 0, enterBegin: 0, exitEnd: 3.6 },\n      { type: 'SVG', width: 1080, height: 1080, track: 1, enterBegin: 0, exitEnd: 3.6, svg: \"<svg width='1080' height='1080' viewBox='0 0 1080 1080' xmlns='http://www.w3.org/2000/svg'><defs><linearGradient id='editorialOpen' x1='0' y1='0' x2='0' y2='1'><stop offset='0' stop-color='\" + background + \"' stop-opacity='.42'/><stop offset='.5' stop-color='\" + background + \"' stop-opacity='.2'/><stop offset='1' stop-color='\" + background + \"' stop-opacity='.9'/></linearGradient></defs><rect width='1080' height='1080' fill='\" + background + \"' fill-opacity='.18'/><rect width='1080' height='1080' fill='url(#editorialOpen)'/></svg>\" },\n      { type: 'TEXT', x: 70, y: 690, width: 940, anchor: 'top-left', track: 2, enterBegin: 0.3, enterEnd: 0.75, enterAnimation: 'smoothup', exitEnd: 3.6, style: { fontFamily: 'Manrope', color: '#FFFFFF' }, html: \"<div style='color:\" + paleAccent + \";font-size:20px;font-weight:800;letter-spacing:4px;'>{{angleLabel}}</div><div style='margin-top:12px;color:#FFFFFF;font-size:54px;line-height:1.08;font-weight:800;letter-spacing:-1px;overflow-wrap:anywhere;'>{{openerHeadline}}</div><div style='margin-top:14px;color:#F7EDE8;font-size:27px;line-height:1.3;font-weight:600;'>{{openerSubheadline}}</div><div style='margin-top:16px;color:\" + paleAccent + \";font-size:19px;font-weight:700;letter-spacing:2px;'>{{brandName}} \u00b7 {{productName}}</div>\" },\n    ] },\n    { id: 'about', duration: 4.2, backgroundColor: cream, transition: 'smoothleft', transitionId: 'benefit', transitionDuration: 0.5, visuals: [\n      { type: 'IMAGE', src: '{{detailImage}}', width: 1080, height: 1080, position: 'center-center', resize: 'cover', zoom: true, track: 0, enterEnd: 0.5, enterAnimation: 'smoothright', exitEnd: 4.2 },\n      { type: 'SVG', width: 1080, height: 1080, track: 1, enterBegin: 0, exitEnd: 4.2, svg: \"<svg width='1080' height='1080' viewBox='0 0 1080 1080' xmlns='http://www.w3.org/2000/svg'><defs><linearGradient id='productReadability' x1='0' y1='0' x2='1' y2='0'><stop offset='0' stop-color='#211612' stop-opacity='.82'/><stop offset='.48' stop-color='#211612' stop-opacity='.38'/><stop offset='.78' stop-color='#211612' stop-opacity='.05'/><stop offset='1' stop-color='#211612' stop-opacity='0'/></linearGradient><linearGradient id='productBottom' x1='0' y1='0' x2='0' y2='1'><stop offset='.45' stop-color='#211612' stop-opacity='0'/><stop offset='1' stop-color='#211612' stop-opacity='.58'/></linearGradient></defs><rect width='1080' height='1080' fill='url(#productReadability)'/><rect width='1080' height='1080' fill='url(#productBottom)'/></svg>\" },\n      { type: 'TEXT', x: 72, y: 720, width: 520, anchor: 'top-left', track: 2, enterBegin: 0.4, enterEnd: 0.85, enterAnimation: 'smoothup', exitEnd: 4.2, style: { fontFamily: 'Manrope', color: '#FFFFFF' }, html: \"<div style='color:\" + paleAccent + \";font-size:20px;font-weight:800;letter-spacing:4px;'>{{storyKicker}}</div><div style='margin-top:14px;color:#FFFFFF;font-size:34px;line-height:1.35;font-weight:700;'>{{storyCopy}}</div>\" },\n    ] },\n    { id: 'benefit', iterate: 'benefits', iterateAs: 'benefit', duration: 3, backgroundColor: '#F4E6DA', transition: 'smoothleft', transitionId: 'offer', transitionDuration: 0.5, visuals: [\n      { type: 'SVG', width: 1080, height: 1080, track: 0, enterBegin: 0, exitEnd: 3, svg: \"<svg width='1080' height='1080' viewBox='0 0 1080 1080' xmlns='http://www.w3.org/2000/svg'><rect width='1080' height='1080' fill='#F4E6DA'/><circle cx='540' cy='385' r='120' fill='\" + accent + \"'/><path d='M485 385 L525 427 L600 343' stroke='#FFFFFF' stroke-width='16' fill='none' stroke-linecap='round' stroke-linejoin='round'/><circle cx='540' cy='385' r='150' fill='none' stroke='\" + accent + \"' stroke-opacity='.35' stroke-width='2'/></svg>\" },\n      { type: 'TEXT', x: 540, y: 590, width: 880, anchor: 'top-center', track: 2, enterBegin: 0.25, enterEnd: 0.7, enterAnimation: 'smoothup', exitEnd: 3, style: { fontFamily: 'Manrope', color: background }, html: \"<div style='text-align:center;color:\" + background + \";font-size:52px;line-height:1.1;font-weight:800;'>{{benefit.title}}</div><div style='margin-top:16px;text-align:center;color:#6C554C;font-size:27px;line-height:1.4;font-weight:600;'>{{benefit.detail}}</div>\" },\n      { type: 'TEXT', x: 540, y: 80, anchor: 'center-center', track: 1, enterBegin: 0, exitEnd: 3, style: { fontFamily: 'Manrope', color: '#6C554C' }, html: \"<div style='color:#6C554C;font-size:19px;font-weight:700;letter-spacing:5px;'>{{brandName}} \u00b7 {{productName}}</div>\" },\n    ] },\n    { id: 'offer', duration: 3.8, backgroundColor: cream, transition: 'fade', transitionId: 'outro', transitionDuration: 0.5, visuals: [\n      { type: 'IMAGE', src: '{{textureImage}}', width: 1080, height: 1080, position: 'center-center', resize: 'cover', zoom: true, track: 0, enterBegin: 0, exitEnd: 3.8 },\n      { type: 'SVG', width: 1080, height: 1080, track: 1, enterBegin: 0, exitEnd: 3.8, svg: \"<svg width='1080' height='1080' viewBox='0 0 1080 1080' xmlns='http://www.w3.org/2000/svg'><defs><linearGradient id='offerShade' x1='0' y1='1' x2='0' y2='0'><stop offset='0' stop-color='\" + background + \"' stop-opacity='.88'/><stop offset='.6' stop-color='\" + background + \"' stop-opacity='.16'/><stop offset='1' stop-color='\" + background + \"' stop-opacity='.34'/></linearGradient></defs><rect width='1080' height='1080' fill='url(#offerShade)'/></svg>\" },\n      { type: 'TEXT', x: 70, y: 720, width: 940, anchor: 'top-left', track: 2, enterBegin: 0.3, enterEnd: 0.75, enterAnimation: 'smoothup', exitEnd: 3.8, style: { fontFamily: 'Manrope', color: '#FFFFFF' }, html: \"<div style='color:\" + paleAccent + \";font-size:21px;font-weight:800;letter-spacing:4px;'>THE OFFER</div><div style='margin-top:12px;color:#FFFFFF;font-size:52px;line-height:1.15;font-weight:800;'>{{offer}}</div>\" },\n    ] },\n    { id: 'outro', duration: 3.8, backgroundColor: background, visuals: [\n      { type: 'SVG', width: 1080, height: 1080, track: 0, enterBegin: 0, exitEnd: 3.8, svg: \"<svg width='1080' height='1080' viewBox='0 0 1080 1080' xmlns='http://www.w3.org/2000/svg'><defs><radialGradient id='editorialOut' cx='.5' cy='.4' r='.9'><stop offset='0' stop-color='#5A3326'/><stop offset='1' stop-color='\" + background + \"'/></radialGradient></defs><rect width='1080' height='1080' fill='url(#editorialOut)'/><rect x='300' y='210' width='480' height='130' rx='65' fill='none' stroke='\" + paleAccent + \"' stroke-opacity='.82' stroke-width='2'/></svg>\" },\n      { type: 'TEXT', x: 540, y: 275, width: 420, anchor: 'center-center', track: 1, enterBegin: 0.2, enterEnd: 0.6, enterAnimation: 'fade', exitEnd: 3.8, style: { fontFamily: 'Cormorant Garamond', color: paleAccent }, html: \"<div style='text-align:center;color:\" + paleAccent + \";font-size:52px;line-height:1;font-weight:700;white-space:nowrap;'>{{brandName}}</div>\" },\n      { type: 'TEXT', x: 540, y: 460, width: 920, anchor: 'top-center', track: 2, enterBegin: 0.45, enterEnd: 0.9, enterAnimation: 'smoothup', exitEnd: 3.8, style: { fontFamily: 'Manrope', color: '#FFFFFF' }, html: \"<div style='text-align:center;color:#FFFFFF;font-size:44px;line-height:1.12;font-weight:800;'>{{productName}}</div><div style='margin-top:18px;text-align:center;color:\" + paleAccent + \";font-size:66px;font-weight:800;'>{{price}}</div>\" },\n      { type: 'TEXT', x: 540, y: 710, anchor: 'top-center', track: 3, enterBegin: 0.9, enterEnd: 1.3, enterAnimation: 'circleopen', exitEnd: 3.8, style: { fontFamily: 'Manrope', color: '#FFFFFF' }, html: \"<div style='display:inline-block;border:2px solid \" + paleAccent + \";color:#FFFFFF;border-radius:999px;padding:22px 58px;font-size:32px;font-weight:800;'>{{ctaText}} \u2192</div>\" },\n      { type: 'TEXT', x: 540, y: 900, width: 900, anchor: 'top-center', track: 4, enterBegin: 1.1, enterEnd: 1.5, enterAnimation: 'fade', exitEnd: 3.8, style: { fontFamily: 'Manrope', color: '#D8C9C1' }, html: \"<div style='text-align:center;color:#D8C9C1;font-size:20px;'>{{website}} \u00b7 {{variantLabel}}</div>\" },\n    ] },\n  ],\n  audios: cfg.musicUrl ? [{ src: '{{musicUrl}}', volume: Number(cfg.musicVolume || 0.16), track: 0 }] : [],\n};\nconst items = ads.map((ad) => ({\n  name: safeJobName(ad.product + ' - ' + ad.creativeFormat + ' - ' + ad.conceptId),\n  variables: { brandName: cfg.brandName, productName: ad.product, angleLabel: ad.angleLabel, openerHeadline: ad.openerHeadline, openerSubheadline: ad.openerSubheadline, storyKicker: ad.storyKicker, storyCopy: ad.storyCopy, ctaText: ad.ctaText, price: ad.price, offer: ad.offer, productImage: ad.productImage, detailImage: ad.productImage2, textureImage: ad.productImage3, benefits: ad.benefits, website: cfg.website, variantLabel: ad.variantLabel, musicUrl: cfg.musicUrl || '' },\n}));\nreturn [{ json: { payload, items, meta: { approvedTemplate: 'Atelier Editorial \u00b7 Contrast Fix', designBasisTemplateId: 'tpl_EghDTSfEMbc4Jh7MGCHh', ads, skipped: source.skipped, productsScanned: source.productsScanned, productsSelected: source.productsSelected, approxVideoSeconds: 18.9 } } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "ac144e5a-982b-4d82-9e9e-0984519370b2",
      "name": "Validate Project via Zvid",
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "position": [
        2016,
        224
      ],
      "parameters": {
        "source": "json",
        "operation": "validate",
        "projectJson": "={{ $json.payload }}",
        "requestOptions": {},
        "additionalFields": {},
        "validationVariables": "={{ $json.items[0].variables }}"
      },
      "credentials": {
        "zvidApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "b02276d7-9885-4bef-a9bd-f2819a5965b6",
      "name": "Verify Validation Result",
      "type": "n8n-nodes-base.code",
      "position": [
        2240,
        224
      ],
      "parameters": {
        "jsCode": "const result = $input.first().json || {};\nif (!result.valid) {\n  const details = Array.isArray(result.errors)\n    ? result.errors.map((entry) => '  - ' + (entry.field || 'project') + ': ' + entry.message).join('\\n')\n    : JSON.stringify(result).slice(0, 500);\n  throw new Error('Zvid rejected the project: ' + (result.message || result.error || 'Validation failed') + '\\n' + details);\n}\nconst build = $('Create Ad Template Code').first().json;\nconst perVideo = Number(result.creditsRequired) || 0;\nreturn [{ json: {\n  ...build,\n  creditsPerVideo: perVideo,\n  totalCredits: perVideo * build.items.length,\n  warnings: result.warnings || [],\n  resolvedFirst: result.payload,\n  schemaVersion: result.schemaVersion,\n} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "2dc8f302-be58-4514-8fd3-63af85d219ac",
      "name": "Check Dry Run Status",
      "type": "n8n-nodes-base.if",
      "position": [
        2480,
        224
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "dry-run--cond",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $('Configure Workflow Parameters').first().json.dryRun }}",
              "rightValue": ""
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.2
    },
    {
      "id": "a213180f-dd79-4c11-b4a9-50e5f923b4fb",
      "name": "Save Draft to Editor via Zvid",
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        2704,
        208
      ],
      "parameters": {
        "resource": "project",
        "projectJson": "={{ $json.resolvedFirst }}",
        "projectName": "={{ ('Ad preview - ' + ($json.meta.ads[0] ? $json.meta.ads[0].product : 'first concept')).slice(0, 120) }}",
        "requestOptions": {}
      },
      "credentials": {
        "zvidApi": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1,
      "alwaysOutputData": true,
      "waitBetweenTries": 5000
    },
    {
      "id": "a008e561-e7ce-48fa-b79b-697693472b65",
      "name": "Summarize Dry Run Results",
      "type": "n8n-nodes-base.code",
      "position": [
        2912,
        208
      ],
      "parameters": {
        "jsCode": "const cfg = $('Configure Workflow Parameters').first().json;\nconst d = $('Verify Validation Result').first().json;\nlet editorLink = null;\nlet editorDraftError = null;\nconst cleanDraftError = (error) => {\n  const text = String((error && error.message) || error || 'Unknown editor draft error');\n  if (/Project limit reached/i.test(text)) return 'Project limit reached. Delete an old saved project in Zvid, then run the dry test again.';\n  return text.slice(0, 500);\n};\ntry {\n  const saved = $('Save Draft to Editor via Zvid').first().json;\n  const project = saved.project;\n  if (project && project.id) editorLink = String(cfg.editorUrl).replace(/\\/+$/, '') + '/?project=' + project.id;\n  if (saved.error) editorDraftError = cleanDraftError(saved.error);\n} catch (error) { editorDraftError = cleanDraftError(error); }\nconst conceptPlan = $('Generate Ad Concepts Code').first().json;\nreturn [{ json: { dryRun: true, productsSelected: conceptPlan.productsSelected, adVariantsPlanned: d.items.length, requested: conceptPlan.requested, allocation: conceptPlan.allocation, variantDistribution: conceptPlan.variantDistribution, concepts: d.meta.ads.map((ad) => ({ trackingKey: ad.trackingKey, product: ad.product, creativeFormat: ad.creativeFormat, hook: ad.audienceHook, openerHeadline: ad.openerHeadline, openerSubheadline: ad.openerSubheadline, storyKicker: ad.storyKicker, storyCopy: ad.storyCopy, offer: ad.offer })), creditsPerVideo: d.creditsPerVideo, totalCredits: d.totalCredits, approxSecondsPerVideo: d.meta.approxVideoSeconds, skipped: d.meta.skipped, warnings: d.warnings, editorLink, editorDraftError, nextStep: editorDraftError ? 'Resolve editorDraftError, then run again with dryRun still true.' : 'Review editorLink, then set dryRun to false. Add deliveryWebhookUrl only when your asset store or social scheduler is ready to accept the rendered manifest.' } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "de6954aa-ff57-4be3-bac7-595709c8b2dd",
      "name": "Initiate Bulk Render via Zvid",
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "maxTries": 3,
      "position": [
        2704,
        560
      ],
      "parameters": {
        "items": "={{ $json.items }}",
        "source": "json",
        "operation": "createBulk",
        "projectJson": "={{ $json.payload }}",
        "requestOptions": {},
        "additionalFields": {
          "batchName": "={{ String($('Configure Workflow Parameters').first().json.bulkName || 'Catalog videos').slice(0, 120) }}"
        }
      },
      "credentials": {
        "zvidApi": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1,
      "waitBetweenTries": 5000
    },
    {
      "id": "ff03da6b-1aba-46d0-82d2-e4d859bf8802",
      "name": "Confirm Batch Acceptance",
      "type": "n8n-nodes-base.code",
      "position": [
        2912,
        560
      ],
      "parameters": {
        "jsCode": "const res = $input.first().json;\n    const build = $('Verify Validation Result').first().json;\nif (!res || !res.bulkId) throw new Error('Bulk submit returned no bulkId: ' + JSON.stringify(res).slice(0, 400));\nconst jobIndexById = {};\nfor (const job of res.jobs || []) jobIndexById[job.jobId] = job.index;\nconst rejected = (res.errors || []).map((error) => { const ad = build.meta.ads[error.item] || {}; return { trackingKey: ad.trackingKey, product: ad.product, creativeFormat: ad.creativeFormat, error: error.error, details: error.details }; });\nreturn [{ json: { bulkId: res.bulkId, totalJobs: res.totalJobs, creditsReserved: res.creditsReserved, queueAhead: res.queueAhead, jobIndexById, rejected } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "26d85ac6-34ef-4cce-b38a-7fc63aaad3a7",
      "name": "Wait for Poll Interval",
      "type": "n8n-nodes-base.wait",
      "position": [
        3152,
        224
      ],
      "parameters": {
        "amount": "={{ $('Configure Workflow Parameters').first().json.pollSeconds }}"
      },
      "typeVersion": 1.1
    },
    {
      "id": "a3e2248f-0f4a-4ca7-8b7f-13090f184df8",
      "name": "Retrieve Batch Status",
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "maxTries": 3,
      "position": [
        3392,
        224
      ],
      "parameters": {
        "bulkId": "={{ $('Confirm Batch Acceptance').first().json.bulkId }}",
        "operation": "getBulk",
        "requestOptions": {}
      },
      "credentials": {
        "zvidApi": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1,
      "waitBetweenTries": 5000
    },
    {
      "id": "aa90903e-a288-4a68-ac0d-fd646d3f6538",
      "name": "Check Batch Completion",
      "type": "n8n-nodes-base.if",
      "position": [
        3632,
        192
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "batch-finished--cond",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.bulk.status !== 'processing' }}",
              "rightValue": ""
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.2
    },
    {
      "id": "9d69d7d8-9748-4baa-99e4-5ce24b71b27c",
      "name": "Assess Rendering Status",
      "type": "n8n-nodes-base.code",
      "position": [
        3632,
        368
      ],
      "parameters": {
        "jsCode": "// Fails fast when the whole batch failed, and stops the poll loop at\n// timeoutMinutes. A timeout does NOT cancel the renders \u2014 they finish on\n// Zvid's side (see https://app.zvid.io); raise timeoutMinutes for bigger\n// catalogs rather than re-running immediately.\nconst cfg = $('Configure Workflow Parameters').first().json;\nconst poll = $input.first().json;\nconst b = poll.bulk || {};\nconst c = b.counts || {};\n\nif (b.status === 'failed') {\n  const firstError = (poll.jobs || []).map((j) => j.errorMessage).filter(Boolean)[0];\n  throw new Error('Every job in bulk ' + b.id + ' failed. First error: ' + (firstError || 'no reason given'));\n}\n\nconst maxPolls = Math.ceil((Number(cfg.timeoutMinutes || 20) * 60) / Number(cfg.pollSeconds || 10));\nif ($runIndex + 1 >= maxPolls) {\n  throw new Error(\n    'Bulk ' + b.id + ' did not finish within ' + cfg.timeoutMinutes + ' minutes (' +\n    (c.completed || 0) + '/' + b.totalJobs + ' done, ' + (c.pending || 0) + ' still rendering). ' +\n    'The remaining renders continue on Zvid\\'s side \u2014 check https://app.zvid.io before re-running, or raise timeoutMinutes.'\n  );\n}\n\nreturn $input.all();"
      },
      "typeVersion": 2
    },
    {
      "id": "e2a3a8f3-ade7-4062-b6c3-5153cdcd1d0e",
      "name": "Gather Rendered Ads",
      "type": "n8n-nodes-base.code",
      "position": [
        3872,
        176
      ],
      "parameters": {
        "jsCode": "const poll = $input.first().json;\nconst sub = $('Confirm Batch Acceptance').first().json;\nconst build = $('Verify Validation Result').first().json;\nconst completed = [];\nconst failures = [...(sub.rejected || [])];\nfor (const job of poll.jobs || []) {\n  const index = sub.jobIndexById[job.id];\n  const ad = index == null ? null : build.meta.ads[index];\n  if (!ad) continue;\n  if (job.status === 'completed' && job.outputUrl) completed.push({ ...ad, videoUrl: job.outputUrl, jobId: job.id, creditsUsed: Number(job.creditsConsumed) || null });\n  else failures.push({ trackingKey: ad.trackingKey, product: ad.product, creativeFormat: ad.creativeFormat, status: job.status, error: job.errorMessage || null });\n}\nif (!completed.length) throw new Error('The batch finished without a video URL. ' + JSON.stringify(failures).slice(0, 700));\nreturn completed.map((ad) => ({ json: { ...ad, renderFailures: failures } }));"
      },
      "typeVersion": 2
    },
    {
      "id": "294a5b23-b8bf-43d4-979b-cbfc56880efb",
      "name": "Create Delivery Manifest",
      "type": "n8n-nodes-base.code",
      "position": [
        4336,
        176
      ],
      "parameters": {
        "jsCode": "const cfg = $('Configure Workflow Parameters').first().json;\nconst rendered = $input.all().map((item) => item.json);\nconst failures = rendered[0] && Array.isArray(rendered[0].renderFailures) ? rendered[0].renderFailures : [];\nreturn [{ json: { source: 'zvid-n8n-shopify-dynamic-ad-factory', generatedAt: new Date().toISOString(), campaign: cfg.bulkName, destination: cfg.deliveryWebhookUrl || null, assets: rendered.map((ad) => ({ trackingKey: ad.trackingKey, productId: ad.shopifyProductId, product: ad.product, productUrl: ad.productUrl, creativeFormat: ad.creativeFormat, hook: ad.audienceHook, offer: ad.offer, videoUrl: ad.videoUrl, jobId: ad.jobId, performance: { status: 'not_published', impressions: 0, clicks: 0, conversions: 0, spend: 0, revenue: 0 } })), failures, creditsUsed: rendered.reduce((sum, ad) => sum + (Number(ad.creditsUsed) || 0), 0) } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "f2bcad96-9431-41fc-bbd6-516548553a56",
      "name": "Verify Delivery Configuration",
      "type": "n8n-nodes-base.if",
      "position": [
        4576,
        176
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "shopify-auto-publish",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ Boolean($('Configure Workflow Parameters').first().json.deliveryWebhookUrl) }}",
              "rightValue": ""
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.2
    },
    {
      "id": "b51ce81b-56da-4887-bbe0-339d755c750c",
      "name": "Post to Asset Store Webhook",
      "type": "n8n-nodes-base.httpRequest",
      "maxTries": 3,
      "position": [
        4800,
        176
      ],
      "parameters": {
        "url": "={{ $('Configure Workflow Parameters').first().json.deliveryWebhookUrl }}",
        "method": "POST",
        "options": {
          "timeout": 60000,
          "response": {
            "response": {
              "neverError": true,
              "fullResponse": true
            }
          }
        },
        "jsonBody": "={{ JSON.stringify($json) }}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "waitBetweenTries": 5000
    },
    {
      "id": "25dc3209-b37f-4de0-a996-f22b759546e8",
      "name": "Summarize Delivery Result",
      "type": "n8n-nodes-base.code",
      "position": [
        5040,
        176
      ],
      "parameters": {
        "jsCode": "const response = $input.first().json || {};\nconst manifest = $('Create Delivery Manifest').first().json;\nconst statusCode = Number(response.statusCode || 0);\nif (statusCode < 200 || statusCode >= 300) throw new Error('Delivery webhook rejected the manifest (HTTP ' + statusCode + '): ' + JSON.stringify(response.body || response).slice(0, 500));\nreturn [{ json: { delivered: true, destination: manifest.destination, assetCount: manifest.assets.length, trackingKeys: manifest.assets.map((asset) => asset.trackingKey), creditsUsed: manifest.creditsUsed, webhookStatus: statusCode } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "6bb2fd99-cffd-4996-a866-0e5385946516",
      "name": "Prepare for Review",
      "type": "n8n-nodes-base.code",
      "position": [
        4800,
        384
      ],
      "parameters": {
        "jsCode": "const manifest = $input.first().json; return [{ json: { delivered: false, assetCount: manifest.assets.length, assets: manifest.assets, failures: manifest.failures, creditsUsed: manifest.creditsUsed, nextStep: 'Review the videos. Set Config.deliveryWebhookUrl to send this exact manifest to an asset store or social scheduler.' } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "3ce4a855-ee47-4d55-a476-1857d6a3be8c",
      "name": "Fetch Video from URL",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        4112,
        384
      ],
      "parameters": {
        "url": "={{ $json.videoUrl }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "alwaysOutputData": true,
      "waitBetweenTries": 5000
    },
    {
      "id": "d5dbe776-4eb6-4e0e-8404-6a865b74bbe4",
      "name": "Sticky Note11",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -80,
        144
      ],
      "parameters": {
        "width": 480,
        "height": 384,
        "content": "## Youtube video\n@[youtube](UrOqwvNICXE)"
      },
      "typeVersion": 1
    },
    {
      "id": "1c4d366b-619c-46f4-b3cf-664b480750da",
      "name": "Overview and safety",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -16,
        -880
      ],
      "parameters": {
        "width": 620,
        "height": 692,
        "content": "## Dynamic Product Ad Factory\n\nReads selected Shopify products, creates product-first, benefit-first, and offer-first concepts, renders them as one Zvid bulk job, and builds a scheduler-neutral delivery/performance manifest.\n\n### Required community node\nBefore configuring credentials or running the workflow, open **Settings \u2192 Community nodes \u2192 Install** and install the exact package **`@zvid/n8n-nodes-zvid`**. A workspace owner or admin may need to do this.\n\n**Safe first run:** `dryRun` defaults to `true`. Validation and the editor draft are free. Set it to `false` only after checking the quote and preview.\n\nThis workflow is intentionally independent and contains all setup instructions on its canvas. Shopify access is **read-only**: it reads catalog data but does not attach videos to products. Leave `deliveryWebhookUrl` blank unless you have an asset store or scheduler ready."
      },
      "typeVersion": 1
    },
    {
      "id": "4ec81e95-a77c-41e3-bcda-da0f1e6baf21",
      "name": "Create the Shopify app",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        624,
        -880
      ],
      "parameters": {
        "width": 700,
        "height": 700,
        "content": "## 1 \u00b7 Create a Shopify app (2026+)\n\nIn Shopify Admin open **Settings \u2192 Apps \u2192 Develop apps \u2192 Build apps in Dev Dashboard**. In Dev Dashboard:\n1. Select **Apps \u2192 Create app \u2192 Start from Dev Dashboard**.\n2. Name the app.\n3. Open **Versions** and create a version.\n4. For an API-only app, keep Shopify's default app-home URL.\n5. Add only the Admin API scope **`read_products`**.\n6. Select **Release**.\n7. Open **Home \u2192 Install app**, select the store, and approve installation.\n\nNew custom apps can no longer be created directly in Shopify Admin. Existing admin-created apps may continue using their installed Admin API token.\n\nOfficial guide: https://shopify.dev/docs/apps/build/dev-dashboard/create-apps-using-dev-dashboard"
      },
      "typeVersion": 1
    },
    {
      "id": "2d55a927-0ed4-45d0-b0ab-1c077b638d70",
      "name": "Get the Shopify token",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1344,
        -880
      ],
      "parameters": {
        "width": 760,
        "height": 700,
        "content": "## 2 \u00b7 Get `X-Shopify-Access-Token`\n\nThe Client ID and Client secret are **not** the access token. In Dev Dashboard open the app \u2192 **Settings**, copy both, then exchange them:\n\n```bash\ncurl -X POST \\\n  https://YOUR_STORE.myshopify.com/admin/oauth/access_token \\\n  -H 'Content-Type: application/x-www-form-urlencoded' \\\n  -d 'grant_type=client_credentials' \\\n  -d 'client_id=YOUR_CLIENT_ID' \\\n  -d 'client_secret=YOUR_CLIENT_SECRET'\n```\n\nCopy `access_token` from the JSON response. Never paste the Client secret into this workflow JSON. Dev Dashboard client-credentials tokens expire after **24 hours**; request a new token and update the n8n credential before a later run. For unattended scheduling, implement secure token refresh. The client-credentials grant is for an app and store owned by the same Shopify organization; use Shopify's OAuth installation flow for other merchants.\n\nOfficial guide: https://shopify.dev/docs/apps/build/dev-dashboard/get-api-access-tokens?lang=curl"
      },
      "typeVersion": 1
    },
    {
      "id": "c3337cb1-2a1f-461e-8eab-f419ff8387ff",
      "name": "Connect Shopify in n8n",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2128,
        -880
      ],
      "parameters": {
        "width": 660,
        "height": 700,
        "content": "## 3 \u00b7 Connect Shopify in n8n\n\nOpen **Credentials \u2192 New credential \u2192 Header Auth** and enter:\n- **Name:** `X-Shopify-Access-Token`\n- **Value:** the returned `access_token`\n\nOpen **Get Shopify catalog**, choose that credential, and save the node.\n\nIn **Config** set:\n- `shopDomain`: prefix only; use `example` for `example.myshopify.com`\n- `shopifyProductQuery`: default `status:active`; change it to target a vendor, tag, product type, or another Shopify Admin filter\n- `scanLimit`: products fetched from Shopify\n- `maxProducts`: eligible products selected\n- `variantsPerProduct`: maximum variants for each selected product\n- `maxTotalAds`: total video cap across all products\n\nThe planner allocates one variant to each selected product before starting the second round. For 2 products \u00d7 2 variants, set `maxTotalAds` to 4. Set it to 2 for one variant per product.\n\nA `401 Unauthorized` usually means the wrong value was used or the 24-hour token expired. A `403` usually means `read_products` was not granted or the new app version was not approved."
      },
      "typeVersion": 1
    },
    {
      "id": "d1451bef-cc34-4120-a415-1549a9ca620e",
      "name": "Install and connect Zvid",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2816,
        -880
      ],
      "parameters": {
        "width": 620,
        "height": 708,
        "content": "## 4 \u00b7 Install and connect Zvid\n\nThe Zvid nodes are supplied by a community package and are required by this workflow. Before running it:\n1. Open **Settings \u2192 Community nodes** in n8n.\n2. Select **Install**.\n3. Enter the exact package name **`@zvid/n8n-nodes-zvid`**.\n4. Confirm the installation. If you cannot install community nodes, ask the n8n workspace owner or admin.\n\nCreate an API key at **https://app.zvid.io/api-keys**. In n8n create a **Zvid API** credential and enter:\n- **API Key:** your `zvid_\u2026` key\n- **Base URL:** keep `https://api.zvid.io`\n\nAssign the same credential to all four native Zvid nodes:\n- **Validate project (free)** \u2014 Render \u2192 Validate\n- **Save draft to editor** \u2014 Project \u2192 Create Editor Project\n- **Submit bulk render** \u2014 Render \u2192 Create Bulk\n- **Get batch status** \u2014 Render \u2192 Get Bulk\n\nThen set your brand, website, colors, CTA, and optional licensed `musicUrl` in **Config**. Keep `dryRun: true`, run **Test manually**, review the exact credit quote and editor link, then set it to `false` to render."
      },
      "typeVersion": 1
    },
    {
      "id": "7008d48c-9c82-4f9f-a9ce-0c8c821306f8",
      "name": "Concept strategy",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3456,
        -880
      ],
      "parameters": {
        "width": 600,
        "height": 700,
        "content": "## Concept matrix\n\nFor each selected product the workflow rotates through:\n1. **product-first** \u2014 product-led headline and price context\n2. **benefit-first** \u2014 benefit headline and Shopify description\n3. **offer-first** \u2014 price or real compare-at savings\n\nEach format supplies distinct opener, story, benefit, and offer copy to Zvid. The planner uses round-robin allocation across products: every selected product receives its first variant before any product receives a second. The total is capped by `maxTotalAds` (default 5).\n\nExample: `maxProducts: 2`, `variantsPerProduct: 2`, `maxTotalAds: 4` creates 4 ads. A total cap of 2 creates 2 ads: one per product.\n\nNo unsupported discount is invented. Shopify's product connection does not support `BEST_SELLING`; use `UPDATED_AT` plus your query or feed it sales-ranked IDs from another workflow."
      },
      "typeVersion": 1
    },
    {
      "id": "91c0d289-6ac0-4a1a-8e44-6fca3a4a8464",
      "name": "Delivery and tracking",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4080,
        -880
      ],
      "parameters": {
        "width": 650,
        "height": 700,
        "content": "## Delivery contract\n\nLeave `deliveryWebhookUrl` blank to receive review links in n8n. Add an HTTPS endpoint only when it is ready to accept one JSON manifest containing every rendered asset. Add any destination credential to **Send to asset store or scheduler**.\n\nEach asset includes a stable `trackingKey`, Shopify product ID/URL, format, hook, offer, video URL, job ID, and zeroed performance fields. Store the tracking key beside the platform post/ad ID so a later reporting workflow can join metrics to the exact creative.\n\nThis template does **not** publish an ad or collect live metrics by itself. It hands off assets and a tracking contract to the destination. With no webhook, the tested review branch returns all URLs and plays the MP4s inside n8n."
      },
      "typeVersion": 1
    },
    {
      "id": "5a0a8732-1ad9-47ce-b18a-c28d8642c713",
      "name": "Product example",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4752,
        -880
      ],
      "parameters": {
        "width": 620,
        "height": 700,
        "content": "## Approved Atelier Editorial design\n\nA product such as **Harbor Stripe Breton Tee \u2014 Navy & Ivory** at **$36** produces up to three editorial variants:\n- Product-first: direct new-edit framing\n- Benefit-first: description-led benefits\n- Offer-first: price or real compare-at savings\n\nThe design uses up to three Shopify images across an image-led opener, product story, benefits, offer, and CTA. If only one image exists, it is safely reused. The first scene uses a full-frame dark wash plus a strong bottom gradient; the second uses a dark two-axis overlay behind white copy. The end-card brand sits inside a wide bordered capsule that accommodates realistic brand names.\n\nThe design resolves to about **18.9 seconds** with two benefit cards. Always use the free validation quote returned by your own dry run before rendering."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "49b938dd-dfec-4553-8a09-bf3b9d59e80b",
  "nodeGroups": [],
  "connections": {
    "Manual Trigger": {
      "main": [
        [
          {
            "node": "Configure Workflow Parameters",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Ads Found": {
      "main": [
        [
          {
            "node": "Create Ad Template Code",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Handle No Ads Code",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Gather Rendered Ads": {
      "main": [
        [
          {
            "node": "Create Delivery Manifest",
            "type": "main",
            "index": 0
          },
          {
            "node": "Fetch Video from URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Dry Run Status": {
      "main": [
        [
          {
            "node": "Save Draft to Editor via Zvid",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Initiate Bulk Render via Zvid",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Retrieve Batch Status": {
      "main": [
        [
          {
            "node": "Check Batch Completion",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Batch Completion": {
      "main": [
        [
          {
            "node": "Gather Rendered Ads",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Assess Rendering Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait for Poll Interval": {
      "main": [
        [
          {
            "node": "Retrieve Batch Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Assess Rendering Status": {
      "main": [
        [
          {
            "node": "Wait for Poll Interval",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Ad Template Code": {
      "main": [
        [
          {
            "node": "Validate Project via Zvid",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Confirm Batch Acceptance": {
      "main": [
        [
          {
            "node": "Wait for Poll Interval",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Delivery Manifest": {
      "main": [
        [
          {
            "node": "Verify Delivery Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify Validation Result": {
      "main": [
        [
          {
            "node": "Check Dry Run Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate Ad Concepts Code": {
      "main": [
        [
          {
            "node": "Check Ads Found",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Project via Zvid": {
      "main": [
        [
          {
            "node": "Verify Validation Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post to Asset Store Webhook": {
      "main": [
        [
          {
            "node": "Summarize Delivery Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Trigger Every Monday at 9am": {
      "main": [
        [
          {
            "node": "Configure Workflow Parameters",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post Shopify Catalog Request": {
      "main": [
        [
          {
            "node": "Generate Ad Concepts Code",
            "type": "main",
            "index": 0
          }
     

Credentials you'll need

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

Pro

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

About this workflow

Create Shopify product video ads at scale with n8n and Zvid. This workflow selects eligible products, generates product-first, benefit-first, and offer-first variants, validates costs, bulk-renders MP4s, and returns review links or sends a tracking-ready manifest to your asset…

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

More Web Scraping workflows → · Browse all categories →

Related workflows

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

Web Scraping

This workflow listens for an “Approved” label on a Trello card, reads the AI draft bookkeeping JSON from card comments, and posts the corresponding transaction to Xero. It then adds a Xero deep link b

Trello Trigger, HTTP Request
Web Scraping

02_LLM_Pipeline v1.0. Uses executeWorkflowTrigger, httpRequest, seaTable. Event-driven trigger; 65 nodes.

Execute Workflow Trigger, HTTP Request, Sea Table
Web Scraping

This workflow allows you to import any workflow from a file or another n8n instance and map the credentials easily. A multi-form setup guides you through the entire process At the beginning you have t

Execute Command, Read Write File, HTTP Request +3
Web Scraping

[n8n] Advanced URL Parsing and Shortening Workflow - Switchy.io Integration. Uses splitInBatches, stickyNote, httpRequest, html. Event-driven trigger; 56 nodes.

HTTP Request, GitHub, Stop And Error +1
Web Scraping

[](https://youtu.be/c7yCZhmMjtI)

HTTP Request, GitHub, Stop And Error +1