{
  "name": "Generate beat-synced AI video shorts from a CSV with Claude and Shotstack",
  "nodes": [
    {
      "parameters": {},
      "id": "5b3e1a2c-0001-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Start",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "url": "https://shotstack.io/docs/guide/llms-full.txt",
        "options": {
          "response": {
            "response": {
              "responseFormat": "text",
              "outputPropertyName": "guide"
            }
          }
        }
      },
      "id": "5b3e1a2c-0002-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Get the Shotstack guide",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        220,
        0
      ]
    },
    {
      "parameters": {
        "fileSelector": "/home/node/.n8n-files/shorts.csv",
        "options": {}
      },
      "id": "5b3e1a2c-0003-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Read the CSV",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1.1,
      "position": [
        440,
        0
      ]
    },
    {
      "parameters": {
        "operation": "csv",
        "options": {
          "enableBOM": true
        }
      },
      "id": "5b3e1a2c-0004-4d6e-9f70-8a1b2c3d4e5f",
      "name": "One item per row",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1.1,
      "position": [
        660,
        0
      ]
    },
    {
      "parameters": {
        "batchSize": 1,
        "options": {}
      },
      "id": "5b3e1a2c-0005-4d6e-9f70-8a1b2c3d4e5f",
      "name": "One row at a time",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        880,
        0
      ]
    },
    {
      "parameters": {},
      "id": "5b3e1a2c-0006-4d6e-9f70-8a1b2c3d4e5f",
      "name": "This row",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1200,
        0
      ]
    },
    {
      "parameters": {
        "promptType": "define",
        "text": "=Make a 32-second vertical promo video for this business, with every picture made by AI.\n\nWhat it is about: {{ $('This row').first().json['About'] }}\n\nTiming\n- mp4, 1080 x 1920, 30 fps, exactly 32 seconds.\n- The music is 120 BPM: a beat is 0.5 seconds and a bar is 2 seconds. Start every cut, word and animation on a beat, and change shot on the bar. The drop is at 16 seconds: make it the biggest moment, with a white flash, then hit every 2 beats with a cut or a punch-in zoom.\n\nPictures\n- No stock footage or stock images. Every image and video is generated, and each one is its own clip: never repeat a prompt, because a repeat is generated and paid for again.\n- Generate 8 images with nano-banana-2 (options aspectRatio \"9:16\", resolution \"2K\"), each a different scene that shows what the business offers, in one consistent style. No words, logos or watermarks in them.\n- Anyone in a picture is an adult: give their age, and never write young, kid or teen, because the video model refuses anyone who could be a minor.\n- Bring 3 of them to life with gemini-omni-flash-1.1-image-to-video. Give the image clip an alias such as \"pic1\", and give the video the options startSrc \"alias://pic1\", aspectRatio \"9:16\" and resolution \"720p\", a length of 4, and a prompt that describes the motion. Use them for the hook, the drop and one more big moment.\n- Keep every still moving: slow push-ins and pans, punch-in zooms on the beat, a small tilt or slide, and one scene where a picture sits as an inset over the brand colour.\n\nWords and motion graphics\n- Only state facts the description gives. Never make up prices, numbers or claims.\n- An animated hook in the first 2 seconds, three facts that slam in on the beat (a big number or word with a short label), a call to action, and an end card with the business name for the last 4 seconds.\n- All words are rich-text. Load these fonts in timeline.fonts and use the file name as font.family: Anton https://fonts.gstatic.com/s/anton/v27/1Ptgg87LROyAm0K08i4gS7lu.ttf and Montserrat ExtraBold https://fonts.gstatic.com/s/montserrat/v31/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCvr70w-.ttf\n- Panels, stickers, underlines, shines and flashes are html5 assets animated with GSAP, on transparent pages, with no text and no web fonts. Move a parent element, not an SVG element's own transform.\n- Pick three brand colours that suit the business: a dark, a light and an accent.\n\nSound and captions\n- Music: one audio clip generated with the elevenlabs-music model from a plain prompt (genre, mood, instruments, \"120 BPM, instrumental only, no vocals\"), start 0, length 32, volume 0.3. Do not use a composition plan.\n- Voiceover: one audio clip generated with elevenlabs-multilingual-v2, alias \"voice\", start 0.5, length \"auto\", 60 to 75 words in the language of the description, numbers spelled out.\n- Captions: a rich-caption clip with src \"alias://voice\", start and length \"alias://voice\", width 960, height 120, fit \"none\", low in the frame: one line at a time, uppercase Montserrat ExtraBold at weight 800 with style letterSpacing 4, so the outlined letters and words never touch, white with a dark stroke, the spoken word in the accent colour. Keep other words out of that band.\n\nEasy to miss\n- tracks[0] is the top layer. Give every word and graphic its own track.\n- Only clips that start at 0 may use the length \"end\".\n\nReply with the edit JSON only: no code fence, no comments.",
        "messages": {
          "messageValues": [
            {
              "type": "SystemMessagePromptTemplate",
              "message": "=You are a video editor and motion designer. You write Shotstack edits as JSON. Below is Shotstack's complete guide. Use only the asset types, properties, models and values it documents.\n\n{{ $('Get the Shotstack guide').first().json.guide }}"
            }
          ]
        }
      },
      "id": "5b3e1a2c-0009-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Claude makes the video",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.7,
      "position": [
        1420,
        0
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "claude-opus-5-5",
          "cachedResultName": "Claude Opus 5.5"
        },
        "options": {
          "maxTokensToSample": 64000,
          "thinkingMode": "adaptive",
          "effort": "low",
          "streaming": true,
          "promptCaching": "1h"
        }
      },
      "id": "5b3e1a2c-0010-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Claude",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "typeVersion": 1.6,
      "position": [
        1440,
        220
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "t11",
              "name": "text",
              "value": "={{ $json.text }}",
              "type": "string"
            },
            {
              "id": "a11",
              "name": "attempt",
              "value": 1,
              "type": "number"
            }
          ]
        },
        "options": {}
      },
      "id": "5b3e1a2c-0011-4d6e-9f70-8a1b2c3d4e5f",
      "name": "First try",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1760,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Takes Claude's edit out of its reply, fixes what is safe to fix, and lists anything else,\n// so Claude can fix it before Shotstack spends anything.\nconst attempt = $json.attempt;\nconst answer = String($json.text ?? '');\nconst problems = [];\nlet edit = null;\ntry {\n  edit = JSON.parse(answer.slice(answer.indexOf('{'), answer.lastIndexOf('}') + 1));\n} catch (e) {\n  problems.push(answer.trim().endsWith('}')\n    ? `The reply is not one valid JSON object (${e.message}).`\n    : 'The reply was cut off before the JSON ended. Write a more compact edit.');\n}\nif (edit) {\n  edit.output = { format: 'mp4', size: { width: 1080, height: 1920 }, fps: 30 };\n  const clips = (edit.timeline?.tracks ?? []).flatMap((t) => t.clips ?? []);\n  if (!clips.length) problems.push('The timeline has no clips.');\n  if (clips.some((k) => ['image', 'video'].includes(k.asset?.type) && !k.asset?.prompt)) problems.push('Use only generated images and videos: no stock media.');\n  // Room around the brief's 8 images and 3 video shots, and a cap on what they cost.\n  const images = clips.filter((k) => k.asset?.type === 'image' && k.asset?.prompt).length;\n  if (images < 4 || images > 10) problems.push(`Generate 8 images, not ${String(images)}.`);\n  const videos = clips.filter((k) => k.asset?.type === 'video' && k.asset?.prompt);\n  if (videos.length > 3) problems.push(`Generate 3 videos at most, not ${videos.length}.`);\n  if (videos.some((k) => !(Number(k.length) <= 5))) problems.push('Give each generated video a length of 4 seconds.');\n  // Generated videos come with their own sound. Here the music and the voice carry the sound.\n  for (const k of clips) if (k.asset?.type === 'video') k.asset.volume = 0;\n  const music = clips.filter((k) => k.asset?.type === 'audio' && k.asset?.model === 'elevenlabs-music');\n  if (music.length !== 1) problems.push('Use exactly one music clip generated with elevenlabs-music.');\n  // Composition plans fail at Shotstack today (9 Oct 2026), so the plain prompt is used instead.\n  for (const m of music) if (m.asset.options?.compositionPlan) delete m.asset.options.compositionPlan;\n  // The guide offers \"full\" as a rich-text animation style, but Shotstack takes only these two (9 Oct 2026).\n  for (const k of clips) if (k.asset?.type === 'rich-text' && k.asset.animation?.style && !['character', 'word'].includes(k.asset.animation.style)) delete k.asset.animation.style;\n  // Heavy uppercase captions with an outline run together without extra letter spacing.\n  for (const k of clips) if (k.asset?.type === 'rich-caption') k.asset.style = { ...k.asset.style, letterSpacing: Math.max(4, Number(k.asset.style?.letterSpacing) || 0) };\n  if (!clips.some((k) => k.alias === 'voice' && k.asset?.type === 'audio' && k.asset?.prompt)) {\n    problems.push('Add the voiceover: an audio clip with the alias \"voice\" generated with elevenlabs-multilingual-v2.');\n  }\n  if (!clips.some((k) => k.asset?.type === 'rich-caption' && k.asset?.src === 'alias://voice')) {\n    problems.push('Add the captions: a rich-caption clip with src \"alias://voice\".');\n  }\n  const end = Math.max(0, ...clips.map((k) => (typeof k.start === 'number' && typeof k.length === 'number' ? k.start + k.length : 0)));\n  if (end > 40) problems.push(`The video runs ${String(end)} seconds: keep it to 32.`);\n}\nreturn [{ json: { edit, problems: problems.join(' '), attempt } }];"
      },
      "id": "5b3e1a2c-0012-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Check the edit",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1980,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "ready",
              "leftValue": "={{ $json.problems }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "empty"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "5b3e1a2c-0013-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Is the edit ready?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2200,
        0
      ]
    },
    {
      "parameters": {
        "resource": "render",
        "operation": "postRender",
        "edit": "={{ JSON.stringify($json.edit) }}",
        "requestOptions": {}
      },
      "id": "5b3e1a2c-0014-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Make the video",
      "type": "@shotstack/n8n-nodes-shotstack.shotstack",
      "typeVersion": 1,
      "position": [
        2520,
        0
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "said",
              "name": "problems",
              "type": "string",
              "value": "={{ 'Shotstack said: ' + ($input.item.error?.context?.data?.response?.errors?.map(e => e.message).join(' ') || JSON.stringify($input.item.error?.context?.data ?? $input.item.error?.message)) }}"
            },
            {
              "id": "code",
              "name": "code",
              "type": "string",
              "value": "={{ String($input.item.error?.httpCode) }}"
            }
          ]
        },
        "options": {}
      },
      "id": "5b3e1a2c-0026-4d6e-9f70-8a1b2c3d4e5f",
      "name": "What Shotstack said",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        2520,
        660
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "fixable",
              "leftValue": "={{ $json.code }}",
              "rightValue": "400",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "5b3e1a2c-0027-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Can Claude fix it?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2740,
        660
      ]
    },
    {
      "parameters": {
        "errorType": "errorMessage",
        "errorMessage": "={{ $json.problems }}"
      },
      "id": "5b3e1a2c-0028-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Shotstack refused it",
      "type": "n8n-nodes-base.stopAndError",
      "typeVersion": 1,
      "position": [
        2740,
        880
      ]
    },
    {
      "parameters": {
        "amount": 15,
        "unit": "seconds"
      },
      "id": "5b3e1a2c-0015-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Wait 15 seconds",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        2740,
        0
      ],
      "webhookId": "5b3e1a2c-0015-4d6e-9f70-8a1b2c3d4e60"
    },
    {
      "parameters": {
        "resource": "render",
        "operation": "getRender",
        "renderId": "={{ $('Make the video').first().json.id }}",
        "requestOptions": {}
      },
      "id": "5b3e1a2c-0016-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Check the render",
      "type": "@shotstack/n8n-nodes-shotstack.shotstack",
      "typeVersion": 1,
      "position": [
        2960,
        0
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "done",
              "leftValue": "={{ $json.status }}",
              "rightValue": "done",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "5b3e1a2c-0017-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Is it done?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3180,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "failed",
              "leftValue": "={{ $json.status }}",
              "rightValue": "failed",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "5b3e1a2c-0018-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Did it fail?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3180,
        220
      ]
    },
    {
      "parameters": {
        "url": "={{ $json.url }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        }
      },
      "id": "5b3e1a2c-0019-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Download the short",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3400,
        0
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 3000
    },
    {
      "parameters": {
        "operation": "write",
        "fileName": "=/home/node/.n8n-files/shorts/{{ $runIndex + 1 }} {{ String($('This row').first().json['About']).split(':')[0].replace(/[\\\\/:*?\"<>|]/g, '').slice(0, 60).trim() }}.mp4",
        "options": {}
      },
      "id": "5b3e1a2c-0020-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Save the short",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1.1,
      "position": [
        3620,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "tries",
              "leftValue": "={{ $('Check the edit').first().json.attempt }}",
              "rightValue": 3,
              "operator": {
                "type": "number",
                "operation": "lt"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "5b3e1a2c-0021-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Tries left?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2960,
        660
      ]
    },
    {
      "parameters": {
        "errorType": "errorMessage",
        "errorMessage": "=Still not working after two fixes: {{ $json.problems || $json.error || $json.status }}"
      },
      "id": "5b3e1a2c-0022-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Stop and say why",
      "type": "n8n-nodes-base.stopAndError",
      "typeVersion": 1,
      "position": [
        2960,
        880
      ]
    },
    {
      "parameters": {
        "promptType": "define",
        "text": "=Your edit for this video needs fixing.\n\nThe problem: {{ $json.problems || ('Shotstack could not render it: ' + ($json.error || $json.status)) }}\n\nYour edit:\n{{ JSON.stringify($('Check the edit').first().json.edit) }}\n\nFix the problem and keep the rest. The brief it has to follow:\n\nMake a 32-second vertical promo video for this business, with every picture made by AI.\n\nWhat it is about: {{ $('This row').first().json['About'] }}\n\nTiming\n- mp4, 1080 x 1920, 30 fps, exactly 32 seconds.\n- The music is 120 BPM: a beat is 0.5 seconds and a bar is 2 seconds. Start every cut, word and animation on a beat, and change shot on the bar. The drop is at 16 seconds: make it the biggest moment, with a white flash, then hit every 2 beats with a cut or a punch-in zoom.\n\nPictures\n- No stock footage or stock images. Every image and video is generated, and each one is its own clip: never repeat a prompt, because a repeat is generated and paid for again.\n- Generate 8 images with nano-banana-2 (options aspectRatio \"9:16\", resolution \"2K\"), each a different scene that shows what the business offers, in one consistent style. No words, logos or watermarks in them.\n- Anyone in a picture is an adult: give their age, and never write young, kid or teen, because the video model refuses anyone who could be a minor.\n- Bring 3 of them to life with gemini-omni-flash-1.1-image-to-video. Give the image clip an alias such as \"pic1\", and give the video the options startSrc \"alias://pic1\", aspectRatio \"9:16\" and resolution \"720p\", a length of 4, and a prompt that describes the motion. Use them for the hook, the drop and one more big moment.\n- Keep every still moving: slow push-ins and pans, punch-in zooms on the beat, a small tilt or slide, and one scene where a picture sits as an inset over the brand colour.\n\nWords and motion graphics\n- Only state facts the description gives. Never make up prices, numbers or claims.\n- An animated hook in the first 2 seconds, three facts that slam in on the beat (a big number or word with a short label), a call to action, and an end card with the business name for the last 4 seconds.\n- All words are rich-text. Load these fonts in timeline.fonts and use the file name as font.family: Anton https://fonts.gstatic.com/s/anton/v27/1Ptgg87LROyAm0K08i4gS7lu.ttf and Montserrat ExtraBold https://fonts.gstatic.com/s/montserrat/v31/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCvr70w-.ttf\n- Panels, stickers, underlines, shines and flashes are html5 assets animated with GSAP, on transparent pages, with no text and no web fonts. Move a parent element, not an SVG element's own transform.\n- Pick three brand colours that suit the business: a dark, a light and an accent.\n\nSound and captions\n- Music: one audio clip generated with the elevenlabs-music model from a plain prompt (genre, mood, instruments, \"120 BPM, instrumental only, no vocals\"), start 0, length 32, volume 0.3. Do not use a composition plan.\n- Voiceover: one audio clip generated with elevenlabs-multilingual-v2, alias \"voice\", start 0.5, length \"auto\", 60 to 75 words in the language of the description, numbers spelled out.\n- Captions: a rich-caption clip with src \"alias://voice\", start and length \"alias://voice\", width 960, height 120, fit \"none\", low in the frame: one line at a time, uppercase Montserrat ExtraBold at weight 800 with style letterSpacing 4, so the outlined letters and words never touch, white with a dark stroke, the spoken word in the accent colour. Keep other words out of that band.\n\nEasy to miss\n- tracks[0] is the top layer. Give every word and graphic its own track.\n- Only clips that start at 0 may use the length \"end\".\n\nReply with the edit JSON only: no code fence, no comments.",
        "messages": {
          "messageValues": [
            {
              "type": "SystemMessagePromptTemplate",
              "message": "=You are a video editor and motion designer. You write Shotstack edits as JSON. Below is Shotstack's complete guide. Use only the asset types, properties, models and values it documents.\n\n{{ $('Get the Shotstack guide').first().json.guide }}"
            }
          ]
        }
      },
      "id": "5b3e1a2c-0023-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Claude fixes the edit",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.7,
      "position": [
        3180,
        660
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "claude-opus-5-5",
          "cachedResultName": "Claude Opus 5.5"
        },
        "options": {
          "maxTokensToSample": 64000,
          "thinkingMode": "adaptive",
          "effort": "low",
          "streaming": true,
          "promptCaching": "1h"
        }
      },
      "id": "5b3e1a2c-0024-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Claude (fixes)",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "typeVersion": 1.6,
      "position": [
        3200,
        880
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "t25",
              "name": "text",
              "value": "={{ $json.text }}",
              "type": "string"
            },
            {
              "id": "a25",
              "name": "attempt",
              "value": "={{ $('Check the edit').first().json.attempt + 1 }}",
              "type": "number"
            }
          ]
        },
        "options": {}
      },
      "id": "5b3e1a2c-0025-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Next try",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        3520,
        660
      ]
    },
    {
      "parameters": {
        "content": "## Turn each row of a CSV into a beat-synced AI short\n\nOne line about a business in, a 32-second vertical promo out. Claude Opus 5.5 designs the whole video: 8 AI images (3 turned into video), AI music at 120 BPM, a voiceover, subtitles and animated words, all cut to the beat. No stock footage.\n\n**Self-hosted n8n only:** it reads and saves files on disk. On n8n Cloud, swap in Google Sheets and Google Drive.\n\n### How it works\n1. Reads Shotstack's guide for AI agents, then `shorts.csv`, one row at a time.\n2. Claude writes the Shotstack edit from the brief in **Claude makes the video**.\n3. **Check the edit** fixes small things and sends anything else back to Claude.\n4. One Shotstack render generates every image, video shot and sound, then renders the short.\n5. If Shotstack refuses the edit or the render fails, Claude gets the error and fixes it, up to twice.\n\n### Setup steps\n1. Add an Anthropic API key to **Claude** and **Claude (fixes)**.\n2. Add a Shotstack API key to **Make the video** and **Check the render**. Free trial: https://shotstack.io/?utm_source=n8n&utm_medium=template&utm_campaign=beat-shorts&utm_content=workflow_note\n3. Save `shorts.csv` in n8n's files folder with one column, **About**: what the business is and what to say. Make an empty `shorts` folder next to it. In Docker the folder is `/home/node/.n8n-files`. On an npm install, change that path in **Read the CSV** and **Save the short**.\n4. Click **Execute workflow**. Try one row first: each short takes 10 to 25 minutes.\n\n### Customization tips\n- Change the length, tempo, number of images or style in the brief in **Claude makes the video**.\n- A short costs about 18 Shotstack credits plus Claude. Fewer video shots cost less: each is 2.5 credits.",
        "width": 760,
        "height": 790,
        "color": 1
      },
      "id": "5b3e1a2c-0000-4d6e-9f70-8a1b2c3d4e5f",
      "name": "About this workflow",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -60,
        -1010
      ]
    },
    {
      "parameters": {
        "content": "## Read the list\nGets Shotstack's guide for AI agents once, then reads shorts.csv one row at a time.",
        "width": 1110,
        "height": 340,
        "color": 7
      },
      "id": "5b3e1a2c-0040-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Section: read the list",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -60,
        -130
      ]
    },
    {
      "parameters": {
        "content": "## Claude writes the edit\nThe brief is in Claude makes the video. Check the edit fixes small things and sends the rest back to Claude.",
        "width": 1230,
        "height": 560,
        "color": 7
      },
      "id": "5b3e1a2c-0041-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Section: Claude writes the edit",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1140,
        -130
      ]
    },
    {
      "parameters": {
        "content": "## Shotstack makes the short\nOne render generates the images, video shots, music and voice. The MP4 is saved in the shorts folder.",
        "width": 1330,
        "height": 560,
        "color": 7
      },
      "id": "5b3e1a2c-0042-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Section: Shotstack makes the short",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2460,
        -130
      ]
    },
    {
      "parameters": {
        "content": "## If something goes wrong\nClaude gets Shotstack's exact error and fixes the edit, up to twice. A wrong key or an empty balance stops the run in red.",
        "width": 1230,
        "height": 560,
        "color": 7
      },
      "id": "5b3e1a2c-0043-4d6e-9f70-8a1b2c3d4e5f",
      "name": "Section: if something goes wrong",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2460,
        530
      ]
    }
  ],
  "connections": {
    "Start": {
      "main": [
        [
          {
            "node": "Get the Shotstack guide",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get the Shotstack guide": {
      "main": [
        [
          {
            "node": "Read the CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read the CSV": {
      "main": [
        [
          {
            "node": "One item per row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "One item per row": {
      "main": [
        [
          {
            "node": "One row at a time",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "This row": {
      "main": [
        [
          {
            "node": "Claude makes the video",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude makes the video": {
      "main": [
        [
          {
            "node": "First try",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "First try": {
      "main": [
        [
          {
            "node": "Check the edit",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check the edit": {
      "main": [
        [
          {
            "node": "Is the edit ready?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "One row at a time": {
      "main": [
        [],
        [
          {
            "node": "This row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is the edit ready?": {
      "main": [
        [
          {
            "node": "Make the video",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Tries left?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Make the video": {
      "main": [
        [
          {
            "node": "Wait 15 seconds",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "What Shotstack said",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "What Shotstack said": {
      "main": [
        [
          {
            "node": "Can Claude fix it?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Can Claude fix it?": {
      "main": [
        [
          {
            "node": "Tries left?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Shotstack refused it",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait 15 seconds": {
      "main": [
        [
          {
            "node": "Check the render",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check the render": {
      "main": [
        [
          {
            "node": "Is it done?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is it done?": {
      "main": [
        [
          {
            "node": "Download the short",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Did it fail?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Did it fail?": {
      "main": [
        [
          {
            "node": "Tries left?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Wait 15 seconds",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download the short": {
      "main": [
        [
          {
            "node": "Save the short",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save the short": {
      "main": [
        [
          {
            "node": "One row at a time",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Tries left?": {
      "main": [
        [
          {
            "node": "Claude fixes the edit",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Stop and say why",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude fixes the edit": {
      "main": [
        [
          {
            "node": "Next try",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Next try": {
      "main": [
        [
          {
            "node": "Check the edit",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude": {
      "ai_languageModel": [
        [
          {
            "node": "Claude makes the video",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Claude (fixes)": {
      "ai_languageModel": [
        [
          {
            "node": "Claude fixes the edit",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}
