This workflow follows the Google Sheets → HTTP Request recipe pattern — see all workflows that pair these two integrations.
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 →
{
"name": "Daily motivational quote videos from a Google Sheet (Zvid)",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"triggerAtHour": 8
}
]
}
},
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
-460,
380
],
"id": "zvid-dq-0001",
"name": "Every day at 8am"
},
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
-460,
200
],
"id": "zvid-dq-0002",
"name": "Test manually"
},
{
"parameters": {
"mode": "raw",
"jsonOutput": "{\n \"apiUrl\": \"https://api.zvid.io\",\n \"channelName\": \"@stillmind.daily\",\n \"ctaText\": \"Follow for a daily reset\",\n \"quoteFont\": \"Playfair Display\",\n \"uiFont\": \"Inter\",\n \"paperColor\": \"#F0ECE3\",\n \"mutedColor\": \"#B9B4A6\",\n \"inkColor\": \"#14181A\",\n \"musicVolume\": 0.15,\n \"poolIndexOverride\": -1,\n \"backgroundVideoOverride\": \"\",\n \"musicUrlOverride\": \"\",\n \"statusDoneValue\": \"done\",\n \"dryRun\": false,\n \"pollSeconds\": 10,\n \"timeoutMinutes\": 20\n}",
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
-240,
300
],
"id": "zvid-dq-0003",
"name": "Config"
},
{
"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": [
-20,
300
],
"id": "zvid-dq-0004",
"name": "Read quote sheet",
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 5000
},
{
"parameters": {
"jsCode": "// The Google Sheets node emits one item per row plus a row_number field.\n// Keep the FIRST row whose Status column is empty \u2014 row_number lets the\n// update node at the end mark exactly this 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));\nif (!row) {\n return [{ json: { found: false, checked: rows.length } }];\n}\nconst quote = String(row.Quote || '').trim();\nconst author = String(row.Author || '').trim();\nif (!quote || !author) {\n throw new Error('Row ' + row.row_number + ' is missing Quote or Author. Fill both columns, or put anything in Status to skip the row.');\n}\nreturn [{\n json: {\n found: true,\n quote,\n author,\n authorNote: String(row.AuthorNote || '').trim(),\n rowNumber: row.row_number,\n checked: rows.length,\n },\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
200,
300
],
"id": "zvid-dq-0005",
"name": "Pick next quote"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "row-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": [
420,
300
],
"id": "zvid-dq-0006",
"name": "Row found?"
},
{
"parameters": {
"jsCode": "// Every row already has a Status value, so there is nothing to render today.\n// This is a normal, successful outcome \u2014 not an error.\nconst picked = $('Pick next quote').first().json;\nreturn [{\n json: {\n rendered: false,\n reason: 'No row with an empty Status column. Add fresh quote rows to the sheet.',\n rowsChecked: picked.checked,\n },\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
640,
480
],
"id": "zvid-dq-0007",
"name": "Nothing to render today"
},
{
"parameters": {
"jsCode": "// === buildProject \u2014 identical to the frame-reviewed standalone builder ===\nfunction buildProject(config, data) {\n const cfg = config || {};\n const clean = (v) => String(v == null ? '' : v).replace(/\\s+/g, ' ').trim();\n const quote = clean(data.quote);\n const author = clean(data.author);\n const authorNote = clean(data.authorNote);\n if (!quote) throw new Error('The sheet row has no Quote text.');\n if (!author) throw new Error('The sheet row has no Author.');\n\n const channelName = clean(cfg.channelName) || '@yourchannel';\n const ctaText = clean(cfg.ctaText) || 'Follow for more';\n const quoteFont = clean(cfg.quoteFont) || 'Playfair Display';\n const uiFont = clean(cfg.uiFont) || 'Inter';\n const paper = clean(cfg.paperColor) || '#F0ECE3';\n const muted = clean(cfg.mutedColor) || '#B9B4A6';\n const ink = clean(cfg.inkColor) || '#14181A';\n\n const esc = (s) => String(s).replace(/[&<>\"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '\"': '"' }[c]));\n const round = (n) => Math.round(n * 100) / 100;\n\n // ---- daily rotation pool ------------------------------------------------\n // Every URL verified reachable and probed (dimensions/duration/size) when\n // this template was authored. `videoSeconds` is the source clip length: the\n // quote scene must never outlast its background. `overlay` darkens the clip\n // so the paper-coloured text always has contrast \u2014 brighter clips get more.\n const MUSIC_CALM = 'https://cdn.pixabay.com/audio/2025/04/21/audio_ed6f0ed574.mp3'; // 48.6s, 1.5 MB\n const MUSIC_WARM = 'https://cdn.pixabay.com/audio/2025/03/19/audio_56ae1dae5f.mp3'; // 115.5s, 3.7 MB\n const POOL = [\n { label: 'mist-forest', video: 'https://videos.pexels.com/video-files/30149713/12928657_1080_1920_60fps.mp4', videoSeconds: 15.0, music: MUSIC_CALM, overlay: 0.5 },\n { label: 'ember-ink', video: 'https://videos.pexels.com/video-files/15168374/15168374-hd_1080_1920_24fps.mp4', videoSeconds: 38.9, music: MUSIC_WARM, overlay: 0.55 },\n { label: 'rain-window', video: 'https://videos.pexels.com/video-files/19802056/19802056-uhd_1440_2560_30fps.mp4', videoSeconds: 50.7, music: MUSIC_CALM, overlay: 0.45 },\n { label: 'cloud-sea', video: 'https://videos.pexels.com/video-files/32634790/13916180_1080_1920_25fps.mp4', videoSeconds: 11.0, music: MUSIC_WARM, overlay: 0.62 },\n { label: 'calm-shore', video: 'https://videos.pexels.com/video-files/28197979/12324927_1080_1920_60fps.mp4', videoSeconds: 10.4, music: MUSIC_CALM, overlay: 0.62 },\n ];\n\n const when = data.date ? new Date(data.date) : new Date();\n const dayOfYear = Math.floor((Date.UTC(when.getUTCFullYear(), when.getUTCMonth(), when.getUTCDate()) - Date.UTC(when.getUTCFullYear(), 0, 0)) / 86400000);\n const overrideIdx = Number(cfg.poolIndexOverride);\n const poolIndex = Number.isInteger(overrideIdx) && overrideIdx >= 0 ? overrideIdx % POOL.length : dayOfYear % POOL.length;\n const entry = POOL[poolIndex];\n\n const bgVideo = clean(cfg.backgroundVideoOverride) || entry.video;\n const musicUrl = clean(cfg.musicUrlOverride) || entry.music;\n // An override video has an unknown length, so never let the quote scene run\n // past the shortest clip in the pool.\n const bgSeconds = clean(cfg.backgroundVideoOverride) ? 10.4 : entry.videoSeconds;\n\n // ---- adaptive type ------------------------------------------------------\n // Greedy word-wrap estimate calibrated against the rendered source example\n // (\"Almost everything will work\" = 27 chars exactly fills 840px at 62px, so\n // the average glyph runs ~0.50em in Playfair italic).\n const wrapLines = (text, fontPx, boxPx, avgEm) => {\n const perLine = Math.max(6, Math.floor(boxPx / (fontPx * avgEm)));\n let lines = 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 lines += 1;\n used = Math.min(w.length, perLine);\n }\n return lines;\n };\n\n const qLen = quote.length;\n const quoteSize = qLen <= 90 ? 62 : qLen <= 140 ? 54 : qLen <= 180 ? 48 : 42;\n const quoteLines = wrapLines(quote, quoteSize, 840, 0.5);\n const quoteBlockH = Math.round(quoteLines * quoteSize * 1.35);\n // Keep the block visually centred where the source design places it, but\n // never above the big quote glyph.\n const quoteY = Math.max(600, Math.min(700, Math.round(800 - quoteBlockH / 2)));\n\n // Reading-time scene length: never past the background clip.\n const quoteSeconds = round(Math.min(Math.max(7, 3.5 + qLen * 0.03), 9.8, bgSeconds - 0.4));\n const cardSeconds = 4.6;\n\n const nameSize = author.length <= 20 ? 56 : author.length <= 30 ? 46 : 40;\n const nameLines = wrapLines(author, nameSize, 860, 0.55);\n const noteY = Math.round(850 + nameLines * nameSize * 1.25 + 30);\n const noteLines = authorNote ? wrapLines(authorNote, 27, 800, 0.62 + 2 / 27) : 0;\n const ctaY = Math.max(1160, noteY + noteLines * 40 + 110);\n\n // ---- scenes -------------------------------------------------------------\n const s1 = {\n id: 'quote',\n duration: quoteSeconds,\n backgroundColor: '#1B1E22',\n transition: 'fade',\n transitionId: 'author',\n transitionDuration: 0.8,\n visuals: [\n { type: 'VIDEO', src: bgVideo, width: 1080, height: 1920, position: 'center-center', resize: 'cover', volume: 0, track: 0, enterBegin: 0, exitEnd: quoteSeconds },\n {\n type: 'SVG', width: 1080, height: 1920, track: 1, enterBegin: 0, exitEnd: quoteSeconds,\n svg: \"<svg width='1080' height='1920' viewBox='0 0 1080 1920' xmlns='http://www.w3.org/2000/svg'><rect width='1080' height='1920' fill='rgba(20,24,26,\" + entry.overlay + \")'/><rect x='70' y='70' width='940' height='1780' fill='none' stroke='rgba(240,236,227,0.35)' stroke-width='1'/><text x='150' y='560' font-family='Georgia' font-size='220' fill='rgba(240,236,227,0.55)'>“</text><path d='M420 1480 H660' stroke='rgba(240,236,227,0.6)' stroke-width='1.5'/></svg>\",\n },\n {\n type: 'TEXT', x: 540, y: 170, anchor: 'top-center', track: 2, enterBegin: 0.3, enterEnd: 0.9, enterAnimation: 'fade', exitEnd: quoteSeconds,\n style: { fontFamily: uiFont },\n html: \"<div style=\\\"text-align:center; color:\" + muted + \"; font-size:23px; font-weight:600; letter-spacing:7px; text-shadow:0 2px 14px rgba(20,24,26,0.75);\\\">\" + esc(channelName) + '</div>',\n },\n {\n type: 'TEXT', x: 540, y: quoteY, width: 840, anchor: 'top-center', track: 3, enterBegin: 0.7, enterEnd: 1.5, enterAnimation: 'smoothup', exitEnd: quoteSeconds,\n style: { fontFamily: quoteFont },\n html: \"<div style=\\\"text-align:center; color:\" + paper + '; font-size:' + quoteSize + \"px; line-height:1.35; font-style:italic;\\\">\" + esc(quote) + '</div>',\n },\n {\n type: 'TEXT', x: 540, y: 1530, width: 800, anchor: 'top-center', track: 4, enterBegin: 1.8, enterEnd: 2.4, enterAnimation: 'fade', exitEnd: quoteSeconds,\n style: { fontFamily: uiFont },\n html: \"<div style=\\\"text-align:center; color:\" + paper + \"; font-size:34px; font-weight:700; letter-spacing:2px;\\\">— \" + esc(author) + '</div>',\n },\n ],\n };\n\n const s2 = {\n id: 'author',\n duration: cardSeconds,\n backgroundColor: ink,\n visuals: [\n { type: 'IMAGE', src: 'https://images.pexels.com/photos/15637846/pexels-photo-15637846.jpeg?auto=compress&cs=tinysrgb&w=1620', width: 1080, height: 1920, position: 'center-center', resize: 'cover', zoom: true, opacity: 0.5, track: 0, enterBegin: 0, exitEnd: cardSeconds },\n {\n type: 'SVG', width: 1080, height: 1920, track: 1, enterBegin: 0, exitEnd: cardSeconds,\n svg: \"<svg width='1080' height='1920' viewBox='0 0 1080 1920' xmlns='http://www.w3.org/2000/svg'><defs><linearGradient id='dqrEnd' x1='0' y1='0' x2='0' y2='1'><stop offset='0' stop-color='#14181A' stop-opacity='0.6'/><stop offset='1' stop-color='#14181A' stop-opacity='0.92'/></linearGradient></defs><rect width='1080' height='1920' fill='url(#dqrEnd)'/><circle cx='540' cy='760' r='6' fill='#F0ECE3'/><path d='M420 760 H520 M560 760 H660' stroke='rgba(240,236,227,0.5)' stroke-width='1'/></svg>\",\n },\n {\n type: 'TEXT', x: 540, y: 850, width: 860, anchor: 'top-center', track: 2, enterBegin: 0.3, enterEnd: 0.9, enterAnimation: 'smoothup', exitEnd: cardSeconds,\n style: { fontFamily: quoteFont },\n html: \"<div style=\\\"text-align:center; color:\" + paper + '; font-size:' + nameSize + \"px; font-weight:700;\\\">\" + esc(author) + '</div>',\n },\n {\n type: 'TEXT', x: 540, y: ctaY, anchor: 'top-center', track: 4, enterBegin: 1.1, enterEnd: 1.5, enterAnimation: 'circleopen', exitEnd: cardSeconds,\n style: { fontFamily: uiFont },\n html: \"<div style=\\\"display:inline-block; border:1.5px solid \" + paper + '; color:' + paper + \"; border-radius:999px; padding:22px 56px; font-size:30px; font-weight:700;\\\">\" + esc(ctaText) + \"</div><div style=\\\"margin-top:16px; text-align:center; color:#7E7A6F; font-size:23px; letter-spacing:5px;\\\">\" + esc(channelName) + '</div>',\n },\n ],\n };\n if (authorNote) {\n s2.visuals.splice(3, 0, {\n type: 'TEXT', x: 540, y: noteY, width: 800, anchor: 'top-center', track: 3, enterBegin: 0.7, enterEnd: 1.2, enterAnimation: 'fade', exitEnd: cardSeconds,\n style: { fontFamily: uiFont },\n html: \"<div style=\\\"text-align:center; color:#A8A396; font-size:27px; font-weight:500; letter-spacing:2px;\\\">\" + esc(authorNote) + '</div>',\n });\n }\n\n const totalSeconds = round(quoteSeconds + cardSeconds - 0.8);\n // The render API only allows letters, digits, spaces, _ and - in `name`.\n const safeName = ('daily-quote-' + author).toLowerCase().normalize('NFKD').replace(/[\\u0300-\\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);\n\n const payload = {\n name: safeName || 'Daily quote',\n resolution: 'instagram-reel',\n frameRate: 30,\n backgroundColor: '#1B1E22',\n outputFormat: 'mp4',\n scenes: [s1, s2],\n // NOTE: no `track` here \u2014 the render API rejects audios[].track.\n audios: [{ src: musicUrl, volume: Number(cfg.musicVolume ?? 0.15) }],\n };\n\n return {\n payload,\n meta: {\n poolIndex,\n poolLabel: entry.label,\n bgVideo,\n musicUrl,\n quoteSeconds,\n totalSeconds,\n quoteSize,\n quoteLines,\n quote,\n author,\n quoteChars: qLen,\n },\n };\n}\n\n// === n8n glue ===\nconst config = $('Config').first().json;\nconst row = $input.first().json;\nconst built = buildProject(config, { quote: row.quote, author: row.author, authorNote: row.authorNote, date: new Date() });\nbuilt.meta.rowNumber = row.rowNumber;\nreturn [{ json: built }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
640,
220
],
"id": "zvid-dq-0008",
"name": "Build project JSON"
},
{
"parameters": {
"method": "POST",
"url": "={{ $('Config').first().json.apiUrl }}/api/render/validate/api-key",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ payload: $json.payload }) }}",
"options": {
"response": {
"response": {
"fullResponse": true,
"neverError": true
}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
860,
220
],
"id": "zvid-dq-0009",
"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 \u2014 surface the field list instead of a\n// generic HTTP error.\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;\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": [
1080,
220
],
"id": "zvid-dq-0010",
"name": "Check validation"
},
{
"parameters": {
"method": "POST",
"url": "={{ $('Config').first().json.apiUrl }}/api/render/api-key",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ payload: $json.payload }) }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1520,
380
],
"id": "zvid-dq-0014",
"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": [
1740,
380
],
"id": "zvid-dq-0015",
"name": "Wait"
},
{
"parameters": {
"url": "={{ $('Config').first().json.apiUrl }}/api/jobs/{{ $('Submit render').first().json.jobId }}",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1960,
380
],
"id": "zvid-dq-0016",
"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": [
2180,
380
],
"id": "zvid-dq-0017",
"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": [
2180,
600
],
"id": "zvid-dq-0018",
"name": "Still rendering?"
},
{
"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": [
2400,
280
],
"id": "zvid-dq-0019",
"name": "Mark row done",
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 5000
},
{
"parameters": {
"jsCode": "// Final report for a live run. By the time this executes the sheet row is\n// already marked done with the video URL.\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\nreturn [{\n json: {\n rendered: true,\n videoUrl: url,\n jobId: job.id,\n author: build.meta.author,\n background: build.meta.poolLabel,\n videoSeconds: build.meta.totalSeconds,\n creditsCharged: build.creditsRequired,\n sheetRow: build.meta.rowNumber,\n sheetStatus: cfg.statusDoneValue,\n channel: cfg.channelName,\n },\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2620,
280
],
"id": "zvid-dq-0020",
"name": "Run summary"
},
{
"parameters": {
"url": "={{ $json.videoUrl }}",
"options": {
"response": {
"response": {
"responseFormat": "file",
"outputPropertyName": "data"
}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2840,
280
],
"id": "zvid-dq-0027",
"name": "\u25b6 Watch video",
"onError": "continueRegularOutput",
"alwaysOutputData": true,
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 5000
},
{
"parameters": {
"width": 420,
"height": 240,
"color": 3,
"content": "## \u25b6 Watch video\n\nClick this node after a run: n8n renders the downloaded MP4 as an inline\nvideo player with a download button \u2014 today's quote reel, no URL to copy.\n\n*Run summary* before it still carries `videoUrl` (and `jobId`,\n`creditsCharged`) if you want to pipe the result onward."
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
2800,
440
],
"id": "zvid-dq-0028",
"name": "Note 27"
},
{
"parameters": {
"width": 860,
"height": 600,
"color": 4,
"content": "## Daily motivational quote videos from a Google Sheet\n\nEvery morning this workflow takes the first quote in your sheet that has not\nbeen used yet, renders it as a polished 1080x1920 quote reel (~11-14 s) with\nZvid, then writes the finished video URL back to that row and marks it done.\nThe background footage and music rotate daily from a curated pool, so the\nchannel never posts the same look two days in a row.\n\n### Setup (about three minutes)\n\n1. **Zvid API key** \u2014 create one at https://app.zvid.io/api-keys\n Add an n8n **Header Auth** credential: name `x-api-key`, value = your key.\n Attach it to *Validate project (free)*, *Save draft to editor*,\n *Submit render* and *Get render status*.\n2. **Google Sheets** \u2014 attach your Google credential to *Read quote sheet* and\n *Mark row done*, then pick your spreadsheet and tab in both nodes.\n The expected columns are in the yellow note next to the sheet node.\n3. **Open `Config`** \u2014 set `channelName` and `ctaText` to your brand.\n4. **Run it \u2014 the first run renders for real and spends about 11 credits.**\n `dryRun` is `false` out of the box. When it finishes, click\n **\u25b6 Watch video** to play the reel right here in n8n.\n5. Want a free preview first? Set `dryRun: true` in **Config**: you get the\n credit quote plus an **editorLink** to watch the video in the editor,\n nothing spent and the sheet untouched. Set it back to `false` to render.\n6. **Activate** \u2014 it posts a fresh quote video to the sheet every day at 8am.\n\n### Cost per video\n\nA ~11 s quote reel is about **11 credits**; a two-hundred-character quote\n(~14 s) is about **14**. *Validate project (free)* runs before every render and\nreports the figure as `creditsCharged`, but the render then proceeds on its\nown \u2014 a `dryRun: true` pass gives you the number without spending anything."
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-480,
-320
],
"id": "zvid-dq-0021",
"name": "Note 21"
},
{
"parameters": {
"width": 620,
"height": 340,
"color": 5,
"content": "## Your sheet: five columns\n\nHeader row (row 1), spelled exactly like this:\n\n| Quote | Author | AuthorNote | Status | VideoUrl |\n| --- | --- | --- | --- | --- |\n\n- **Quote** + **Author** \u2014 required. AuthorNote is an optional line under the\n author's name on the end card (\"Writer & essayist\").\n- **Status** \u2014 leave EMPTY for quotes waiting to be rendered. The workflow\n takes the first empty-Status row (top to bottom), and writes `done` +\n the video URL when the render finishes. Put anything in Status by hand to\n skip a row.\n- Batch-paste a month of quotes whenever you like \u2014 one row is consumed per\n day, and when every row is used the workflow simply reports \"nothing to\n render\" until you add more."
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-80,
560
],
"id": "zvid-dq-0022",
"name": "Note 22"
},
{
"parameters": {
"width": 640,
"height": 340,
"color": 6,
"content": "## Why it looks different every day\n\n*Build project JSON* carries a **pool of five curated background clips, each\npaired with a music bed** \u2014 misty forest, ember ink, rain on a window, a sea\nof clouds, a calm shore. The day-of-year picks the entry\n(`dayOfYear % 5`), so the rotation is deterministic: no repeats on\nconsecutive days within a year, and a re-run on the same day produces the same\nlook. (A leap year ends on day 366 and 366 % 5 == 1 % 5, so that one New Year's\nDay repeats New Year's Eve \u2014 pin poolIndexOverride if it matters.)\nEvery URL in the pool was verified reachable when this template was built.\n\n- Pin one look: set `poolIndexOverride` (0-4) in Config.\n- Bring your own: `backgroundVideoOverride` / `musicUrlOverride`\n (portrait ~1080x1920, at least 10 s of video).\n\nType scales with content: the quote drops from 62 px to 42 px as it grows\ntowards ~220 characters, and long author names shrink to fit the end card \u2014\nno clipping, no overflow."
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
560,
-180
],
"id": "zvid-dq-0023",
"name": "Note 23"
},
{
"parameters": {
"width": 620,
"height": 300,
"color": 4,
"content": "## Free preview: the optional dry run\n\n**`dryRun` is `false` in Config, so a run renders for real.** Set it to\n`true` whenever you want a free pass instead: the workflow validates the\npayload, quotes the exact credit cost and saves the project as a **draft**,\nthen reports an **editorLink** like `https://editor.zvid.io/?project=prj_...`\n\nOpen it to watch the exact video and tweak anything by hand, with nothing\nspent. **The sheet row is not marked done on a dry run**, so the next real\nrun renders the same quote.\n\nSet `dryRun` back to `false` to render and mark the row done."
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
1480,
-260
],
"id": "zvid-dq-0024",
"name": "Note 24"
},
{
"parameters": {
"width": 660,
"height": 320,
"color": 7,
"content": "## Publishing the video (optional tail)\n\nThe workflow ends with the video URL in the sheet \u2014 a clean handoff you can\nextend after **Mark row done**:\n\n- **YouTube Shorts** \u2014 add an *HTTP Request* node (GET the `videoUrl`,\n response format `File`) followed by the native **YouTube** node\n (Video \u2192 Upload, binary `data`). Needs a YouTube OAuth2 credential.\n- **Instagram / TikTok / everywhere** \u2014 pass `videoUrl` to a scheduler\n like Blotato, Buffer or Metricool via their HTTP API; they accept a public\n video URL directly, no download step needed.\n- **Human in the loop** \u2014 a Slack or Email node that sends `videoUrl` to\n whoever posts manually.\n\nThey are left out of the required path on purpose so the template imports\nand runs with a Zvid key and a Google account, nothing else."
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
2560,
-60
],
"id": "zvid-dq-0025",
"name": "Note 25"
},
{
"parameters": {
"width": 620,
"height": 260,
"color": 6,
"content": "## Official Zvid nodes\n\nThese HTTP Request nodes are deliberately core-only so the workflow runs\nanywhere, including n8n Cloud with nothing installed.\n\nOn self-hosted n8n you can install **`@zvid/n8n-nodes-zvid`**\n(Settings \u2192 Community nodes) and swap:\n\n| HTTP Request node | Native replacement |\n| --- | --- |\n| Validate project (free) | **Zvid** \u2192 Render \u2192 Validate |\n| Submit render | **Zvid** \u2192 Render \u2192 Create |\n| Get render status + Wait | **Zvid Trigger** (render webhook) |"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
1860,
660
],
"id": "zvid-dq-0026",
"name": "Note 26"
},
{
"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": [
1300,
220
],
"id": "zvid-dq-0011",
"name": "Dry run?"
},
{
"parameters": {
"method": "POST",
"url": "={{ $('Config').first().json.apiUrl }}/api/projects",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ name: $json.payload.name, payload: $json.payload }) }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1520,
60
],
"id": "zvid-dq-0012",
"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. The sheet row is\n// NOT touched on a dry run, so tomorrow's real run picks the same quote.\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 quote: d.meta.quote,\n author: d.meta.author,\n sheetRow: d.meta.rowNumber,\n background: d.meta.poolLabel,\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 and mark the row done.'\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": [
1740,
60
],
"id": "zvid-dq-0013",
"name": "Dry run summary"
}
],
"connections": {
"Every day at 8am": {
"main": [
[
{
"node": "Config",
"type": "main",
"index": 0
}
]
]
},
"Test manually": {
"main": [
[
{
"node": "Config",
"type": "main",
"index": 0
}
]
]
},
"Config": {
"main": [
[
{
"node": "Read quote sheet",
"type": "main",
"index": 0
}
]
]
},
"Read quote sheet": {
"main": [
[
{
"node": "Pick next quote",
"type": "main",
"index": 0
}
]
]
},
"Pick next quote": {
"main": [
[
{
"node": "Row found?",
"type": "main",
"index": 0
}
]
]
},
"Row found?": {
"main": [
[
{
"node": "Build project JSON",
"type": "main",
"index": 0
}
],
[
{
"node": "Nothing to render today",
"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": "Mark row done",
"type": "main",
"index": 0
}
],
[
{
"node": "Still rendering?",
"type": "main",
"index": 0
}
]
]
},
"Still rendering?": {
"main": [
[
{
"node": "Wait",
"type": "main",
"index": 0
}
]
]
},
"Mark row done": {
"main": [
[
{
"node": "Run summary",
"type": "main",
"index": 0
}
]
]
},
"Run summary": {
"main": [
[
{
"node": "\u25b6 Watch video",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "f2b8d4a1-9c37-4e62-8a05-6d1c9e3b7f48",
"meta": {
"templateCredsSetupCompleted": false
},
"id": "ZvidDailyQuoteReels",
"tags": []
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Daily motivational quote videos from a Google Sheet (Zvid). Uses googleSheets, httpRequest. Scheduled trigger; 28 nodes.
Source: https://github.com/Zvid-io/daily-quote-videos/blob/master/n8n/zvid-daily-quote-reels.workflow.json — 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.
Complaint Triage Orchestrator (S2.3). Uses httpRequest, googleSheets. Scheduled trigger; 65 nodes.
This workflow automates video distribution to 9 social platforms simultaneously using Blotato's API. It includes both a scheduled publisher (checks Google Sheets for videos marked "Ready") and a subwo
YogiAI. Uses googleSheets, googleSheetsTool, httpRequest, stopAndError. Scheduled trigger; 61 nodes.
This workflow monitors Google Calendar for events indicating that a customer will visit the company today or the next day, retrieves the required details, and sends reminder notifications to the relev
ofn hook v0.24.0 beta. Uses start, httpRequest, functionItem, itemLists. Scheduled trigger; 42 nodes.