{
  "name": "Faceless YouTube Shorts on autopilot (Zvid)",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 9
            }
          ]
        }
      },
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        40,
        360
      ],
      "id": "zvid-fs-0001",
      "name": "Every day at 9am"
    },
    {
      "parameters": {},
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        40,
        200
      ],
      "id": "zvid-fs-0002",
      "name": "Test manually"
    },
    {
      "parameters": {
        "mode": "raw",
        "jsonOutput": "{\n  \"apiUrl\": \"https://api.zvid.io\",\n  \"editorUrl\": \"https://editor.zvid.io\",\n  \"niche\": \"surprising space facts\",\n  \"audience\": \"curious people scrolling YouTube Shorts\",\n  \"channelHandle\": \"@dailyspacefacts\",\n  \"language\": \"English\",\n  \"sceneCount\": 5,\n  \"targetSeconds\": 30,\n  \"llmModel\": \"openai/gpt-4.1-mini\",\n  \"voiceId\": \"nPczCjzI2devNBz1zQrb\",\n  \"voiceLabel\": \"Brian (deep narrator)\",\n  \"elevenModel\": \"eleven_multilingual_v2\",\n  \"voiceStability\": 0.4,\n  \"voiceSimilarity\": 0.75,\n  \"brandBackground\": \"#0b0d12\",\n  \"brandAccent\": \"#7CFFB2\",\n  \"captionActiveColor\": \"#0b0d12\",\n  \"font\": \"Montserrat\",\n  \"captionAnimation\": \"karaoke\",\n  \"captionWordsPerCue\": 3,\n  \"captionSize\": 64,\n  \"captionStrokeWidth\": 6,\n  \"kenBurnsDepth\": 1.12,\n  \"sceneTransition\": \"fade\",\n  \"transitionSeconds\": 0.35,\n  \"outroSeconds\": 2.5,\n  \"outroText\": \"Follow for more\",\n  \"musicVolume\": 0.12,\n  \"maxMusicSeconds\": 200,\n  \"maxMusicBytes\": 5242880,\n  \"resolution\": \"youtube-short\",\n  \"frameRate\": 30,\n  \"tailSeconds\": 0.4,\n  \"dryRun\": false,\n  \"pollSeconds\": 10,\n  \"timeoutMinutes\": 20,\n  \"uploadToYouTube\": false,\n  \"youTubePrivacy\": \"private\"\n}",
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        280,
        280
      ],
      "id": "zvid-fs-0003",
      "name": "Channel Config"
    },
    {
      "parameters": {
        "jsCode": "// Remembers the last 40 topics so the writer never repeats itself.\n// NOTE: workflow static data only persists on PRODUCTION executions (an active\n// workflow). Manual test runs always start from an empty list.\nconst cfg = $input.first().json;\nconst store = $getWorkflowStaticData('global');\nif (!Array.isArray(store.recentTopics)) store.recentTopics = [];\n\nconst recentTopics = store.recentTopics.slice(-40);\n\n// Asking the model for a word count is far more reliable than asking it to\n// reason about seconds. Spoken narration runs at roughly 2.5 words/second.\nconst targetWords = Math.round(Number(cfg.targetSeconds || 30) * 2.5);\n// Small models hit a per-sentence budget far more reliably than a total.\nconst perSceneWords = Math.round(targetWords / Math.max(1, Number(cfg.sceneCount || 5)));\n\n// The writer prompt is built here so the HTTP node body stays readable.\nconst promptText = \"Channel niche: {{ $json.niche }}\\nAudience: {{ $json.audience }}\\nLanguage: {{ $json.language }}\\nTarget length: about {{ $json.targetSeconds }} seconds of speech\\nScene count: exactly {{ $json.sceneCount }}\\nAlready covered (do NOT repeat these): {{ ($json.recentTopics || []).join(\\\"; \\\") || \\\"nothing yet\\\" }}\\n\\nWrite one short-form video script. Rules:\\n1. Scene 1 is the hook: under twelve words, opens an information gap, no greeting, no \\\"did you know\\\".\\n2. One spoken sentence per scene. Plain words. No emoji, markdown, quotes, parentheses or hashtags.\\n3. Spell every number out as words (\\\"nineteen sixty nine\\\", not \\\"1969\\\") and avoid abbreviations.\\n4. Use plain ASCII punctuation only. No curly quotes, no em dashes, no accented characters.\\n5. Every claim must be a well-established fact a viewer could verify. If you are not\\n   confident a detail is true, pick a different topic. Never invent a phenomenon.\\n6. The final scene lands the payoff and ends with a natural call to action for the channel.\\n7. Length: about {{ $json.targetWords }} words of narration in total, which is roughly\\n   {{ $json.perSceneWords }} words in every scene after the hook. Count the words in each\\n   sentence before you answer — a short script makes the finished video too brief.\\n8. visualQuery is two to four plain words naming concrete, filmable stock footage for that line\\n   (an object, place or action — never an abstract idea).\\n\\nReturn exactly this JSON shape:\\n{\\n  \\\"topic\\\": \\\"short topic label\\\",\\n  \\\"title\\\": \\\"YouTube Shorts title under seventy characters\\\",\\n  \\\"description\\\": \\\"two or three sentence description\\\",\\n  \\\"tags\\\": [\\\"five to ten lowercase tags\\\"],\\n  \\\"musicTag\\\": \\\"one of: ambient, cinematic, inspiring, chill, suspense, calm, dramatic, rock, piano\\\",\\n  \\\"scenes\\\": [{ \\\"narration\\\": \\\"one sentence\\\", \\\"visualQuery\\\": \\\"two to four words\\\" }]\\n}\"\n  .replace('{{ $json.niche }}', cfg.niche)\n  .replace('{{ $json.audience }}', cfg.audience)\n  .replace('{{ $json.language }}', cfg.language)\n  .replace(/\\{\\{ \\$json\\.targetSeconds \\}\\}/g, String(cfg.targetSeconds))\n  .replace('{{ $json.targetWords }}', String(targetWords))\n  .replace('{{ $json.perSceneWords }}', String(perSceneWords))\n  .replace('{{ $json.sceneCount }}', String(cfg.sceneCount))\n  .replace('{{ ($json.recentTopics || []).join(\"; \") || \"nothing yet\" }}', recentTopics.join('; ') || 'nothing yet');\n\nreturn [{ json: { ...cfg, recentTopics, promptText } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        500,
        280
      ],
      "id": "zvid-fs-0004",
      "name": "Recent topics"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://openrouter.ai/api/v1/chat/completions",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openRouterApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: $json.llmModel, response_format: { type: \"json_object\" }, messages: [ { role: \"system\", content: \"You write scripts for a faceless short-form video channel. You return JSON only.\\n\\nEvery line you write is spoken by a text-to-speech voice and burned on screen as\\nword-timed captions, so the script must be clean spoken English with no formatting.\" }, { role: \"user\", content: $json.promptText } ] }) }}",
        "options": {}
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        860,
        280
      ],
      "id": "zvid-fs-0005",
      "name": "Write script",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "// Parses the writer's JSON, sanity-checks it, and builds the exact narration\n// string that will be sent to TTS. Everything downstream reads this node.\nconst cfg = $('Channel Config').first().json;\nconst res = $input.first().json;\n\nconst content = res?.choices?.[0]?.message?.content;\nif (!content) {\n  throw new Error('The model returned no content: ' + JSON.stringify(res).slice(0, 400));\n}\n\nlet script;\ntry {\n  script = JSON.parse(content);\n} catch (e) {\n  const m = /\\{[\\s\\S]*\\}/.exec(content);\n  if (!m) throw new Error('The model did not return JSON:\\n' + content.slice(0, 400));\n  script = JSON.parse(m[0]);\n}\n\n// Models slip curly quotes and dashes in even when told not to, and unusual\n// characters make the voice stumble. Normalise to ASCII before anything counts\n// words or reaches the synthesiser.\nconst toAscii = (s) =>\n  String(s)\n    .replace(/[\\u2018\\u2019\\u02BC]/g, \"'\")\n    .replace(/[\\u201C\\u201D]/g, '\"')\n    .replace(/[\\u2013\\u2014]/g, '-')\n    .replace(/\\u2026/g, '...')\n    .replace(/\\s+/g, ' ')\n    .trim();\n\nconst scenes = Array.isArray(script.scenes) ? script.scenes : [];\nif (!scenes.length) throw new Error('The model returned no scenes.');\nfor (const [i, s] of scenes.entries()) {\n  if (!s || typeof s.narration !== 'string' || !s.narration.trim()) {\n    throw new Error(`Scene ${i + 1} has no narration.`);\n  }\n  s.narration = toAscii(s.narration);\n  if (!s.visualQuery) s.visualQuery = script.topic || cfg.niche;\n}\n\n// The stock music catalogue matches single tags, so anything the model invents\n// is coerced back onto a list of tags known to return results.\nconst MUSIC_TAGS = ['ambient', 'cinematic', 'inspiring', 'chill', 'suspense', 'calm', 'dramatic', 'rock', 'piano'];\nconst musicTag = MUSIC_TAGS.includes(String(script.musicTag || '').toLowerCase())\n  ? String(script.musicTag).toLowerCase()\n  : 'cinematic';\n\n// One space between sentences. This exact string is what gets spoken, and the\n// alignment that comes back is measured against it — which is what keeps\n// captions and cuts frame-accurate.\nconst narration = scenes.map((s) => s.narration).join(' ');\n\nconst targetWords = Math.round(Number(cfg.targetSeconds || 30) * 2.5);\nconst wordCount = narration.split(/\\s+/).filter(Boolean).length;\nif (wordCount < targetWords * 0.6) {\n  throw new Error(`The script is only ${wordCount} words for a ${cfg.targetSeconds}s video (expected around ${targetWords}). Retry, or use a stronger model.`);\n}\n\nconst topic = String(script.topic || script.title || '').trim();\nif (topic) {\n  const store = $getWorkflowStaticData('global');\n  if (!Array.isArray(store.recentTopics)) store.recentTopics = [];\n  store.recentTopics.push(topic);\n  store.recentTopics = store.recentTopics.slice(-40);\n}\n\nreturn [{\n  json: {\n    topic,\n    title: String(script.title || topic || 'Untitled').slice(0, 100),\n    description: String(script.description || ''),\n    tags: Array.isArray(script.tags) ? script.tags.slice(0, 15) : [],\n    musicTag,\n    scenes,\n    narration,\n    wordCount,\n    targetWords,\n  },\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1080,
        280
      ],
      "id": "zvid-fs-0006",
      "name": "Parse script"
    },
    {
      "parameters": {
        "jsCode": "// One item per scene so the stock search below runs once per scene.\nconst script = $input.first().json;\nreturn script.scenes.map((s, index) => ({\n  json: { index, narration: s.narration, visualQuery: s.visualQuery },\n}));"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1460,
        280
      ],
      "id": "zvid-fs-0007",
      "name": "Expand scenes"
    },
    {
      "parameters": {
        "resource": "stockMedia",
        "operation": "search",
        "stockType": "video",
        "stockQuery": "={{ $json.visualQuery }}",
        "stockPage": 1,
        "stockPerPage": "20"
      },
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "typeVersion": 1,
      "position": [
        1680,
        280
      ],
      "id": "zvid-fs-0008",
      "name": "Search stock clips",
      "onError": "continueRegularOutput",
      "alwaysOutputData": true,
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "// Picks one b-roll clip per scene from the Zvid stock proxy.\n// Portrait clips win because the canvas is 1080x1920 — a landscape clip still\n// works via resize:\"cover\", it is just cropped harder.\nconst cfg = $('Channel Config').first().json;\nconst script = $('Parse script').first().json;\nconst responses = $input.all();\nconst approxSceneSeconds = Number(cfg.targetSeconds || 30) / Math.max(1, script.scenes.length);\n\nfunction pick(items, needSeconds) {\n  const usable = (items || []).filter((it) => it && it.src);\n  if (!usable.length) return null;\n  return usable\n    .map((it) => {\n      const portrait = Number(it.height) > Number(it.width);\n      const longEnough = Number(it.duration || 0) >= needSeconds;\n      return {\n        it,\n        score: (portrait ? 2 : 0) + (longEnough ? 1 : 0) + Math.min(1, Number(it.duration || 0) / 30),\n      };\n    })\n    .sort((a, b) => b.score - a.score)[0].it;\n}\n\nconst sceneMedia = script.scenes.map((_, i) => {\n  const body = responses[i]?.json || {};\n  const chosen = pick(body.items, approxSceneSeconds);\n  if (!chosen) return null;\n  return {\n    kind: chosen.kind || 'video',\n    src: chosen.src,\n    duration: Number(chosen.duration) || null,\n    width: Number(chosen.width) || null,\n    height: Number(chosen.height) || null,\n    credit: chosen.credit || null,\n  };\n});\n\nconst missing = sceneMedia.filter((m) => !m).length;\nif (missing === sceneMedia.length) {\n  throw new Error('No stock footage matched any scene. Make the visualQuery hints more concrete.');\n}\n\nreturn [{ json: { sceneMedia, missing } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1900,
        280
      ],
      "id": "zvid-fs-0009",
      "name": "Pick scene clip"
    },
    {
      "parameters": {
        "jsCode": "// The music catalogue is tag-matched and occasionally returns nothing for a tag\n// that worked a minute ago, so ask for the writer's tag plus two broad\n// fallbacks. One item per query -> the search node runs once per tag.\nconst tag = $('Parse script').first().json.musicTag;\nconst queries = [...new Set([tag, 'cinematic', 'ambient'])];\nreturn queries.map((query) => ({ json: { query } }));"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2280,
        280
      ],
      "id": "zvid-fs-0010",
      "name": "Music queries"
    },
    {
      "parameters": {
        "resource": "stockMedia",
        "operation": "search",
        "stockType": "audio",
        "stockQuery": "={{ $json.query }}",
        "stockPage": 1,
        "stockPerPage": "10"
      },
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "typeVersion": 1,
      "position": [
        2500,
        280
      ],
      "id": "zvid-fs-0011",
      "name": "Find background music",
      "onError": "continueRegularOutput",
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "// Rank the music candidates. Long tracks mean big files, and the renderer\n// rejects any asset over the plan's audio size cap — a 6.3 MB track fails the\n// WHOLE render, so a nice-to-have music bed must never be able to do that.\n// Shortest-but-long-enough wins; the next node probes the real byte size.\nconst cfg = $('Channel Config').first().json;\nconst maxSeconds = Number(cfg.maxMusicSeconds ?? 200);\n\nconst seen = new Set();\nconst all = [];\nfor (const r of $input.all()) {\n  for (const m of r.json.items || []) {\n    if (!m || !m.src) continue;\n    const key = m.id || m.src;\n    if (seen.has(key)) continue;\n    seen.add(key);\n    all.push(m);\n  }\n}\n\nconst sized = all\n  .filter((m) => { const d = Number(m.duration) || 0; return d >= 45 && d <= maxSeconds; })\n  .sort((a, b) => (Number(a.duration) || 0) - (Number(b.duration) || 0));\n\nconst pool = (sized.length ? sized : all).slice(0, 4);\nif (!pool.length) {\n  // Nothing to probe — send one item the probe can hit harmlessly.\n  return [{ json: { candidate: null, probeUrl: cfg.apiUrl } }];\n}\nreturn pool.map((candidate) => ({ json: { candidate, probeUrl: candidate.src } }));"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2720,
        280
      ],
      "id": "zvid-fs-0012",
      "name": "Shortlist music"
    },
    {
      "parameters": {
        "method": "HEAD",
        "url": "={{ $json.probeUrl }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          },
          "redirect": {
            "redirect": {}
          },
          "timeout": 15000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2940,
        280
      ],
      "id": "zvid-fs-0013",
      "name": "Check music asset",
      "onError": "continueRegularOutput",
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "// Keep the first candidate that is actually downloadable and within the size\n// cap, then collapse to ONE item — otherwise the voiceover step downstream\n// would run once per candidate. No usable track just means no music bed; it\n// must never fail the run.\nconst cfg = $('Channel Config').first().json;\nconst limit = Number(cfg.maxMusicBytes ?? 5242880);\nconst shortlist = $('Shortlist music').all().map((i) => i.json);\nconst probes = $input.all();\n\nlet music = null;\nconst rejected = [];\nfor (let i = 0; i < probes.length; i += 1) {\n  const candidate = shortlist[i] && shortlist[i].candidate;\n  if (!candidate) continue;\n  const p = probes[i] ? probes[i].json : {};\n  const status = Number(p.statusCode);\n  const headers = p.headers || {};\n  const bytes = Number(headers['content-length'] || headers['Content-Length'] || 0);\n\n  if (status !== 200) { rejected.push(`${candidate.id}: HTTP ${status || '?'}`); continue; }\n  if (bytes && bytes > limit) { rejected.push(`${candidate.id}: ${bytes} bytes > ${limit}`); continue; }\n  music = candidate;\n  break;\n}\n\nreturn [{ json: { music, checked: probes.length, rejected } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3160,
        280
      ],
      "id": "zvid-fs-0014",
      "name": "Pick music"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://api.elevenlabs.io/v1/text-to-speech/{{ $('Channel Config').first().json.voiceId }}/with-timestamps",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "output_format",
              "value": "mp3_44100_128"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: $('Parse script').first().json.narration, model_id: $('Channel Config').first().json.elevenModel, voice_settings: { stability: $('Channel Config').first().json.voiceStability, similarity_boost: $('Channel Config').first().json.voiceSimilarity } }) }}",
        "options": {
          "timeout": 120000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        40,
        920
      ],
      "id": "zvid-fs-0015",
      "name": "Generate voiceover",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "// Turns one ElevenLabs \"with-timestamps\" response into (a) an mp3 binary ready\n// for upload and (b) word timings.\n//\n// The alignment comes from the synthesiser itself, so the timings ARE the audio\n// — there is no separate transcription step to disagree with, and nothing that\n// can drift. That is the whole reason this endpoint is used instead of plain\n// text-to-speech.\nconst res = $input.first().json;\n\nif (!res || !res.audio_base64) {\n  throw new Error(\n    'ElevenLabs returned no audio. Check the xi-api-key credential, the voiceId, and your character quota. Response: ' +\n      JSON.stringify(res).slice(0, 300)\n  );\n}\n\nconst a = res.alignment || res.normalized_alignment;\nif (!a || !Array.isArray(a.characters)) {\n  throw new Error('ElevenLabs returned audio but no alignment — make sure the URL ends in /with-timestamps.');\n}\n\nconst chars = a.characters;\nconst starts = a.character_start_times_seconds || [];\nconst ends = a.character_end_times_seconds || [];\n\n// Alignment is per CHARACTER; group runs of non-whitespace into words.\nconst words = [];\nlet cur = null;\nfor (let i = 0; i < chars.length; i += 1) {\n  const ch = chars[i];\n  if (/\\s/.test(ch)) {\n    if (cur) { words.push(cur); cur = null; }\n    continue;\n  }\n  if (!cur) cur = { text: ch, start: Number(starts[i]), end: Number(ends[i]) };\n  else { cur.text += ch; cur.end = Number(ends[i]); }\n}\nif (cur) words.push(cur);\n\nif (!words.length) throw new Error('Alignment contained no words.');\n\nconst duration = Number(ends[ends.length - 1]) || words[words.length - 1].end;\n\nreturn [{\n  json: { words, duration, wordCount: words.length },\n  // n8n binary payloads carry base64 in `data`; naming it matters because Zvid\n  // derives the media kind from the MIME type on upload.\n  binary: {\n    data: {\n      data: res.audio_base64,\n      mimeType: 'audio/mpeg',\n      fileName: 'voiceover.mp3',\n      fileExtension: 'mp3',\n    },\n  },\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        260,
        920
      ],
      "id": "zvid-fs-0016",
      "name": "Voice + timings"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Channel Config').first().json.apiUrl }}/api/uploads",
        "authentication": "predefinedCredentialType",
        "sendBody": true,
        "contentType": "multipart-form-data",
        "bodyParameters": {
          "parameters": [
            {
              "parameterType": "formBinaryData",
              "name": "file",
              "inputDataFieldName": "data"
            }
          ]
        },
        "options": {},
        "nodeCredentialType": "zvidApi"
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        480,
        920
      ],
      "id": "zvid-fs-0017",
      "name": "Upload voiceover",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "notes": "File upload exception: the published Zvid node does not expose multipart uploads. The upload step uses HTTP Request → `POST /api/uploads`, authenticated with the same **Zvid API** credential. All supported Zvid operations use the Zvid node.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "jsCode": "// Assembles the Zvid project JSON: one scene per narration sentence, cuts\n// aligned to real word timings, word-timed captions, music bed and outro.\nconst cfg = $('Channel Config').first().json;\nconst script = $('Parse script').first().json;\nconst sceneMedia = $('Pick scene clip').first().json.sceneMedia;\nconst music = $('Pick music').first().json.music || null;\nconst upload = $('Upload voiceover').first().json.upload;\nconst timings = $('Voice + timings').first().json;\n\nif (!upload || !upload.url) {\n  throw new Error('The voiceover upload returned no URL: ' + JSON.stringify($('Upload voiceover').first().json).slice(0, 300));\n}\n\nconst clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));\nconst round = (n) => Math.round(n * 100) / 100;\nconst escapeHtml = (s) =>\n  String(s).replace(/[&<>\"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;' }[c]));\n\n// ---- narration timing ---------------------------------------------------\nconst words = (Array.isArray(timings.words) ? timings.words : [])\n  .map((w) => ({ text: String(w.text ?? '').trim(), start: Number(w.start), end: Number(w.end) }))\n  .filter((w) => w.text && Number.isFinite(w.start) && Number.isFinite(w.end));\n\nconst narrationEnd = words.length ? words[words.length - 1].end : Number(timings.duration) || 30;\nconst audioDuration = Math.max(Number(timings.duration) || 0, narrationEnd);\n\n// Apostrophes stay inside the word (both ASCII and curly) so \"planet's\" counts\n// as one word here and one word in the transcript.\nconst countWords = (s) =>\n  String(s || '').replace(/[^\\p{L}\\p{N}'\\u2019]+/gu, ' ').trim().split(/\\s+/).filter(Boolean).length;\n\nconst perScene = script.scenes.map((s) => countWords(s.narration));\nconst totalScripted = perScene.reduce((a, b) => a + b, 0);\n\n// The alignment covers the exact text that was spoken, so walking the word\n// stream by per-scene word count puts every cut on a sentence boundary. If the\n// tokenisation drifts (digits, abbreviations) fall back to a proportional split\n// so the render can never desync.\nconst drift = totalScripted ? Math.abs(words.length - totalScripted) / totalScripted : 1;\nconst useWordMap = words.length > 0 && drift <= 0.2;\n\nconst spans = [];\nif (useWordMap) {\n  let cursor = 0;\n  for (let i = 0; i < perScene.length; i += 1) {\n    const start = cursor === 0 ? 0 : words[cursor - 1].end;\n    cursor = Math.min(cursor + perScene[i], words.length);\n    const last = words[cursor - 1];\n    spans.push({ start, end: i === perScene.length - 1 ? audioDuration : last ? last.end : audioDuration });\n  }\n} else {\n  const weightTotal = totalScripted || perScene.length;\n  let t = 0;\n  for (let i = 0; i < perScene.length; i += 1) {\n    const start = t;\n    t = i === perScene.length - 1 ? audioDuration : t + ((perScene[i] || 1) / weightTotal) * audioDuration;\n    spans.push({ start, end: t });\n  }\n}\nspans[spans.length - 1].end += Number(cfg.tailSeconds ?? 0.4);\n\n// ---- scenes -------------------------------------------------------------\nconst transition = cfg.sceneTransition || null;\nconst tDur = transition ? Number(cfg.transitionSeconds ?? 0.35) : 0;\nconst minScene = Math.max(1.2, tDur * 3);\n\nconst scenes = spans.map((span, i) => {\n  const visible = Math.max(minScene, round(span.end - span.start));\n  const media = sceneMedia[i];\n  const visuals = [];\n\n  if (media && media.kind === 'video') {\n    // videoEnd is measured in SOURCE time, so a clip played at speed s covers\n    // \"visible * s\" seconds of source. Slowing a short clip down beats cutting\n    // to black.\n    const speed = media.duration && media.duration < visible\n      ? clamp(round(media.duration / visible), 0.5, 1)\n      : 1;\n    const sourceWindow = round(visible * speed);\n    visuals.push({\n      type: 'VIDEO',\n      src: media.src,\n      resize: 'cover',\n      position: 'center-center',\n      anchor: 'center-center',\n      volume: 0,\n      videoBegin: 0,\n      videoEnd: media.duration ? Math.min(media.duration, sourceWindow) : sourceWindow,\n      ...(speed !== 1 ? { speed } : {}),\n      zoom: { depth: Number(cfg.kenBurnsDepth ?? 1.12) },\n    });\n  } else if (media && media.kind === 'image') {\n    visuals.push({\n      type: 'IMAGE',\n      src: media.src,\n      resize: 'cover',\n      position: 'center-center',\n      anchor: 'center-center',\n      exitEnd: visible,\n      zoom: { depth: Number(cfg.kenBurnsDepth ?? 1.25) },\n    });\n  }\n\n  const isLast = i === spans.length - 1;\n  return {\n    id: `scene-${i + 1}`,\n    // A transition OVERLAPS its two scenes, which would drag every later cut\n    // earlier and desync the voiceover. Padding each scene by the overlap keeps\n    // every visible cut on its narration timestamp.\n    duration: round(visible + (isLast ? 0 : tDur)),\n    backgroundColor: cfg.brandBackground || '#0b0d12',\n    ...(isLast || !transition ? {} : { transition, transitionDuration: tDur }),\n    visuals,\n  };\n});\n\nconst outroSeconds = Number(cfg.outroSeconds ?? 0);\nif (outroSeconds > 0) {\n  if (transition) {\n    const prev = scenes[scenes.length - 1];\n    prev.duration = round(prev.duration + tDur);\n    prev.transition = transition;\n    prev.transitionDuration = tDur;\n  }\n  scenes.push({\n    id: 'outro',\n    duration: outroSeconds,\n    backgroundColor: cfg.brandBackground || '#0b0d12',\n    visuals: [{\n      type: 'TEXT',\n      html: `<div class=\"cta\"><span class=\"k\">${escapeHtml(cfg.channelHandle || '')}</span><b>${escapeHtml(cfg.outroText || 'Follow for more')}</b></div>`,\n      position: 'center-center',\n      anchor: 'center-center',\n      // The layout warning checker reads `style`, not customCode CSS.\n      style: { color: '#ffffff', fontFamily: cfg.font || 'Montserrat' },\n      customCode: {\n        // customCode captures ONE loop and repeats it. Capturing the whole\n        // outro makes the pop play once and hold, instead of retriggering.\n        animationDuration: Math.min(15, outroSeconds),\n        css: `.cta{display:flex;flex-direction:column;align-items:center;gap:24px;font-family:${cfg.font || 'Montserrat'},sans-serif;text-align:center;animation:pop .5s cubic-bezier(.2,.8,.2,1) both}\n.cta .k{font-size:44px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:${cfg.brandAccent || '#7CFFB2'}}\n.cta b{font-size:92px;font-weight:900;line-height:1.05;color:#ffffff;max-width:880px}\n@keyframes pop{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}`,\n      },\n    }],\n  });\n}\n\nconst totalDuration = round(\n  scenes.reduce((sum, s) => sum + s.duration, 0) - tDur * Math.max(0, scenes.length - 1)\n);\n\n// ---- captions -----------------------------------------------------------\nconst perCue = Math.max(1, Number(cfg.captionWordsPerCue ?? 3));\nconst captions = [];\nfor (let i = 0; i < words.length; i += perCue) {\n  const chunk = words.slice(i, i + perCue);\n  captions.push({\n    start: chunk[0].start,\n    end: chunk[chunk.length - 1].end,\n    text: chunk.map((w) => w.text).join(' '),\n    words: chunk.map((w) => ({ start: w.start, end: w.end, text: w.text })),\n  });\n}\n\n// ---- audio + overlay ----------------------------------------------------\nconst audios = [{ src: upload.url, volume: 1, enter: 0 }];\nif (music) {\n  audios.push({ src: music.src, volume: Number(cfg.musicVolume ?? 0.12), enter: 0, exit: totalDuration });\n}\n\nconst visuals = [];\nif (cfg.channelHandle) {\n  visuals.push({\n    type: 'TEXT',\n    text: cfg.channelHandle,\n    // x/y are ignored when a `position` preset is set, and TEXT has no\n    // `margin` field — an inset watermark needs explicit coordinates.\n    x: 540,\n    y: 120,\n    anchor: 'top-center',\n    track: 5,\n    opacity: 0.72,\n    style: {\n      fontFamily: cfg.font || 'Montserrat',\n      fontSize: '38px',\n      fontWeight: '700',\n      color: '#ffffff',\n      letterSpacing: '2px',\n    },\n  });\n}\n\n// ---- caption styling ----------------------------------------------------\n// In box modes (\"highlight\") the chip behind the spoken word is drawn first and\n// the text outline is drawn on top of it, so a heavy outline with no padding\n// hides the chip and the word becomes unreadable. Box modes therefore get a\n// thin outline plus padding; every other mode recolours the spoken word with\n// the brand accent and keeps the heavy outline, which is what keeps captions\n// readable over both dark and bright footage.\nconst BOX_MODES = ['highlight'];\nconst animation = cfg.captionAnimation || 'karaoke';\nconst usesBox = BOX_MODES.includes(animation);\nconst strokeWidth = Math.max(0, Number(cfg.captionStrokeWidth ?? 6));\nconst accent = cfg.brandAccent || '#7CFFB2';\n\nconst subtitle = {\n  captions,\n  animation,\n  activeWord: usesBox\n    ? { color: cfg.captionActiveColor || '#0b0d12', background: accent }\n    : { color: accent },\n  font: {\n    family: cfg.font || 'Montserrat',\n    size: Number(cfg.captionSize ?? 64),\n    color: '#ffffff',\n    bold: true,\n    transform: 'uppercase',\n  },\n  stroke: { color: '#000000', width: usesBox ? Math.min(strokeWidth, 2) : strokeWidth },\n  ...(usesBox ? { background: { padding: 14 } } : {}),\n  position: 'center',\n  margin: { x: 90, y: 0 },\n  maxWordsPerLine: perCue,\n};\n\nconst name = (String(script.title || 'faceless short').replace(/[^\\p{L}\\p{N} _-]+/gu, '').trim().slice(0, 60)) || 'faceless short';\n\nconst payload = {\n  name,\n  resolution: cfg.resolution || 'youtube-short',\n  frameRate: Number(cfg.frameRate ?? 30),\n  backgroundColor: cfg.brandBackground || '#0b0d12',\n  outputFormat: 'mp4',\n  scenes,\n  visuals,\n  audios,\n  subtitle,\n};\n\nreturn [{\n  json: {\n    payload,\n    meta: {\n      title: script.title,\n      description: script.description,\n      tags: script.tags,\n      topic: script.topic,\n      narrationSeconds: round(audioDuration),\n      totalDuration,\n      sceneCount: scenes.length,\n      captionCount: captions.length,\n      cutsAlignedToSpeech: useWordMap,\n      musicTrack: music ? music.description : null,\n      credits: sceneMedia.filter(Boolean).map((m) => m.credit).filter(Boolean),\n    },\n  },\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        860,
        920
      ],
      "id": "zvid-fs-0018",
      "name": "Build project JSON"
    },
    {
      "parameters": {
        "resource": "render",
        "operation": "validate",
        "source": "json",
        "projectJson": "={{ JSON.stringify(({ payload: $json.payload }).payload) }}"
      },
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "typeVersion": 1,
      "position": [
        1080,
        920
      ],
      "id": "zvid-fs-0019",
      "name": "Validate project (free)"
    },
    {
      "parameters": {
        "jsCode": "// The free validate endpoint runs the exact pipeline a render submission runs,\n// so a failure here is a payload bug — surface the field list instead of a\n// generic HTTP error.\nconst res = $input.first().json;\nconst status = res.statusCode ?? (res.valid === true ? 200 : 400);\nconst body = res.body || { ...res, details: res.errors ?? res.details };\n\nif (status !== 200) {\n  const details = Array.isArray(body.details)\n    ? body.details.map((d) => `  - ${d.field}: ${d.message}`).join('\\n')\n    : JSON.stringify(body).slice(0, 500);\n  throw new Error(`Zvid rejected the project (HTTP ${status}): ${body.message || body.error}\\n${details}`);\n}\n\nconst build = $('Build project JSON').first().json;\nreturn [{\n  json: {\n    ...build,\n    creditsRequired: body.creditsRequired,\n    warnings: body.warnings || [],\n    schemaVersion: body.schemaVersion,\n  },\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1300,
        920
      ],
      "id": "zvid-fs-0020",
      "name": "Check validation"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "dry-run--cond",
              "leftValue": "={{ $('Channel Config').first().json.dryRun }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1680,
        920
      ],
      "id": "zvid-fs-0021",
      "name": "Dry run?"
    },
    {
      "parameters": {
        "resource": "project",
        "operation": "create",
        "projectName": "={{ ({ name: String($json.meta.title || \"n8n draft\").trim().slice(0, 120) || \"n8n draft\", payload: $json.payload }).name }}",
        "projectJson": "={{ JSON.stringify(({ name: String($json.meta.title || \"n8n draft\").trim().slice(0, 120) || \"n8n draft\", payload: $json.payload }).payload) }}"
      },
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "typeVersion": 1,
      "position": [
        1900,
        920
      ],
      "id": "zvid-fs-0022",
      "name": "Save draft to editor",
      "onError": "continueRegularOutput",
      "alwaysOutputData": true,
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "// Dry run: everything except the paid render. Read this output, open the draft\n// in the editor, then set dryRun to false in Channel Config to go live.\nconst cfg = $('Channel Config').first().json;\nconst d = $('Check validation').first().json;\nconst scenes = d.payload.scenes.map((s, i) => `  ${i + 1}. ${s.duration}s  ${s.visuals[0]?.type || 'empty'}`).join('\\n');\n\n// The draft save is best-effort (onError: continue) so a hiccup there never\n// hides the rest of the dry-run report.\nlet editorLink = null;\nlet draftId = null;\ntry {\n  const project = $('Save draft to editor').first().json.project;\n  if (project && project.id) {\n    draftId = project.id;\n    editorLink = `${String(cfg.editorUrl || 'https://editor.zvid.io').replace(/\\/+$/, '')}/?project=${project.id}`;\n  }\n} catch (e) { /* draft step skipped or failed */ }\n\nreturn [{\n  json: {\n    dryRun: true,\n    title: d.meta.title,\n    topic: d.meta.topic,\n    editorLink,\n    draftId,\n    creditsRequired: d.creditsRequired,\n    videoSeconds: d.meta.totalDuration,\n    narrationSeconds: d.meta.narrationSeconds,\n    cutsAlignedToSpeech: d.meta.cutsAlignedToSpeech,\n    captions: d.meta.captionCount,\n    musicTrack: d.meta.musicTrack,\n    warnings: d.warnings,\n    sceneBreakdown: scenes,\n    nextStep: editorLink\n      ? 'Open editorLink to preview and tweak the draft. Set dryRun to false in Channel Config to render.'\n      : 'Set dryRun to false in Channel Config to render this for real.',\n    payload: d.payload,\n  },\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2120,
        920
      ],
      "id": "zvid-fs-0023",
      "name": "Dry run summary"
    },
    {
      "parameters": {
        "resource": "render",
        "operation": "create",
        "source": "json",
        "projectJson": "={{ JSON.stringify(({ payload: $json.payload }).payload) }}",
        "renderType": "video",
        "waitForCompletion": false
      },
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "typeVersion": 1,
      "position": [
        2500,
        920
      ],
      "id": "zvid-fs-0024",
      "name": "Submit render",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "amount": "={{ $('Channel Config').first().json.pollSeconds }}",
        "unit": "seconds"
      },
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        2720,
        920
      ],
      "id": "zvid-fs-0025",
      "name": "Wait",
      "webhookId": "b5b0b7f4-5f2a-4c2b-9a3e-1f6b2c0d8e71"
    },
    {
      "parameters": {
        "resource": "render",
        "operation": "get",
        "jobId": "={{ $('Submit render').first().json.jobId }}",
        "waitForCompletion": false
      },
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "typeVersion": 1,
      "position": [
        2940,
        920
      ],
      "id": "zvid-fs-0026",
      "name": "Get render status",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "render-finished--cond",
              "leftValue": "={{ $json.state === 'completed' }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3160,
        920
      ],
      "id": "zvid-fs-0027",
      "name": "Render finished?"
    },
    {
      "parameters": {
        "jsCode": "// Fails fast on a failed render and stops the poll loop from running forever.\nconst cfg = $('Channel Config').first().json;\nconst job = $input.first().json;\n\nif (job.state === 'failed') {\n  throw new Error('Render failed: ' + (job.failedReason || 'no reason given'));\n}\n\nconst maxPolls = Math.ceil((Number(cfg.timeoutMinutes || 20) * 60) / Number(cfg.pollSeconds || 10));\nif ($runIndex + 1 >= maxPolls) {\n  throw new Error(`Render did not finish within ${cfg.timeoutMinutes} minutes (job ${job.id}, state ${job.state}).`);\n}\n\nreturn $input.all();"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3160,
        1160
      ],
      "id": "zvid-fs-0028",
      "name": "Still rendering?"
    },
    {
      "parameters": {
        "url": "={{ typeof $json.result === 'string' ? $json.result : $json.result.url }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file",
              "outputPropertyName": "data"
            }
          }
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2500,
        1780
      ],
      "id": "zvid-fs-0029",
      "name": "Download video",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "publish-to-youtube--cond",
              "leftValue": "={{ $('Channel Config').first().json.uploadToYouTube }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2720,
        1780
      ],
      "id": "zvid-fs-0030",
      "name": "Publish to YouTube?"
    },
    {
      "parameters": {
        "resource": "video",
        "operation": "upload",
        "title": "={{ $('Check validation').first().json.meta.title }}",
        "regionCode": "US",
        "categoryId": "27",
        "binaryProperty": "data",
        "options": {
          "description": "={{ $('Check validation').first().json.meta.description }}\n\n{{ ($('Check validation').first().json.meta.tags || []).map(t => '#' + t.replace(/\\s+/g, '')).join(' ') }}",
          "privacyStatus": "={{ $('Channel Config').first().json.youTubePrivacy }}",
          "tags": "={{ ($('Check validation').first().json.meta.tags || []).join(',') }}",
          "notifySubscribers": true,
          "selfDeclaredMadeForKids": false
        }
      },
      "type": "n8n-nodes-base.youTube",
      "typeVersion": 1,
      "position": [
        2940,
        1680
      ],
      "id": "zvid-fs-0031",
      "name": "Upload to YouTube",
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Final report for the run.\nconst cfg = $('Channel Config').first().json;\nconst build = $('Check validation').first().json;\nconst job = $('Get render status').first().json;\nconst url = typeof job.result === 'string' ? job.result : job.result?.url;\n\nlet youTubeId = null;\ntry { youTubeId = $('Upload to YouTube').first().json.id || null; } catch (e) { /* not published */ }\n\nreturn [{\n  json: {\n    published: Boolean(youTubeId),\n    youTubeId,\n    youTubeUrl: youTubeId ? `https://youtube.com/shorts/${youTubeId}` : null,\n    title: build.meta.title,\n    description: build.meta.description,\n    tags: build.meta.tags,\n    videoUrl: url,\n    jobId: job.id,\n    creditsCharged: build.creditsRequired,\n    videoSeconds: build.meta.totalDuration,\n    cutsAlignedToSpeech: build.meta.cutsAlignedToSpeech,\n    stockCredits: build.meta.credits,\n    channel: cfg.channelHandle,\n  },\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2940,
        1920
      ],
      "id": "zvid-fs-0032",
      "name": "Run summary"
    },
    {
      "id": "zvid-watch-video",
      "name": "▶ Watch video",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3380,
        1920
      ],
      "parameters": {
        "url": "={{ $json.videoUrl }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file",
              "outputPropertyName": "data"
            }
          }
        }
      },
      "onError": "continueRegularOutput",
      "alwaysOutputData": true,
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "id": "zvid-watch-ready",
      "name": "Video ready to watch?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3160,
        1920
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "zvid-watch-ready-condition",
              "leftValue": "={{ !$json.dryRun && /^https?:\\/\\//i.test(String(($json.videoUrl) || '')) }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "publication-overview-faceless",
      "name": "Workflow overview and setup",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -800,
        0
      ],
      "parameters": {
        "content": "## Create faceless YouTube Shorts\n\n### How it works\nFor creators making narrated videos without filming. Run manually or daily: OpenRouter writes a script, Zvid finds stock footage and music, and ElevenLabs supplies narration with timing data. Zvid renders a branded, captioned 1080×1920 video. Review the MP4 in **Watch video → Binary → data → View**. YouTube upload is optional and off by default.\n\n### Setup\n1. Install **`@zvid/n8n-nodes-zvid`** from **Settings → Community nodes** before configuring credentials. A workspace owner/admin may need to install it. On n8n Cloud, the owner can install verified **Zvid** from the Nodes panel.\n2. Create a **Zvid API** credential using [your API key](https://app.zvid.io/api-keys) and Base URL `https://api.zvid.io`. Select it on every Zvid node and **Upload voiceover**.\n3. Connect OpenRouter to **Write script**. Add an ElevenLabs Header Auth credential named `xi-api-key` to **Generate voiceover**. Choose a voice your plan supports.\n4. Set your niche, audience, handle, voice and branding in **Channel Config**.\n5. Run **Test manually**. **`dryRun: false` spends Zvid credits.** Use `true` for an editor draft and quote; script and voice generation can still incur charges.\n6. For optional publishing, connect YouTube OAuth2 and enable `uploadToYouTube`; privacy defaults to `private`. Review the video and factual claims before activating the schedule.\n\n### Customization\nAdjust captions, scene count, transitions and outro in **Channel Config**. All supported Zvid operations use its node; multipart voice upload uses HTTP Request because the published node lacks that operation.",
        "color": 1,
        "width": 720,
        "height": 1160
      }
    },
    {
      "id": "publication-section-faceless-configure",
      "name": "1. Choose the channel and schedule",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        0
      ],
      "parameters": {
        "content": "## 1. Choose the channel and schedule\n\nEdit Channel Config first. Recent topics helps avoid repeating topics from previous production runs.",
        "color": 7,
        "width": 740,
        "height": 520
      }
    },
    {
      "id": "publication-section-faceless-script",
      "name": "2. Write the narration",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        820,
        0
      ],
      "parameters": {
        "content": "## 2. Write the narration\n\nGenerate and check a short script before selecting media.",
        "color": 7,
        "width": 520,
        "height": 520
      }
    },
    {
      "id": "publication-section-faceless-footage",
      "name": "3. Find footage for each scene",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1420,
        0
      ],
      "parameters": {
        "content": "## 3. Find footage for each scene\n\nSearch Zvid’s stock library and select a clip for each narration scene.",
        "color": 7,
        "width": 740,
        "height": 520
      }
    },
    {
      "id": "publication-section-faceless-music",
      "name": "4. Choose optional background music",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2240,
        0
      ],
      "parameters": {
        "content": "## 4. Choose optional background music\n\nCheck candidate files before using them. Continue without music if no suitable track is available.",
        "color": 7,
        "width": 1180,
        "height": 520
      }
    },
    {
      "id": "publication-section-faceless-voice",
      "name": "5. Generate narration and timings",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        640
      ],
      "parameters": {
        "content": "## 5. Generate narration and timings\n\nUse the voice alignment for captions and scene cuts. Upload the generated audio to Zvid.",
        "color": 7,
        "width": 740,
        "height": 520
      }
    },
    {
      "id": "publication-section-faceless-validate",
      "name": "6. Build and validate the video",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        820,
        640
      ],
      "parameters": {
        "content": "## 6. Build and validate the video\n\nCombine narration, media, captions and branding. Stop on validation errors before submitting a paid render.",
        "color": 7,
        "width": 740,
        "height": 520
      }
    },
    {
      "id": "publication-section-faceless-preview",
      "name": "7. Choose an optional editor preview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1640,
        640
      ],
      "parameters": {
        "content": "## 7. Choose an optional editor preview\n\ndryRun=true saves a draft and quote. The default false branch continues to rendering.",
        "color": 7,
        "width": 740,
        "height": 520
      }
    },
    {
      "id": "publication-section-faceless-render",
      "name": "8. Render and wait for the result",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2460,
        640
      ],
      "parameters": {
        "content": "## 8. Render and wait for the result\n\nRendering spends Zvid credits. Check progress until the video is ready, or stop on failure or timeout.",
        "color": 7,
        "width": 960,
        "height": 740
      }
    },
    {
      "id": "zvid-watch-instructions",
      "name": "9. Review the MP4 and optionally upload",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2460,
        1500
      ],
      "parameters": {
        "content": "## 9. Review the MP4 and optionally upload\n\nYouTube upload is off by default. Open Watch video → Binary → data → View to play the completed MP4.",
        "color": 7,
        "width": 1200,
        "height": 740
      }
    }
  ],
  "pinData": {},
  "connections": {
    "Every day at 9am": {
      "main": [
        [
          {
            "node": "Channel Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Test manually": {
      "main": [
        [
          {
            "node": "Channel Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Channel Config": {
      "main": [
        [
          {
            "node": "Recent topics",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Recent topics": {
      "main": [
        [
          {
            "node": "Write script",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write script": {
      "main": [
        [
          {
            "node": "Parse script",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse script": {
      "main": [
        [
          {
            "node": "Expand scenes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Expand scenes": {
      "main": [
        [
          {
            "node": "Search stock clips",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Search stock clips": {
      "main": [
        [
          {
            "node": "Pick scene clip",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick scene clip": {
      "main": [
        [
          {
            "node": "Music queries",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Music queries": {
      "main": [
        [
          {
            "node": "Find background music",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find background music": {
      "main": [
        [
          {
            "node": "Shortlist music",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Shortlist music": {
      "main": [
        [
          {
            "node": "Check music asset",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check music asset": {
      "main": [
        [
          {
            "node": "Pick music",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick music": {
      "main": [
        [
          {
            "node": "Generate voiceover",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate voiceover": {
      "main": [
        [
          {
            "node": "Voice + timings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Voice + timings": {
      "main": [
        [
          {
            "node": "Upload voiceover",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload voiceover": {
      "main": [
        [
          {
            "node": "Build project JSON",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build project JSON": {
      "main": [
        [
          {
            "node": "Validate project (free)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate project (free)": {
      "main": [
        [
          {
            "node": "Check validation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check validation": {
      "main": [
        [
          {
            "node": "Dry run?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dry run?": {
      "main": [
        [
          {
            "node": "Save draft to editor",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Submit render",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save draft to editor": {
      "main": [
        [
          {
            "node": "Dry run summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Submit render": {
      "main": [
        [
          {
            "node": "Wait",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait": {
      "main": [
        [
          {
            "node": "Get render status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get render status": {
      "main": [
        [
          {
            "node": "Render finished?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Render finished?": {
      "main": [
        [
          {
            "node": "Download video",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Still rendering?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Still rendering?": {
      "main": [
        [
          {
            "node": "Wait",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download video": {
      "main": [
        [
          {
            "node": "Publish to YouTube?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Publish to YouTube?": {
      "main": [
        [
          {
            "node": "Upload to YouTube",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Run summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to YouTube": {
      "main": [
        [
          {
            "node": "Run summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run summary": {
      "main": [
        [
          {
            "node": "Video ready to watch?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Video ready to watch?": {
      "main": [
        [
          {
            "node": "▶ Watch video",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "a1f0c3d2-7b64-4e58-9c31-2d5a8f4b6e90",
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "id": "ZvidFacelessShorts",
  "tags": []
}
