{
  "id": "Io1Sfma343VRyNNS",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Create and publish Shopify product videos with Zvid",
  "tags": [],
  "nodes": [
    {
      "id": "cec7641a-17e8-4131-be24-607c6ee1442b",
      "name": "Test manually",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        -3712,
        5776
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "6581c028-ef12-48be-b8ca-728af2e26558",
      "name": "Every day at 6am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -3712,
        5968
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 6 * * *"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "6235c28b-4673-402c-b11e-af9e61ce2fbd",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "position": [
        -3488,
        5840
      ],
      "parameters": {
        "mode": "raw",
        "options": {},
        "jsonOutput": "{\n  \"apiUrl\": \"https://api.zvid.io\",\n  \"editorUrl\": \"https://editor.zvid.io\",\n  \"shopDomain\": \"zvid-2\",\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\": \"zvid-2.myshopify.com\",\n  \"kicker\": \"THE EVERYDAY EDIT\",\n  \"featureHeading\": \"CUT \u00b7 CLOTH \u00b7 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\": \"Zvid Studio catalog videos\",\n  \"dryRun\": true,\n  \"pollSeconds\": 10,\n  \"timeoutMinutes\": 20\n}"
      },
      "typeVersion": 3.4
    },
    {
      "id": "57ed07f0-67ca-4b4d-b035-3c0f9bab5ace",
      "name": "Get Zvid video headers",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        48,
        5664
      ],
      "parameters": {
        "url": "={{ $json.videoUrl }}",
        "method": "HEAD",
        "options": {
          "timeout": 60000,
          "response": {
            "response": {
              "neverError": true,
              "fullResponse": true
            }
          }
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "b9a2707b-8f12-4256-94c6-d9eb037174cc",
      "name": "Check Zvid video headers",
      "type": "n8n-nodes-base.code",
      "position": [
        288,
        5664
      ],
      "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 } }));"
      },
      "typeVersion": 2
    },
    {
      "id": "096bbae0-411d-4680-9b2b-8b05ee466b74",
      "name": "Create Shopify upload targets",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        576,
        5664
      ],
      "parameters": {
        "url": "={{ 'https://' + $('Config').first().json.shopDomain + '.myshopify.com/admin/api/' + $('Config').first().json.shopifyApiVersion + '/graphql.json' }}",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "fullResponse": true
            }
          }
        },
        "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 }] } }) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "bf3a552f-2984-45b4-9f14-96285f3ceba3",
      "name": "Check Shopify upload targets",
      "type": "n8n-nodes-base.code",
      "position": [
        816,
        5664
      ],
      "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 } }));"
      },
      "typeVersion": 2
    },
    {
      "id": "00bf9b32-c6ec-4503-b5f7-78f56a070d7c",
      "name": "Download videos for Shopify",
      "type": "n8n-nodes-base.httpRequest",
      "maxTries": 3,
      "position": [
        1040,
        5664
      ],
      "parameters": {
        "url": "={{ $json.videoUrl }}",
        "options": {
          "timeout": 600000,
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "waitBetweenTries": 5000
    },
    {
      "id": "c3afbd10-f62a-4039-87d6-dd36de4c7110",
      "name": "Upload videos to Shopify staging",
      "type": "n8n-nodes-base.httpRequest",
      "maxTries": 3,
      "position": [
        1312,
        5664
      ],
      "parameters": {
        "url": "={{ $('Check Shopify upload targets').item.json.stagedUploadUrl }}",
        "method": "POST",
        "options": {
          "timeout": 600000,
          "response": {
            "response": {
              "neverError": true,
              "fullResponse": true
            }
          }
        },
        "sendBody": true,
        "contentType": "multipart-form-data",
        "bodyParameters": {
          "parameters": [
            {
              "name": "GoogleAccessId",
              "value": "={{ $('Check Shopify upload targets').item.json.stagedParameters.GoogleAccessId }}"
            },
            {
              "name": "key",
              "value": "={{ $('Check Shopify upload targets').item.json.stagedParameters.key }}"
            },
            {
              "name": "policy",
              "value": "={{ $('Check Shopify upload targets').item.json.stagedParameters.policy }}"
            },
            {
              "name": "signature",
              "value": "={{ $('Check Shopify upload targets').item.json.stagedParameters.signature }}"
            },
            {
              "name": "file",
              "parameterType": "formBinaryData",
              "inputDataFieldName": "data"
            }
          ]
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "waitBetweenTries": 5000
    },
    {
      "id": "691b67cd-3542-4563-94b0-ccf0676b4c9b",
      "name": "Check Shopify staged uploads",
      "type": "n8n-nodes-base.code",
      "position": [
        1568,
        5664
      ],
      "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 } }));"
      },
      "typeVersion": 2
    },
    {
      "id": "b28a5fd2-bf4a-44d8-9a76-cbb737664aaf",
      "name": "Get Shopify catalog",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -3280,
        5840
      ],
      "parameters": {
        "url": "={{ 'https://' + $('Config').first().json.shopDomain + '.myshopify.com/admin/api/' + $('Config').first().json.shopifyApiVersion + '/graphql.json' }}",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "fullResponse": true
            }
          }
        },
        "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') } }) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "ce86484e-0f6b-47e7-87f6-a4d8bf492ae2",
      "name": "Rows needing videos",
      "type": "n8n-nodes-base.code",
      "position": [
        -3056,
        5840
      ],
      "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,;:.\u2014-]+$/, '') + '\u2026';\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(' \u00b7 ')}` : 'Available now',\n    MetaLine: [vendor || cfg.brandName, productType].filter(Boolean).join(' \u00b7 '),\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} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "8611d0cf-c1a7-42ea-a382-6206d5e69891",
      "name": "Products found?",
      "type": "n8n-nodes-base.if",
      "position": [
        -2832,
        5840
      ],
      "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.found }}",
              "rightValue": ""
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.2
    },
    {
      "id": "2423e6e9-b278-4847-b275-b9b684e14efd",
      "name": "Nothing to render",
      "type": "n8n-nodes-base.code",
      "position": [
        -2592,
        6000
      ],
      "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} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "c5a27663-b978-473f-9f8f-a7149cac6652",
      "name": "Build project JSON",
      "type": "n8n-nodes-base.code",
      "position": [
        -2592,
        5760
      ],
      "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 || 'zvid-2.myshopify.com');\n  const kicker = esc(config.kicker || 'THE EVERYDAY EDIT');\n  const featureHeading = esc(config.featureHeading || 'CUT \u00b7 CLOTH \u00b7 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 \u00b7 M \u00b7 L \u00b7 XL \u00b7 XXL', meta: 'Zvid Studio \u00b7 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} } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "b488271e-02a0-46a1-883f-ce15fdf8a452",
      "name": "Validate project (free)",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -2352,
        5760
      ],
      "parameters": {
        "url": "={{ $('Config').first().json.apiUrl }}/api/render/validate/api-key",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "fullResponse": true
            }
          }
        },
        "jsonBody": "={{ JSON.stringify({ payload: $json.payload, variables: $json.items[0].variables }) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "c003841f-aecc-4021-985a-9af2f4609772",
      "name": "Check validation",
      "type": "n8n-nodes-base.code",
      "position": [
        -2112,
        5760
      ],
      "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 \u2014 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;\nconst body = res.body || {};\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}];"
      },
      "typeVersion": 2
    },
    {
      "id": "25dcf47b-1ccb-4646-b490-426a99e1f8d8",
      "name": "Dry run?",
      "type": "n8n-nodes-base.if",
      "position": [
        -1888,
        5760
      ],
      "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": "={{ $('Config').first().json.dryRun }}",
              "rightValue": ""
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.2
    },
    {
      "id": "45bcde91-d141-4d1b-8ce5-c29faea248d6",
      "name": "Save draft to editor",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        -1632,
        5568
      ],
      "parameters": {
        "url": "={{ $('Config').first().json.apiUrl }}/api/projects",
        "method": "POST",
        "options": {},
        "jsonBody": "={{ JSON.stringify({ name: ('Catalog preview - ' + ($json.meta.products[0] ? $json.meta.products[0].product : 'first product')).slice(0, 120), payload: $json.resolvedFirst }) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "alwaysOutputData": true,
      "waitBetweenTries": 5000
    },
    {
      "id": "d3f3eb54-4939-4171-a92c-0993a903f83d",
      "name": "Dry run summary",
      "type": "n8n-nodes-base.code",
      "position": [
        -1392,
        5568
      ],
      "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} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "f03df87e-1b71-442b-bcd5-bb0db6d81464",
      "name": "Submit bulk render",
      "type": "n8n-nodes-base.httpRequest",
      "maxTries": 3,
      "position": [
        -1632,
        5760
      ],
      "parameters": {
        "url": "={{ $('Config').first().json.apiUrl }}/api/render/bulk/api-key",
        "method": "POST",
        "options": {},
        "jsonBody": "={{ JSON.stringify({ name: String($('Config').first().json.bulkName || 'Catalog videos').slice(0, 120), payload: $json.payload, items: $json.items }) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "waitBetweenTries": 5000
    },
    {
      "id": "b26b9a6d-edbc-4c9f-9302-d5577e30010f",
      "name": "Check batch accepted",
      "type": "n8n-nodes-base.code",
      "position": [
        -1392,
        5760
      ],
      "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) \u2014 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 \u2192 submit index \u2192 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}];"
      },
      "typeVersion": 2
    },
    {
      "id": "604f9f74-a33b-49ca-bff8-5980e347d59f",
      "name": "Wait",
      "type": "n8n-nodes-base.wait",
      "position": [
        -1152,
        5760
      ],
      "parameters": {
        "amount": "={{ $('Config').first().json.pollSeconds }}"
      },
      "typeVersion": 1.1
    },
    {
      "id": "9d150851-060f-475a-ac30-fa32d158702c",
      "name": "Get batch status",
      "type": "n8n-nodes-base.httpRequest",
      "maxTries": 3,
      "position": [
        -912,
        5760
      ],
      "parameters": {
        "url": "={{ $('Config').first().json.apiUrl }}/api/render/bulk/{{ $('Check batch accepted').first().json.bulkId }}",
        "options": {},
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "waitBetweenTries": 5000
    },
    {
      "id": "bb5892de-e1a6-404f-bd64-fad637ca3536",
      "name": "Batch finished?",
      "type": "n8n-nodes-base.if",
      "position": [
        -672,
        5760
      ],
      "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": "701e71a4-3108-4e6f-83b6-fd2c24a40517",
      "name": "Still rendering?",
      "type": "n8n-nodes-base.code",
      "position": [
        -672,
        5968
      ],
      "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 = $('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 \u2014 check https://app.zvid.io before re-running, or raise timeoutMinutes.'\n  );\n}\n\nreturn $input.all();"
      },
      "typeVersion": 2
    },
    {
      "id": "3597804f-8284-4118-ac8f-a2bb42ddac94",
      "name": "Collect video links",
      "type": "n8n-nodes-base.code",
      "position": [
        -432,
        5664
      ],
      "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 } }));"
      },
      "typeVersion": 2
    },
    {
      "id": "bd7b5c04-cf5d-435c-9bc9-9a09af3e97f7",
      "name": "Publish automatically?",
      "type": "n8n-nodes-base.if",
      "position": [
        -192,
        5760
      ],
      "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": "={{ $('Config').first().json.publishToShopify }}",
              "rightValue": ""
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.2
    },
    {
      "id": "37feae7b-88a8-4794-b843-17754474bf40",
      "name": "Rendered for review",
      "type": "n8n-nodes-base.code",
      "position": [
        48,
        5888
      ],
      "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} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "d7a24826-4437-4ba8-91c7-d2a179c4f29f",
      "name": "Attach video to Shopify product",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2096,
        5664
      ],
      "parameters": {
        "url": "={{ 'https://' + $('Config').first().json.shopDomain + '.myshopify.com/admin/api/' + $('Config').first().json.shopifyApiVersion + '/graphql.json' }}",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "fullResponse": true
            }
          }
        },
        "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' }] } }) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "65e97ddc-23a5-40fb-b301-3044e6f6d9d8",
      "name": "Check Shopify publish requests",
      "type": "n8n-nodes-base.code",
      "position": [
        2336,
        5664
      ],
      "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 } }));"
      },
      "typeVersion": 2
    },
    {
      "id": "306f55de-cbbb-432f-ae85-467fba8c6a77",
      "name": "Mark product processed",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2576,
        5664
      ],
      "parameters": {
        "url": "={{ 'https://' + $('Config').first().json.shopDomain + '.myshopify.com/admin/api/' + $('Config').first().json.shopifyApiVersion + '/graphql.json' }}",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "fullResponse": true
            }
          }
        },
        "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')] } }) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "5cc15f91-d704-425a-b351-3ab65807d4c9",
      "name": "Published summary",
      "type": "n8n-nodes-base.code",
      "position": [
        2816,
        5664
      ],
      "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} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "ee3aab72-cb18-47c1-a6b3-64683f5d3eb4",
      "name": "\u25b6 Watch video",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        -192,
        5568
      ],
      "parameters": {
        "url": "={{ $json.videoUrl }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "alwaysOutputData": true,
      "waitBetweenTries": 5000
    },
    {
      "id": "0d12c264-d825-4952-acf4-2c24f11b4f39",
      "name": "Overview \u2014 required",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -3952,
        4640
      ],
      "parameters": {
        "width": 900,
        "height": 704,
        "content": "# Create and publish Shopify product videos with Zvid\n\nTurn active Shopify apparel products into branded vertical videos, review them, and optionally attach the finished MP4s to the matching product pages. It is designed for ecommerce and marketing teams that want catalog-scale product creative without rebuilding every video by hand.\n\n### How it works\nThe workflow reads active products, skips items already tagged or carrying usable video, maps product images and data into one shared Zvid design, validates the batch, and offers a free dry-run preview. Approved batches render together. Publishing uses Shopify's staged-upload flow, associates each hosted video with its product, and adds the processed tag only after Shopify accepts the request.\n\n### Setup\n1. Complete the Shopify and Zvid credential notes to the right.\n2. Open Config and replace the store, brand, copy, palette, limits, and schedule values.\n3. Keep `dryRun` true for the first manual execution.\n4. Review the editor link and credit quote before rendering.\n5. Keep `publishToShopify` false until the videos are approved.\n\n### Customization\nThe sample is optimized for apparel with square studio or flat-lay images, description bullets, prices, and sizes. Other product categories can reuse the flow after changing the scene labels and copy in Config. Never put secrets in Config."
      },
      "typeVersion": 1
    },
    {
      "id": "93b567f1-3a30-4cd9-866e-509bc38ecc8e",
      "name": "Shopify app setup",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -3008,
        4640
      ],
      "parameters": {
        "color": 7,
        "width": 500,
        "height": 280,
        "content": "## Shopify app (2026+)\n\nShopify Admin \u2192 Settings \u2192 Apps \u2192 Develop apps \u2192 Build apps in Dev Dashboard. Create an app/version, request `read_products`, `write_products`, and `write_files`, release it, then install it on the target store."
      },
      "typeVersion": 1
    },
    {
      "id": "96cd8eae-a544-40bb-b44b-1f9c78d62a5a",
      "name": "Shopify token setup",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2496,
        4640
      ],
      "parameters": {
        "color": 7,
        "width": 500,
        "height": 280,
        "content": "## Get the Shopify token\n\nDev Dashboard apps show Client ID and Client secret\u2014not a token. Exchange them with `POST https://{shop}.myshopify.com/admin/oauth/access_token` using `grant_type=client_credentials`. Copy the returned `access_token`; it expires after 24 hours. The ID or secret alone is never the access token."
      },
      "typeVersion": 1
    },
    {
      "id": "17592f74-b98d-4ebe-ba9d-bd4bcece346a",
      "name": "Shopify n8n credential",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1968,
        4640
      ],
      "parameters": {
        "color": 7,
        "width": 500,
        "height": 280,
        "content": "## Connect Shopify in n8n\n\nCreate Header Auth: Name `X-Shopify-Access-Token`; Value = returned Admin API token. Assign it to Get catalog, Create upload targets, Attach video, and Mark processed. Existing admin-created apps can use their installed token. Refresh expiring tokens before scheduled runs."
      },
      "typeVersion": 1
    },
    {
      "id": "76d139fe-021b-4072-97b4-83803bf41f8b",
      "name": "Zvid n8n credential",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1456,
        4640
      ],
      "parameters": {
        "color": 7,
        "width": 500,
        "height": 280,
        "content": "## Connect Zvid in n8n\n\nCreate a key at https://app.zvid.io/api-keys. Create Header Auth with Name `x-api-key` and Value `zvid_\u2026`. Assign it to Validate project, Save draft, Submit bulk render, and Get batch status. Never paste either provider's secret into Config."
      },
      "typeVersion": 1
    },
    {
      "id": "d540f070-6c49-46bf-be15-9cfa5bd32959",
      "name": "Example Shopify products",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -928,
        4640
      ],
      "parameters": {
        "color": 7,
        "width": 620,
        "height": 280,
        "content": "## Example catalog\n\nFive unisex tees: Alpine Crest ivory ($34), Clay Pocket terracotta ($35), Dawnline washed black ($32), Harbor Stripe navy/ivory ($36), and Meadow Sprig sage ($30). Each has square studio or flat-lay imagery, product copy, and S\u2013XXL variants."
      },
      "typeVersion": 1
    },
    {
      "id": "080ebfe7-c058-4ab5-90ba-6b89e33828e5",
      "name": "Product data requirements",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -288,
        4640
      ],
      "parameters": {
        "color": 7,
        "width": 620,
        "height": 280,
        "content": "## Product data used\n\nRequired: active title, public product image, and priced variant. Up to four images feed the scenes. Description bullets, sizes, vendor, type, prices, title, tags, handle, and storefront domain personalize the output. Missing essentials are reported in `skipped`."
      },
      "typeVersion": 1
    },
    {
      "id": "5fd0c13f-211e-4aa3-9902-d9872cd7c261",
      "name": "Run modes and credits",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        352,
        4640
      ],
      "parameters": {
        "color": 7,
        "width": 600,
        "height": 280,
        "content": "## Safe run modes\n\nStart with `dryRun: true` for free validation, quote, and editor link. Next use `dryRun: false` with `publishToShopify: false` for review links. Enable publishing only after approval: rendering consumes Zvid credits and publishing changes Shopify media."
      },
      "typeVersion": 1
    },
    {
      "id": "8252b4bd-240c-4016-802d-c976c538923a",
      "name": "Why HTTP Request nodes",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        976,
        4640
      ],
      "parameters": {
        "color": 7,
        "width": 600,
        "height": 280,
        "content": "## Why HTTP Request?\n\nn8n's Shopify node does not expose the required GraphQL media selection, `stagedUploadsCreate`, signed multipart upload, `productUpdate(media: VIDEO)`, or `tagsAdd`. HTTP Request nodes keep the exact Shopify contract while credentials remain in n8n."
      },
      "typeVersion": 1
    },
    {
      "id": "afee3e03-5b43-4c77-8491-39fc5ac5181d",
      "name": "Shopify publishing behavior",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1600,
        4640
      ],
      "parameters": {
        "color": 7,
        "width": 600,
        "height": 280,
        "content": "## Shopify video publishing\n\nA public MP4 cannot be attached directly as hosted video. This flow reads file size, creates a staged target, uploads the MP4, then associates Shopify's staged URL. Only accepted products are tagged; Shopify processes the media asynchronously."
      },
      "typeVersion": 1
    },
    {
      "id": "47a43a13-619e-481f-a46b-9c2a2d80f494",
      "name": "Catalog queue behavior",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2208,
        4640
      ],
      "parameters": {
        "color": 7,
        "width": 600,
        "height": 280,
        "content": "## Queue and final status\n\nSuccessful products receive `zvid-video`; the query excludes that tag. Existing non-failed videos are skipped, so scheduled batches advance safely. Final media status is initially `PROCESSING`; confirm `READY` in Shopify Admin before treating it as live."
      },
      "typeVersion": 1
    },
    {
      "id": "dd61b931-4335-4115-8c80-0ec9d7229afe",
      "name": "Section 1 \u2014 Catalog input",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -3792,
        5600
      ],
      "parameters": {
        "color": 7,
        "width": 1080,
        "height": 560,
        "content": "## 1. Start and load products\n\nManual test or daily schedule \u2192 Config \u2192 Shopify catalog \u2192 eligibility checks."
      },
      "typeVersion": 1
    },
    {
      "id": "b0ad06cf-08da-4f70-9cbf-13dfbf2ace18",
      "name": "Section 2 \u2014 Build and validate",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2688,
        5600
      ],
      "parameters": {
        "color": 7,
        "width": 920,
        "height": 560,
        "content": "## 2. Build and validate\n\nCreate the shared video design, validate every product, then choose dry run or rendering."
      },
      "typeVersion": 1
    },
    {
      "id": "ed9cb9eb-071c-4c8b-8fa7-b57384ecdfaa",
      "name": "Section 3 \u2014 Preview or render",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1712,
        5392
      ],
      "parameters": {
        "color": 7,
        "width": 1240,
        "height": 760,
        "content": "## 3. Preview or render\n\nSave a free editor draft, or submit and monitor one Zvid bulk render."
      },
      "typeVersion": 1
    },
    {
      "id": "0bae77d2-7c7f-4616-9c5e-a4ffa97daa43",
      "name": "Section 4 \u2014 Review gate",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -448,
        5392
      ],
      "parameters": {
        "color": 7,
        "width": 560,
        "height": 760,
        "content": "## 4. Review or publish\n\nMap completed videos to Shopify product GIDs and stop for review unless publishing is enabled."
      },
      "typeVersion": 1
    },
    {
      "id": "370c4448-c980-4b24-b064-7bec57f37c9e",
      "name": "Section 5 \u2014 Shopify staging",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        208,
        5456
      ],
      "parameters": {
        "color": 7,
        "width": 1680,
        "height": 560,
        "content": "## 5. Stage videos in Shopify\n\nInspect each MP4, create secure upload targets, download, upload, and verify every staged transfer."
      },
      "typeVersion": 1
    },
    {
      "id": "eaed5a58-aa8c-4f31-b9ac-e8011533f1d1",
      "name": "Section 6 \u2014 Publish result",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1984,
        5440
      ],
      "parameters": {
        "color": 7,
        "width": 1080,
        "height": 420,
        "content": "## 6. Attach, tag, summarize\n\nAssociate staged videos, tag only successful products, and return processing status plus failures."
      },
      "typeVersion": 1
    },
    {
      "id": "36f6986a-a712-4c2d-a209-3e1fd48dc047",
      "name": "Shopify app setup1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -4464,
        5584
      ],
      "parameters": {
        "color": 7,
        "width": 500,
        "height": 424,
        "content": "## \ud83c\udfa5 Video Tutorial\n\nWatch the full workflow walkthrough:\n@[youtube](qe5KHfJoZmY)"
      },
      "typeVersion": 1
    }
  ],
  "active": true,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "7680e827-7687-40b5-b841-f1a07741457a",
  "nodeGroups": [],
  "connections": {
    "Wait": {
      "main": [
        [
          {
            "node": "Get batch status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Get Shopify catalog",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dry run?": {
      "main": [
        [
          {
            "node": "Save draft to editor",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Submit bulk render",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Test manually": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Batch finished?": {
      "main": [
        [
          {
            "node": "Collect video links",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Still rendering?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Products found?": {
      "main": [
        [
          {
            "node": "Build project JSON",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Nothing to render",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check validation": {
      "main": [
        [
          {
            "node": "Dry run?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every day at 6am": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get batch status": {
      "main": [
        [
          {
            "node": "Batch finished?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Still rendering?": {
      "main": [
        [
          {
            "node": "Wait",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Published summary": {
      "main": [
        []
      ]
    },
    "Build project JSON": {
      "main": [
        [
          {
            "node": "Validate project (free)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Submit bulk render": {
      "main": [
        [
          {
            "node": "Check batch accepted",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Collect video links": {
      "main": [
        [
          {
            "node": "Publish automatically?",
            "type": "main",
            "index": 0
          },
          {
            "node": "\u25b6 Watch video",
            "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
          }
        ]
      ]
    },
    "Check batch accepted": {
      "main": [
        [
          {
            "node": "Wait",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save draft to editor": {
      "main": [
        [
          {
            "node": "Dry run summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Zvid video headers": {
      "main": [
        [
          {
            "node": "Check Zvid video headers",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark product processed": {
      "main": [
        [
          {
            "node": "Published summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Publish automatically?": {
      "main": [
        [
          {
            "node": "Get Zvid video headers",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Rendered for review",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate project (free)": {
      "main": [
        [
          {
            "node": "Check validation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Zvid video headers": {
      "main": [
        [
          {
            "node": "Create Shopify upload targets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download videos for Shopify": {
      "main": [
        [
          {
            "node": "Upload videos to Shopify staging",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Shopify staged uploads": {
      "main": [
        [
          {
            "node": "Attach video to Shopify product",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Shopify upload targets": {
      "main": [
        [
          {
            "node": "Download videos for Shopify",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Shopify upload targets": {
      "main": [
        [
          {
            "node": "Check Shopify upload targets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Shopify publish requests": {
      "main": [
        [
          {
            "node": "Mark product processed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Attach video to Shopify product": {
      "main": [
        [
          {
            "node": "Check Shopify publish requests",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload videos to Shopify staging": {
      "main": [
        [
          {
            "node": "Check Shopify staged uploads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}