{
  "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
          }
        ]
      ]
    },
    "Configure Workflow Parameters": {
      "main": [
        [
          {
            "node": "Post Shopify Catalog Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Initiate Bulk Render via Zvid": {
      "main": [
        [
          {
            "node": "Confirm Batch Acceptance",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save Draft to Editor via Zvid": {
      "main": [
        [
          {
            "node": "Summarize Dry Run Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify Delivery Configuration": {
      "main": [
        [
          {
            "node": "Post to Asset Store Webhook",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare for Review",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}