This workflow corresponds to n8n.io template #17932 — we link there as the canonical source.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"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(/ /gi, ' ')\n .replace(/&/gi, '&')\n .replace(/"/gi, '\"')\n .replace(/&#(?:39|x27);/gi, \"'\")\n .replace(/</gi, '<')\n .replace(/>/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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"' })[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 creditsS
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
httpHeaderAuth
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow runs manually or on an optional daily schedule, finds active Shopify products without usable video, creates branded vertical videos with Zvid, and either returns editor or render review links or uploads completed MP4s to the matching products through Shopify’s…
Source: https://n8n.io/workflows/17932/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This workflow listens for an “Approved” label on a Trello card, reads the AI draft bookkeeping JSON from card comments, and posts the corresponding transaction to Xero. It then adds a Xero deep link b
02_LLM_Pipeline v1.0. Uses executeWorkflowTrigger, httpRequest, seaTable. Event-driven trigger; 65 nodes.
This workflow allows you to import any workflow from a file or another n8n instance and map the credentials easily. A multi-form setup guides you through the entire process At the beginning you have t
[n8n] Advanced URL Parsing and Shortening Workflow - Switchy.io Integration. Uses splitInBatches, stickyNote, httpRequest, html. Event-driven trigger; 56 nodes.
[](https://youtu.be/c7yCZhmMjtI)