{
  "name": "Create and publish Shopify product videos with Zvid",
  "nodes": [
    {
      "parameters": {},
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        50,
        240
      ],
      "id": "zvid-sc-0001",
      "name": "Test manually"
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 6 * * *"
            }
          ]
        }
      },
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        310,
        240
      ],
      "id": "zvid-sc-0002",
      "name": "Every day at 6am"
    },
    {
      "parameters": {
        "mode": "raw",
        "jsonOutput": "{\n  \"apiUrl\": \"https://api.zvid.io\",\n  \"editorUrl\": \"https://editor.zvid.io\",\n  \"shopDomain\": \"your-store\",\n  \"shopifyApiVersion\": \"2026-07\",\n  \"shopifyProductQuery\": \"status:active -tag:zvid-video\",\n  \"scanLimit\": 100,\n  \"maxProducts\": 5,\n  \"publishToShopify\": false,\n  \"processedTag\": \"zvid-video\",\n  \"brandName\": \"Zvid Studio\",\n  \"website\": \"your-store.myshopify.com\",\n  \"kicker\": \"THE EVERYDAY EDIT\",\n  \"featureHeading\": \"CUT · CLOTH · FINISH\",\n  \"dealKicker\": \"YOUR NEXT UNIFORM\",\n  \"tagline\": \"Quiet staples. Built with intention.\",\n  \"ctaFallback\": \"Explore the fit\",\n  \"accentColor\": \"#A85F45\",\n  \"accentSoft\": \"#E7D6CC\",\n  \"paperColor\": \"#F5F0E8\",\n  \"inkColor\": \"#1C1D1D\",\n  \"mutedColor\": \"#746F68\",\n  \"lineColor\": \"#C9BDAE\",\n  \"font\": \"Archivo\",\n  \"serifFont\": \"Fraunces\",\n  \"currencySymbol\": \"$\",\n  \"musicUrl\": \"\",\n  \"musicVolume\": 0.16,\n  \"heroSeconds\": 4.2,\n  \"featureSeconds\": 5.2,\n  \"ctaSeconds\": 4.2,\n  \"transitionSeconds\": 0.5,\n  \"frameRate\": 30,\n  \"resolution\": \"instagram-reel\",\n  \"bulkName\": \"Shopify catalog videos\",\n  \"dryRun\": false,\n  \"pollSeconds\": 10,\n  \"timeoutMinutes\": 20\n}",
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        570,
        240
      ],
      "id": "zvid-sc-0003",
      "name": "Config"
    },
    {
      "parameters": {
        "method": "HEAD",
        "url": "={{ $json.videoUrl }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          },
          "timeout": 60000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        50,
        1840
      ],
      "id": "zvid-sc-0038",
      "name": "Get Zvid video headers"
    },
    {
      "parameters": {
        "jsCode": "const responses = $input.all().map((i) => i.json);\nconst source = $('Collect video links').all().map((i) => i.json);\nconst videos = [];\nconst failures = [];\nfor (let i = 0; i < responses.length; i += 1) {\n  const res = responses[i] || {};\n  const item = source[i] || {};\n  const headers = res.headers || {};\n  const statusCode = Number(res.statusCode || 0);\n  const fileSize = Number(headers['content-length'] || headers['Content-Length'] || 0);\n  const mimeType = String(headers['content-type'] || headers['Content-Type'] || 'video/mp4').split(';')[0].trim();\n  if (statusCode < 200 || statusCode >= 300 || !fileSize) {\n    failures.push({ productId: item.productId, title: item.title, videoUrl: item.videoUrl, errors: ['Could not read the rendered video byte size (HTTP ' + statusCode + ')'] });\n    continue;\n  }\n  videos.push({ ...item, videoFileSize: String(fileSize), videoMimeType: mimeType || 'video/mp4', videoFilename: ('zvid-' + item.jobId + '.mp4').slice(0, 255) });\n}\nif (!videos.length) throw new Error('No rendered video could be prepared for Shopify staging: ' + JSON.stringify(failures).slice(0, 1200));\nreturn videos.map((video) => ({ json: { ...video, publishFailures: failures } }));"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        310,
        1840
      ],
      "id": "zvid-sc-0039",
      "name": "Check Zvid video headers"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ 'https://' + $('Config').first().json.shopDomain + '.myshopify.com/admin/api/' + $('Config').first().json.shopifyApiVersion + '/graphql.json' }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ query: `mutation CreateVideoStagingTarget($input: [StagedUploadInput!]!) { stagedUploadsCreate(input: $input) { stagedTargets { url resourceUrl parameters { name value } } userErrors { field message } } }`, variables: { input: [{ filename: $json.videoFilename, mimeType: $json.videoMimeType, httpMethod: 'POST', resource: 'VIDEO', fileSize: $json.videoFileSize }] } }) }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        570,
        1840
      ],
      "id": "zvid-sc-0040",
      "name": "Create Shopify upload targets"
    },
    {
      "parameters": {
        "jsCode": "const responses = $input.all().map((i) => i.json);\nconst source = $('Check Zvid video headers').all().map((i) => i.json);\nconst prepared = [];\nconst failures = source[0] && Array.isArray(source[0].publishFailures) ? [...source[0].publishFailures] : [];\nfor (let i = 0; i < responses.length; i += 1) {\n  const res = responses[i] || {};\n  const body = res.body === undefined ? res : res.body;\n  const item = source[i] || {};\n  const topErrors = Array.isArray(body.errors) ? body.errors.map((e) => e.message) : [];\n  const payload = body.data && body.data.stagedUploadsCreate;\n  const userErrors = payload && Array.isArray(payload.userErrors) ? payload.userErrors.map((e) => e.message) : [];\n  const target = payload && Array.isArray(payload.stagedTargets) ? payload.stagedTargets[0] : null;\n  const parameters = target && Array.isArray(target.parameters) ? Object.fromEntries(target.parameters.map((parameter) => [parameter.name, parameter.value])) : {};\n  const errors = [...topErrors, ...userErrors];\n  const required = ['GoogleAccessId', 'key', 'policy', 'signature'];\n  if (Number(res.statusCode || 200) !== 200 || errors.length || !target || !target.url || !target.resourceUrl || required.some((name) => !parameters[name])) {\n    failures.push({ productId: item.productId, title: item.title, videoUrl: item.videoUrl, errors: errors.length ? errors : ['Shopify returned an incomplete staged upload target'] });\n    continue;\n  }\n  prepared.push({ ...item, stagedUploadUrl: target.url, stagedResourceUrl: target.resourceUrl, stagedParameters: parameters });\n}\nif (!prepared.length) throw new Error('Shopify could not create any staged video upload target: ' + JSON.stringify(failures).slice(0, 1200));\nreturn prepared.map((item) => ({ json: { ...item, publishFailures: failures } }));"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        830,
        1840
      ],
      "id": "zvid-sc-0041",
      "name": "Check Shopify upload targets"
    },
    {
      "parameters": {
        "url": "={{ $json.videoUrl }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file",
              "outputPropertyName": "data"
            }
          },
          "timeout": 600000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1310,
        1840
      ],
      "id": "zvid-sc-0042",
      "name": "Download videos for Shopify",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Check Shopify upload targets').item.json.stagedUploadUrl }}",
        "sendBody": true,
        "contentType": "multipart-form-data",
        "bodyParameters": {
          "parameters": [
            {
              "parameterType": "formData",
              "name": "GoogleAccessId",
              "value": "={{ $('Check Shopify upload targets').item.json.stagedParameters.GoogleAccessId }}"
            },
            {
              "parameterType": "formData",
              "name": "key",
              "value": "={{ $('Check Shopify upload targets').item.json.stagedParameters.key }}"
            },
            {
              "parameterType": "formData",
              "name": "policy",
              "value": "={{ $('Check Shopify upload targets').item.json.stagedParameters.policy }}"
            },
            {
              "parameterType": "formData",
              "name": "signature",
              "value": "={{ $('Check Shopify upload targets').item.json.stagedParameters.signature }}"
            },
            {
              "parameterType": "formBinaryData",
              "name": "file",
              "inputDataFieldName": "data"
            }
          ]
        },
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          },
          "timeout": 600000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1570,
        1840
      ],
      "id": "zvid-sc-0043",
      "name": "Upload videos to Shopify staging",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "const responses = $input.all().map((i) => i.json);\nconst source = $('Check Shopify upload targets').all().map((i) => i.json);\nconst uploaded = [];\nconst failures = source[0] && Array.isArray(source[0].publishFailures) ? [...source[0].publishFailures] : [];\nfor (let i = 0; i < responses.length; i += 1) {\n  const res = responses[i] || {};\n  const item = source[i] || {};\n  const statusCode = Number(res.statusCode || 0);\n  if (statusCode < 200 || statusCode >= 300) {\n    failures.push({ productId: item.productId, title: item.title, videoUrl: item.videoUrl, errors: ['Shopify staged upload failed with HTTP ' + statusCode] });\n    continue;\n  }\n  uploaded.push(item);\n}\nif (!uploaded.length) throw new Error('No video reached Shopify staging: ' + JSON.stringify(failures).slice(0, 1200));\nreturn uploaded.map((item) => ({ json: { ...item, publishFailures: failures } }));"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1830,
        1840
      ],
      "id": "zvid-sc-0044",
      "name": "Check Shopify staged uploads"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ 'https://' + $('Config').first().json.shopDomain + '.myshopify.com/admin/api/' + $('Config').first().json.shopifyApiVersion + '/graphql.json' }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ query: \"query CatalogCandidates($first: Int!, $query: String!) {\\n  shop { name currencyCode primaryDomain { url } }\\n  products(first: $first, query: $query, sortKey: UPDATED_AT, reverse: true) {\\n    pageInfo { hasNextPage endCursor }\\n    nodes {\\n      id\\n      title\\n      handle\\n      description\\n      descriptionHtml\\n      vendor\\n      productType\\n      tags\\n      status\\n      productImages: media(first: 4, query: \\\"media_type:IMAGE\\\") {\\n        nodes { ... on MediaImage { image { url(transform: { maxWidth: 1600, maxHeight: 1600, preferredContentType: WEBP }) altText } } }\\n      }\\n      hostedVideos: media(first: 1, query: \\\"media_type:VIDEO\\\") {\\n        nodes { id mediaContentType status }\\n      }\\n      externalVideos: media(first: 1, query: \\\"media_type:EXTERNAL_VIDEO\\\") {\\n        nodes { id mediaContentType status }\\n      }\\n      variants(first: 20) {\\n        nodes { title price compareAtPrice selectedOptions { name value } }\\n      }\\n    }\\n  }\\n}\", variables: { first: Math.min(250, Math.max(1, Number($('Config').first().json.scanLimit || 100))), query: String($('Config').first().json.shopifyProductQuery || 'status:active -tag:zvid-video') } }) }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1050,
        240
      ],
      "id": "zvid-sc-0004",
      "name": "Get Shopify catalog"
    },
    {
      "parameters": {
        "jsCode": "// Convert Shopify GraphQL products into Zvid bulk-template rows. The catalog\n// copy and swatches come directly from the merchant's current product data.\nconst cfg = $('Config').first().json;\nconst res = $input.first().json || {};\nconst status = Number(res.statusCode);\nconst body = res.body === undefined ? res : res.body;\n\nif (!Number.isFinite(status) || status <= 0) {\n  throw new Error('Shopify did not return an HTTP response. Check shopDomain and the Shopify Header Auth credential.');\n}\nif (status !== 200) {\n  throw new Error('Shopify catalog query failed (HTTP ' + status + '): ' + JSON.stringify(body).slice(0, 700));\n}\nif (Array.isArray(body.errors) && body.errors.length) {\n  throw new Error('Shopify GraphQL error: ' + body.errors.map((e) => e.message).join('; '));\n}\n\nconst data = body.data || {};\nconst shop = data.shop || {};\nconst connection = data.products || {};\nconst products = Array.isArray(connection.nodes) ? connection.nodes : [];\nconst clean = (value) => String(value == null ? '' : value).replace(/\\s+/g, ' ').trim();\nconst decodeHtml = (value) => String(value == null ? '' : value)\n  .replace(/&nbsp;/gi, ' ')\n  .replace(/&amp;/gi, '&')\n  .replace(/&quot;/gi, '\"')\n  .replace(/&#(?:39|x27);/gi, \"'\")\n  .replace(/&lt;/gi, '<')\n  .replace(/&gt;/gi, '>')\n  .replace(/&#(\\d+);/g, (_, code) => String.fromCharCode(Number(code)));\nconst stripHtml = (value) => clean(decodeHtml(String(value || '').replace(/<[^>]*>/g, ' ')));\nconst trim = (value, max) => {\n  const text = clean(value);\n  if (text.length <= max) return text;\n  const cut = text.slice(0, max - 1);\n  const at = cut.lastIndexOf(' ');\n  return (at > max * 0.55 ? cut.slice(0, at) : cut).replace(/[\\s,;:.—-]+$/, '') + '…';\n};\nconst currency = clean(shop.currencyCode) || 'USD';\nconst displayMoney = (value) => {\n  const amount = Number(value);\n  if (!Number.isFinite(amount)) return clean(value);\n  try {\n    return new Intl.NumberFormat('en', {\n      style: 'currency',\n      currency,\n      minimumFractionDigits: Number.isInteger(amount) ? 0 : 2,\n      maximumFractionDigits: 2,\n    }).format(amount);\n  } catch {\n    return (cfg.currencySymbol || '$') + amount.toFixed(2);\n  }\n};\nconst storefront = clean(shop.primaryDomain && shop.primaryDomain.url).replace(/\\/+$/, '');\n\nconst paletteFor = (product) => {\n  const search = clean([product.title, ...(Array.isArray(product.tags) ? product.tags : [])].join(' ')).toLowerCase();\n  const palettes = [\n    { terms: ['terracotta', 'clay'], label: 'Terracotta', accent: '#A85F45', tint: '#E7D6CC', accentText: '#F5F0E8' },\n    { terms: ['navy', 'breton', 'harbor'], label: 'Navy & Ivory', accent: '#28374F', tint: '#DCE1E6', accentText: '#F5F0E8' },\n    { terms: ['sage', 'meadow'], label: 'Sage', accent: '#6F7D66', tint: '#DEE4D9', accentText: '#F5F0E8' },\n    { terms: ['washed black', 'dawnline'], label: 'Washed Black', accent: '#444541', tint: '#D9D7D1', accentText: '#F5F0E8' },\n    { terms: ['ivory', 'alpine'], label: 'Warm Ivory', accent: '#B9AA8D', tint: '#EAE2D4', accentText: '#1C1D1D' },\n  ];\n  return palettes.find((entry) => entry.terms.some((term) => search.includes(term))) || {\n    label: 'Everyday Essential',\n    accent: cfg.accentColor || '#A85F45',\n    tint: cfg.accentSoft || '#E7D6CC',\n    accentText: cfg.paperColor || '#F5F0E8',\n  };\n};\n\nconst rows = [];\nconst skipped = [];\nfor (const product of products) {\n  const title = clean(product.title);\n  const media = [\n    ...((product.hostedVideos && Array.isArray(product.hostedVideos.nodes)) ? product.hostedVideos.nodes : []),\n    ...((product.externalVideos && Array.isArray(product.externalVideos.nodes)) ? product.externalVideos.nodes : []),\n  ];\n  const hasVideo = media.some((item) =>\n    ['VIDEO', 'EXTERNAL_VIDEO'].includes(String(item.mediaContentType || '').toUpperCase()) &&\n    String(item.status || '').toUpperCase() !== 'FAILED'\n  );\n  const imageConnection = product.productImages || product.images || {};\n  const images = Array.isArray(imageConnection.nodes)\n    ? imageConnection.nodes\n      .map((item) => clean(item && ((item.image && item.image.url) || item.url)))\n      .filter((url) => /^https?:\\/\\//i.test(url))\n    : [];\n  const variants = product.variants && Array.isArray(product.variants.nodes) ? product.variants.nodes : [];\n  const variant = variants[0] || null;\n  let reason = null;\n  if (hasVideo) reason = 'already has Shopify video media';\n  else if (!title) reason = 'missing title';\n  else if (!images.length) reason = 'missing a public product image';\n  else if (!variant || clean(variant.price) === '') reason = 'missing a priced variant';\n  if (reason) {\n    skipped.push({ productId: product.id, product: title || '(untitled)', reason });\n    continue;\n  }\n\n  const descriptionHtml = String(product.descriptionHtml || '');\n  const listFeatures = [...descriptionHtml.matchAll(/<li\\b[^>]*>([\\s\\S]*?)<\\/li>/gi)]\n    .map((match) => trim(stripHtml(match[1]), 72))\n    .filter(Boolean);\n  const description = trim(product.description, 72);\n  const vendor = trim(product.vendor, 50);\n  const productType = trim(product.productType, 50);\n  const fallbacks = [\n    description || 'Designed as an easy everyday layer.',\n    productType ? `${productType} by ${vendor || cfg.brandName}` : `Made by ${vendor || cfg.brandName}`,\n    'Made for repeat wear.',\n  ];\n  const features = [0, 1, 2].map((index) => listFeatures[index] || fallbacks[index]);\n  const sizes = [];\n  for (const optionVariant of variants) {\n    const options = Array.isArray(optionVariant.selectedOptions) ? optionVariant.selectedOptions : [];\n    const sizeOption = options.find((option) => clean(option.name).toLowerCase() === 'size');\n    const size = clean(sizeOption ? sizeOption.value : optionVariant.title);\n    if (size && size.toLowerCase() !== 'default title' && !sizes.includes(size)) sizes.push(size);\n  }\n  const palette = paletteFor(product);\n\n  rows.push({\n    Product: title,\n    Price: displayMoney(variant.price),\n    CompareAtPrice: variant.compareAtPrice ? displayMoney(variant.compareAtPrice) : '',\n    Feature1: features[0],\n    Feature2: features[1],\n    Feature3: features[2],\n    ImageUrl1: images[0],\n    ImageUrl2: images[1] || images[0],\n    ImageUrl3: images[2] || images[1] || images[0],\n    ImageUrl4: images[3] || images[1] || images[0],\n    CtaText: cfg.ctaFallback || 'Explore the fit',\n    SizeLine: sizes.length ? `Sizes ${sizes.join(' · ')}` : 'Available now',\n    MetaLine: [vendor || cfg.brandName, productType].filter(Boolean).join(' · '),\n    ColorLabel: palette.label,\n    ProductAccent: palette.accent,\n    ProductTint: palette.tint,\n    AccentText: palette.accentText,\n    VideoUrl: '',\n    rowNumber: null,\n    shopifyProductId: product.id,\n    handle: clean(product.handle),\n    productUrl: storefront && product.handle ? storefront + '/products/' + product.handle : null,\n  });\n}\n\nconst max = Math.max(1, Number(cfg.maxProducts || 5));\nconst selected = rows.slice(0, max);\nreturn [{ json: {\n  found: selected.length > 0,\n  rows: selected,\n  skipped,\n  totalRowsRead: products.length,\n  leftForNextRun: Math.max(0, rows.length - selected.length),\n  moreCatalogPages: Boolean(connection.pageInfo && connection.pageInfo.hasNextPage),\n  shop: { name: shop.name, currencyCode: currency, storefront },\n  reason: selected.length ? null : 'No eligible product without video was found in this catalog scan.',\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1310,
        240
      ],
      "id": "zvid-sc-0005",
      "name": "Rows needing videos"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "shopify-products-found",
              "leftValue": "={{ $json.found }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1570,
        240
      ],
      "id": "zvid-sc-0006",
      "name": "Products found?"
    },
    {
      "parameters": {
        "jsCode": "const scan = $('Rows needing videos').first().json;\nreturn [{ json: {\n  rendered: false,\n  reason: scan.reason,\n  productsScanned: scan.totalRowsRead,\n  skipped: scan.skipped,\n  hint: scan.moreCatalogPages\n    ? 'This scan reached scanLimit. Raise it up to 250 or narrow shopifyProductQuery if older eligible products exist.'\n    : 'The visible catalog is already covered, or products need a title, price, and image.',\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1830,
        240
      ],
      "id": "zvid-sc-0007",
      "name": "Nothing to render"
    },
    {
      "parameters": {
        "jsCode": "// Build one shared Zvid fashion template plus one variable set per Shopify\n// product. Square catalog imagery is preserved in editorial cards instead of\n// being stretched into a full-screen vertical crop.\nconst cfg = $('Config').first().json;\nconst picked = $('Rows needing videos').first().json;\n\nconst esc = (value) => String(value ?? '').replace(\n  /[&<>\"]/g,\n  (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;' })[char]\n);\nconst asMoneyNumber = (value) => {\n  const match = String(value ?? '').replace(/[^0-9.]/g, '');\n  const amount = parseFloat(match);\n  return Number.isFinite(amount) ? amount : null;\n};\nconst formatMoney = (amount, symbol) => {\n  const rounded = Math.round(amount * 100) / 100;\n  return `${symbol}${Number.isInteger(rounded) ? rounded : rounded.toFixed(2)}`;\n};\nconst displayPrice = (value, symbol) => {\n  const text = String(value ?? '').trim();\n  if (!text) return '';\n  return /^[0-9]/.test(text) ? `${symbol}${text}` : text;\n};\nconst nameSizeFor = (name) => {\n  const length = String(name).length;\n  if (length <= 24) return 78;\n  if (length <= 42) return 68;\n  if (length <= 58) return 60;\n  return 52;\n};\nconst featureSizeFor = (features) => {\n  const length = Math.max(...features.map((feature) => String(feature).length));\n  if (length <= 38) return 34;\n  if (length <= 58) return 31;\n  return 28;\n};\nconst slugify = (value) => String(value)\n  .toLowerCase()\n  .replace(/[^a-z0-9]+/g, '-')\n  .replace(/^-+|-+$/g, '')\n  .slice(0, 60) || 'product';\n\nfunction buildPayload(config) {\n  const paper = config.paperColor || '#F5F0E8';\n  const ink = config.inkColor || '#1C1D1D';\n  const muted = config.mutedColor || '#746F68';\n  const line = config.lineColor || '#C9BDAE';\n  const font = config.font || 'Archivo';\n  const serif = config.serifFont || 'Fraunces';\n  const heroSeconds = Number(config.heroSeconds ?? 4.2);\n  const featureSeconds = Number(config.featureSeconds ?? 5.2);\n  const ctaSeconds = Number(config.ctaSeconds ?? 4.2);\n  const transitionSeconds = Number(config.transitionSeconds ?? 0.5);\n  const brand = esc(config.brandName || 'Zvid Studio');\n  const website = esc(config.website || 'your-store.myshopify.com');\n  const kicker = esc(config.kicker || 'THE EVERYDAY EDIT');\n  const featureHeading = esc(config.featureHeading || 'CUT · CLOTH · FINISH');\n  const dealKicker = esc(config.dealKicker || 'YOUR NEXT UNIFORM');\n  const tagline = esc(config.tagline || 'Quiet staples. Built with intention.');\n  const rgba = (hex, alpha) => {\n    const raw = String(hex).replace('#', '');\n    const number = parseInt(raw.length === 3 ? raw.split('').map((char) => char + char).join('') : raw, 16);\n    return `rgba(${(number >> 16) & 255},${(number >> 8) & 255},${number & 255},${alpha})`;\n  };\n\n  const heroBackground =\n    `<svg width='1080' height='1920' viewBox='0 0 1080 1920' xmlns='http://www.w3.org/2000/svg'>` +\n    `<rect width='1080' height='1920' fill='${paper}'/>` +\n    `<rect x='54' y='184' width='972' height='972' rx='42' fill='{{tint}}'/>` +\n    `<path d='M54 1198 H1026' stroke='${line}' stroke-width='2'/>` +\n    `<path d='M54 1760 H1026' stroke='${line}' stroke-width='2'/>` +\n    `</svg>`;\n\n  const hero = {\n    id: 'hero',\n    duration: heroSeconds,\n    backgroundColor: paper,\n    transition: 'smoothup',\n    transitionId: 'details',\n    transitionDuration: transitionSeconds,\n    visuals: [\n      { type: 'SVG', width: 1080, height: 1920, track: 0, enterBegin: 0, exitEnd: heroSeconds, svg: heroBackground },\n      {\n        type: 'IMAGE', src: '{{img1}}', x: 90, y: 220, width: 900, height: 900,\n        anchor: 'top-left', radius: { tl: 30, tr: 30, bl: 30, br: 30 }, zoom: true, track: 1,\n        enterBegin: 0.05, enterEnd: 0.65, enterAnimation: 'fade', exitEnd: heroSeconds,\n      },\n      {\n        type: 'TEXT', x: 54, y: 64, width: 430, anchor: 'top-left', track: 2,\n        enterBegin: 0.1, enterEnd: 0.45, enterAnimation: 'fade', exitEnd: heroSeconds,\n        style: { fontFamily: font, color: ink },\n        html: `<div style='font-size:25px; font-weight:800; letter-spacing:5px;'>${brand}</div>`,\n      },\n      {\n        type: 'TEXT', x: 720, y: 64, width: 306, anchor: 'top-left', track: 5,\n        enterBegin: 0.1, enterEnd: 0.45, enterAnimation: 'fade', exitEnd: heroSeconds,\n        style: { fontFamily: font, color: muted },\n        html: `<div style='font-size:21px; font-weight:700; letter-spacing:4px;'>${kicker}</div>`,\n      },\n      {\n        type: 'TEXT', x: 72, y: 1240, width: 936, anchor: 'top-left', track: 3,\n        enterBegin: 0.45, enterEnd: 0.95, enterAnimation: 'smoothup', exitEnd: heroSeconds,\n        style: { fontFamily: serif, color: ink },\n        html:\n          `<div style='color:{{accent}}; font-family:${font}; font-size:23px; font-weight:800; letter-spacing:5px;'>{{colorLabel}}</div>` +\n          `<div style='margin-top:20px; color:${ink}; font-size:{{nameSize}}px; line-height:1.03; font-weight:650; letter-spacing:-2px;'>{{product}}</div>`,\n      },\n      {\n        type: 'TEXT', x: 72, y: 1535, width: 936, anchor: 'top-left', track: 4,\n        enterBegin: 0.7, enterEnd: 1.1, enterAnimation: 'smoothup', exitEnd: heroSeconds,\n        style: { fontFamily: font, color: ink },\n        html:\n          `<div style='display:flex; justify-content:space-between; align-items:flex-end;'>` +\n          `<span style='font-size:66px; font-weight:900; letter-spacing:-2px;'>{{price}}</span>` +\n          `<span style='color:${muted}; font-size:25px; font-weight:650; letter-spacing:2px;'>{{sizes}}</span>` +\n          `</div>` +\n          `<div style='margin-top:48px; color:${muted}; font-family:${serif}; font-size:34px; font-style:italic;'>${tagline}</div>`,\n      },\n    ],\n  };\n\n  const detailsBackground =\n    `<svg width='1080' height='1920' viewBox='0 0 1080 1920' xmlns='http://www.w3.org/2000/svg'>` +\n    `<rect width='1080' height='1920' fill='${paper}'/>` +\n    `<rect x='40' y='188' width='650' height='650' rx='34' fill='{{tint}}'/>` +\n    `<rect x='650' y='444' width='390' height='390' rx='30' fill='{{accent}}' fill-opacity='0.18'/>` +\n    `<path d='M70 896 H1010' stroke='{{accent}}' stroke-width='5'/>` +\n    `<path d='M70 1768 H1010' stroke='${line}' stroke-width='2'/>` +\n    `</svg>`;\n  const featureRow = (index, variable) =>\n    `<div style='display:flex; gap:30px; border-top:1.5px solid ${rgba(ink, 0.14)}; padding-top:24px; margin-top:24px;'>` +\n    `<div style='min-width:52px; color:{{accent}}; font-size:22px; font-weight:850; letter-spacing:2px;'>${index}</div>` +\n    `<div style='color:${ink}; font-size:{{featureSize}}px; line-height:1.28; font-weight:600;'>{{${variable}}}</div>` +\n    `</div>`;\n\n  const details = {\n    id: 'details',\n    duration: featureSeconds,\n    backgroundColor: paper,\n    transition: 'fade',\n    transitionId: 'shop',\n    transitionDuration: transitionSeconds,\n    visuals: [\n      { type: 'SVG', width: 1080, height: 1920, track: 0, enterBegin: 0, exitEnd: featureSeconds, svg: detailsBackground },\n      {\n        type: 'IMAGE', src: '{{img2}}', x: 70, y: 218, width: 590, height: 590,\n        anchor: 'top-left', radius: { tl: 28, tr: 28, bl: 28, br: 28 }, zoom: true, track: 1,\n        enterBegin: 0.05, enterEnd: 0.6, enterAnimation: 'smoothup', exitEnd: featureSeconds,\n      },\n      {\n        type: 'IMAGE', src: '{{img3}}', x: 684, y: 478, width: 320, height: 320,\n        anchor: 'top-left', radius: { tl: 24, tr: 24, bl: 24, br: 24 }, zoom: true, track: 2,\n        enterBegin: 0.35, enterEnd: 0.85, enterAnimation: 'smoothup', exitEnd: featureSeconds,\n      },\n      {\n        type: 'TEXT', x: 70, y: 68, width: 430, anchor: 'top-left', track: 3,\n        enterBegin: 0.1, enterEnd: 0.45, enterAnimation: 'fade', exitEnd: featureSeconds,\n        style: { fontFamily: font, color: ink },\n        html: `<div style='font-size:24px; font-weight:800; letter-spacing:5px;'>${brand}</div>`,\n      },\n      {\n        type: 'TEXT', x: 785, y: 68, width: 225, anchor: 'top-left', track: 6,\n        enterBegin: 0.1, enterEnd: 0.45, enterAnimation: 'fade', exitEnd: featureSeconds,\n        style: { fontFamily: font, color: muted },\n        html: `<div style='font-size:22px; font-weight:700; letter-spacing:4px;'>02 / DETAILS</div>`,\n      },\n      {\n        type: 'TEXT', x: 88, y: 940, width: 904, anchor: 'top-left', track: 4,\n        enterBegin: 0.55, enterEnd: 1, enterAnimation: 'smoothup', exitEnd: featureSeconds,\n        style: { fontFamily: font, color: ink },\n        html:\n          `<div style='color:{{accent}}; font-size:25px; font-weight:850; letter-spacing:6px;'>${featureHeading}</div>` +\n          featureRow('01', 'f1') + featureRow('02', 'f2') + featureRow('03', 'f3'),\n      },\n      {\n        type: 'TEXT', x: 70, y: 1794, width: 940, anchor: 'top-left', track: 5,\n        enterBegin: 0.45, enterEnd: 0.8, enterAnimation: 'fade', exitEnd: featureSeconds,\n        style: { fontFamily: font, color: muted },\n        html:\n          `<div style='display:flex; justify-content:space-between; font-size:22px; font-weight:700; letter-spacing:3px;'>` +\n          `<span>{{meta}}</span><span>{{sizes}}</span></div>`,\n      },\n    ],\n  };\n\n  const shopBackground =\n    `<svg width='1080' height='1920' viewBox='0 0 1080 1920' xmlns='http://www.w3.org/2000/svg'>` +\n    `<rect width='1080' height='1920' fill='${ink}'/>` +\n    `<rect x='54' y='122' width='972' height='972' rx='42' fill='{{accent}}'/>` +\n    `<path d='M54 1138 H1026' stroke='${rgba(paper, 0.25)}' stroke-width='2'/>` +\n    `</svg>`;\n  const shop = {\n    id: 'shop',\n    duration: ctaSeconds,\n    backgroundColor: ink,\n    visuals: [\n      { type: 'SVG', width: 1080, height: 1920, track: 0, enterBegin: 0, exitEnd: ctaSeconds, svg: shopBackground },\n      {\n        type: 'IMAGE', src: '{{img4}}', x: 90, y: 158, width: 900, height: 900,\n        anchor: 'top-left', radius: { tl: 30, tr: 30, bl: 30, br: 30 }, zoom: true, track: 1,\n        enterBegin: 0.05, enterEnd: 0.65, enterAnimation: 'fade', exitEnd: ctaSeconds,\n      },\n      {\n        type: 'TEXT', x: 54, y: 52, width: 430, anchor: 'top-left', track: 2,\n        enterBegin: 0.1, enterEnd: 0.45, enterAnimation: 'fade', exitEnd: ctaSeconds,\n        style: { fontFamily: font, color: paper },\n        html: `<div style='font-size:23px; font-weight:800; letter-spacing:5px;'>${brand}</div>`,\n      },\n      {\n        type: 'TEXT', x: 845, y: 52, width: 181, anchor: 'top-left', track: 6,\n        enterBegin: 0.1, enterEnd: 0.45, enterAnimation: 'fade', exitEnd: ctaSeconds,\n        style: { fontFamily: font, color: paper },\n        html: `<div style='color:${rgba(paper, 0.62)}; font-size:23px; font-weight:800; letter-spacing:5px;'>03 / SHOP</div>`,\n      },\n      {\n        type: 'TEXT', x: 72, y: 1185, width: 936, anchor: 'top-left', track: 3,\n        enterBegin: 0.4, enterEnd: 0.85, enterAnimation: 'smoothup', exitEnd: ctaSeconds,\n        style: { fontFamily: serif, color: paper },\n        html:\n          `<div style='color:{{accent}}; font-family:${font}; font-size:23px; font-weight:850; letter-spacing:5px;'>${dealKicker}</div>` +\n          `<div style='margin-top:18px; color:${paper}; font-size:{{nameSize}}px; line-height:1.04; font-weight:650; letter-spacing:-2px;'>{{product}}</div>`,\n      },\n      {\n        type: 'TEXT', condition: '{{showSingle}}', x: 72, y: 1460, width: 936,\n        anchor: 'top-left', track: 4, enterBegin: 0.65, enterEnd: 1.05,\n        enterAnimation: 'smoothup', exitEnd: ctaSeconds, style: { fontFamily: font, color: paper },\n        html: `<div style='font-size:72px; font-weight:900; letter-spacing:-2px;'>{{price}}</div>`,\n      },\n      {\n        type: 'TEXT', condition: '{{showCompare}}', x: 72, y: 1460, width: 936,\n        anchor: 'top-left', track: 4, enterBegin: 0.65, enterEnd: 1.05,\n        enterAnimation: 'smoothup', exitEnd: ctaSeconds, style: { fontFamily: font, color: paper },\n        html:\n          `<div><span style='font-size:72px; font-weight:900;'>{{price}}</span>` +\n          `<span style='margin-left:26px; color:${rgba(paper, 0.5)}; font-size:42px; text-decoration:line-through;'>{{compareAt}}</span></div>` +\n          `<div style='margin-top:12px; color:${rgba(paper, 0.7)}; font-size:24px; font-weight:700;'>{{savingsLine}}</div>`,\n      },\n      {\n        type: 'TEXT', x: 72, y: 1640, width: 936, anchor: 'top-left', track: 5,\n        enterBegin: 0.85, enterEnd: 1.2, enterAnimation: 'smoothup', exitEnd: ctaSeconds,\n        style: { fontFamily: font, color: paper },\n        html:\n          `<div style='display:inline-block; background:{{accent}}; color:{{accentText}}; border-radius:999px; padding:24px 52px; font-size:34px; font-weight:850;'>{{cta}}</div>` +\n          `<div style='margin-top:28px; color:${rgba(paper, 0.58)}; font-size:23px; font-weight:650; letter-spacing:3px;'>${website}</div>`,\n      },\n    ],\n  };\n\n  return {\n    name: 'zvid-studio-product-video',\n    resolution: config.resolution || 'instagram-reel',\n    frameRate: Number(config.frameRate ?? 30),\n    backgroundColor: paper,\n    outputFormat: 'mp4',\n    variables: {\n      product: 'Product name', nameSize: 68, price: '$0', compareAt: '',\n      showCompare: false, showSingle: true, savingsLine: '',\n      img1: 'https://cdn.zvid.io/library/docs/chroma-demo.poster.jpg',\n      img2: 'https://cdn.zvid.io/library/docs/chroma-demo.poster.jpg',\n      img3: 'https://cdn.zvid.io/library/docs/chroma-source.poster.jpg',\n      img4: 'https://cdn.zvid.io/library/docs/chroma-source.poster.jpg',\n      cta: 'Explore the fit', featureSize: 31,\n      f1: 'Premium cotton jersey', f2: 'A considered unisex fit', f3: 'Built for repeat wear',\n      sizes: 'Sizes S · M · L · XL · XXL', meta: 'Zvid Studio · T-Shirts',\n      colorLabel: 'Everyday Essential', accent: '#A85F45', tint: '#E7D6CC', accentText: paper,\n    },\n    scenes: [hero, details, shop],\n    audios: config.musicUrl ? [{ src: config.musicUrl, volume: Number(config.musicVolume ?? 0.16) }] : [],\n  };\n}\n\nfunction buildItemVariables(config, row) {\n  const symbol = config.currencySymbol || '$';\n  const product = String(row.Product || '').trim();\n  const features = [row.Feature1, row.Feature2, row.Feature3].map((value) => String(value || '').trim());\n  const price = displayPrice(row.Price, symbol);\n  const compareAt = displayPrice(row.CompareAtPrice, symbol);\n  const priceNumber = asMoneyNumber(row.Price);\n  const compareNumber = asMoneyNumber(row.CompareAtPrice);\n  const showCompare = Boolean(compareAt && priceNumber != null && compareNumber != null && compareNumber > priceNumber);\n  const image1 = String(row.ImageUrl1 || '').trim();\n  const image2 = String(row.ImageUrl2 || '').trim() || image1;\n  const image3 = String(row.ImageUrl3 || '').trim() || image2;\n  const image4 = String(row.ImageUrl4 || '').trim() || image2;\n  return {\n    product: esc(product),\n    nameSize: nameSizeFor(product),\n    price: esc(price),\n    compareAt: esc(compareAt),\n    showCompare,\n    showSingle: !showCompare,\n    savingsLine: showCompare ? esc(`You save ${formatMoney(compareNumber - priceNumber, symbol)}`) : '',\n    img1: image1, img2: image2, img3: image3, img4: image4,\n    cta: esc(String(row.CtaText || '').trim() || config.ctaFallback || 'Explore the fit'),\n    featureSize: featureSizeFor(features),\n    f1: esc(features[0]), f2: esc(features[1]), f3: esc(features[2]),\n    sizes: esc(row.SizeLine || 'Available now'),\n    meta: esc(row.MetaLine || config.brandName || 'Zvid Studio'),\n    colorLabel: esc(row.ColorLabel || 'Everyday Essential'),\n    accent: String(row.ProductAccent || config.accentColor || '#A85F45'),\n    tint: String(row.ProductTint || config.accentSoft || '#E7D6CC'),\n    accentText: String(row.AccentText || config.paperColor || '#F5F0E8'),\n  };\n}\n\nfunction buildProject(config, rows) {\n  if (!Array.isArray(rows) || rows.length === 0) throw new Error('buildProject needs at least one catalog row.');\n  const payload = buildPayload(config);\n  const items = rows.map((row, index) => ({\n    variables: buildItemVariables(config, row),\n    name: `${slugify(row.Product)}-r${row.rowNumber ?? index + 1}`,\n  }));\n  const meta = {\n    products: rows.map((row, index) => ({\n      index, rowNumber: row.rowNumber ?? null, product: String(row.Product || '').trim(),\n      shopifyProductId: row.shopifyProductId || null, handle: row.handle || null,\n      productUrl: row.productUrl || null,\n    })),\n    sceneSeconds: payload.scenes.map((scene) => scene.duration),\n    approxVideoSeconds: Math.round((\n      payload.scenes.reduce((sum, scene) => sum + scene.duration, 0) -\n      payload.scenes.reduce((sum, scene) => sum + (scene.transitionDuration || 0), 0)\n    ) * 100) / 100,\n  };\n  return { payload, items, meta };\n}\n\nconst { payload, items, meta } = buildProject(cfg, picked.rows);\nreturn [{ json: { payload, items, meta: {\n  ...meta, skipped: picked.skipped, leftForNextRun: picked.leftForNextRun,\n  totalRowsRead: picked.totalRowsRead,\n} } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2310,
        240
      ],
      "id": "zvid-sc-0008",
      "name": "Build project JSON"
    },
    {
      "parameters": {
        "resource": "render",
        "operation": "validate",
        "source": "json",
        "projectJson": "={{ JSON.stringify(({ payload: $json.payload, variables: $json.items[0].variables }).payload) }}",
        "validationVariables": "={{ JSON.stringify(({ payload: $json.payload, variables: $json.items[0].variables }).variables) }}"
      },
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "typeVersion": 1,
      "position": [
        2570,
        240
      ],
      "id": "zvid-sc-0009",
      "name": "Validate project (free)"
    },
    {
      "parameters": {
        "jsCode": "// The free validate endpoint runs the exact resolve+validate pipeline a bulk\n// item runs (first row's variables against the shared payload), so a failure\n// here is a payload/data bug — surface the field list, not a generic HTTP\n// error. It also returns the per-video credit price, which multiplied by the\n// row count is the whole catalog's exact cost.\nconst res = $input.first().json;\nconst status = res.statusCode ?? (res.valid === true ? 200 : 400);\nconst body = res.body || { ...res, details: res.errors ?? res.details };\n\nif (status !== 200) {\n  const details = Array.isArray(body.details)\n    ? body.details.map((d) => '  - ' + d.field + ': ' + d.message).join('\\n')\n    : JSON.stringify(body).slice(0, 500);\n  throw new Error('Zvid rejected the project (HTTP ' + status + '): ' + (body.message || body.error) + '\\n' + details);\n}\n\nconst build = $('Build project JSON').first().json;\nconst perVideo = Number(body.creditsRequired) || 0;\nreturn [{\n  json: {\n    ...build,\n    creditsPerVideo: perVideo,\n    totalCredits: perVideo * build.items.length,\n    warnings: body.warnings || [],\n    resolvedFirst: body.payload,\n    schemaVersion: body.schemaVersion,\n  },\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2830,
        240
      ],
      "id": "zvid-sc-0010",
      "name": "Check validation"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "dry-run--cond",
              "leftValue": "={{ $('Config').first().json.dryRun }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        50,
        920
      ],
      "id": "zvid-sc-0011",
      "name": "Dry run?"
    },
    {
      "parameters": {
        "resource": "project",
        "operation": "create",
        "projectName": "={{ ({ name: ('Catalog preview - ' + ($json.meta.products[0] ? $json.meta.products[0].product : 'first product')).slice(0, 120), payload: $json.resolvedFirst }).name }}",
        "projectJson": "={{ JSON.stringify(({ name: ('Catalog preview - ' + ($json.meta.products[0] ? $json.meta.products[0].product : 'first product')).slice(0, 120), payload: $json.resolvedFirst }).payload) }}"
      },
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "typeVersion": 1,
      "position": [
        310,
        920
      ],
      "id": "zvid-sc-0012",
      "name": "Save draft to editor",
      "onError": "continueRegularOutput",
      "alwaysOutputData": true,
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "// Free review gate: validates the whole design, quotes the full batch and\n// saves the first resolved product as a Zvid editor draft.\nconst cfg = $('Config').first().json;\nconst d = $('Check validation').first().json;\nlet editorLink = null;\ntry {\n  const project = $('Save draft to editor').first().json.project;\n  if (project && project.id) editorLink = String(cfg.editorUrl).replace(/\\/+$/, '') + '/?project=' + project.id;\n} catch {}\nreturn [{ json: {\n  dryRun: true,\n  videosPlanned: d.items.length,\n  productsToRender: d.meta.products.map((p) => ({ productId: p.shopifyProductId, title: p.product, productUrl: p.productUrl })),\n  creditsPerVideo: d.creditsPerVideo,\n  totalCredits: d.totalCredits,\n  approxSecondsPerVideo: d.meta.approxVideoSeconds,\n  skipped: d.meta.skipped,\n  warnings: d.warnings,\n  editorLink,\n  nextStep: 'Review editorLink, then set dryRun to false. Keep publishToShopify false for rendered review links, or set it true to attach accepted videos to the matching Shopify products.',\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        570,
        920
      ],
      "id": "zvid-sc-0013",
      "name": "Dry run summary"
    },
    {
      "parameters": {
        "resource": "render",
        "operation": "createBulk",
        "renderType": "video",
        "source": "json",
        "projectJson": "={{ $json.payload }}",
        "items": "={{ $json.items }}",
        "additionalFields": {
          "batchName": "={{ String($('Config').first().json.bulkName || 'Catalog videos').slice(0, 120) }}"
        }
      },
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "typeVersion": 1,
      "position": [
        1050,
        920
      ],
      "id": "zvid-sc-0014",
      "name": "Submit bulk render",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "// HTTP 202 means the batch is queued. Individual items can still be rejected\n// at submit time (validation is best-effort per item) — map those back to\n// Shopify products so the summary can name them. Job names stay NULL until a job\n// completes, so results are mapped by jobId → submit index → row, never by\n// name.\nconst res = $input.first().json;\nconst build = $('Check validation').first().json;\n\nif (!res || !res.bulkId) {\n  throw new Error('Bulk submit returned no bulkId: ' + JSON.stringify(res).slice(0, 400));\n}\n\nconst jobIndexById = {};\nfor (const j of res.jobs || []) jobIndexById[j.jobId] = j.index;\n\nconst rejected = (res.errors || []).map((e) => {\n  const p = build.meta.products[e.item] || {};\n  return { productId: p.shopifyProductId, product: p.product, error: e.error, details: e.details };\n});\n\nreturn [{\n  json: {\n    bulkId: res.bulkId,\n    totalJobs: res.totalJobs,\n    creditsReserved: res.creditsReserved,\n    queueAhead: res.queueAhead,\n    jobIndexById,\n    rejected,\n  },\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1310,
        920
      ],
      "id": "zvid-sc-0015",
      "name": "Check batch accepted"
    },
    {
      "parameters": {
        "amount": "={{ $('Config').first().json.pollSeconds }}",
        "unit": "seconds"
      },
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        1570,
        920
      ],
      "id": "zvid-sc-0016",
      "name": "Wait"
    },
    {
      "parameters": {
        "resource": "render",
        "operation": "getBulk",
        "bulkId": "={{ $('Check batch accepted').first().json.bulkId }}"
      },
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "typeVersion": 1,
      "position": [
        1830,
        920
      ],
      "id": "zvid-sc-0017",
      "name": "Get batch status",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "batch-finished--cond",
              "leftValue": "={{ $json.bulk.status !== 'processing' }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1830,
        1160
      ],
      "id": "zvid-sc-0018",
      "name": "Batch finished?"
    },
    {
      "parameters": {
        "jsCode": "// Fails fast when the whole batch failed, and stops the poll loop at\n// timeoutMinutes. A timeout does NOT cancel the renders — 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 = $('Config').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 — check https://app.zvid.io before re-running, or raise timeoutMinutes.'\n  );\n}\n\nreturn $input.all();"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1570,
        1160
      ],
      "id": "zvid-sc-0019",
      "name": "Still rendering?"
    },
    {
      "parameters": {
        "jsCode": "// Maps Zvid jobs back to Shopify product GIDs by the submit-time job index.\nconst poll = $input.first().json;\nconst sub = $('Check batch accepted').first().json;\nconst build = $('Check validation').first().json;\nconst bulk = poll.bulk || {};\nconst completed = [];\nconst failures = [];\nfor (const job of poll.jobs || []) {\n  const index = sub.jobIndexById[job.id];\n  const product = index == null ? null : build.meta.products[index];\n  if (!product) continue;\n  if (job.status === 'completed' && job.outputUrl) {\n    completed.push({\n      productId: product.shopifyProductId,\n      title: product.product,\n      handle: product.handle,\n      productUrl: product.productUrl,\n      videoUrl: job.outputUrl,\n      jobId: job.id,\n      creditsUsed: Number(job.creditsConsumed) || null,\n    });\n  } else {\n    failures.push({ productId: product.shopifyProductId, title: product.product, status: job.status, error: job.errorMessage || null });\n  }\n}\nif (!completed.length) throw new Error('Bulk ' + bulk.id + ' finished without a video URL. ' + JSON.stringify(failures).slice(0, 700));\nreturn completed.map((item) => ({ json: { ...item, renderFailures: failures } }));"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2310,
        920
      ],
      "id": "zvid-sc-0020",
      "name": "Collect video links"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "shopify-auto-publish",
              "leftValue": "={{ $('Config').first().json.publishToShopify }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2570,
        920
      ],
      "id": "zvid-sc-0021",
      "name": "Publish automatically?"
    },
    {
      "parameters": {
        "jsCode": "const videos = $input.all().map((i) => i.json);\nreturn [{ json: {\n  rendered: videos.length,\n  publishedToShopify: false,\n  creditsSpent: videos.reduce((sum, v) => sum + (Number(v.creditsUsed) || 0), 0),\n  videos: videos.map((v) => ({ productId: v.productId, title: v.title, productUrl: v.productUrl, videoUrl: v.videoUrl })),\n  renderFailures: videos[0] ? videos[0].renderFailures : [],\n  nextStep: 'Review the video URLs. To publish automatically on the next catalog batch, set publishToShopify to true in Config.',\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2830,
        920
      ],
      "id": "zvid-sc-0022",
      "name": "Rendered for review"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ 'https://' + $('Config').first().json.shopDomain + '.myshopify.com/admin/api/' + $('Config').first().json.shopifyApiVersion + '/graphql.json' }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ query: `mutation AttachImportedVideo($product: ProductUpdateInput!, $media: [CreateMediaInput!]) { productUpdate(product: $product, media: $media) { product { id title } userErrors { field message } } }`, variables: { product: { id: $json.productId }, media: [{ originalSource: $json.stagedResourceUrl, alt: ($json.title + ' product video').slice(0, 512), mediaContentType: 'VIDEO' }] } }) }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2310,
        1840
      ],
      "id": "zvid-sc-0036",
      "name": "Attach video to Shopify product"
    },
    {
      "parameters": {
        "jsCode": "// The staged upload has completed before this mutation runs. Confirm the\n// product association; failed products are never tagged and stay eligible for retry.\nconst responses = $input.all().map((i) => i.json);\nconst source = $('Check Shopify staged uploads').all().map((i) => i.json);\nconst successes = [];\nconst failures = source[0] && Array.isArray(source[0].publishFailures) ? [...source[0].publishFailures] : [];\nfor (let i = 0; i < responses.length; i += 1) {\n  const res = responses[i] || {};\n  const body = res.body === undefined ? res : res.body;\n  const item = source[i] || {};\n  const topErrors = Array.isArray(body.errors) ? body.errors.map((e) => e.message) : [];\n  const payload = body.data && body.data.productUpdate;\n  const userErrors = payload && Array.isArray(payload.userErrors) ? payload.userErrors.map((e) => e.message) : [];\n  const errors = [...topErrors, ...userErrors];\n  if (Number(res.statusCode || 200) !== 200 || errors.length || !payload || !payload.product) {\n    failures.push({ productId: item.productId, title: item.title, videoUrl: item.videoUrl, stagedResourceUrl: item.stagedResourceUrl, errors: errors.length ? errors : ['Shopify returned no updated product'] });\n    continue;\n  }\n  successes.push({ ...item, shopifyMediaId: null, shopifyMediaStatus: 'PROCESSING' });\n}\nif (!successes.length) throw new Error('Shopify rejected every staged product-video association: ' + JSON.stringify(failures).slice(0, 1200));\nreturn successes.map((item) => ({ json: { ...item, publishFailures: failures } }));"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2570,
        1840
      ],
      "id": "zvid-sc-0037",
      "name": "Check Shopify publish requests"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ 'https://' + $('Config').first().json.shopDomain + '.myshopify.com/admin/api/' + $('Config').first().json.shopifyApiVersion + '/graphql.json' }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ query: `mutation MarkZvidVideo($id: ID!, $tags: [String!]!) { tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } } }`, variables: { id: $json.productId, tags: [String($('Config').first().json.processedTag || 'zvid-video')] } }) }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2830,
        1840
      ],
      "id": "zvid-sc-0025",
      "name": "Mark product processed"
    },
    {
      "parameters": {
        "jsCode": "const published = $('Check Shopify publish requests').all().map((i) => i.json);\nconst tagResponses = $input.all().map((i) => i.json);\nconst tagFailures = [];\nfor (let i = 0; i < tagResponses.length; i += 1) {\n  const res = tagResponses[i] || {};\n  const body = res.body === undefined ? res : res.body;\n  const errors = [\n    ...(Array.isArray(body.errors) ? body.errors.map((e) => e.message) : []),\n    ...((body.data && body.data.tagsAdd && Array.isArray(body.data.tagsAdd.userErrors)) ? body.data.tagsAdd.userErrors.map((e) => e.message) : []),\n  ];\n  if (Number(res.statusCode || 200) !== 200 || errors.length) {\n    tagFailures.push({ productId: published[i] && published[i].productId, errors: errors.length ? errors : ['Tag request failed'] });\n  }\n}\nreturn [{ json: {\n  rendered: $('Collect video links').all().length,\n  acceptedByShopify: published.length,\n  creditsSpent: $('Collect video links').all().reduce((sum, i) => sum + (Number(i.json.creditsUsed) || 0), 0),\n  products: published.map((p) => ({ productId: p.productId, title: p.title, productUrl: p.productUrl, videoUrl: p.videoUrl, shopifyMediaId: p.shopifyMediaId, shopifyMediaStatus: p.shopifyMediaStatus })),\n  renderFailures: published[0] ? published[0].renderFailures : [],\n  publishFailures: published[0] ? published[0].publishFailures : [],\n  tagFailures,\n  note: 'Each Zvid MP4 was uploaded to Shopify staging and accepted for asynchronous processing on its matching product.',\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        50,
        2520
      ],
      "id": "zvid-sc-0026",
      "name": "Published summary"
    },
    {
      "parameters": {
        "url": "={{ $json.videoUrl }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file",
              "outputPropertyName": "data"
            }
          }
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        570,
        2520
      ],
      "id": "zvid-sc-0027",
      "name": "▶ Watch video",
      "onError": "continueRegularOutput",
      "alwaysOutputData": true,
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "id": "zvid-watch-ready",
      "name": "Video ready to watch?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        310,
        2520
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "zvid-watch-ready-condition",
              "leftValue": "={{ !$json.dryRun && /^https?:\\/\\//i.test(String(($json.videoUrl) || '')) }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "publication-overview-shopify-catalog-videos",
      "name": "Workflow overview and setup",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -800,
        0
      ],
      "parameters": {
        "content": "## Create and publish Shopify product videos\n\n### How it works\nSelect eligible Shopify products, render branded vertical videos in a batch, and return them for review. When publishing is enabled, stage the files in Shopify, attach them to matching products and tag accepted results.\n\n### Setup\n1. Install **`@zvid/n8n-nodes-zvid`** from **Settings → Community nodes** before credentials. A workspace owner/admin may need to install it.\n2. Create a **Zvid API** credential with [your API key](https://app.zvid.io/api-keys) and Base URL `https://api.zvid.io`. Select it on every Zvid node.\n3. Set the canonical shopDomain, branding and maxProducts in Config. Keep publishToShopify=false while reviewing videos.\n4. Create Header Auth named X-Shopify-Access-Token with an installed Shopify Admin token and assign it to the Shopify requests. The app needs read_products, write_products and write_files; refresh expiring tokens before scheduled runs.\n5. **dryRun=false** renders and spends Zvid credits. Set it to **true** for an editor draft and quote.\n6. Test manually and review the output before activating automatic runs.\n\n### Customization\nAdjust branding, media and timing in the configuration node. [Detailed setup guide](https://github.com/Zvid-io/zvid-n8n/blob/master/workflows/shopify-catalog-videos.md).",
        "color": 1,
        "width": 720,
        "height": 1160
      }
    },
    {
      "id": "publication-section-shopify-catalog-videos-configure",
      "name": "1. Choose settings and an entry point",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        0
      ],
      "parameters": {
        "content": "## 1. Choose settings and an entry point\n\nComplete the overview setup first. Use the manual trigger for a test, then check the trigger settings and workflow timezone before activation.",
        "color": 7,
        "width": 880,
        "height": 520
      }
    },
    {
      "id": "publication-section-shopify-catalog-videos-catalog",
      "name": "2. Load eligible Shopify products",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1000,
        0
      ],
      "parameters": {
        "content": "## 2. Load eligible Shopify products\n\nRequire an active product with a title, public photo and priced variant. Skip already processed products and usable existing videos.",
        "color": 7,
        "width": 1140,
        "height": 520
      }
    },
    {
      "id": "publication-section-shopify-catalog-videos-validate",
      "name": "3. Build and validate the design",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2260,
        0
      ],
      "parameters": {
        "content": "## 3. Build and validate the design\n\nPrepare the media and video design, then validate the payload and credit quote. Resolve validation errors before submitting a render.",
        "color": 7,
        "width": 880,
        "height": 520
      }
    },
    {
      "id": "publication-section-shopify-catalog-videos-preview",
      "name": "4. Choose an optional editor preview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        680
      ],
      "parameters": {
        "content": "## 4. Choose an optional editor preview\n\ndryRun=true returns an editor draft and credit quote. The default false branch renders and spends Zvid credits; upstream AI services may charge separately.",
        "color": 7,
        "width": 880,
        "height": 520
      }
    },
    {
      "id": "publication-section-shopify-catalog-videos-render",
      "name": "5. Render and wait for completion",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1000,
        680
      ],
      "parameters": {
        "content": "## 5. Render and wait for completion\n\nSubmit the render, then check its status until completion or timeout. Keep each result paired with its source item. Check Zvid before retrying a timed-out job.",
        "color": 7,
        "width": 1140,
        "height": 760
      }
    },
    {
      "id": "publication-section-shopify-catalog-videos-review",
      "name": "6. Review or begin publishing",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2260,
        680
      ],
      "parameters": {
        "content": "## 6. Review or begin publishing\n\npublishToShopify=false returns videos for review. Enabling it changes product media in Shopify.",
        "color": 7,
        "width": 880,
        "height": 520
      }
    },
    {
      "id": "publication-section-shopify-catalog-videos-headers",
      "name": "7. Check each video before upload",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        1600
      ],
      "parameters": {
        "content": "## 7. Check each video before upload\n\nRead each completed file’s size and create a Shopify staged-upload target. Shopify media transfer requires HTTP Request nodes.",
        "color": 7,
        "width": 1140,
        "height": 520
      }
    },
    {
      "id": "publication-section-shopify-catalog-videos-stage",
      "name": "8. Transfer videos to Shopify",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1260,
        1600
      ],
      "parameters": {
        "content": "## 8. Transfer videos to Shopify\n\nDownload each MP4, upload it to the staged target, and check that the transfer succeeded before attaching it.",
        "color": 7,
        "width": 880,
        "height": 520
      }
    },
    {
      "id": "publication-section-shopify-catalog-videos-publish",
      "name": "9. Attach and tag accepted products",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2260,
        1600
      ],
      "parameters": {
        "content": "## 9. Attach and tag accepted products\n\nAssociate staged videos with their matching product IDs. Tag accepted products only; Shopify processes the media asynchronously, so confirm READY in Shopify Admin.",
        "color": 7,
        "width": 880,
        "height": 520
      }
    },
    {
      "id": "zvid-watch-instructions",
      "name": "10. Review the finished media",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        2280
      ],
      "parameters": {
        "content": "## 10. Review the finished media\n\nOpen Watch video → Binary → data → View. Select each output item for batches; image outputs open as images. Empty links and preview results skip the download.",
        "color": 7,
        "width": 880,
        "height": 520
      }
    }
  ],
  "pinData": {},
  "connections": {
    "Test manually": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every day at 6am": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Get Shopify catalog",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Shopify catalog": {
      "main": [
        [
          {
            "node": "Rows needing videos",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rows needing videos": {
      "main": [
        [
          {
            "node": "Products found?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Products found?": {
      "main": [
        [
          {
            "node": "Build project JSON",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Nothing to render",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build project JSON": {
      "main": [
        [
          {
            "node": "Validate project (free)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate project (free)": {
      "main": [
        [
          {
            "node": "Check validation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check validation": {
      "main": [
        [
          {
            "node": "Dry run?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dry run?": {
      "main": [
        [
          {
            "node": "Save draft to editor",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Submit bulk render",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save draft to editor": {
      "main": [
        [
          {
            "node": "Dry run summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Submit bulk render": {
      "main": [
        [
          {
            "node": "Check batch accepted",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check batch accepted": {
      "main": [
        [
          {
            "node": "Wait",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait": {
      "main": [
        [
          {
            "node": "Get batch status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get batch status": {
      "main": [
        [
          {
            "node": "Batch finished?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Batch finished?": {
      "main": [
        [
          {
            "node": "Collect video links",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Still rendering?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Still rendering?": {
      "main": [
        [
          {
            "node": "Wait",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Collect video links": {
      "main": [
        [
          {
            "node": "Publish automatically?",
            "type": "main",
            "index": 0
          },
          {
            "node": "Video ready to watch?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Publish automatically?": {
      "main": [
        [
          {
            "node": "Get Zvid video headers",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Rendered for review",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Zvid video headers": {
      "main": [
        [
          {
            "node": "Check Zvid video headers",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Zvid video headers": {
      "main": [
        [
          {
            "node": "Create Shopify upload targets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Shopify upload targets": {
      "main": [
        [
          {
            "node": "Check Shopify upload targets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Shopify upload targets": {
      "main": [
        [
          {
            "node": "Download videos for Shopify",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download videos for Shopify": {
      "main": [
        [
          {
            "node": "Upload videos to Shopify staging",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload videos to Shopify staging": {
      "main": [
        [
          {
            "node": "Check Shopify staged uploads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Shopify staged uploads": {
      "main": [
        [
          {
            "node": "Attach video to Shopify product",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Attach video to Shopify product": {
      "main": [
        [
          {
            "node": "Check Shopify publish requests",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Shopify publish requests": {
      "main": [
        [
          {
            "node": "Mark product processed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark product processed": {
      "main": [
        [
          {
            "node": "Published summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Video ready to watch?": {
      "main": [
        [
          {
            "node": "▶ Watch video",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "tags": []
}
