AutomationFlowsAI & RAG › AI YouTube Shorts with HeyGen and Gemini

AI YouTube Shorts with HeyGen and Gemini

Original n8n title: Create AI Shorts with Heygen, Creatomate, Replicate, Gemini and Openai

ByAdam Goodyer @adamfromapgsoftware on n8n.io

This workflow is a fully automated YouTube Shorts production pipeline. It takes the structured output from a video digestion workflow (transcript, key moments, metadata) and produces finished, rendered vertical shorts complete with AI-generated avatar narration, AI-generated…

Event trigger★★★★★ complexityAI-powered50 nodesHTTP RequestGoogle DriveExecute Workflow TriggerGoogle SheetsGoogle Gemini ChatOutput Parser StructuredAgentOpenAI
AI & RAG Trigger: Event Nodes: 50 Complexity: ★★★★★ AI nodes: yes Added:
AI YouTube Shorts with HeyGen and Gemini — n8n workflow card showing HTTP Request, Google Drive, Execute Workflow Trigger integration

This workflow corresponds to n8n.io template #13676 — we link there as the canonical source.

This workflow follows the Agent → Execute Workflow Trigger 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 →

Download .json
{
  "id": "zyBv0G4EeHomIfXd",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Shorts Creation v10 - Telegram Filming",
  "tags": [],
  "nodes": [
    {
      "id": "25d380bf-7e85-4b68-b5a1-ed31013a02af",
      "name": "Stage 3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        5888,
        1696
      ],
      "parameters": {
        "color": 6,
        "width": 320,
        "height": 200,
        "content": "## Stage 3: AI Analysis\n\nGPT-4o:\n1. Analyzes transcript\n2. Identifies best moments\n3. Generates 3 concepts\n4. Creates full avatar scripts\n\nEach concept uses different\nsource material."
      },
      "typeVersion": 1
    },
    {
      "id": "03bd4748-2e0f-49be-8061-49491f0249d2",
      "name": "Stage 4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        6784,
        1696
      ],
      "parameters": {
        "color": 7,
        "width": 320,
        "height": 200,
        "content": "## Stage 4: HeyGen Avatar\n\n**Single API Call**\n\nGenerates ONE video with:\n- Hook (5-8 sec)\n- Body narration (25-40 sec)\n- CTA (3-5 sec)\n\nDimensions: 1080x1920 (vertical)"
      },
      "typeVersion": 1
    },
    {
      "id": "8639a448-1fb7-4223-8559-9dcfff630a48",
      "name": "Stage 5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        8672,
        1696
      ],
      "parameters": {
        "color": 3,
        "width": 340,
        "height": 220,
        "content": "## Stage 5: AI Video Director\n\n**Dynamic Layouts**\n\nAI generates RenderScript with:\n- avatar_full (hook/cta)\n- split_screen (demo)\n- pip_overlay (focus)\n\nTransitions every 4-8 seconds!\n\nNo template required."
      },
      "typeVersion": 1
    },
    {
      "id": "cd82f2ca-ef7e-4216-9d3d-ead1076e97c2",
      "name": "Loop Through Concepts",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        2592,
        1920
      ],
      "parameters": {
        "options": {
          "reset": false
        }
      },
      "typeVersion": 3
    },
    {
      "id": "11a9919e-ab3a-42e6-9fc4-dd44c73b721f",
      "name": "HeyGen - Generate Full Avatar",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        3936,
        1744
      ],
      "parameters": {
        "url": "https://api.heygen.com/v2/video/generate",
        "method": "POST",
        "options": {
          "timeout": 60000
        },
        "jsonBody": "={\n  \"video_inputs\": [\n    {\n      \"character\": {\n        \"type\": \"avatar\",\n        \"avatar_id\": \"{{ [\"e707a00d8b5549108f795f635d2de923\", \"eaa27b740b054bc18076968b9c5e3646\", \"e707a00d8b5549108f795f635d2de923\"][$('Loop Through Concepts').item.json.concept_number - 1] }}\",\n        \"avatar_style\": \"normal\"\n      },\n      \"voice\": {\n        \"type\": \"text\",\n        \"input_text\": {{ $('Loop Through Concepts').item.json.full_script.toJsonString() }},\n        \"voice_id\": \"8fac12c49e844f60aa672bff8273d154\",\n        \"speed\": 1.0,\n        \"pitch\": 5, \n        \"emotion\": \"Friendly\",  \n        \"stability\": 0.4,          \n        \"similarity_boost\": 0.85,  \n        \"style_exaggeration\": 0.5, \n        \"speaker_boost\": true\n      }\n    }\n  ],\n  \"dimension\": {\n    \"width\": 1920,\n    \"height\": 1080\n  }\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "dbb8ab81-5f14-498d-b182-8e498e0e7e2e",
      "name": "Set Avatar Video ID",
      "type": "n8n-nodes-base.set",
      "position": [
        4160,
        1744
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "avatar_video_id",
              "name": "avatar_video_id",
              "type": "string",
              "value": "={{ $json.data.video_id }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "aa91af19-85a7-44b0-912e-334e4ef5b0bb",
      "name": "Poll Avatar Status",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        4608,
        1664
      ],
      "parameters": {
        "url": "=https://api.heygen.com/v1/video_status.get?video_id={{ $('Set Avatar Video ID').item.json.avatar_video_id }}",
        "options": {},
        "sendHeaders": true,
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        }
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "4f91bcc2-54a1-4f20-a92c-ea460f596d4d",
      "name": "Avatar Done?",
      "type": "n8n-nodes-base.if",
      "position": [
        4832,
        1744
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "avatar-done",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.data.status }}",
              "rightValue": "completed"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "4c36afcc-eeba-4dd1-b312-5b2dd8c35c19",
      "name": "Set Avatar URL",
      "type": "n8n-nodes-base.set",
      "position": [
        5056,
        1744
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "avatar_video_url",
              "name": "avatar_video_url",
              "type": "string",
              "value": "={{ $json.data.video_url }}"
            },
            {
              "id": "4a528e4d-7dfc-45c5-9dd7-762ccd5903bd",
              "name": "avatar_duration",
              "type": "number",
              "value": "={{ $json.data.duration }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "6f445fdb-84d9-418c-b082-4bcf702cef22",
      "name": "Creatomate - Render",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        6000,
        1744
      ],
      "parameters": {
        "url": "https://api.creatomate.com/v2/renders",
        "method": "POST",
        "options": {
          "timeout": 120000
        },
        "jsonBody": "={{ $json}}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "headerParameters": {
          "parameters": [
            {}
          ]
        }
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "43290299-e7b3-472d-afdf-d281eab6bcae",
      "name": "Set Render ID",
      "type": "n8n-nodes-base.set",
      "position": [
        6224,
        1744
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "render_id",
              "name": "render_id",
              "type": "string",
              "value": "={{ Array.isArray($json) ? $json[0].id : $json.id }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "9410f289-7181-4c5a-8194-e67fa6d94675",
      "name": "Wait for Render",
      "type": "n8n-nodes-base.wait",
      "position": [
        6448,
        1744
      ],
      "parameters": {
        "amount": 30
      },
      "typeVersion": 1.1
    },
    {
      "id": "15b534ae-3780-4ddc-a957-7e5b2f35b113",
      "name": "Poll Render Status",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        6672,
        1664
      ],
      "parameters": {
        "url": "=https://api.creatomate.com/v2/renders/{{ $('Set Render ID').item.json.render_id }}",
        "options": {},
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "waitBetweenTries": 5000
    },
    {
      "id": "cfdd0819-a613-487a-ae58-47f6be30d7d1",
      "name": "Render Done?",
      "type": "n8n-nodes-base.if",
      "position": [
        6896,
        1744
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "render-done",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.status }}",
              "rightValue": "succeeded"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "9360db52-8aad-49f0-9bdd-ed4ea1f6e9e9",
      "name": "Download Rendered Video",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        7120,
        1744
      ],
      "parameters": {
        "url": "={{ $json.url }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "e0c53c3c-fdb2-42e0-8d9e-2f97e639d1e3",
      "name": "Upload to Google Drive",
      "type": "n8n-nodes-base.googleDrive",
      "position": [
        7344,
        1744
      ],
      "parameters": {
        "name": "={{ $('AI Video Director').item.json.output[0].content[0].text.title }}_{{ $('Loop Through Concepts').item.json.concept_index }}.mp4",
        "driveId": {
          "__rl": true,
          "mode": "list",
          "value": "My Drive"
        },
        "options": {},
        "folderId": {
          "__rl": true,
          "mode": "list",
          "value": "1KnH7S9ltO1VMhahtNQcKLkUnFwekbOqL",
          "cachedResultUrl": "https://drive.google.com/drive/folders/1KnH7S9ltO1VMhahtNQcKLkUnFwekbOqL",
          "cachedResultName": "Adam Goodyer Short-Form Content"
        }
      },
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 3
    },
    {
      "id": "c569730f-a65d-4d44-9ebf-36e9a0662fdf",
      "name": "Creatomate Template Builder Code1",
      "type": "n8n-nodes-base.code",
      "position": [
        5776,
        1744
      ],
      "parameters": {
        "jsCode": "// ============================================================\n// CREATOMATE TEMPLATE BUILDER v23.0 (STEPPED FLASH FIXED)\n// ============================================================\n// \n// FIX v23.0:\n// - FIXED: Flash now uses SEQUENTIAL COMPOSITIONS on same track\n// - Each composition has rgba opacity baked into fill_color\n// - No animations needed - stepped opacity creates smooth fade-out\n// - Compositions with empty elements[] act as solid color overlays\n// - Zoom animation uses back-out easing for punch feel\n//\n// ============================================================\n\n// ============================================================\n// \ud83c\udf9b\ufe0f INPUT NODES - All data sources in one place\n// ============================================================\n\nconst INPUT_NODES = {\n  aiDirector: $('AI Video Director').first().json,\n  conceptLoop: $('Loop Through Concepts').first().json,\n  avatarUrl: $('Set Avatar URL').first().json,\n  flashBroll: $('Aggregate Flash B-Roll').first().json,\n  effectsLibrary: $('Creatomate Effects Library').first().json,\n  shortsSource: $('Shorts Trigger').first().json?.source || {}\n};\n\n// ============================================================\n// \ud83c\udf9b\ufe0f CONFIGURABLE SETTINGS\n// ============================================================\n\n// Video Output Settings\nconst VIDEO_SETTINGS = {\n  outputFormat: 'mp4',\n  width: 1080,\n  height: 1920,\n  frameRate: 30,\n  previewWidth: 1080,\n  previewHeight: 1920,\n  usePreviewSize: true\n};\n\n// ============================================================\n// \ud83d\ude80 SCROLL-STOPPER INTRO SETTINGS (STEPPED COMPOSITION APPROACH)\n// ============================================================\n// \n// Psychology of short-form scroll-stopping:\n// 1. INSTANT IMPACT: White flash at frame 0 triggers attention\n// 2. MOTION PARALLAX: Zoom creates depth, signals \"something happening\"\n// 3. TIMING: Must complete < 0.5s to not annoy, but long enough to register\n// 4. CONTRAST: White on dark background = maximum visual punch\n//\n// TECHNICAL: Uses sequential compositions on same track with\n// decreasing rgba opacity baked into fill_color. This creates\n// a smooth stepped fade-out effect without needing animations.\n//\n// ============================================================\n\nconst INTRO_EFFECT = {\n  enabled: true,\n  \n  // Stepped flash using sequential compositions\n  // Each step is a composition with opacity in rgba fill_color\n  flash: {\n    enabled: true,\n    baseTrack: 30,  // High track = renders on top\n    steps: [\n      // Step 1: FULL PUNCH - 100% white (quick hit)\n      { time: 0,     duration: 0.017, opacity: 1.0 },\n      // Step 2: 80% white\n      { time: 0.017, duration: 0.017, opacity: 0.8 },\n      // Step 3: 55% white\n      { time: 0.034, duration: 0.017, opacity: 0.55 },\n      // Step 4: 30% white\n      { time: 0.051, duration: 0.025, opacity: 0.3 },\n      // Step 5: 15% white\n      { time: 0.076, duration: 0.035, opacity: 0.15 },\n      // Step 6: 5% white (quick fade out)\n      { time: 0.111, duration: 0.04,  opacity: 0.05 }\n    ]\n  },\n  \n  // Zoom settings - creates depth and \"impact\" feeling\n  zoom: {\n    enabled: true,\n    startScale: \"150%\",       // Start zoomed in more for punch\n    endScale: \"100%\",         // Settle to normal\n    duration: 0.4,            // Quick but not jarring\n    easing: \"back-out\"        // Overshoots slightly for punch feel\n  }\n};\n\n// Screen Recording Glow Effect\nconst SCREEN_GLOW = {\n  enabled: true,\n  color: '#355A15',\n  primaryOpacity: '60%',\n  secondaryOpacity: '40%',\n  primaryBlur: '25 vmin',\n  secondaryBlur: '15 vmin'\n};\n\n// Caption Settings\nconst CAPTION_SETTINGS = {\n  enabled: true,\n  fontFamily: 'Montserrat',\n  fontWeight: 900,\n  fontSize: '9.5 vmin',\n  fillColor: '#FFFFFF',\n  strokeColor: '#000000',\n  strokeWidth: '1.1 vmin',\n  shadowColor: 'rgba(0,0,0,0.95)',\n  shadowBlur: '1.3 vmin',\n  maxLength: 14,\n  effect: 'highlight',\n  positionY: '68%'\n};\n\n// Background Settings\nconst BACKGROUND = {\n  color: '#0a0a0a'\n};\n\n// Audio Settings\nconst AUDIO_SETTINGS = {\n  baseUrl: 'https://pub-1710591b286a4bf3877267e2ce0828e1.r2.dev',\n  defaultVolume: '7%',\n  fadeIn: 0.5,\n  fadeOut: 1.5\n};\n\n// ============================================================\n// \ud83d\udce5 PARSE AI DIRECTOR OUTPUT\n// ============================================================\n\nlet directorData;\nconst aiOutput = INPUT_NODES.aiDirector.output;\n\nif (aiOutput && typeof aiOutput === 'object' && !Array.isArray(aiOutput)) {\n  directorData = aiOutput;\n} else if (Array.isArray(aiOutput)) {\n  try {\n    const messageBlock = aiOutput.find(o => o.type === 'message');\n    if (messageBlock) {\n      const textContent = messageBlock.content[0].text;\n      directorData = typeof textContent === 'string' \n        ? JSON.parse(textContent) \n        : textContent;\n    } else {\n      const textContent = aiOutput[1]?.content?.[0]?.text;\n      directorData = typeof textContent === 'string' \n        ? JSON.parse(textContent) \n        : textContent;\n    }\n  } catch (e) {\n    console.error('Parse error:', e.message);\n    directorData = {};\n  }\n} else {\n  directorData = {};\n}\n\n// ============================================================\n// \ud83d\udcca DERIVED CONSTANTS\n// ============================================================\n\nconst conceptData = INPUT_NODES.conceptLoop;\nconst avatarUrl = INPUT_NODES.avatarUrl.avatar_video_url;\nconst avatarDuration = INPUT_NODES.avatarUrl.avatar_duration || INPUT_NODES.avatarUrl.duration || null;\nconst flashBrollGenerated = INPUT_NODES.flashBroll.flash_broll_generated || [];\nconst lib = INPUT_NODES.effectsLibrary;\nconst sourceData = INPUT_NODES.shortsSource;\n\n// Build AI B-roll URL lookup\nconst aiBrollUrls = {};\nflashBrollGenerated.forEach(clip => {\n  const index = (clip.flash_number || 1) - 1;\n  aiBrollUrls[index] = clip.video_url;\n});\n\n// Storyboard and styling\nconst storyboard = directorData.enhanced_storyboard || directorData.storyboard || [];\nconst conceptType = directorData.concept_type || conceptData.concept_type || 'bold';\nconst accentColor = lib.accent_colors?.[conceptType] || lib.accent_colors?.[Object.keys(lib.accent_colors || {})[0]] || '#00E676';\n\n// Final constants\nconst AVATAR_URL = avatarUrl;\nconst SCREEN_URL = sourceData.video_url || directorData.video_url;\nconst AI_BROLL_URLS = aiBrollUrls;\nconst STORYBOARD = storyboard;\nconst ACTUAL_AVATAR_DURATION = avatarDuration || directorData.total_duration_seconds || directorData.total_duration;\nconst ACCENT_COLOR = accentColor;\n\n// Audio track selection\nconst audioTrackId = directorData.audio_track || Object.keys(lib.audio_tracks || {})[0] || 7;\nconst selectedTrack = lib.audio_tracks?.[audioTrackId];\n\n// ============================================================\n// \ud83d\udd27 HELPER FUNCTIONS\n// ============================================================\n\nfunction getDefaultLayout() {\n  const layoutKeys = Object.keys(lib.layouts || {});\n  const withAvatar = layoutKeys.find(k => lib.layouts[k].avatar);\n  const anyLayout = layoutKeys[0];\n  \n  if (withAvatar) return lib.layouts[withAvatar];\n  if (anyLayout) return lib.layouts[anyLayout];\n  \n  return {\n    id: \"FALLBACK\",\n    avatar: { x: \"50%\", y: \"50%\", width: \"100%\", height: \"100%\" },\n    screen: null,\n    divider_y: null\n  };\n}\n\nfunction getLayout(layoutId) {\n  if (layoutId && lib.layouts?.[layoutId]) {\n    return lib.layouts[layoutId];\n  }\n  return getDefaultLayout();\n}\n\n// ============================================================\n// \ud83c\udfac TRANSITION SYSTEM (DYNAMIC FROM EFFECTS LIBRARY)\n// ============================================================\n\nconst OVERLAY_TRANSITION_TYPES = [\n  'flash', 'double_flash', 'rgb_glitch', 'accent_flash', \n  'glitch_bars', 'zoom_shake', 'whip_pan'\n];\n\nfunction isOverlayTransition(transitionId) {\n  if (!transitionId) return false;\n  const trans = lib.transitions?.[transitionId];\n  if (!trans) return false;\n  return OVERLAY_TRANSITION_TYPES.includes(trans.type);\n}\n\n// ============================================================\n// \ud83d\ude80 SCROLL-STOPPER INTRO BUILDERS (STEPPED COMPOSITION APPROACH)\n// ============================================================\n\n/**\n * Build intro flash overlay elements using STEPPED COMPOSITIONS.\n * \n * KEY INSIGHT: Creatomate's fade animations don't support fade-OUT.\n * Instead, we use sequential compositions on the SAME TRACK with\n * decreasing rgba opacity baked into fill_color. This creates a\n * smooth stepped fade-out effect without needing any animations.\n * \n * - All compositions on same track = they play SEQUENTIALLY\n * - Each has explicit time + duration for precise control\n * - Opacity is in rgba format: \"rgba(255,255,255,0.75)\"\n * - Empty elements[] array = solid color overlay\n * \n * @returns {Array} Array of composition elements for the flash effect\n */\nfunction buildIntroFlashElements() {\n  if (!INTRO_EFFECT.enabled || !INTRO_EFFECT.flash.enabled) return [];\n  \n  const flashElements = [];\n  const track = INTRO_EFFECT.flash.baseTrack;\n  \n  INTRO_EFFECT.flash.steps.forEach((step) => {\n    flashElements.push({\n      type: \"composition\",\n      track: track,  // ALL on same track = sequential playback\n      time: step.time,\n      duration: step.duration,\n      fill_color: `rgba(255,255,255,${step.opacity})`,  // Opacity baked in!\n      width: \"100%\",\n      height: \"100%\",\n      elements: []  // Empty = solid color overlay\n    });\n  });\n  \n  return flashElements;\n}\n\n/**\n * Build intro zoom animation using PROPER Creatomate syntax.\n * \n * Uses back-out easing for that \"punch\" feel where it slightly\n * overshoots then settles back. Combined with flash, creates\n * the scroll-stopping impact moment.\n * \n * @returns {Object|null} Animation object for the animations array\n */\nfunction buildIntroZoomAnimation() {\n  if (!INTRO_EFFECT.enabled || !INTRO_EFFECT.zoom.enabled) return null;\n  \n  return {\n    type: \"scale\",\n    time: 0,  // Start immediately\n    duration: INTRO_EFFECT.zoom.duration,\n    easing: INTRO_EFFECT.zoom.easing,\n    start_scale: INTRO_EFFECT.zoom.startScale,\n    end_scale: INTRO_EFFECT.zoom.endScale,\n    scope: \"element\",\n    fade: false\n  };\n}\n\n// ============================================================\n// \ud83c\udfac OTHER TRANSITION/ANIMATION HELPERS\n// ============================================================\n\nfunction buildTransitionOverlays(time, transitionId) {\n  const trans = lib.transitions?.[transitionId];\n  if (!trans) return [];\n  \n  const elements = [];\n  let trackOffset = 20;\n  \n  switch (trans.type) {\n    case 'flash':\n    case 'double_flash':\n      // Use stepped composition approach for mid-video flashes too\n      const flashSteps = [\n        { timeOffset: 0,     duration: 0.03, opacity: 1.0 },\n        { timeOffset: 0.03,  duration: 0.03, opacity: 0.7 },\n        { timeOffset: 0.06,  duration: 0.04, opacity: 0.4 },\n        { timeOffset: 0.10,  duration: 0.05, opacity: 0.15 }\n      ];\n      flashSteps.forEach((step, i) => {\n        elements.push({\n          type: \"composition\",\n          track: trackOffset + i,\n          time: time + step.timeOffset,\n          duration: step.duration,\n          fill_color: `rgba(255,255,255,${step.opacity})`,\n          width: \"100%\",\n          height: \"100%\",\n          elements: []\n        });\n      });\n      break;\n    \n    case 'rgb_glitch':\n      // Build RGB color layers\n      const rgbSteps = [\n        { color: \"255,0,0\", opacity: 0.5, xOffset: \"-2%\" },\n        { color: \"0,255,0\", opacity: 0.5, xOffset: \"0%\" },\n        { color: \"0,0,255\", opacity: 0.5, xOffset: \"2%\" }\n      ];\n      rgbSteps.forEach((layer, i) => {\n        elements.push({\n          type: \"composition\",\n          track: trackOffset + i,\n          time: time,\n          duration: trans.duration || 0.12,\n          fill_color: `rgba(${layer.color},${layer.opacity})`,\n          width: \"100%\",\n          height: \"100%\",\n          x: layer.xOffset,\n          blend_mode: \"screen\",\n          elements: []\n        });\n      });\n      // Follow-up white flash\n      const whiteFlashSteps = [\n        { timeOffset: 0,    duration: 0.03, opacity: 1.0 },\n        { timeOffset: 0.03, duration: 0.05, opacity: 0.5 },\n        { timeOffset: 0.08, duration: 0.07, opacity: 0.15 }\n      ];\n      whiteFlashSteps.forEach((step, i) => {\n        elements.push({\n          type: \"composition\",\n          track: trackOffset + rgbSteps.length + i,\n          time: time + (trans.duration || 0.12) * 0.5 + step.timeOffset,\n          duration: step.duration,\n          fill_color: `rgba(255,255,255,${step.opacity})`,\n          width: \"100%\",\n          height: \"100%\",\n          elements: []\n        });\n      });\n      break;\n    \n    case 'accent_flash':\n      // Accent color punch followed by white\n      const accentSteps = [\n        { timeOffset: 0,    duration: 0.04, opacity: 0.7, color: ACCENT_COLOR.replace('#', '') },\n        { timeOffset: 0.04, duration: 0.04, opacity: 0.3, color: ACCENT_COLOR.replace('#', '') }\n      ];\n      // Convert hex to rgb for accent\n      const hexToRgb = (hex) => {\n        const r = parseInt(hex.substring(0, 2), 16);\n        const g = parseInt(hex.substring(2, 4), 16);\n        const b = parseInt(hex.substring(4, 6), 16);\n        return `${r},${g},${b}`;\n      };\n      const accentRgb = hexToRgb(ACCENT_COLOR.replace('#', ''));\n      accentSteps.forEach((step, i) => {\n        elements.push({\n          type: \"composition\",\n          track: trackOffset + i,\n          time: time + step.timeOffset,\n          duration: step.duration,\n          fill_color: `rgba(${accentRgb},${step.opacity})`,\n          width: \"100%\",\n          height: \"100%\",\n          elements: []\n        });\n      });\n      // White flash after accent\n      const whiteAfterAccent = [\n        { timeOffset: 0.05, duration: 0.03, opacity: 1.0 },\n        { timeOffset: 0.08, duration: 0.04, opacity: 0.6 },\n        { timeOffset: 0.12, duration: 0.08, opacity: 0.2 }\n      ];\n      whiteAfterAccent.forEach((step, i) => {\n        elements.push({\n          type: \"composition\",\n          track: trackOffset + accentSteps.length + i,\n          time: time + step.timeOffset,\n          duration: step.duration,\n          fill_color: `rgba(255,255,255,${step.opacity})`,\n          width: \"100%\",\n          height: \"100%\",\n          elements: []\n        });\n      });\n      break;\n    \n    case 'zoom_shake':\n      // White flash with zoom feel\n      const zoomFlashSteps = [\n        { timeOffset: 0,    duration: 0.04, opacity: 1.0 },\n        { timeOffset: 0.04, duration: 0.06, opacity: 0.6 },\n        { timeOffset: 0.10, duration: 0.10, opacity: 0.25 },\n        { timeOffset: 0.20, duration: 0.10, opacity: 0.08 }\n      ];\n      zoomFlashSteps.forEach((step, i) => {\n        elements.push({\n          type: \"composition\",\n          track: trackOffset + i,\n          time: time + step.timeOffset,\n          duration: step.duration,\n          fill_color: `rgba(255,255,255,${step.opacity})`,\n          width: \"100%\",\n          height: \"100%\",\n          elements: []\n        });\n      });\n      break;\n    \n    case 'whip_pan':\n      // Horizontal wipe-style flash\n      elements.push({\n        type: \"composition\",\n        track: trackOffset,\n        time: time,\n        duration: trans.duration || 0.15,\n        fill_color: \"rgba(255,255,255,1)\",\n        width: trans.width || \"120%\",\n        height: \"100%\",\n        y: \"50%\",\n        x_anchor: \"50%\",\n        y_anchor: \"50%\",\n        elements: [],\n        animations: [\n          {\n            type: \"slide\",\n            time: 0,\n            duration: trans.duration || 0.15,\n            direction: \"0\u00b0\",\n            distance: \"120%\",\n            easing: \"quadratic-out\"\n          }\n        ]\n      });\n      break;\n    \n    default:\n      // Generic flash fallback using stepped approach\n      if (trans.color) {\n        const genericSteps = [\n          { timeOffset: 0,    duration: 0.03, opacity: 1.0 },\n          { timeOffset: 0.03, duration: 0.05, opacity: 0.5 },\n          { timeOffset: 0.08, duration: 0.07, opacity: 0.15 }\n        ];\n        genericSteps.forEach((step, i) => {\n          elements.push({\n            type: \"composition\",\n            track: trackOffset + i,\n            time: time + step.timeOffset,\n            duration: step.duration,\n            fill_color: `rgba(255,255,255,${step.opacity})`,\n            width: \"100%\",\n            height: \"100%\",\n            elements: []\n          });\n        });\n      }\n  }\n  \n  return elements;\n}\n\nfunction buildTransitionAnimation(transitionId) {\n  if (!transitionId) return null;\n  \n  const trans = lib.transitions?.[transitionId];\n  if (!trans) return null;\n  \n  if (OVERLAY_TRANSITION_TYPES.includes(trans.type)) return null;\n  if (trans.type === 'cut') return null;\n  \n  const animation = {\n    time: 0,\n    duration: trans.duration || 0.15,\n    transition: true,\n    easing: trans.easing || \"quadratic-out\"\n  };\n  \n  switch (trans.type) {\n    case 'scale':\n      animation.type = \"scale\";\n      animation.start_scale = trans.start_scale || \"120%\";\n      break;\n      \n    case 'slide':\n      animation.type = \"slide\";\n      animation.direction = trans.direction || \"0\u00b0\";\n      if (trans.distance) animation.distance = trans.distance;\n      animation.fade = trans.fade !== undefined ? trans.fade : false;\n      break;\n      \n    case 'wipe':\n      animation.type = \"wipe\";\n      animation.direction = trans.direction || \"left\";\n      break;\n      \n    case 'fade':\n      animation.type = \"fade\";\n      break;\n      \n    default:\n      return null;\n  }\n  \n  return animation;\n}\n\nfunction getAvatarAnimation(animationId) {\n  if (!animationId) return [];\n  \n  const anim = lib.avatar_animations?.[animationId];\n  if (!anim || anim.type === 'none') return [];\n  \n  switch (anim.type) {\n    case 'scale':\n      return [{ \n        type: \"scale\", \n        time: 0, \n        duration: anim.duration || 0.2, \n        start_scale: anim.start_scale || \"108%\", \n        easing: anim.easing || \"quadratic-out\"\n      }];\n      \n    case 'slide':\n      return [{ \n        type: \"slide\", \n        time: 0, \n        duration: anim.duration || 0.25, \n        direction: anim.direction === 'up' ? \"90\u00b0\" : \"270\u00b0\",\n        distance: anim.distance || \"10%\",\n        easing: anim.easing || \"back-out\"\n      }];\n      \n    case 'fade':\n      return [{\n        type: \"fade\",\n        time: 0,\n        duration: anim.duration || 0.3,\n        start_opacity: anim.start_opacity || \"0%\"\n      }];\n      \n    default:\n      return [];\n  }\n}\n\nfunction getScreenMotion(motionId, duration) {\n  const defaultMotion = { \n    scale: [{ time: \"0 s\", value: \"100%\" }], \n    x: [{ time: \"0 s\", value: \"50%\" }], \n    y: [{ time: \"0 s\", value: \"50%\" }] \n  };\n  \n  if (!motionId) return defaultMotion;\n  \n  const motion = lib.screen_motions?.[motionId];\n  if (!motion) return defaultMotion;\n  \n  if (motion.type === 'static') return defaultMotion;\n  \n  if (motion.type === 'zoom') {\n    return {\n      scale: [\n        { time: \"0 s\", value: motion.start_scale || \"100%\", easing: motion.easing || \"linear\" },\n        { time: `${duration} s`, value: motion.end_scale || \"108%\" }\n      ],\n      x: [{ time: \"0 s\", value: \"50%\" }],\n      y: [{ time: \"0 s\", value: \"50%\" }]\n    };\n  }\n  \n  if (motion.type === 'pan') {\n    return {\n      scale: [{ time: \"0 s\", value: \"100%\" }],\n      x: [\n        { time: \"0 s\", value: motion.start_x || \"50%\", easing: motion.easing || \"linear\" },\n        { time: `${duration} s`, value: motion.end_x || \"50%\" }\n      ],\n      y: [\n        { time: \"0 s\", value: motion.start_y || \"50%\", easing: motion.easing || \"linear\" },\n        { time: `${duration} s`, value: motion.end_y || \"50%\" }\n      ]\n    };\n  }\n  \n  return defaultMotion;\n}\n\nfunction buildTextOverlay(styleId, content, time, duration) {\n  if (!styleId || !content) return null;\n  \n  const style = lib.text_styles?.[styleId];\n  if (!style) return null;\n  \n  const fillColor = (style.fill_color || \"#FFFFFF\").replace(\"{{ACCENT_COLOR}}\", ACCENT_COLOR);\n  const bgColor = style.background_color?.replace(\"{{ACCENT_COLOR}}\", ACCENT_COLOR);\n  \n  const textElement = {\n    type: \"text\",\n    track: 10,\n    time: time + 0.2,\n    duration: duration - 0.3,\n    text: content,\n    x: style.position?.x || \"50%\",\n    y: style.position?.y || \"78%\",\n    width: \"92%\",\n    x_anchor: \"50%\",\n    y_anchor: \"50%\",\n    x_alignment: \"50%\",\n    font_family: style.font_family,\n    font_weight: style.font_weight,\n    font_size: style.font_size,\n    fill_color: fillColor,\n    animations: [\n      { type: \"scale\", time: 0, duration: 0.25, easing: \"back-out\", start_scale: \"90%\" },\n      { type: \"fade\", time: \"end\", duration: 0.15 }\n    ]\n  };\n  \n  if (style.stroke_color) textElement.stroke_color = style.stroke_color;\n  if (style.stroke_width) textElement.stroke_width = style.stroke_width;\n  if (style.shadow_color) textElement.shadow_color = style.shadow_color;\n  if (style.shadow_blur) textElement.shadow_blur = style.shadow_blur;\n  if (bgColor) textElement.background_color = bgColor;\n  if (style.background_x_padding) textElement.background_x_padding = style.background_x_padding;\n  if (style.background_y_padding) textElement.background_y_padding = style.background_y_padding;\n  if (style.background_border_radius) textElement.background_border_radius = style.background_border_radius;\n  \n  return textElement;\n}\n\nfunction getBrollVideoUrl(beat) {\n  if (beat.broll_type === 'screen') return SCREEN_URL;\n  if (beat.broll_type === 'ai_generated' && beat.ai_broll_index != null && beat.ai_broll_index >= 0) {\n    return AI_BROLL_URLS[beat.ai_broll_index] || null;\n  }\n  return null;\n}\n\nfunction getBrollTrimSettings(beat, duration) {\n  if (beat.broll_type === 'screen') {\n    const startTime = beat.broll_start_seconds || 0;\n    const aiSpecifiedDuration = beat.broll_end_seconds && beat.broll_start_seconds \n      ? beat.broll_end_seconds - beat.broll_start_seconds \n      : null;\n    \n    return {\n      trim_start: startTime,\n      trim_duration: duration + 0.5,\n      _ai_specified_duration: aiSpecifiedDuration,\n      _was_corrected: aiSpecifiedDuration !== null && aiSpecifiedDuration < duration\n    };\n  }\n  \n  if (beat.broll_type === 'ai_generated') {\n    return { \n      trim_start: 0, \n      trim_duration: duration + 0.5\n    };\n  }\n  \n  return null;\n}\n\n// ============================================================\n// \ud83c\udfd7\ufe0f BUILD ELEMENTS ARRAY\n// ============================================================\n\nconst elements = [];\nlet avatarTrimPosition = 0;\nlet overlayTransitionCount = 0;\nlet brollDurationCorrections = [];\nlet introFlashCount = 0;\nlet introZoomApplied = false;\n\n// Track 1: Background Music\nif (selectedTrack) {\n  elements.push({\n    type: \"audio\",\n    track: 1,\n    source: `${AUDIO_SETTINGS.baseUrl}/${selectedTrack.file || selectedTrack.name.toLowerCase().replace(/ /g, '_') + '.mp3'}`,\n    volume: selectedTrack.volume || AUDIO_SETTINGS.defaultVolume,\n    loop: true,\n    audio_fade_in: AUDIO_SETTINGS.fadeIn,\n    audio_fade_out: AUDIO_SETTINGS.fadeOut\n  });\n}\n\n// Track 2: Master Avatar (hidden, for audio & caption sync)\nelements.push({\n  id: \"avatar-master\",\n  type: \"video\",\n  track: 2,\n  time: 0,\n  source: AVATAR_URL,\n  volume: \"100%\",\n  y: \"200%\",\n  fit: \"cover\"\n});\n\n// Track 3: Black Background\nelements.push({\n  type: \"shape\",\n  track: 3,\n  time: 0,\n  duration: ACTUAL_AVATAR_DURATION + 5,\n  shape: \"rectangle\",\n  width: \"100%\",\n  height: \"100%\",\n  fill_color: BACKGROUND.color\n});\n\n// ============================================================\n// \ud83d\ude80 ADD INTRO FLASH ELEMENTS (Track 30 - stepped compositions)\n// ============================================================\nconst introFlashElements = buildIntroFlashElements();\nintroFlashElements.forEach(el => {\n  elements.push(el);\n  introFlashCount++;\n});\n\n// ============================================================\n// \ud83c\udfac PROCESS EACH STORYBOARD BEAT\n// ============================================================\n\nlet currentTime = 0;\n\nSTORYBOARD.forEach((beat, idx) => {\n  const duration = beat.duration_seconds || 4;\n  const layout = getLayout(beat.layout);\n  const screenMotion = getScreenMotion(beat.screen_motion, duration);\n  const avatarAnimations = getAvatarAnimation(beat.avatar_animation);\n  \n  const isIntroBeat = idx === 0;\n  \n  // Handle transitions (not for first beat)\n  let transitionAnimation = null;\n  if (!isIntroBeat && beat.transition_in) {\n    if (isOverlayTransition(beat.transition_in)) {\n      const overlayElements = buildTransitionOverlays(currentTime, beat.transition_in);\n      overlayElements.forEach(el => elements.push(el));\n      overlayTransitionCount++;\n    } else {\n      transitionAnimation = buildTransitionAnimation(beat.transition_in);\n    }\n  }\n  \n  // ============================================================\n  // AVATAR ELEMENT\n  // ============================================================\n  if (layout.avatar) {\n    const avatarElement = {\n      type: \"video\",\n      track: 4,\n      time: currentTime,\n      duration: duration,\n      source: AVATAR_URL,\n      trim_start: avatarTrimPosition,\n      trim_duration: duration,\n      volume: 0,\n      x: layout.avatar.x,\n      y: layout.avatar.y,\n      width: layout.avatar.width,\n      height: layout.avatar.height,\n      x_anchor: \"50%\",\n      y_anchor: \"50%\",\n      fit: \"cover\",\n      clip: true\n    };\n    \n    if (layout.avatar.border_radius) {\n      avatarElement.border_radius = layout.avatar.border_radius;\n    }\n    \n    // Build animations array\n    const allAnimations = [];\n    \n    // INTRO: First beat gets scroll-stopper zoom\n    if (isIntroBeat && INTRO_EFFECT.enabled && INTRO_EFFECT.zoom.enabled) {\n      const introZoom = buildIntroZoomAnimation();\n      if (introZoom) {\n        allAnimations.push(introZoom);\n        introZoomApplied = true;\n      }\n    } else {\n      if (transitionAnimation) allAnimations.push(transitionAnimation);\n      avatarAnimations.forEach(a => allAnimations.push(a));\n    }\n    \n    if (allAnimations.length > 0) {\n      avatarElement.animations = allAnimations;\n    }\n    \n    elements.push(avatarElement);\n  }\n  \n  // ============================================================\n  // B-ROLL ELEMENT\n  // ============================================================\n  const brollUrl = getBrollVideoUrl(beat);\n  const brollTrim = getBrollTrimSettings(beat, duration);\n  \n  if (brollTrim && brollTrim._was_corrected) {\n    brollDurationCorrections.push({\n      beat_index: idx,\n      beat_type: beat.beat_type,\n      beat_duration: duration,\n      ai_specified_duration: brollTrim._ai_specified_duration,\n      correction_applied: true\n    });\n  }\n  \n  if (layout.screen && brollUrl && brollTrim) {\n    \n    const isScreenRecording = beat.broll_type === 'screen' || layout.is_padded_screen === true;\n    const fitMode = isScreenRecording ? 'contain' : 'cover';\n    \n    const videoWidth = isScreenRecording ? '92%' : '120%';\n    const videoHeight = isScreenRecording ? '88%' : '130%';\n    \n    const innerElements = [];\n    \n    if (isScreenRecording && SCREEN_GLOW.enabled) {\n      innerElements.push({\n        type: \"shape\",\n        track: 1,\n        shape: \"rectangle\",\n        width: \"100%\",\n        height: \"100%\",\n        fill_color: \"#0d0d0d\"\n      });\n      \n      innerElements.push({\n        type: \"shape\",\n        track: 2,\n        shape: \"rectangle\",\n        width: \"70%\",\n        height: \"60%\",\n        x: \"50%\",\n        y: \"50%\",\n        x_anchor: \"50%\",\n        y_anchor: \"50%\",\n        fill_color: SCREEN_GLOW.color,\n        opacity: SCREEN_GLOW.primaryOpacity,\n        shadow_color: SCREEN_GLOW.color,\n        shadow_blur: SCREEN_GLOW.primaryBlur,\n        border_radius: \"5 vmin\"\n      });\n      \n      innerElements.push({\n        type: \"shape\",\n        track: 3,\n        shape: \"rectangle\",\n        width: \"50%\",\n        height: \"40%\",\n        x: \"50%\",\n        y: \"50%\",\n        x_anchor: \"50%\",\n        y_anchor: \"50%\",\n        fill_color: SCREEN_GLOW.color,\n        opacity: SCREEN_GLOW.secondaryOpacity,\n        shadow_color: SCREEN_GLOW.color,\n        shadow_blur: SCREEN_GLOW.secondaryBlur,\n        border_radius: \"3 vmin\"\n      });\n      \n      innerElements.push({\n        type: \"shape\",\n        track: 4,\n        shape: \"rectangle\",\n        width: \"93%\",\n        height: \"89%\",\n        x: \"50%\",\n        y: \"50%\",\n        x_anchor: \"50%\",\n        y_anchor: \"50%\",\n        fill_color: \"#0a0a0a\",\n        border_radius: \"1.2 vmin\",\n        shadow_color: \"rgba(0,0,0,0.8)\",\n        shadow_blur: \"2 vmin\"\n      });\n    }\n    \n    innerElements.push({\n      type: \"video\",\n      track: 5,\n      source: brollUrl,\n      volume: 0,\n      trim_start: brollTrim.trim_start,\n      trim_duration: brollTrim.trim_duration,\n      x: \"50%\",\n      y: \"50%\",\n      x_anchor: \"50%\",\n      y_anchor: \"50%\",\n      width: videoWidth,\n      height: videoHeight,\n      fit: fitMode,\n      border_radius: isScreenRecording ? \"1 vmin\" : \"0\",\n      ...screenMotion\n    });\n    \n    if (isScreenRecording && SCREEN_GLOW.enabled) {\n      innerElements.push({\n        type: \"shape\",\n        track: 6,\n        shape: \"rectangle\",\n        width: \"92%\",\n        height: \"88%\",\n        x: \"50%\",\n        y: \"50%\",\n        x_anchor: \"50%\",\n        y_anchor: \"50%\",\n        fill_color: \"rgba(0,0,0,0)\",\n        border_color: \"rgba(255,255,255,0.1)\",\n        border_width: \"0.3 vmin\",\n        border_radius: \"1 vmin\"\n      });\n    }\n    \n    const compositionElement = {\n      type: \"composition\",\n      track: 5,\n      time: currentTime,\n      duration: duration,\n      x: layout.screen.x,\n      y: layout.screen.y,\n      width: layout.screen.width,\n      height: layout.screen.height,\n      x_anchor: \"50%\",\n      y_anchor: \"50%\",\n      clip: true,\n      elements: innerElements\n    };\n    \n    // INTRO: First beat gets scroll-stopper zoom on screen too\n    if (isIntroBeat && INTRO_EFFECT.enabled && INTRO_EFFECT.zoom.enabled) {\n      const introZoom = buildIntroZoomAnimation();\n      if (introZoom) compositionElement.animations = [introZoom];\n    } else if (!layout.avatar && transitionAnimation) {\n      compositionElement.animations = [transitionAnimation];\n    }\n    \n    elements.push(compositionElement);\n  }\n  \n  // ============================================================\n  // DIVIDER LINE\n  // ============================================================\n  if (layout.divider_y) {\n    elements.push({\n      type: \"shape\",\n      track: 6,\n      time: currentTime,\n      duration: duration,\n      shape: \"rectangle\",\n      x: \"50%\",\n      y: layout.divider_y,\n      width: \"100%\",\n      height: \"0.5%\",\n      x_anchor: \"50%\",\n      y_anchor: \"50%\",\n      fill_color: ACCENT_COLOR,\n      shadow_color: \"rgba(0,0,0,0.5)\",\n      shadow_blur: \"0.5 vmin\"\n    });\n  }\n  \n  // ============================================================\n  // TEXT OVERLAY\n  // ============================================================\n  if (beat.text_overlay && beat.text_content) {\n    const textElement = buildTextOverlay(beat.text_overlay, beat.text_content, currentTime, duration);\n    if (textElement) elements.push(textElement);\n  }\n  \n  currentTime += duration;\n  avatarTrimPosition += duration;\n});\n\n// ============================================================\n// \ud83d\udcdd AUTO-CAPTIONS\n// ============================================================\nif (CAPTION_SETTINGS.enabled) {\n  elements.push({\n    type: \"text\",\n    track: 11,\n    transcript_source: \"avatar-master\",\n    transcript_effect: CAPTION_SETTINGS.effect,\n    transcript_maximum_length: CAPTION_SETTINGS.maxLength,\n    x: \"50%\",\n    y: CAPTION_SETTINGS.positionY,\n    width: \"90%\",\n    height: \"20%\",\n    x_anchor: \"50%\",\n    y_anchor: \"50%\",\n    x_alignment: \"50%\",\n    font_family: CAPTION_SETTINGS.fontFamily,\n    font_weight: CAPTION_SETTINGS.fontWeight,\n    font_size: CAPTION_SETTINGS.fontSize,\n    fill_color: CAPTION_SETTINGS.fillColor,\n    stroke_color: CAPTION_SETTINGS.strokeColor,\n    stroke_width: CAPTION_SETTINGS.strokeWidth,\n    shadow_color: CAPTION_SETTINGS.shadowColor,\n    shadow_blur: CAPTION_SETTINGS.shadowBlur\n  });\n}\n\n// ============================================================\n// \ud83d\udce4 OUTPUT\n// ============================================================\n\nconst outputWidth = VIDEO_SETTINGS.usePreviewSize ? VIDEO_SETTINGS.previewWidth : VIDEO_SETTINGS.width;\nconst outputHeight = VIDEO_SETTINGS.usePreviewSize ? VIDEO_SETTINGS.previewHeight : VIDEO_SETTINGS.height;\n\nconst outputScript = {\n  output_format: VIDEO_SETTINGS.outputFormat,\n  width: outputWidth,\n  height: outputHeight,\n  frame_rate: VIDEO_SETTINGS.frameRate,\n  elements: elements,\n  \n  _debug: {\n    version: \"v23.0-stepped-flash\",\n    fix_notes: [\n      \"FIXED: Flash now uses SEQUENTIAL COMPOSITIONS on same track\",\n      \"Each composition has rgba opacity baked into fill_color\",\n      \"No animations needed - stepped opacity creates smooth fade-out\",\n      \"Compositions with empty elements[] act as solid color overlays\",\n      \"Zoom animation uses back-out easing for punch feel\"\n    ],\n    total_beats: STORYBOARD.length,\n    overlay_transitions: overlayTransitionCount,\n    ai_broll_clips: Object.keys(AI_BROLL_URLS).length,\n    avatar_duration: ACTUAL_AVATAR_DURATION,\n    concept_type: conceptType,\n    \n    // INTRO DEBUG\n    intro_effect: {\n      enabled: INTRO_EFFECT.enabled,\n      flash_steps_added: introFlashCount,\n      zoom_applied: introZoomApplied,\n      flash_config: INTRO_EFFECT.flash.enabled ? {\n        step_count: INTRO_EFFECT.flash.steps.length,\n        total_duration: INTRO_EFFECT.flash.steps.reduce((sum, s) => Math.max(sum, s.time + s.duration), 0),\n        approach: \"sequential compositions with rgba opacity\"\n      } : null,\n      zoom_config: INTRO_EFFECT.zoom.enabled ? {\n        start_scale: INTRO_EFFECT.zoom.startScale,\n        end_scale: INTRO_EFFECT.zoom.endScale,\n        duration: INTRO_EFFECT.zoom.duration,\n        easing: INTRO_EFFECT.zoom.easing\n      } : null\n    },\n    \n    layouts_available: Object.keys(lib.layouts || {}),\n    transitions_available: Object.keys(lib.transitions || {}),\n    broll_corrections_applied: brollDurationCorrections.length\n  }\n};\n\nif (ACTUAL_AVATAR_DURATION && typeof ACTUAL_AVATAR_DURATION === 'number') {\n  outputScript.duration = ACTUAL_AVATAR_DURATION;\n}\n\nreturn [{ json: outputScript }];"
      },
      "typeVersion": 2
    },
    {
      "id": "0c129bf6-6116-4c4f-b692-6c3091a6512a",
      "name": "Wait for Avatar",
      "type": "n8n-nodes-base.wait",
      "position": [
        4384,
        1744
      ],
      "parameters": {
        "amount": 45
      },
      "typeVersion": 1.1
    },
    {
      "id": "9085960a-9cac-4432-a200-76e915320c5e",
      "name": "Shorts Trigger",
      "type": "n8n-nodes-base.executeWorkflowTrigger",
      "position": [
        1072,
        1664
      ],
      "parameters": {
        "inputSource": "passthrough"
      },
      "typeVersion": 1.1
    },
    {
      "id": "345e3552-e9c6-4cdf-a8df-a23f865898bd",
      "name": "Extract Snippets",
      "type": "n8n-nodes-base.code",
      "position": [
        1296,
        1664
      ],
      "parameters": {
        "jsCode": "// ============================================================================\n// EXTRACT SHORT FORM SNIPPETS v4.0 - WITH B-ROLL CLIPS\n// ============================================================================\n// CHANGE: Now includes key_moments (4-8 second B-roll clips) from Gemini\n// WHY: AI scriptwriter needs to know what's visually happening on screen\n// to pick timestamps with maximum movement and write accurate descriptions\n// ============================================================================\n\nconst input = $('Shorts Trigger').first().json;\n\n// Get the pre-analyzed data from parent workflow\nconst source = input.source || {};\nconst overview = input.overview || {};\nconst transcript = input.transcript || {};\nconst links = input.links || {};\n\n// NEW: B-roll clips from Gemini visual analysis (4-8 second moments)\nconst brollClips = input.key_moments || [];\nconst visualAnalysis = input.visual_analysis || {};\n\n// Sections now include nested segments[] with sentence-level timestamps\nconst sections = transcript.sections || [];\nconst segments = transcript.segments || [];\nconst bestSectionsForShorts = transcript.best_sections_for_shorts || [];\n\n// Convert sections to key_moments format (for downstream compatibility)\nconst keyMoments = sections\n  .filter(section => section.hook_potential && section.hook_potential !== 'low')\n  .map(section => ({\n    section_index: section.index,\n    timestamp_start: section.start,\n    timestamp_end: section.end,\n    start_formatted: section.start_formatted,\n    end_formatted: section.end_formatted,\n    duration_seconds: section.duration_seconds,\n    description: section.summary,\n    hook_potential: mapHookPotential(section.hook_potential),\n    quote: section.quotable_moment || null,\n    tools_shown: section.key_topics || [],\n    section_title: section.title,\n    section_type: section.section_type,\n    segment_count: section.segment_count || (section.segments ? section.segments.length : 0)\n  }));\n\n// Map hook_potential to our concept types\nfunction mapHookPotential(potential) {\n  if (potential === 'high') return 'bold';\n  if (potential === 'medium') return 'curiosity';\n  return 'pain';\n}\n\n// Use best_sections_for_shorts as best_clips (already identified by AI!)\nconst bestClips = bestSectionsForShorts.map(sectionIndex => {\n  const section = sections[sectionIndex];\n  if (!section) return null;\n  return {\n    section_index: sectionIndex,\n    start: section.start,\n    end: section.end,\n    start_formatted: section.start_formatted,\n    end_formatted: section.end_formatted,\n    duration_seconds: section.duration_seconds,\n    description: section.summary,\n    hook_potential: section.hook_potential,\n    segment_count: section.segment_count || (section.segments ? section.segments.length : 0)\n  };\n}).filter(Boolean);\n\n// If no best_sections identified, pick top 3 high-potential sections\nif (bestClips.length === 0) {\n  const highPotential = sections\n    .filter(s => s.hook_potential === 'high' || s.hook_potential === 'medium')\n    .slice(0, 3);\n  \n  highPotential.forEach(section => {\n    bestClips.push({\n      section_index: section.index,\n      start: section.start,\n      end: section.end,\n      start_formatted: section.start_formatted,\n      end_formatted: section.end_formatted,\n      duration_seconds: section.duration_seconds,\n      description: section.summary,\n      hook_potential: section.hook_potential,\n      segment_count: section.segment_count || (section.segments ? section.segments.length : 0)\n    });\n  });\n}\n\n// ============================================================================\n// ENRICH B-ROLL CLIPS WITH SECTION CONTEXT\n// ============================================================================\n// Match each B-roll clip to its corresponding section for context\n// ============================================================================\n\nconst enrichedBrollClips = brollClips.map((clip, index) => {\n  // Find which section this clip falls into\n  const matchingSection = sections.find(s => \n    clip.start_seconds >= s.start && clip.start_seconds < s.end\n  );\n  \n  return {\n    index: index,\n    start: clip.start,\n    end: clip.end,\n    start_seconds: clip.start_seconds,\n    end_seconds: clip.end_seconds,\n    duration_seconds: (clip.end_seconds || 0) - (clip.start_seconds || 0),\n    app: clip.app,\n    action: clip.action,\n    screen_description: clip.screen_description || null,\n    // Section context\n    section_index: matchingSection ? matchingSection.index : null,\n    section_title: matchingSection ? matchingSection.title : null\n  };\n});\n\nreturn [{\n  json: {\n    // Video metadata\n    video_id: source.video_id,\n    video_name: source.video_name,\n    video_url: source.video_url,\n    duration: source.duration_seconds,\n    duration_formatted: source.duration_formatted,\n    \n    // Pre-analyzed overview (no more re-summarizing!)\n    video_summary: overview.summary,\n    one_liner: overview.one_liner,\n    main_argument: overview.main_argument,\n    target_audience: overview.target_audience,\n    content_style: overview.content_style,\n    tone: overview.tone,\n    key_takeaways: overview.key_takeaways || [],\n    problems_addressed: overview.problems_addressed || [],\n    tools_mentioned: overview.tools_mentioned || [],\n    frameworks_explained: overview.frameworks_explained || [],\n    \n    // Section-based moments (exact timestamps!)\n    key_moments: keyMoments,\n    best_clips: bestClips,\n    \n    // =========================================================================\n    // NEW: B-ROLL CLIPS FROM GEMINI VISUAL ANALYSIS (v4 UPDATE)\n    // 4-8 second clips with high visual movement - use these for body_segments!\n    // =========================================================================\n    broll_clips: enrichedBrollClips,\n    broll_count: enrichedBrollClips.length,\n    visual_analysis: {\n      enabled: visualAnalysis.enabled || brollClips.length > 0,\n      total_clips: brollClips.length,\n      unique_apps: visualAnalysis.unique_apps || [...new Set(brollClips.map(c => c.app).filter(Boolean))],\n      total_broll_time: enrichedBrollClips.reduce((sum, c) => sum + (c.duration_seconds || 0), 0),\n      coverage_percentage: visualAnalysis.coverage_percentage || null\n    },\n    \n    // Main topics\n    main_topics: transcript.main_topics || [],\n    best_sections_for_shorts: bestSectionsForShorts,\n    \n    // =========================================================================\n    // FULL SECTION DATA WITH NESTED SEGMENTS\n    // Each section now contains segments[] array with sentence-level timestamps\n    // =========================================================================\n    sections: sections,\n    \n    // Stats\n    segment_count: transcript.segment_count,\n    section_count: transcript.section_count,\n    \n    // Pass through links for CTAs\n    links: links\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "dddc1e5b-4bfa-476a-a8de-4d565c6727a4",
      "name": "Creatomate Effects Library",
      "type": "n8n-nodes-base.code",
      "position": [
        3488,
        1648
      ],
      "parameters": {
        "jsCode": "// ============================================================\n// CREATOMATE EFFECTS LIBRARY v2.0 - ENHANCED TRANSITIONS\n// ============================================================\n// Place this node BEFORE the AI Video Director node\n// \n// CHANGELOG v2.0:\n// - Added 6 NEW transition effects for high-retention content:\n//   * RGB_GLITCH - Red/Cyan color channel split with position jitter\n//   * ACCENT_FLASH - Brand color flash before white (depth effect)\n//   * GLITCH_BARS - Moving horizontal scan lines (cyan/magenta)\n//   * ZOOM_SHAKE - Flash with scale jitter and position shake\n//   * DOUBLE_FLASH - Quick double-tap flash for punchy moments\n//   * WHIP_PAN - Flash sweeps horizontally across screen\n// - Removed unused transitions: WIPE_LEFT_FAST, WIPE_RIGHT_FAST, WIPE_UP, FADE_QUICK, CUT\n// - Added transition variety guidelines\n//\n// CHANGELOG v1.9:\n// - Added SPLIT_70_30 layout (screen 70% top, avatar 30% bottom)\n// - Added SCREEN_PADDED layout (fullscreen screen recording with centered padding and glow)\n// ============================================================\n\nreturn [{\n  json: {\n    // ============================================================\n    // LAYOUT LIBRARY\n    // ============================================================\n    layouts: {\n      \n      AVATAR_FULLSCREEN: {\n        id: \"AVATAR_FULLSCREEN\",\n        description: \"Avatar fills entire frame - hooks and CTAs ONLY\",\n        avatar: { x: \"50%\", y: \"50%\", width: \"100%\", height: \"100%\" },\n        screen: null,\n        divider_y: null,\n        best_for: [\"hook_snap\", \"cta_close\"],\n        energy: \"high\",\n        restriction: \"ONLY use for first beat (hook_snap) and last beat (cta_close)\"\n      },\n      \n      SCREEN_FULLSCREEN: {\n        id: \"SCREEN_FULLSCREEN\",\n        description: \"AI B-roll fills entire frame edge-to-edge - cinematic moments\",\n        avatar: null,\n        screen: { x: \"50%\", y: \"50%\", width: \"100%\", height: \"100%\" },\n        divider_y: null,\n        is_padded_screen: false,\n        best_for: [\"ai_broll\", \"cinematic_moments\", \"dramatic_reveals\"],\n        energy: \"high\",\n        restriction: \"Use ONLY with broll_type: ai_generated\"\n      },\n      \n      SCREEN_PADDED: {\n        id: \"SCREEN_PADDED\",\n        description: \"Fullscreen screen recording with centered padding and glow - for widescreen B-roll without avatar\",\n        avatar: null,\n        screen: { x: \"50%\", y: \"50%\", width: \"100%\", height: \"100%\" },\n        divider_y: null,\n        is_padded_screen: true,\n        best_for: [\"screen_broll_fullscreen\", \"desktop_recordings\", \"widescreen_demos\", \"detailed_walkthroughs\"],\n        energy: \"medium\",\n        restriction: \"Use ONLY with broll_type: screen\"\n      },\n      \n      SPLIT_50_50: {\n        id: \"SPLIT_50_50\",\n        description: \"Screen 50% top, Avatar 50% bottom - equal split\",\n        avatar: { x: \"50%\", y: \"75%\", width: \"100%\", height: \"50%\" },\n        screen: { x: \"50%\", y: \"25%\", width: \"100%\", height: \"50%\" },\n        divider_y: \"50%\",\n        best_for: [\"balanced_demos\", \"conversational_teaching\", \"screen_with_presence\"],\n        energy: \"medium\"\n      },\n      \n      SPLIT_60_40: {\n        id: \"SPLIT_60_40\",\n        description: \"Screen 60% top, Avatar 40% bottom - screen-dominant\",\n        avatar: { x: \"50%\", y: \"80%\", width: \"100%\", height: \"40%\" },\n        screen: { x: \"50%\", y: \"30%\", width: \"100%\", height: \"60%\" },\n        divider_y: \"60%\",\n        best_for: [\"standard_demos\", \"code_walkthroughs\", \"body_flash\", \"transitions\"],\n        energy: \"medium\"\n      },\n      \n      SPLIT_70_30: {\n        id: \"SPLIT_70_30\",\n        description: \"Screen 70% top, Avatar 30% bottom - maximum screen focus\",\n        avatar: { x: \"50%\", y: \"85%\", width: \"100%\", height: \"30%\" },\n        screen: { x: \"50%\", y: \"35%\", width: \"100%\", height: \"70%\" },\n        divider_y: \"70%\",\n        best_for: [\"detailed_demos\", \"code_focus\", \"body_demo\", \"screen_heavy_content\", \"complex_ui\"],\n        energy: \"medium\"\n      }\n    },\n\n    // ============================================================\n    // TRANSITION LIBRARY\n    // ============================================================\n    transitions: {\n      \n      // ===========

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.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

This workflow is a fully automated YouTube Shorts production pipeline. It takes the structured output from a video digestion workflow (transcript, key moments, metadata) and produces finished, rendered vertical shorts complete with AI-generated avatar narration, AI-generated…

Source: https://n8n.io/workflows/13676/ — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

🎯 Create viral TikToks, Shorts, Reels, podcasts, and ASMR videos in minutes — all on autopilot.

OpenAI, HTTP Request, Form Trigger +7
AI & RAG

Generate AI viral videos with NanoBanana & VEO3, shared on socials via Blotato 2. Uses @blotato/n8n-nodes-blotato, googleSheets, lmChatOpenAi, toolThink. Event-driven trigger; 94 nodes.

@Blotato/N8N Nodes Blotato, Google Sheets, OpenAI Chat +9
AI & RAG

How it Works

Memory Buffer Window, Agent, Output Parser Structured +9
AI & RAG

The best content automation template in the market is now even better—with “deep research” on time-sensitive topics\! Unlike most n8n content automation templates that are mainly for “demo purposes,”

OpenAI, HTTP Request, XML +11
AI & RAG

This template is designed for marketers, content creators, and e-commerce brands who want to automate the creation of professional ad videos at scale. It’s ideal for teams looking to generate consiste

Telegram, Telegram Trigger, Google Drive +8