{
  "name": "Turn every new Shopify product into a promo video (Zvid)",
  "nodes": [
    {
      "parameters": {},
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        50,
        240
      ],
      "id": "zvid-sp-0001",
      "name": "Test manually"
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 30
            }
          ]
        }
      },
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        310,
        240
      ],
      "id": "zvid-sp-0002",
      "name": "Every 30 minutes"
    },
    {
      "parameters": {
        "mode": "raw",
        "jsonOutput": "{\n  \"apiUrl\": \"https://api.zvid.io\",\n  \"editorUrl\": \"https://editor.zvid.io\",\n  \"source\": \"sheet\",\n  \"shopDomain\": \"\",\n  \"apiVersion\": \"2024-07\",\n  \"brandName\": \"NORTHBOUND\",\n  \"brandHandle\": \"@northbound.supply\",\n  \"storeUrl\": \"https://northbound-supply.myshopify.com\",\n  \"brandColor\": \"#141A20\",\n  \"brandAccent\": \"#E1623C\",\n  \"titleFont\": \"Fraunces\",\n  \"uiFont\": \"Archivo\",\n  \"newArrivalKicker\": \"NEW ARRIVAL\",\n  \"shopNowText\": \"Shop the drop\",\n  \"currencySymbol\": \"$\",\n  \"fallbackTagline\": \"Just landed — available now.\",\n  \"maxTaglineChars\": 90,\n  \"statusDoneValue\": \"done\",\n  \"musicUrl\": \"https://cdn.zvid.io/blog/shopify-product-promo/207c2be25021/sample-music.mp3\",\n  \"musicVolume\": 0.16,\n  \"maxMusicBytes\": 5242880,\n  \"dryRun\": false,\n  \"pollSeconds\": 10,\n  \"timeoutMinutes\": 20\n}",
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        570,
        240
      ],
      "id": "zvid-sp-0003",
      "name": "Config"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "source--cond",
              "leftValue": "={{ String($('Config').first().json.source || 'sheet').toLowerCase() === 'sheet' }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1050,
        240
      ],
      "id": "zvid-sp-0004",
      "name": "Source?"
    },
    {
      "parameters": {
        "resource": "sheet",
        "operation": "read",
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "options": {}
      },
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1310,
        240
      ],
      "id": "zvid-sp-0005",
      "name": "Read products sheet",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "// Sheet mode only. The Google Sheets node emits one item per row plus a\n// row_number field; keep the FIRST row whose Status column is empty so the\n// update node at the end can mark exactly that row as done.\nconst rows = $input.all().map((i) => i.json);\nconst isEmpty = (v) => v === undefined || v === null || String(v).trim() === '';\nconst row = rows.find((r) => isEmpty(r.Status));\n\nif (!row) {\n  return [{ json: { found: false, checked: rows.length, reason: 'No row with an empty Status column. Add product rows to the sheet.' } }];\n}\nif (isEmpty(row.Title)) {\n  throw new Error('Row ' + row.row_number + ' has no Title. Fill it in, or put anything in its Status column to skip the row.');\n}\nif (isEmpty(row.ImageUrl1)) {\n  throw new Error('Row ' + row.row_number + ' has no ImageUrl1. Every product needs at least one public image URL (a Shopify CDN link works).');\n}\n\nreturn [{ json: { found: true, row, rowNumber: row.row_number, checked: rows.length } }];\n"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1570,
        240
      ],
      "id": "zvid-sp-0006",
      "name": "Pick next product"
    },
    {
      "parameters": {
        "url": "=https://{{ $('Config').first().json.shopDomain }}.myshopify.com/admin/api/{{ $('Config').first().json.apiVersion }}/products.json?limit=5&order=created_at+desc",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          },
          "timeout": 20000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1830,
        240
      ],
      "id": "zvid-sp-0007",
      "name": "Poll new products",
      "onError": "continueRegularOutput",
      "alwaysOutputData": true,
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "// Normalizes BOTH sources to one product shape, so everything downstream —\n// the builder, the render chain, the summary — is source-agnostic:\n//\n//   { found, title, price, compareAtPrice, imageUrls[], tagline,\n//     rowNumber?, productId?, source }\n//\n// Shopify mode also de-duplicates: the newest product is compared against the\n// last one this workflow announced (kept in the workflow's static data). The\n// FIRST shopify run only seeds that marker — it never floods the channel with\n// a back catalogue.\nconst cfg = $('Config').first().json;\nconst source = String(cfg.source || 'sheet').trim().toLowerCase();\nconst clean = (v) => String(v == null ? '' : v).replace(/\\s+/g, ' ').trim();\nconst fallbackTagline = clean(cfg.fallbackTagline) || 'Just landed — available now.';\nconst maxTagline = Number(cfg.maxTaglineChars) > 20 ? Number(cfg.maxTaglineChars) : 90;\n\n// Trims to a word boundary and adds an ellipsis, so a full product description\n// never runs off the design.\nconst trimTo = (text, max) => {\n  const s = clean(text);\n  if (s.length <= max) return s;\n  const cut = s.slice(0, max - 1);\n  const at = cut.lastIndexOf(' ');\n  let out = (at > max * 0.5 ? cut.slice(0, at) : cut).replace(/[\\s,;:.—-]+$/, '');\n  // \"…talc-free and kind to…\" reads badly; drop a dangling article or\n  // preposition so the ellipsis lands on a word that carries meaning.\n  out = out.replace(/\\s+(a|an|and|as|at|but|by|for|from|in|into|of|on|or|the|to|with)$/i, '');\n  return out.replace(/[\\s,;:.—-]+$/, '') + '…';\n};\n\nif (source === 'sheet') {\n  const picked = $input.first().json;\n  if (!picked || picked.found !== true) {\n    return [{ json: { found: false, source: 'sheet', checked: (picked && picked.checked) || 0, reason: (picked && picked.reason) || 'No product row waiting.' } }];\n  }\n  const row = picked.row || {};\n  const imageUrls = [clean(row.ImageUrl1), clean(row.ImageUrl2)].filter((u) => /^https?:\\/\\//i.test(u));\n  if (!imageUrls.length) {\n    throw new Error('Row ' + picked.rowNumber + ': ImageUrl1 is not a public http(s) URL.');\n  }\n  return [{\n    json: {\n      found: true,\n      source: 'sheet',\n      title: clean(row.Title),\n      price: clean(row.Price),\n      compareAtPrice: clean(row.CompareAtPrice),\n      tagline: trimTo(row.Tagline, maxTagline) || fallbackTagline,\n      imageUrls,\n      rowNumber: picked.rowNumber,\n      productId: null,\n      checked: picked.checked,\n    },\n  }];\n}\n\n// ---- Shopify mode ---------------------------------------------------------\nconst res = $input.first().json || {};\nconst status = Number(res.statusCode);\nconst body = res.body === undefined ? res : res.body;\n\n// `Poll new products` is deliberately non-fatal (alwaysOutputData +\n// continueRegularOutput + neverError) so a Shopify hiccup never red-flags a\n// scheduled run mid-flight. That also means a DNS/connection failure — an empty\n// or mistyped shopDomain, a shop that is down — arrives here as an item with NO\n// status code at all. Reporting that as \"your shop has no products\" would leave\n// a broken install looking green forever, so it is an error with the fix in it.\nif (!Number.isFinite(status) || status <= 0) {\n  const raw = res.error;\n  const detail = clean(typeof raw === 'string' ? raw : (raw && (raw.message || raw.description)) || res.message);\n  const shop = clean(cfg.shopDomain);\n  throw new Error(\n    'Could not reach the Shopify Admin API at https://' + (shop || '<shopDomain is empty>') + '.myshopify.com — no HTTP response came back'\n    + (detail ? ' (' + detail + ')' : '') + '. Check that shopDomain in Config is just the subdomain (my-shop, not my-shop.myshopify.com) and that the shop is reachable.'\n  );\n}\n\nif (status !== 200) {\n  const hint = status === 401 || status === 403\n    ? 'The X-Shopify-Access-Token credential was rejected. Re-check the custom app token (it starts with shpat_) and that the app has read_products.'\n    : status === 404\n      ? 'That admin API path does not exist on this shop. Check shopDomain (just the my-shop part) and apiVersion in Config.'\n      : status === 429\n        ? 'Shopify is rate-limiting the admin API. The schedule polls every 30 minutes, which never hits the limit on its own — if you lowered the interval or another workflow shares this token, raise the interval again. Nothing was lost; the next poll picks the product up.'\n        : 'Shopify returned an error.';\n  throw new Error('Shopify products poll failed (HTTP ' + status + '). ' + hint);\n}\n\nconst products = body && Array.isArray(body.products) ? body.products : [];\nif (!products.length) {\n  return [{ json: { found: false, source: 'shopify', checked: 0, reason: 'The shop returned no products. Nothing to announce.' } }];\n}\n\nconst store = $getWorkflowStaticData('global');\nconst newest = products[0];\nconst newestId = String(newest.id);\n\nif (!store.lastProductId) {\n  store.lastProductId = newestId;\n  return [{\n    json: {\n      found: false,\n      source: 'shopify',\n      checked: products.length,\n      seeded: true,\n      reason: 'First Shopify run: remembered \"' + clean(newest.title) + '\" as the latest product. The next product you publish gets a video.',\n    },\n  }];\n}\n\nif (String(store.lastProductId) === newestId) {\n  return [{ json: { found: false, source: 'shopify', checked: products.length, reason: 'No product newer than the last one announced.' } }];\n}\n\nconst variant = (Array.isArray(newest.variants) ? newest.variants : [])[0] || {};\nconst imageUrls = (Array.isArray(newest.images) ? newest.images : [])\n  .map((im) => clean(im && im.src))\n  .filter((u) => /^https?:\\/\\//i.test(u))\n  .slice(0, 2);\nif (!imageUrls.length && newest.image && clean(newest.image.src)) imageUrls.push(clean(newest.image.src));\nif (!imageUrls.length) {\n  throw new Error('Product \"' + clean(newest.title) + '\" has no image in the Shopify API response. Add a product photo and publish it again.');\n}\n\n// body_html is real HTML — strip tags and decode the entities Shopify emits\n// before any of it reaches the design.\nconst plain = String(newest.body_html || '')\n  .replace(/<br\\s*\\/?>/gi, ' ')\n  .replace(/<\\/(p|div|li|h[1-6])>/gi, ' ')\n  .replace(/<[^>]*>/g, ' ')\n  .replace(/&nbsp;/gi, ' ')\n  .replace(/&amp;/gi, '&')\n  .replace(/&lt;/gi, '<')\n  .replace(/&gt;/gi, '>')\n  .replace(/&quot;/gi, '\"')\n  .replace(/&#39;/g, \"'\")\n  .replace(/\\s+/g, ' ')\n  // Stripping tags leaves gaps in front of punctuation (\"matte , satin\").\n  .replace(/\\s+([,.;:!?%])/g, '$1')\n  .trim();\n\nreturn [{\n  json: {\n    found: true,\n    source: 'shopify',\n    title: clean(newest.title),\n    price: clean(variant.price),\n    compareAtPrice: clean(variant.compare_at_price),\n    tagline: trimTo(plain, maxTagline) || fallbackTagline,\n    imageUrls,\n    rowNumber: null,\n    productId: newestId,\n    handle: clean(newest.handle),\n    checked: products.length,\n  },\n}];\n"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1830,
        480
      ],
      "id": "zvid-sp-0008",
      "name": "New product?"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "product-found--cond",
              "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": [
        2310,
        240
      ],
      "id": "zvid-sp-0009",
      "name": "Product found?"
    },
    {
      "parameters": {
        "jsCode": "// Nothing is waiting. This is a normal, successful outcome - not an error, so\n// the scheduled run stays green and simply reports why it did nothing.\nconst picked = $input.first().json;\nreturn [{\n  json: {\n    rendered: false,\n    source: picked.source,\n    reason: picked.reason || 'Nothing to render.',\n    productsChecked: picked.checked,\n    seededShopifyMarker: picked.seeded === true,\n  },\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2570,
        240
      ],
      "id": "zvid-sp-0010",
      "name": "Nothing to render"
    },
    {
      "parameters": {
        "method": "HEAD",
        "url": "={{ $('Config').first().json.musicUrl }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          },
          "redirect": {
            "redirect": {}
          },
          "timeout": 15000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        50,
        1160
      ],
      "id": "zvid-sp-0011",
      "name": "Check music",
      "onError": "continueRegularOutput",
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "// === buildProject — identical to the frame-reviewed standalone builder ===\n// One product (from the sheet or from the Shopify products poll) becomes a\n// three-scene 1080x1920 promo: hook / offer / call to action.\n//\n// Product photography is the one thing this template must never damage, so\n// every product image is placed with resize:'contain' inside a designed plate.\n// Nothing is ever cropped, whatever aspect ratio the store uploads.\nfunction buildProject(config, product, probe) {\n  const cfg = config || {};\n  const clean = (v) => String(v == null ? '' : v).replace(/\\s+/g, ' ').trim();\n  const esc = (s) => String(s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n  const round2 = (n) => Math.round(n * 100) / 100;\n\n  // ---- colour helpers -----------------------------------------------------\n  const hexToRgb = (hex) => {\n    const m = /^#?([0-9a-f]{6})$/i.exec(clean(hex));\n    if (!m) return null;\n    const n = parseInt(m[1], 16);\n    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n  };\n  const rgba = (hex, a, fallback) => {\n    const c = hexToRgb(hex) || fallback || [20, 26, 32];\n    return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')';\n  };\n  const lighten = (hex, amt) => {\n    const c = hexToRgb(hex) || [20, 26, 32];\n    const h = (v) => ('0' + Math.min(255, Math.max(0, v + amt)).toString(16)).slice(-2);\n    return '#' + h(c[0]) + h(c[1]) + h(c[2]);\n  };\n  // Relative luminance + WCAG contrast, so the design stays legible whatever\n  // colours the store puts in Config.\n  const luminance = (hex) => {\n    const c = hexToRgb(hex) || [0, 0, 0];\n    const lin = c.map((v) => {\n      const s = v / 255;\n      return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);\n    });\n    return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];\n  };\n  const contrast = (a, b) => {\n    const la = luminance(a);\n    const lb = luminance(b);\n    return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);\n  };\n\n  // ---- brand knobs --------------------------------------------------------\n  const ink = /^#?[0-9a-f]{6}$/i.test(clean(cfg.brandColor)) ? clean(cfg.brandColor) : '#141A20';\n  const accent = /^#?[0-9a-f]{6}$/i.test(clean(cfg.brandAccent)) ? clean(cfg.brandAccent) : '#E1623C';\n  const CREAM = '#F6F2EA'; // fixed paper tone the whole system is built on\n  const CREAM_RGB = [246, 242, 234];\n  // Label colour for anything sitting ON the accent (chips, badge, button,\n  // monogram): whichever of the brand ink / paper / plain black / plain white\n  // contrasts most. Picking the best of those is never worse than 4:1, so a\n  // pale yellow accent gets dark labels and a deep navy accent gets light ones\n  // without anyone having to think about it.\n  const onAccent = [ink, CREAM, '#111111', '#FFFFFF']\n    .map((c) => ({ c, r: contrast(c, accent) }))\n    .sort((a, b) => b.r - a.r)[0].c;\n  // Accent used as TEXT on the dark base: nudged towards the light until it\n  // clears 3.5:1, so a very dark brand accent is still readable on ink.\n  const readableAccent = (() => {\n    let c = hexToRgb(accent) || [225, 98, 60];\n    const hex2 = (v) => ('0' + Math.max(0, Math.min(255, Math.round(v))).toString(16)).slice(-2);\n    const goLighter = luminance(ink) < 0.35;\n    for (let i = 0; i < 14; i += 1) {\n      const cur = '#' + hex2(c[0]) + hex2(c[1]) + hex2(c[2]);\n      if (contrast(cur, ink) >= 3.5) return cur;\n      c = c.map((v) => (goLighter ? v + (255 - v) * 0.18 : v * 0.82));\n    }\n    return goLighter ? '#FFFFFF' : '#111111';\n  })();\n  const titleFont = clean(cfg.titleFont) || 'Fraunces';\n  const uiFont = clean(cfg.uiFont) || 'Archivo';\n  const brandName = clean(cfg.brandName) || 'Your store';\n  const brandHandle = clean(cfg.brandHandle);\n  const shopNowText = clean(cfg.shopNowText) || 'Shop now';\n  const kicker = (clean(cfg.newArrivalKicker) || 'NEW ARRIVAL').toUpperCase();\n\n  // storeUrl may be a full URL or a bare domain — the video only ever shows\n  // the host, never a protocol or a tracking query string.\n  const storeDomain = clean(cfg.storeUrl)\n    .replace(/^[a-z]+:\\/\\//i, '')\n    .replace(/^www\\./i, '')\n    .split(/[/?#]/)[0]\n    .toLowerCase();\n\n  // ---- product ------------------------------------------------------------\n  const title = clean(product && product.title);\n  if (!title) throw new Error('The product has no title — nothing to put on screen.');\n  const tagline = clean(product && product.tagline);\n  const images = (Array.isArray(product && product.imageUrls) ? product.imageUrls : [])\n    .map(clean)\n    .filter((u) => /^https?:\\/\\//i.test(u));\n  if (!images.length) throw new Error('The product has no usable image URL. Product images must be public https URLs (Shopify CDN links are).');\n  const heroImage = images[0];\n  // A second angle is a nicer second scene, but one photo is the common case:\n  // the offer scene then re-frames the same shot — a different plate, and a\n  // drift that moves the opposite way on both axes (see `drawBack` below).\n  const offerImage = images[1] || images[0];\n  const imageReused = !images[1];\n\n  // ---- price / saving -----------------------------------------------------\n  // Sheets carry \"$189.00\" or \"189\"; the Shopify API carries \"189.00\". Keep a\n  // symbol the merchant typed, add the configured one when there is none.\n  const symbol = clean(cfg.currencySymbol) || '$';\n  const parseMoney = (raw) => {\n    const digits = clean(raw).replace(/[^0-9.,]/g, '').replace(/,(?=\\d{3}\\b)/g, '');\n    const n = Number(digits.replace(/,/g, '.'));\n    return Number.isFinite(n) && n > 0 ? n : 0;\n  };\n  const displayMoney = (raw) => {\n    const s = clean(raw);\n    if (!s) return '';\n    return /[0-9]/.test(s) && !/[^\\d.,\\s]/.test(s) ? symbol + s : s;\n  };\n  const priceValue = parseMoney(product && product.price);\n  const compareValue = parseMoney(product && product.compareAtPrice);\n  const priceText = displayMoney(product && product.price);\n  const compareText = compareValue > priceValue ? displayMoney(product && product.compareAtPrice) : '';\n  const savePct = compareText && priceValue > 0 ? Math.round((1 - priceValue / compareValue) * 100) : 0;\n  const showSave = savePct >= 1 && savePct <= 95;\n\n  // ---- music guard --------------------------------------------------------\n  // `probe` is the full HEAD response for cfg.musicUrl. An unreachable or\n  // oversized bed is dropped, never fatal — the promo still renders.\n  const musicUrl = clean(cfg.musicUrl);\n  const limit = Number(cfg.maxMusicBytes) > 0 ? Number(cfg.maxMusicBytes) : 5242880;\n  const headers = (probe && probe.headers) || {};\n  const musicBytes = Number(headers['content-length'] || headers['Content-Length'] || 0);\n  const musicOk = !!musicUrl && Number(probe && probe.statusCode) === 200 && (!musicBytes || musicBytes <= limit);\n  const musicSkipped = musicOk\n    ? null\n    : (!musicUrl ? 'musicUrl empty' : (Number(probe && probe.statusCode) !== 200 ? 'HEAD ' + ((probe && probe.statusCode) || 'failed') : musicBytes + ' bytes > ' + limit));\n\n  // ---- adaptive type ------------------------------------------------------\n  // Greedy word-wrap estimate calibrated on the rendered design sources:\n  // Fraunces 700 mixed-case runs ~0.52 em per character, Archivo 500 ~0.50 em.\n  const wrapLines = (text, fontPx, boxPx, avgEm) => {\n    const perLine = Math.max(5, Math.floor(boxPx / (fontPx * avgEm)));\n    let n = 1;\n    let used = 0;\n    for (const w of String(text).split(' ')) {\n      const need = used === 0 ? w.length : used + 1 + w.length;\n      if (need <= perLine) { used = need; continue; }\n      n += 1;\n      used = Math.min(w.length, perLine);\n    }\n    return n;\n  };\n  // Steps down through a size ladder until the copy fits in `maxLines`, so a\n  // 70-character product name never pushes the block into the footer. When even\n  // the smallest step still overflows — Shopify allows a 255-character product\n  // title and a sheet cell is unbounded — the copy itself is trimmed on a word\n  // boundary and marked with an ellipsis. The block can then never grow past\n  // `maxLines`, so nothing below it is ever pushed off the 1920 px canvas.\n  const fitBlock = (text, sizes, boxPx, avgEm, maxLines) => {\n    const src = String(text);\n    for (const size of sizes) {\n      const lines = wrapLines(src, size, boxPx, avgEm);\n      if (lines <= maxLines) return { size, lines, text: src, truncated: false };\n    }\n    const size = sizes[sizes.length - 1];\n    const perLine = Math.max(5, Math.floor(boxPx / (size * avgEm)));\n    const budget = Math.max(4, perLine * maxLines - 1); // one char of room for the ellipsis\n    let cut = src.slice(0, budget);\n    const at = cut.lastIndexOf(' ');\n    if (at > budget * 0.5) cut = cut.slice(0, at);\n    let out = cut.replace(/[\\s,;:.—-]+$/, '') + '…';\n    while (wrapLines(out, size, boxPx, avgEm) > maxLines && out.length > 3) {\n      out = out.slice(0, -2).replace(/[\\s,;:.—-]+$/, '') + '…';\n    }\n    return { size, lines: wrapLines(out, size, boxPx, avgEm), text: out, truncated: true };\n  };\n\n  const titleFit = fitBlock(title, [92, 80, 68, 58, 50, 44], 900, 0.52, 3);\n  const taglineFit = tagline\n    ? fitBlock(tagline, [46, 40, 35, 31], 900, 0.5, 3)\n    : { size: 0, lines: 0, text: '', truncated: false };\n  // A one-line block can rise into place; a paragraph must not, because the\n  // masked rise uncovers its LAST line first and the sentence reads bottom-up\n  // for a third of a second. Multi-line copy fades in as one block.\n  const reveal = (fit) => (fit.lines > 1 ? 'fade' : 'smoothup');\n\n  // ---- product photo layer ------------------------------------------------\n  // A store's photos arrive in every aspect ratio there is (square, 4:5, 2:3,\n  // wide lifestyle shots) and cropping one is how an automation ruins a\n  // product page. This places the photo as an IMAGE LAYER inside a fixed\n  // plate: `object-fit: contain` fits the whole shot in the box, centred,\n  // never cropped and never stretched — the box, not the photo, decides the\n  // composition. (The element-level `resize: 'contain'` field is a different\n  // thing: it fits the media to the whole 1080x1920 canvas.)\n  // `max-width`/`max-height` (not width/height) keep the browser's own\n  // aspect-preserving fit, and leaving the element unsized makes the rendered\n  // layer exactly the size of the fitted photo — so `anchor: center-center`\n  // centres the PHOTO in the plate, with no transparent letterbox padding.\n  //\n  // `edge` is a hairline drawn on the span that WRAPS the photo, so it lands\n  // exactly on the fitted photo's real edge whatever aspect ratio the store\n  // uploaded. It is the tone the plate is NOT, so the photo still reads as a\n  // mounted print in the two cases where the plate would otherwise swallow it:\n  // a white studio cut-out on the cream plate, and a dark lifestyle shot on the\n  // dark card.\n  //\n  // The photo also MOVES. `.kbox` is the plate's photo box (fixed w x h,\n  // overflow hidden); the photo lives inside it and is animated by customCode:\n  // an entrance, then a slow continuous drift for the rest of the scene. Every\n  // keyframe keeps the scale at or below 1, and the box is what the photo is\n  // measured against — so the whole product is on screen the entire time and\n  // the drift can never crop it. The two scenes drift in DIFFERENT directions\n  // (scene 1 rises and pushes in, scene 2 slides across and pulls back), which\n  // is what re-frames the shot when a product has only one photo.\n  //\n  // customCode captures ONE loop and repeats it, so `animationDuration` is the\n  // element's full on-screen length and the loop never restarts mid-scene.\n  // The element itself stays UNSIZED: `.kbox` carries the box, so the rendered\n  // layer measures exactly w x h from its content. (Declaring width/height on\n  // the element instead makes the layout linter treat the photo as a sized\n  // region overlapping the full-canvas backdrop and warn about it.)\n  const photoLayer = (src, cx, cy, w, h, track, textColor, edge, motion) => ({\n    type: 'TEXT',\n    x: cx,\n    y: cy,\n    anchor: 'center-center',\n    track,\n    enterBegin: 0,\n    exitEnd: motion.seconds,\n    style: { fontFamily: uiFont, color: textColor },\n    customCode: {\n      animationDuration: motion.seconds,\n      css:\n        '.kbox{width:' + w + 'px; height:' + h + 'px; display:flex; align-items:center; justify-content:center; overflow:hidden}' +\n        '.kbi{display:inline-block; line-height:0; border:1px solid ' + edge + '; animation:' + motion.name + ' ' + motion.seconds + 's linear 1 both}' +\n        motion.keyframes,\n    },\n    html:\n      \"<div class='kbox'><span class='kbi'><img src='\" + esc(src) +\n      \"' alt='' style='max-width:\" + (w - 2) + 'px; max-height:' + (h - 2) + \"px; display:block;'/></span></div>\",\n  });\n\n  // Rise + push in (scene 1). Slack check: at scale 0.86 a box-filling photo\n  // leaves 7% of the box free on each side, which is more than the 46 px it is\n  // lifted by, so even the entrance frame is fully inside the box.\n  const EASE = 'cubic-bezier(0.22,0.61,0.36,1)';\n  const riseIn = (seconds) => ({\n    name: 'sppKbRise',\n    seconds,\n    keyframes:\n      '@keyframes sppKbRise{' +\n      '0%{opacity:0; transform:translate3d(0,46px,0) scale(0.86); animation-timing-function:' + EASE + '}' +\n      '13%{opacity:1; transform:translate3d(0,0,0) scale(0.9); animation-timing-function:linear}' +\n      '100%{opacity:1; transform:translate3d(0,0,0) scale(1)}}',\n  });\n  // Slide across + pull back (scene 2) — the opposite move on both axes, so the\n  // same photograph reads as a new shot after the cut.\n  const drawBack = (seconds) => ({\n    name: 'sppKbDraw',\n    seconds,\n    keyframes:\n      '@keyframes sppKbDraw{' +\n      '0%{opacity:0; transform:translate3d(-54px,0,0) scale(0.86); animation-timing-function:' + EASE + '}' +\n      '12%{opacity:1; transform:translate3d(-10px,0,0) scale(0.94); animation-timing-function:linear}' +\n      '100%{opacity:1; transform:translate3d(16px,0,0) scale(0.88)}}',\n  });\n\n  // Photo boxes are inset from their plate by MAT on every side. MAT is larger\n  // than the plates' 48px corner radius on purpose: a square product photo (the\n  // most common e-commerce crop) fills its box in BOTH axes, so a smaller inset\n  // would push the photo's square corners under the plate's rounded ones and\n  // clip them. Insetting past the radius makes every aspect ratio safe.\n  const MAT = 56;\n\n  // Chips, badges and buttons: the pill itself is the ELEMENT (background +\n  // radius on `style`), the inner div only carries padding and type. That way\n  // the declared colours are the ones actually painted — the layout linter can\n  // check the real label-on-accent contrast instead of guessing at the scene\n  // background behind it.\n  const chipStyle = (bg, fg) => ({ fontFamily: uiFont, color: fg, backgroundColor: bg, borderRadius: '999px' });\n\n  // ---- timing -------------------------------------------------------------\n  // A scene transition overlaps its two scenes, so every non-last scene is\n  // padded by its transition length and the on-screen time stays as designed.\n  const T1 = 0.6;\n  const T2 = 0.55;\n  const hookDur = round2(4.5 + T1);\n  const offerDur = round2(5.2 + T2);\n  const ctaDur = 4.6;\n  const totalSeconds = round2(hookDur + offerDur + ctaDur - T1 - T2);\n\n  // =========================================================================\n  // Scene 1 — hook: kicker, product on a cream plate, product name\n  // =========================================================================\n  const hookSvg =\n    \"<svg width='1080' height='1920' viewBox='0 0 1080 1920' xmlns='http://www.w3.org/2000/svg'><defs>\" +\n    \"<radialGradient id='sppGlow' cx='0.5' cy='0.4' r='0.75'>\" +\n    \"<stop offset='0' stop-color='\" + accent + \"' stop-opacity='0.42'/>\" +\n    \"<stop offset='0.52' stop-color='\" + accent + \"' stop-opacity='0.12'/>\" +\n    \"<stop offset='1' stop-color='\" + ink + \"' stop-opacity='0'/>\" +\n    '</radialGradient></defs>' +\n    \"<rect width='1080' height='1920' fill='\" + ink + \"'/>\" +\n    \"<rect width='1080' height='1920' fill='url(#sppGlow)'/>\" +\n    \"<rect x='54' y='54' width='972' height='1812' rx='44' fill='none' stroke='\" + rgba(CREAM, 0.18, CREAM_RGB) + \"' stroke-width='1.5'/>\" +\n    \"<path d='M112 320 L112 214 L218 214' fill='none' stroke='\" + accent + \"' stroke-width='8' stroke-linecap='square'/>\" +\n    \"<path d='M968 1606 L968 1712 L862 1712' fill='none' stroke='\" + accent + \"' stroke-width='8' stroke-linecap='square'/>\" +\n    \"<rect x='104' y='424' width='872' height='992' rx='58' fill='\" + rgba(CREAM, 0.07, CREAM_RGB) + \"'/>\" +\n    \"<rect x='120' y='440' width='840' height='960' rx='48' fill='\" + CREAM + \"'/>\" +\n    \"<circle cx='858' cy='1786' r='6' fill='\" + accent + \"'/>\" +\n    \"<circle cx='894' cy='1786' r='6' fill='\" + rgba(CREAM, 0.4, CREAM_RGB) + \"'/>\" +\n    \"<circle cx='930' cy='1786' r='6' fill='\" + rgba(CREAM, 0.4, CREAM_RGB) + \"'/>\" +\n    '</svg>';\n\n  const titleTopY = 1478;\n  const titleBlockH = Math.round(titleFit.lines * titleFit.size * 1.08);\n  const hookVisuals = [\n    { type: 'SVG', width: 1080, height: 1920, track: 0, enterBegin: 0, exitEnd: hookDur, svg: hookSvg },\n    // cream plate: x 120..960, y 440..1400 -> centre (540, 920)\n    photoLayer(heroImage, 540, 920, 840 - 2 * MAT, 960 - 2 * MAT, 1, CREAM, rgba(ink, 0.16), riseIn(hookDur)),\n    {\n      type: 'TEXT', x: 540, y: 150, anchor: 'top-center', track: 2, enterBegin: 0.1, enterEnd: 0.5, enterAnimation: 'fade', exitEnd: hookDur,\n      style: { fontFamily: uiFont, color: CREAM, borderRadius: '999px', border: '1.5px solid ' + rgba(CREAM, 0.55, CREAM_RGB) },\n      html: \"<div style='display:inline-block; padding:15px 40px; font-size:25px; font-weight:700; letter-spacing:7px;'>\" + esc(brandName.toUpperCase()) + '</div>',\n    },\n    {\n      type: 'TEXT', x: 90, y: 1408, anchor: 'top-left', track: 3, enterBegin: 0.45, enterEnd: 0.85, enterAnimation: 'smoothright', exitEnd: hookDur,\n      style: { fontFamily: uiFont, color: onAccent, backgroundColor: accent, borderRadius: '8px' },\n      html: \"<div style='display:inline-block; padding:12px 26px; font-size:25px; font-weight:800; letter-spacing:6px;'>\" + esc(kicker) + '</div>',\n    },\n    {\n      type: 'TEXT', x: 90, y: titleTopY, width: 900, anchor: 'top-left', track: 4, enterBegin: 0.7, enterEnd: 1.2, enterAnimation: reveal(titleFit), exitEnd: hookDur,\n      style: { fontFamily: titleFont, color: CREAM },\n      html: \"<div style='color:\" + CREAM + '; font-size:' + titleFit.size + \"px; line-height:1.08; font-weight:700; letter-spacing:-1px;'>\" + esc(titleFit.text) + '</div>',\n    },\n    {\n      type: 'TEXT', x: 90, y: Math.max(1770, titleTopY + titleBlockH + 40), anchor: 'top-left', track: 5, enterBegin: 1.1, enterEnd: 1.45, enterAnimation: 'fade', exitEnd: hookDur,\n      style: { fontFamily: uiFont, color: rgba(CREAM, 0.62, CREAM_RGB) },\n      html: \"<div style='color:\" + rgba(CREAM, 0.62, CREAM_RGB) + \"; font-size:25px; font-weight:600; letter-spacing:4px;'>\" + esc((storeDomain || brandHandle || brandName).toUpperCase()) + '</div>',\n    },\n  ];\n\n  // =========================================================================\n  // Scene 2 — offer: second angle, tagline, price + saving\n  // =========================================================================\n  const offerSvg =\n    \"<svg width='1080' height='1920' viewBox='0 0 1080 1920' xmlns='http://www.w3.org/2000/svg'><defs>\" +\n    \"<linearGradient id='sppCard' x1='0' y1='0' x2='0' y2='1'>\" +\n    \"<stop offset='0' stop-color='\" + lighten(ink, 16) + \"'/><stop offset='1' stop-color='\" + ink + \"'/>\" +\n    '</linearGradient></defs>' +\n    \"<rect width='1080' height='1920' fill='\" + CREAM + \"'/>\" +\n    \"<circle cx='980' cy='190' r='250' fill='\" + rgba(accent, 0.12) + \"'/>\" +\n    \"<circle cx='120' cy='1780' r='170' fill='\" + rgba(accent, 0.1) + \"'/>\" +\n    \"<rect x='110' y='232' width='860' height='886' rx='48' fill='url(#sppCard)'/>\" +\n    \"<path d='M110 1198 H970' stroke='\" + rgba(ink, 0.16) + \"' stroke-width='2'/>\" +\n    \"<rect x='110' y='1198' width='150' height='5' rx='2.5' fill='\" + accent + \"'/>\" +\n    \"<circle cx='930' cy='1802' r='6' fill='\" + rgba(ink, 0.35) + \"'/>\" +\n    \"<circle cx='894' cy='1802' r='6' fill='\" + rgba(ink, 0.35) + \"'/>\" +\n    \"<circle cx='858' cy='1802' r='6' fill='\" + accent + \"'/>\" +\n    '</svg>';\n\n  const taglineTopY = 1256;\n  const taglineBlockH = tagline ? Math.round(taglineFit.lines * taglineFit.size * 1.34) : 0;\n  const priceY = tagline ? Math.max(1452, taglineTopY + taglineBlockH + 46) : 1330;\n\n  // Inline spans (not flex) so the element is measured from its content and the\n  // struck compare-at price sits on the same baseline as the live price.\n  const priceHtml = (() => {\n    let h = \"<div style='white-space:nowrap;'>\";\n    h += \"<span style='color:\" + ink + \"; font-size:96px; line-height:1.1; font-weight:800; letter-spacing:-2px;'>\" + esc(priceText || '—') + '</span>';\n    if (compareText) {\n      h += \"<span style='margin-left:28px; color:\" + rgba(ink, 0.45) + \"; font-size:46px; font-weight:600; text-decoration:line-through;'>\" + esc(compareText) + '</span>';\n    }\n    return h + '</div>';\n  })();\n\n  const offerVisuals = [\n    { type: 'SVG', width: 1080, height: 1920, track: 0, enterBegin: 0, exitEnd: offerDur, svg: offerSvg },\n    // dark card: x 110..970, y 232..1118 -> centre (540, 675)\n    photoLayer(offerImage, 540, 675, 860 - 2 * MAT, 886 - 2 * MAT, 1, ink, rgba(CREAM, 0.2, CREAM_RGB), drawBack(offerDur)),\n    // NOTE: no brand wordmark at the top of this scene. The hook already sets\n    // one at y=150; repeating the same string 2 px away meant the wipe between\n    // the two scenes printed it twice at two offsets. The store signature lives\n    // in the footer of this scene instead.\n    {\n      type: 'TEXT', x: 90, y: priceY, anchor: 'top-left', track: 5, enterBegin: 0.95, enterEnd: 1.35, enterAnimation: 'smoothup', exitEnd: offerDur,\n      style: { fontFamily: uiFont, color: ink },\n      html: priceHtml,\n    },\n    {\n      // The hook footer carries the store signature; this one carries the brand\n      // name, so the two scenes never print the same string in the same place\n      // through the wipe that joins them.\n      type: 'TEXT', x: 90, y: 1790, anchor: 'top-left', track: 7, enterBegin: 1.35, enterEnd: 1.7, enterAnimation: 'fade', exitEnd: offerDur,\n      style: { fontFamily: uiFont, color: rgba(ink, 0.55) },\n      html: \"<div style='color:\" + rgba(ink, 0.55) + \"; font-size:25px; font-weight:600; letter-spacing:4px;'>\" + esc(brandName.toUpperCase()) + '</div>',\n    },\n  ];\n  if (tagline) {\n    offerVisuals.push({\n      type: 'TEXT', x: 90, y: taglineTopY, width: 900, anchor: 'top-left', track: 4, enterBegin: 0.6, enterEnd: 1.05, enterAnimation: reveal(taglineFit), exitEnd: offerDur,\n      style: { fontFamily: titleFont, color: ink },\n      html: \"<div style='color:\" + ink + '; font-size:' + taglineFit.size + \"px; line-height:1.34; font-weight:600; font-style:italic;'>\" + esc(taglineFit.text) + '</div>',\n    });\n  }\n  if (showSave) {\n    offerVisuals.push({\n      type: 'TEXT', x: 852, y: 268, anchor: 'center-center', angle: -8, track: 3, enterBegin: 0.75, enterEnd: 1.15, enterAnimation: 'circleopen', exitEnd: offerDur,\n      style: chipStyle(accent, onAccent),\n      html: \"<div style='display:inline-block; padding:20px 34px; text-align:center; font-size:40px; line-height:1; font-weight:800; letter-spacing:-1px;'>SAVE \" + savePct + '%</div>',\n    });\n  }\n\n  // =========================================================================\n  // Scene 3 — call to action\n  // =========================================================================\n  const monogram = (brandName.replace(/[^A-Za-z0-9]/g, '')[0] || 'S').toUpperCase();\n\n  // The end card is a single centred stack, so its height changes with the\n  // product name (one or two lines) and with whether there is a price to show.\n  // Measure the stack first, then centre it — otherwise a short name leaves a\n  // third of the frame empty under the button.\n  const ctaTitleFit = fitBlock(title, [58, 50, 44, 38], 860, 0.52, 2);\n  const ctaTitleH = Math.round(ctaTitleFit.lines * ctaTitleFit.size * 1.12);\n  const RING_R = 178;\n  const GAP_RING_BRAND = 118;\n  const BRAND_H = 34;\n  const GAP_BRAND_TITLE = 48;\n  const PRICE_H = priceText ? 56 : 0;\n  const GAP_TITLE_PRICE = priceText ? 34 : 0;\n  const GAP_PRICE_PILL = 62;\n  const PILL_H = 100;\n  const GAP_PILL_DOMAIN = 56;\n  const DOMAIN_H = 36;\n  const stackH = 2 * RING_R + GAP_RING_BRAND + BRAND_H + GAP_BRAND_TITLE + ctaTitleH + GAP_TITLE_PRICE + PRICE_H + GAP_PRICE_PILL + PILL_H + GAP_PILL_DOMAIN + DOMAIN_H;\n  const stackTop = Math.max(180, Math.round((1920 - stackH) / 2));\n  const ringCy = stackTop + RING_R;\n  const brandY = stackTop + 2 * RING_R + GAP_RING_BRAND;\n  const ctaTitleY = brandY + BRAND_H + GAP_BRAND_TITLE;\n  const ctaPriceY = ctaTitleY + ctaTitleH + GAP_TITLE_PRICE;\n  const pillY = ctaPriceY + PRICE_H + GAP_PRICE_PILL;\n  const domainY = pillY + PILL_H + GAP_PILL_DOMAIN;\n\n  const ctaSvg =\n    \"<svg width='1080' height='1920' viewBox='0 0 1080 1920' xmlns='http://www.w3.org/2000/svg'><defs>\" +\n    \"<radialGradient id='sppCta' cx='0.5' cy='0.34' r='0.85'>\" +\n    \"<stop offset='0' stop-color='\" + lighten(ink, 26) + \"'/><stop offset='1' stop-color='\" + ink + \"'/>\" +\n    '</radialGradient></defs>' +\n    \"<rect width='1080' height='1920' fill='url(#sppCta)'/>\" +\n    \"<rect x='54' y='54' width='972' height='1812' rx='44' fill='none' stroke='\" + rgba(accent, 0.4) + \"' stroke-width='1.5'/>\" +\n    \"<circle cx='540' cy='\" + ringCy + \"' r='\" + RING_R + \"' fill='none' stroke='\" + rgba(accent, 0.45) + \"' stroke-width='2'/>\" +\n    \"<circle cx='540' cy='\" + ringCy + \"' r='148' fill='\" + accent + \"'/>\" +\n    \"<path d='M112 1606 L112 1712 L218 1712' fill='none' stroke='\" + rgba(CREAM, 0.5, CREAM_RGB) + \"' stroke-width='8' stroke-linecap='square'/>\" +\n    \"<path d='M968 320 L968 214 L862 214' fill='none' stroke='\" + rgba(CREAM, 0.5, CREAM_RGB) + \"' stroke-width='8' stroke-linecap='square'/>\" +\n    '</svg>';\n\n  const ctaVisuals = [\n    { type: 'SVG', width: 1080, height: 1920, track: 0, enterBegin: 0, exitEnd: ctaDur, svg: ctaSvg },\n    {\n      type: 'TEXT', x: 540, y: ringCy, anchor: 'center-center', track: 1, enterBegin: 0.15, enterEnd: 0.55, enterAnimation: 'fade', exitEnd: ctaDur,\n      style: { fontFamily: titleFont, color: onAccent, backgroundColor: accent, borderRadius: '999px' },\n      html: \"<div style='text-align:center; padding:0 30px; font-size:126px; line-height:1.1; font-weight:700;'>\" + esc(monogram) + '</div>',\n    },\n    {\n      type: 'TEXT', x: 540, y: brandY, anchor: 'top-center', track: 2, enterBegin: 0.35, enterEnd: 0.7, enterAnimation: 'fade', exitEnd: ctaDur,\n      style: { fontFamily: uiFont, color: rgba(CREAM, 0.7, CREAM_RGB) },\n      html: \"<div style='text-align:center; color:\" + rgba(CREAM, 0.7, CREAM_RGB) + \"; font-size:26px; font-weight:700; letter-spacing:9px;'>\" + esc(brandName.toUpperCase()) + '</div>',\n    },\n    {\n      type: 'TEXT', x: 540, y: ctaTitleY, width: 860, anchor: 'top-center', track: 3, enterBegin: 0.55, enterEnd: 0.95, enterAnimation: reveal(ctaTitleFit), exitEnd: ctaDur,\n      style: { fontFamily: titleFont, color: CREAM },\n      html: \"<div style='text-align:center; color:\" + CREAM + '; font-size:' + ctaTitleFit.size + \"px; line-height:1.12; font-weight:700;'>\" + esc(ctaTitleFit.text) + '</div>',\n    },\n    {\n      type: 'TEXT', x: 540, y: pillY, anchor: 'top-center', track: 5, enterBegin: 1.0, enterEnd: 1.45, enterAnimation: 'circleopen', exitEnd: ctaDur,\n      style: chipStyle(accent, onAccent),\n      html: \"<div style='display:inline-block; padding:28px 66px; font-size:40px; font-weight:800;'>\" + esc(shopNowText) + '</div>',\n    },\n    {\n      type: 'TEXT', x: 540, y: domainY, anchor: 'top-center', track: 6, enterBegin: 1.35, enterEnd: 1.7, enterAnimation: 'fade', exitEnd: ctaDur,\n      style: { fontFamily: uiFont, color: rgba(CREAM, 0.6, CREAM_RGB) },\n      html: \"<div style='text-align:center; color:\" + rgba(CREAM, 0.6, CREAM_RGB) + \"; font-size:28px; font-weight:600; letter-spacing:3px;'>\" + esc(storeDomain || brandHandle || '') + '</div>',\n    },\n  ];\n  if (priceText) {\n    ctaVisuals.push({\n      type: 'TEXT', x: 540, y: ctaPriceY, anchor: 'top-center', track: 4, enterBegin: 0.8, enterEnd: 1.2, enterAnimation: 'smoothup', exitEnd: ctaDur,\n      style: { fontFamily: uiFont, color: readableAccent },\n      html: \"<div style='text-align:center; color:\" + readableAccent + \"; font-size:44px; font-weight:800; letter-spacing:1px;'>\" + esc(priceText) + (compareText ? \"<span style='margin-left:18px; color:\" + rgba(CREAM, 0.45, CREAM_RGB) + \"; font-size:30px; font-weight:600; text-decoration:line-through;'>\" + esc(compareText) + '</span>' : '') + '</div>',\n    });\n  }\n\n  const scenes = [\n    { id: 'hook', duration: hookDur, backgroundColor: ink, transition: 'smoothup', transitionId: 'offer', transitionDuration: T1, visuals: hookVisuals },\n    { id: 'offer', duration: offerDur, backgroundColor: CREAM, transition: 'fade', transitionId: 'cta', transitionDuration: T2, visuals: offerVisuals },\n    { id: 'cta', duration: ctaDur, backgroundColor: ink, visuals: ctaVisuals },\n  ];\n\n  // The render API only allows letters, digits, spaces, _ and - in `name`.\n  const safeName = ('new-arrival-' + title)\n    .toLowerCase()\n    .normalize('NFKD')\n    .replace(/[\\u0300-\\u036f]/g, '')\n    .replace(/[^a-z0-9]+/g, '-')\n    .replace(/^-+|-+$/g, '')\n    .slice(0, 60);\n\n  const payload = {\n    name: safeName || 'new-arrival',\n    resolution: 'instagram-reel',\n    frameRate: 30,\n    backgroundColor: ink,\n    outputFormat: 'mp4',\n    scenes,\n  };\n  // NOTE: no `track` on audios — the render API rejects audios[].track.\n  if (musicOk) payload.audios = [{ src: musicUrl, volume: Number(cfg.musicVolume) > 0 ? Number(cfg.musicVolume) : 0.16 }];\n\n  return {\n    payload,\n    meta: {\n      title,\n      titleChars: title.length,\n      titleSize: titleFit.size,\n      titleLines: titleFit.lines,\n      titleShown: titleFit.text,\n      titleTruncated: titleFit.truncated,\n      ctaTitleShown: ctaTitleFit.text,\n      ctaTitleTruncated: ctaTitleFit.truncated,\n      tagline,\n      taglineSize: taglineFit.size,\n      taglineLines: taglineFit.lines,\n      taglineTruncated: taglineFit.truncated,\n      priceText,\n      compareText,\n      savePct: showSave ? savePct : 0,\n      imageCount: images.length,\n      imageReused,\n      heroImage,\n      offerImage,\n      storeDomain,\n      monogram,\n      musicIncluded: musicOk,\n      musicSkipped,\n      musicUrl,\n      musicBytes,\n      totalSeconds,\n      source: clean(product && product.source) || 'sheet',\n      rowNumber: product && product.rowNumber,\n      productId: (product && product.productId) || null,\n    },\n  };\n}\n\n// === n8n glue ===\nconst config = $('Config').first().json;\nconst productItem = $('New product?').first().json;\nconst probe = $input.first().json || {};\nconst built = buildProject(config, productItem, probe);\nreturn [{ json: built }];\n"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        310,
        1160
      ],
      "id": "zvid-sp-0012",
      "name": "Build project JSON"
    },
    {
      "parameters": {
        "resource": "render",
        "operation": "validate",
        "source": "json",
        "projectJson": "={{ JSON.stringify(({ payload: $json.payload }).payload) }}"
      },
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "typeVersion": 1,
      "position": [
        570,
        1160
      ],
      "id": "zvid-sp-0013",
      "name": "Validate project (free)"
    },
    {
      "parameters": {
        "jsCode": "// The free validate endpoint runs the exact pipeline a render submission runs,\n// so a failure here is a payload bug - surface the field list instead of a\n// generic HTTP error.\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;\nreturn [{\n  json: {\n    ...build,\n    creditsRequired: body.creditsRequired,\n    warnings: body.warnings || [],\n    schemaVersion: body.schemaVersion,\n  },\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        830,
        1160
      ],
      "id": "zvid-sp-0014",
      "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": [
        1310,
        1160
      ],
      "id": "zvid-sp-0015",
      "name": "Dry run?"
    },
    {
      "parameters": {
        "resource": "project",
        "operation": "create",
        "projectName": "={{ ({ name: $json.payload.name, payload: $json.payload }).name }}",
        "projectJson": "={{ JSON.stringify(({ name: $json.payload.name, payload: $json.payload }).payload) }}"
      },
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "typeVersion": 1,
      "position": [
        1570,
        1160
      ],
      "id": "zvid-sp-0016",
      "name": "Save draft to editor",
      "onError": "continueRegularOutput",
      "alwaysOutputData": true,
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "// Dry run: everything except the paid render. Open editorLink to preview the\n// exact video, then set dryRun to false in Config to go live. Neither the sheet\n// row nor the Shopify last-seen marker is touched, so the next real run picks\n// the same product.\nconst cfg = $('Config').first().json;\nconst d = $('Check validation').first().json;\n\n// The draft save is best-effort (onError: continue) so a hiccup there never\n// hides the rest of the dry-run report.\nlet editorLink = null;\nlet draftId = null;\ntry {\n  const project = $('Save draft to editor').first().json.project;\n  if (project && project.id) {\n    draftId = project.id;\n    editorLink = String(cfg.editorUrl || 'https://editor.zvid.io').replace(/\\/+$/, '') + '/?project=' + project.id;\n  }\n} catch (e) { /* draft step skipped or failed */ }\n\nreturn [{\n  json: {\n    dryRun: true,\n    source: d.meta.source,\n    product: d.meta.title,\n    tagline: d.meta.tagline,\n    price: d.meta.priceText,\n    compareAtPrice: d.meta.compareText || null,\n    savePercent: d.meta.savePct || null,\n    photosUsed: d.meta.imageCount,\n    secondAngle: d.meta.imageReused === false,\n    sheetRow: d.meta.rowNumber,\n    editorLink,\n    draftId,\n    creditsRequired: d.creditsRequired,\n    videoSeconds: d.meta.totalSeconds,\n    warnings: d.warnings,\n    sheetUpdated: false,\n    nextStep: editorLink\n      ? 'Open editorLink to preview the draft, then set dryRun to false in Config to render for real.'\n      : 'Set dryRun to false in Config to render this for real.',\n    payload: d.payload,\n  },\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1830,
        1160
      ],
      "id": "zvid-sp-0017",
      "name": "Dry run summary"
    },
    {
      "parameters": {
        "resource": "render",
        "operation": "create",
        "source": "json",
        "projectJson": "={{ JSON.stringify(({ payload: $json.payload }).payload) }}",
        "renderType": "video",
        "waitForCompletion": false
      },
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "typeVersion": 1,
      "position": [
        2310,
        1160
      ],
      "id": "zvid-sp-0018",
      "name": "Submit render",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "amount": "={{ $('Config').first().json.pollSeconds }}",
        "unit": "seconds"
      },
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        2570,
        1160
      ],
      "id": "zvid-sp-0019",
      "name": "Wait",
      "webhookId": "c4b7e921-3f68-4d05-8a17-52e9d6b03a74"
    },
    {
      "parameters": {
        "resource": "render",
        "operation": "get",
        "jobId": "={{ $('Submit render').first().json.jobId }}",
        "waitForCompletion": false
      },
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "typeVersion": 1,
      "position": [
        2830,
        1160
      ],
      "id": "zvid-sp-0020",
      "name": "Get render status",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "render-finished--cond",
              "leftValue": "={{ $json.state === 'completed' }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3090,
        1160
      ],
      "id": "zvid-sp-0021",
      "name": "Render finished?"
    },
    {
      "parameters": {
        "jsCode": "// Fails fast on a failed render and stops the poll loop from running forever.\nconst cfg = $('Config').first().json;\nconst job = $input.first().json;\n\nif (job.state === 'failed') {\n  throw new Error('Render failed: ' + (job.failedReason || '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('Render did not finish within ' + cfg.timeoutMinutes + ' minutes (job ' + job.id + ', state ' + job.state + ').');\n}\n\nreturn $input.all();"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3090,
        1400
      ],
      "id": "zvid-sp-0022",
      "name": "Still rendering?"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "sheet-mode--cond",
              "leftValue": "={{ String($('Config').first().json.source || 'sheet').toLowerCase() === 'sheet' }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        50,
        2080
      ],
      "id": "zvid-sp-0023",
      "name": "Sheet mode?"
    },
    {
      "parameters": {
        "resource": "sheet",
        "operation": "update",
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "row_number": "={{ $('Build project JSON').first().json.meta.rowNumber }}",
            "Status": "={{ $('Config').first().json.statusDoneValue }}",
            "VideoUrl": "={{ typeof $json.result === 'string' ? $json.result : ($json.result ? $json.result.url : '') }}"
          },
          "matchingColumns": [
            "row_number"
          ],
          "schema": [
            {
              "id": "row_number",
              "displayName": "row_number",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "number",
              "canBeUsedToMatch": true,
              "readOnly": true,
              "removed": false
            },
            {
              "id": "Status",
              "displayName": "Status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "VideoUrl",
              "displayName": "VideoUrl",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        310,
        2080
      ],
      "id": "zvid-sp-0024",
      "name": "Mark row done",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "// Final report for a live run. In sheet mode the row is already marked done by\n// this point. In Shopify mode the announced product id is remembered HERE -\n// only after a successful render - so a failed render retries the same product\n// on the next poll instead of silently skipping it.\nconst cfg = $('Config').first().json;\nconst build = $('Check validation').first().json;\nconst job = $('Get render status').first().json;\nconst url = typeof job.result === 'string' ? job.result : (job.result ? job.result.url : null);\n\nconst source = String(cfg.source || 'sheet').toLowerCase();\nif (source === 'shopify' && build.meta.productId) {\n  const staticData = $getWorkflowStaticData('global');\n  staticData.lastProductId = String(build.meta.productId);\n}\n\nreturn [{\n  json: {\n    rendered: true,\n    videoUrl: url,\n    jobId: job.id,\n    source,\n    product: build.meta.title,\n    price: build.meta.priceText,\n    compareAtPrice: build.meta.compareText || null,\n    savePercent: build.meta.savePct || null,\n    photosUsed: build.meta.imageCount,\n    secondAngle: build.meta.imageReused === false,\n    music: build.meta.musicIncluded ? 'on' : ('off - ' + (build.meta.musicSkipped || 'no musicUrl')),\n    videoSeconds: build.meta.totalSeconds,\n    creditsCharged: build.creditsRequired,\n    sheetRow: build.meta.rowNumber,\n    sheetStatus: source === 'sheet' ? cfg.statusDoneValue : null,\n    productId: build.meta.productId,\n    storeDomain: build.meta.storeDomain,\n  },\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        790,
        2080
      ],
      "id": "zvid-sp-0025",
      "name": "Run summary"
    },
    {
      "parameters": {
        "url": "={{ $json.videoUrl }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file",
              "outputPropertyName": "data"
            }
          }
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1310,
        2080
      ],
      "id": "zvid-sp-0026",
      "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": [
        1050,
        2080
      ],
      "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-product-promo",
      "name": "Workflow overview and setup",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -800,
        0
      ],
      "parameters": {
        "content": "## Create product promo videos\n\n### How it works\nTake one product from Google Sheets or Shopify and render a vertical promo with photos, prices and a call to action. Sheet mode records the completed video URL on the same row.\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. Connect Google Sheets and choose the same spreadsheet and tab in both sheet nodes. Default source is sheet.\n4. For source=shopify, configure shopDomain and Shopify credentials on Poll new products. Set branding and storeUrl in Config.\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-product-promo.md).",
        "color": 1,
        "width": 720,
        "height": 1160
      }
    },
    {
      "id": "publication-section-shopify-product-promo-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-product-promo-source",
      "name": "2. Select a product source",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1000,
        0
      ],
      "parameters": {
        "content": "## 2. Select a product source\n\nChoose sheet or Shopify in Config. Configure only the source you use and keep your product photos publicly accessible.",
        "color": 7,
        "width": 1140,
        "height": 760
      }
    },
    {
      "id": "publication-section-shopify-product-promo-eligible",
      "name": "3. Check the product queue",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2260,
        0
      ],
      "parameters": {
        "content": "## 3. Check the product queue\n\nHeaders: Title, Price, CompareAtPrice, ImageUrl1, ImageUrl2, Tagline, Status, VideoUrl. Title and ImageUrl1 are required; leave Status empty. Use your own product photography.",
        "color": 7,
        "width": 620,
        "height": 520
      }
    },
    {
      "id": "publication-section-shopify-product-promo-validate",
      "name": "4. Build and validate the design",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        920
      ],
      "parameters": {
        "content": "## 4. 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": 1140,
        "height": 520
      }
    },
    {
      "id": "publication-section-shopify-product-promo-preview",
      "name": "5. Choose an optional editor preview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1260,
        920
      ],
      "parameters": {
        "content": "## 5. 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-product-promo-render",
      "name": "6. Render and wait for completion",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2260,
        920
      ],
      "parameters": {
        "content": "## 6. 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-product-promo-record",
      "name": "7. Save the completed result",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        1840
      ],
      "parameters": {
        "content": "## 7. Save the completed result\n\nWrite the completed URL back to its source row. Review the result before adding any further distribution steps.",
        "color": 7,
        "width": 620,
        "height": 520
      }
    },
    {
      "id": "zvid-watch-instructions",
      "name": "8. Review the finished media",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        740,
        1840
      ],
      "parameters": {
        "content": "## 8. 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 30 minutes": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Source?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Source?": {
      "main": [
        [
          {
            "node": "Read products sheet",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Poll new products",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read products sheet": {
      "main": [
        [
          {
            "node": "Pick next product",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick next product": {
      "main": [
        [
          {
            "node": "New product?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Poll new products": {
      "main": [
        [
          {
            "node": "New product?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "New product?": {
      "main": [
        [
          {
            "node": "Product found?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Product found?": {
      "main": [
        [
          {
            "node": "Check music",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Nothing to render",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check music": {
      "main": [
        [
          {
            "node": "Build project JSON",
            "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 render",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save draft to editor": {
      "main": [
        [
          {
            "node": "Dry run summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Submit render": {
      "main": [
        [
          {
            "node": "Wait",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait": {
      "main": [
        [
          {
            "node": "Get render status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get render status": {
      "main": [
        [
          {
            "node": "Render finished?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Render finished?": {
      "main": [
        [
          {
            "node": "Sheet mode?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Still rendering?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Still rendering?": {
      "main": [
        [
          {
            "node": "Wait",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sheet mode?": {
      "main": [
        [
          {
            "node": "Mark row done",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Run summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark row done": {
      "main": [
        [
          {
            "node": "Run summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run summary": {
      "main": [
        [
          {
            "node": "Video ready to watch?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Video ready to watch?": {
      "main": [
        [
          {
            "node": "▶ Watch video",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "6c1d92f4-8ab3-45e7-9f20-b7e3a5d41c86",
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "id": "ZvidShopifyProductPromo",
  "tags": []
}
