{
  "name": "Turn one topic into a scene-by-scene faceless video script (JSON)",
  "nodes": [
    {
      "id": "overview",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -720,
        -420
      ],
      "parameters": {
        "color": 1,
        "width": 660,
        "height": 1180,
        "content": "## Turn one topic into a scene-by-scene faceless video script (JSON)\n\nType a topic, pick a language and how many scenes, and get back a clean JSON script: one `voice_over` line plus one English `image_prompt` per scene. This is the writing half of a faceless-video pipeline \u2014 feed the output straight into a voiceover step and an image step to build the full video.\n\nTwo model options: **Gemini** (has a free tier) or **Claude Haiku** (cheap, paid). A router sends your choice to the right API, and a parser normalises either reply into the same `{ \"scenes\": [...] }` shape, then splits it so every downstream node gets one scene at a time.\n\n### How it works\n\n1. A form takes your topic, language, scene count, and model choice.\n2. A Code node validates the language and sets a per-scene word budget so the finished video stays under 60 seconds (a Shorts limit).\n3. A switch routes to Gemini or Claude, each asked to return strict JSON.\n4. A parser reads either provider's response shape, strips code fences, and validates the scenes array.\n5. Split Out emits one item per scene, ready to pipe into TTS + image generation.\n\n### Setup\n\n- Add your API key as a credential on the model node you use: Gemini via a **Query Auth** credential (`key`), Claude via a **Header Auth** credential (`x-api-key`). No keys are hardcoded.\n- Works on n8n Cloud and self-hosted \u2014 this template is HTTP only, no Execute Command.\n\n### Customization tips\n\nChange `TOTAL_WORDS` in the first Code node to make scripts longer or shorter. Swap in any other chat model by editing one HTTP node and adding its response shape to the parser.\n"
      },
      "typeVersion": 1
    },
    {
      "id": "section---input",
      "name": "Section - input",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -40,
        -180
      ],
      "parameters": {
        "color": 7,
        "width": 240,
        "height": 420,
        "content": "## 1. Input\nTopic, language, number of scenes, and which model to use."
      },
      "typeVersion": 1
    },
    {
      "id": "section---budget",
      "name": "Section - budget",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        200,
        -180
      ],
      "parameters": {
        "color": 7,
        "width": 240,
        "height": 420,
        "content": "## 2. Budget\nValidates the language and sets a per-scene word cap for a <60s Short."
      },
      "typeVersion": 1
    },
    {
      "id": "section---model",
      "name": "Section - model",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        640,
        -320
      ],
      "parameters": {
        "color": 7,
        "width": 300,
        "height": 640,
        "content": "## 3. Write the script\nRouter sends your choice to Gemini (free tier) or Claude Haiku. Both return strict JSON."
      },
      "typeVersion": 1
    },
    {
      "id": "section---parse",
      "name": "Section - parse",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        960,
        -180
      ],
      "parameters": {
        "color": 7,
        "width": 460,
        "height": 420,
        "content": "## 4. Parse + split\nNormalises either reply into one scenes array, then emits one scene per item."
      },
      "typeVersion": 1
    },
    {
      "id": "note---add-api-key",
      "name": "Note - add api key",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        640,
        320
      ],
      "parameters": {
        "color": 3,
        "width": 300,
        "height": 140,
        "content": "## Add your API key\nAttach a credential to the model node you pick. Nothing is hardcoded."
      },
      "typeVersion": 1
    },
    {
      "id": "form-trigger",
      "name": "Video idea form",
      "type": "n8n-nodes-base.formTrigger",
      "notes": "Entry point. Topic, language, scene count and model choice flow downstream from here.",
      "position": [
        0,
        0
      ],
      "parameters": {
        "options": {},
        "formTitle": "Topic to faceless video script (JSON)",
        "formFields": {
          "values": [
            {
              "fieldType": "text",
              "fieldLabel": "Topic",
              "requiredField": true
            },
            {
              "fieldType": "dropdown",
              "fieldLabel": "Language",
              "fieldOptions": {
                "values": [
                  {
                    "option": "English (US)"
                  },
                  {
                    "option": "Vietnamese"
                  },
                  {
                    "option": "Spanish"
                  },
                  {
                    "option": "French"
                  },
                  {
                    "option": "German"
                  },
                  {
                    "option": "Portuguese (Brazil)"
                  },
                  {
                    "option": "Indonesian"
                  }
                ]
              },
              "requiredField": true
            },
            {
              "fieldType": "dropdown",
              "fieldLabel": "Number of Scenes",
              "fieldOptions": {
                "values": [
                  {
                    "option": "2"
                  },
                  {
                    "option": "3"
                  },
                  {
                    "option": "4"
                  },
                  {
                    "option": "5"
                  },
                  {
                    "option": "6"
                  },
                  {
                    "option": "7"
                  },
                  {
                    "option": "8"
                  },
                  {
                    "option": "9"
                  },
                  {
                    "option": "10"
                  }
                ]
              },
              "requiredField": true
            },
            {
              "fieldType": "dropdown",
              "fieldLabel": "LLM Provider",
              "fieldOptions": {
                "values": [
                  {
                    "option": "Gemini (free)"
                  },
                  {
                    "option": "Claude (Haiku)"
                  }
                ]
              },
              "requiredField": true
            }
          ]
        },
        "formDescription": "Enter a topic and pick your options. You get back a scene-by-scene script as JSON: one voice_over line and one English image_prompt per scene, sized to fit a video under 60 seconds."
      },
      "typeVersion": 2.2
    },
    {
      "id": "prepare",
      "name": "Set language and word budget",
      "type": "n8n-nodes-base.code",
      "notes": "Validates the language and computes a per-scene word cap so the finished video stays under 60s. Edit TOTAL_WORDS to change script length.",
      "position": [
        220,
        0
      ],
      "parameters": {
        "jsCode": "// Validate the language and set a per-scene word budget that keeps the finished\n// video under 60s (so it still counts as a YouTube Short).\n//\n// Only languages that put SPACES between words belong here: a downstream karaoke /\n// caption step splits text on whitespace, so Chinese/Japanese/Thai would break.\nconst LANGUAGES = {\n  'English (US)':        'English',\n  'Vietnamese':          'Vietnamese',\n  'Spanish':             'Spanish',\n  'French':              'French',\n  'German':              'German',\n  'Portuguese (Brazil)': 'Brazilian Portuguese',\n  'Indonesian':          'Indonesian',\n};\n\nconst picked = $json['Language'];\nconst languageName = LANGUAGES[picked];\nif (!languageName) {\n  throw new Error(`Unsupported language: \"${picked}\". Add it to the LANGUAGES map in this node.`);\n}\n\n// Word budget = what keeps the script short enough for a <60s Short.\n// A typical TTS voice speaks ~2.32 words/sec including pauses; aim at ~55s => ~128 words\n// for the WHOLE video, split across the scenes as a hard per-scene cap handed to the LLM.\n// Raise TOTAL_WORDS if you don't care about the 60s Shorts limit (~2.32 words = 1 second).\nconst TOTAL_WORDS = 125;\nconst scenes = Number($json['Number of Scenes']) || 5;\nconst wordsPerScene = Math.max(8, Math.floor(TOTAL_WORDS / scenes));\n\nreturn { json: { ...$json, languageName, wordsPerScene } };\n"
      },
      "typeVersion": 2
    },
    {
      "id": "router",
      "name": "Route to the chosen model",
      "type": "n8n-nodes-base.switch",
      "notes": "Sends the item to Gemini or Claude based on the LLM Provider field.",
      "position": [
        460,
        0
      ],
      "parameters": {
        "mode": "rules",
        "rules": {
          "values": [
            {
              "outputKey": "Gemini",
              "conditions": {
                "conditions": [
                  {
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json['LLM Provider'] }}",
                    "rightValue": "Gemini (free)"
                  }
                ]
              }
            },
            {
              "outputKey": "Claude",
              "conditions": {
                "conditions": [
                  {
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json['LLM Provider'] }}",
                    "rightValue": "Claude (Haiku)"
                  }
                ]
              }
            }
          ]
        },
        "options": {
          "fallbackOutput": "none"
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "gen-gemini",
      "name": "Generate script (Gemini, free tier)",
      "type": "n8n-nodes-base.httpRequest",
      "notes": "Add a Query Auth credential with name 'key' = your Gemini API key. responseMimeType json + thinkingBudget 0 make it return strict JSON fast.",
      "position": [
        740,
        -120
      ],
      "parameters": {
        "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-latest:generateContent",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        },
        "jsonBody": "={{ JSON.stringify({ contents: [ { parts: [ { text: `You are a short-form video scriptwriter. Write a video script in ${$json.languageName} about the topic: \\\"${$json['Topic']}\\\". Split it into exactly ${$json['Number of Scenes'] || 5} scenes. Each voice_over MUST be at most ${$json.wordsPerScene} words - this is a hard limit, the finished video has to stay under 60 seconds. Return ONLY valid JSON, no explanation, matching this schema: {\\\"scenes\\\": [{\\\"voice_over\\\": \\\"at most ${$json.wordsPerScene} words in ${$json.languageName}\\\", \\\"image_prompt\\\": \\\"describe the illustration in ENGLISH\\\"}]}` } ] } ], generationConfig: { responseMimeType: 'application/json', maxOutputTokens: 4096, thinkingConfig: { thinkingBudget: 0 } } }) }}",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpQueryAuth"
      },
      "typeVersion": 4.4
    },
    {
      "id": "gen-claude",
      "name": "Generate script (Claude Haiku)",
      "type": "n8n-nodes-base.httpRequest",
      "notes": "Add a Header Auth credential with name 'x-api-key' = your Anthropic API key. A json_schema output forces valid JSON scenes.",
      "position": [
        740,
        120
      ],
      "parameters": {
        "url": "https://api.anthropic.com/v1/messages",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        },
        "jsonBody": "={{ JSON.stringify({ model: 'claude-haiku-4-5', max_tokens: 4096, system: `You are a short-form video scriptwriter writing in ${$json.languageName}.`, messages: [ { role: 'user', content: `Write a short video script in ${$json.languageName} about the topic: \\\"${$json['Topic']}\\\". Split it into exactly ${$json['Number of Scenes'] || 5} scenes. Each voice_over MUST be at most ${$json.wordsPerScene} words - this is a hard limit, the finished video has to stay under 60 seconds. Each scene also needs an image_prompt describing the illustration in ENGLISH for an AI image generator.` } ], output_config: { format: { type: 'json_schema', schema: { type: 'object', properties: { scenes: { type: 'array', items: { type: 'object', properties: { voice_over: { type: 'string' }, image_prompt: { type: 'string' } }, required: ['voice_over','image_prompt'], additionalProperties: false } } }, required: ['scenes'], additionalProperties: false } } } }) }}",
        "sendBody": true,
        "contentType": "json",
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            }
          ]
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "parse",
      "name": "Parse the script JSON",
      "type": "n8n-nodes-base.code",
      "notes": "Reads either provider's response shape, strips ``` fences, and validates a non-empty scenes array before continuing.",
      "position": [
        1020,
        0
      ],
      "parameters": {
        "jsCode": "const raw = $input.first().json;\nlet content;\nif (raw.candidates) {                                // Gemini\n  content = raw.candidates[0] && raw.candidates[0].content && raw.candidates[0].content.parts && raw.candidates[0].content.parts[0] && raw.candidates[0].content.parts[0].text;\n} else if (Array.isArray(raw.content)) {             // Claude\n  const textBlock = raw.content.find((b) => b.type === 'text');\n  content = textBlock && textBlock.text;\n} else if (raw.choices) {                            // OpenAI-style, if you swap the model\n  content = raw.choices[0] && raw.choices[0].message && raw.choices[0].message.content;\n}\nif (!content) {\n  throw new Error('The LLM returned no content. Check that the API key on the model node is valid and still has quota.');\n}\ncontent = content.trim();\nif (content.startsWith('```')) {\n  content = content.replace(/^```[a-zA-Z]*\\n/, '').replace(/```$/, '').trim();\n}\nlet parsed;\ntry {\n  parsed = JSON.parse(content);\n} catch (e) {\n  throw new Error('Could not parse the LLM reply as JSON: ' + e.message + ' -- raw: ' + content.slice(0, 300));\n}\nconst scenes = parsed.scenes || parsed.Scenes || [];\nif (!Array.isArray(scenes) || scenes.length === 0) {\n  throw new Error('The LLM reply contained no valid scenes array. Check the prompt and the raw model output.');\n}\nreturn [{ json: { scenes } }];\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "id": "split",
      "name": "Split into individual scenes",
      "type": "n8n-nodes-base.splitOut",
      "notes": "Emits one item per scene: { voice_over, image_prompt }. Pipe into a TTS step and an image step to build the full video.",
      "position": [
        1240,
        0
      ],
      "parameters": {
        "include": "noOtherFields",
        "options": {},
        "fieldToSplitOut": "scenes"
      },
      "typeVersion": 1
    }
  ],
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Video idea form": {
      "main": [
        [
          {
            "node": "Set language and word budget",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse the script JSON": {
      "main": [
        [
          {
            "node": "Split into individual scenes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route to the chosen model": {
      "main": [
        [
          {
            "node": "Generate script (Gemini, free tier)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Generate script (Claude Haiku)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set language and word budget": {
      "main": [
        [
          {
            "node": "Route to the chosen model",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate script (Claude Haiku)": {
      "main": [
        [
          {
            "node": "Parse the script JSON",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate script (Gemini, free tier)": {
      "main": [
        [
          {
            "node": "Parse the script JSON",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}