{
  "name": "Build a Free Local AI YouTube Shorts Creator with n8n",
  "nodes": [
    {
      "parameters": {
        "triggerOn": "folder",
        "path": "/data/video-work/shorts/inbox",
        "events": [
          "add"
        ],
        "options": {
          "awaitWriteFinish": true,
          "ignoreInitial": true,
          "depth": 0
        }
      },
      "id": "64bff0df-99ef-4602-99d1-f7ef79455925",
      "name": "Watch Shorts Inbox Folder",
      "type": "n8n-nodes-base.localFileTrigger",
      "typeVersion": 1,
      "position": [
        240,
        304
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": false,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "video-extension",
              "leftValue": "={{ $json.path }}",
              "operator": {
                "type": "string",
                "operation": "regex"
              },
              "rightValue": "[.](mp4|mov|mkv|webm)$"
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "ee8dcc6f-3785-4ed4-ab06-3500dd644c73",
      "name": "Only Video Files",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2.3,
      "position": [
        512,
        304
      ]
    },
    {
      "parameters": {
        "fileSelector": "={{ $json.path }}",
        "options": {
          "dataPropertyName": "data"
        }
      },
      "id": "d5f783fa-ee84-41d2-953f-529d6e28dc35",
      "name": "Read Video",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1.1,
      "position": [
        768,
        304
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "job-id",
              "name": "jobId",
              "type": "string",
              "value": "=shorts-{{ $execution.id }}"
            },
            {
              "id": "job-path",
              "name": "sourcePath",
              "type": "string",
              "value": "={{ $('Only Video Files').isExecuted ? $('Only Video Files').first().json.path : $('Test Video Path').first().json.path }}"
            },
            {
              "id": "job-file",
              "name": "fileName",
              "type": "string",
              "value": "={{ ($('Only Video Files').isExecuted ? $('Only Video Files').first().json.path : $('Test Video Path').first().json.path).split('/').pop() }}"
            },
            {
              "id": "job-status",
              "name": "status",
              "type": "string",
              "value": "processing"
            },
            {
              "id": "job-started",
              "name": "startedAt",
              "type": "string",
              "value": "={{ $now.toISO() }}"
            },
            {
              "id": "cfg-whisper",
              "name": "whisperModel",
              "type": "string",
              "value": "Systran/faster-distil-whisper-large-v3"
            },
            {
              "id": "cfg-ollama",
              "name": "ollamaModel",
              "type": "string",
              "value": "gemma4:12b-it-qat"
            },
            {
              "id": "cfg-lang",
              "name": "language",
              "type": "string",
              "value": "en"
            },
            {
              "id": "cfg-min",
              "name": "minClipSec",
              "type": "number",
              "value": 10
            },
            {
              "id": "cfg-max",
              "name": "maxClipSec",
              "type": "number",
              "value": 45
            },
            {
              "id": "cfg-count",
              "name": "maxClips",
              "type": "number",
              "value": 3
            },
            {
              "id": "cfg-out",
              "name": "outputDir",
              "type": "string",
              "value": "/data/video-work/shorts/out"
            },
            {
              "id": "cfg-layout",
              "name": "layout",
              "type": "string",
              "value": "vertical"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {
          "stripBinary": false
        }
      },
      "id": "e6a20dbc-645e-4cb6-bb0a-707ee3db04ac",
      "name": "Create Job",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.5,
      "position": [
        1024,
        432
      ]
    },
    {
      "parameters": {},
      "id": "4c7ce042-0b05-49eb-8b8a-66960e5275dc",
      "name": "Run On Test Video",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        240,
        560
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://speaches:8000/v1/audio/transcriptions",
        "sendBody": true,
        "contentType": "multipart-form-data",
        "bodyParameters": {
          "parameters": [
            {
              "parameterType": "formBinaryData",
              "name": "file",
              "inputDataFieldName": "data"
            },
            {
              "name": "model",
              "value": "={{ $json.whisperModel }}"
            },
            {
              "name": "language",
              "value": "={{ $json.language }}"
            },
            {
              "name": "response_format",
              "value": "verbose_json"
            }
          ]
        },
        "options": {
          "timeout": 900000
        }
      },
      "id": "8caf3a48-324d-48e1-87ff-de6a6b4f19d8",
      "name": "Transcribe with Local Whisper",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        1280,
        432
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "const job = $('Create Job').first().json;\nconst res = $input.first().json;\nconst segments = Array.isArray(res.segments) ? res.segments : [];\nif (!segments.length) {\n  return [{ json: Object.assign({}, job, { transcript: '', durationSec: 0, transcribeError: String((res.error && res.error.message) || res.error || 'Whisper returned no segments') }) }];\n}\nconst NL = String.fromCharCode(10);\nconst transcript = segments\n  .map(function (s) { return '[' + Number(s.start).toFixed(1) + '-' + Number(s.end).toFixed(1) + '] ' + String(s.text).trim(); })\n  .join(NL);\nconst durationSec = Number(res.duration) || Number(segments[segments.length - 1].end) || 0;\nreturn [{ json: Object.assign({}, job, { transcript: transcript, durationSec: durationSec, segmentCount: segments.length, transcribeError: '' }) }];"
      },
      "id": "8d3db827-97e3-4181-8d26-3432c5e28b73",
      "name": "Format Timestamped Transcript",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1552,
        432
      ]
    },
    {
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $json.ollamaModel }}"
        },
        "messages": {
          "values": [
            {
              "content": "=Video length: {{ $json.durationSec }} seconds.\nEach clip must be between {{ $json.minClipSec }} and {{ $json.maxClipSec }} seconds long. Return at most {{ $json.maxClips }} clips.\n\nTranscript (format [startSeconds-endSeconds] text):\n{{ $json.transcript }}"
            }
          ]
        },
        "options": {
          "system": "You pick the best moments of a video for YouTube Shorts. You receive a transcript where every line looks like [startSeconds-endSeconds] text. Choose self-contained moments with a strong hook, a surprising fact, or a clear takeaway. Start and end each clip on a transcript line boundary and never overlap clips. Return ONLY a JSON object shaped like {\"highlights\":[{\"start\":12.5,\"end\":48.0,\"title\":\"short title\",\"reason\":\"why this works as a Short\"}]} with start and end in seconds taken from the transcript timestamps.",
          "temperature": 0.2,
          "think": false,
          "num_ctx": 8192,
          "format": "json"
        }
      },
      "id": "c9f1b381-22da-4cf7-bc17-ea7fb0950156",
      "name": "Find Highlights with Ollama",
      "type": "@n8n/n8n-nodes-langchain.ollama",
      "typeVersion": 1,
      "position": [
        1808,
        432
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "const job = $('Format Timestamped Transcript').first().json;\nconst resp = $input.first().json;\nconst NL = String.fromCharCode(10);\nconst ROOT = '/data/video-work/';\nconst raw = (resp.message && resp.message.content) || resp.content || resp.response || (typeof resp.text === 'string' ? resp.text : '') || '';\nconst problems = [];\n\nfunction fail(msg) {\n  return [{ json: Object.assign({}, job, {\n    ok: false, status: 'failed', error: msg, clips: [],\n    message: 'Shorts job ' + job.jobId + ' FAILED for ' + job.fileName + ': ' + msg\n  }) }];\n}\n\nif (job.transcribeError) { return fail('Transcription failed: ' + job.transcribeError); }\nif (resp.error) { return fail('Ollama request failed: ' + String((resp.error && resp.error.message) || resp.error)); }\n\nlet parsed = null;\ntry {\n  const a = raw.indexOf('{');\n  const b = raw.lastIndexOf('}');\n  parsed = JSON.parse(raw.slice(a, b + 1));\n} catch (e) {\n  return fail('Ollama did not return valid JSON');\n}\nconst list = Array.isArray(parsed.highlights) ? parsed.highlights : [];\n\nfunction toSec(v) {\n  if (typeof v === 'number') { return v; }\n  const parts = String(v).trim().split(':').map(Number);\n  if (parts.some(function (n) { return isNaN(n); })) { return NaN; }\n  return parts.reduce(function (acc, n) { return acc * 60 + n; }, 0);\n}\n\nconst candidates = list\n  .map(function (h) { return { start: toSec(h.start), end: toSec(h.end), title: String(h.title || '').trim(), reason: String(h.reason || '').trim() }; })\n  .filter(function (h) {\n    const len = h.end - h.start;\n    const good = isFinite(h.start) && isFinite(h.end) && h.start >= 0 && h.end <= job.durationSec + 1 && len >= job.minClipSec && len <= job.maxClipSec;\n    if (!good) { problems.push('Dropped range ' + h.start + '-' + h.end); }\n    return good;\n  })\n  .sort(function (a, b) { return a.start - b.start; });\n\nconst kept = [];\ncandidates.forEach(function (h) {\n  const prev = kept[kept.length - 1];\n  if (prev && h.start < prev.end) { problems.push('Dropped overlapping range ' + h.start + '-' + h.end); return; }\n  if (kept.length < job.maxClips) { kept.push(h); }\n});\n\nif (!kept.length) { return fail('No valid highlight ranges (' + problems.join('; ') + ')'); }\n\n// ffmpeg-worker only reads and writes inside the shared video-work folder.\nif (String(job.sourcePath).indexOf(ROOT) !== 0 || String(job.outputDir).indexOf(ROOT) !== 0) {\n  return fail('The video and the output folder must both be inside ' + ROOT + ' so ffmpeg-worker can reach them');\n}\nconst base = String(job.fileName).replace(/[.][^.]+$/, '').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'video';\n\nconst clips = kept.map(function (h, i) {\n  const n = i + 1;\n  const name = base + '-short-' + (n < 10 ? '0' : '') + n;\n  return { index: n, name: name, start: h.start, end: h.end, durationSec: Math.round((h.end - h.start) * 10) / 10, title: h.title, reason: h.reason, outputPath: job.outputDir + '/' + name + '.mp4' };\n});\n\nconst trimRequest = {\n  input: job.sourcePath.slice(ROOT.length),\n  outDir: job.outputDir.slice(ROOT.length),\n  layout: job.layout,\n  clips: clips.map(function (c) { return { start: c.start, end: c.end, name: c.name }; })\n};\n\nreturn [{ json: Object.assign({}, job, { ok: true, status: 'clips_planned', error: '', problems: problems, clips: clips, trimRequest: trimRequest }) }];"
      },
      "id": "661048ce-cd51-4699-83f6-6694f02a3613",
      "name": "Validate Ranges and Plan Clips",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2064,
        432
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "ok-true",
              "leftValue": "={{ $json.ok }}",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "ae8407cd-3eea-4672-a263-450f85408c1d",
      "name": "Highlights Valid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        2320,
        432
      ]
    },
    {
      "parameters": {
        "authentication": "webhook",
        "content": "={{ $json.message }}",
        "options": {}
      },
      "id": "a6338e6b-0564-41e5-b439-373a1b0e0c33",
      "name": "Discord: Clips Ready",
      "type": "n8n-nodes-base.discord",
      "typeVersion": 2,
      "position": [
        3104,
        240
      ]
    },
    {
      "parameters": {
        "authentication": "webhook",
        "content": "={{ $json.message }}",
        "options": {}
      },
      "id": "380a0391-6e4a-4353-8ce4-0c4cd0b282c1",
      "name": "Discord: Job Failed",
      "type": "n8n-nodes-base.discord",
      "typeVersion": 2,
      "position": [
        3104,
        544
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "test-path",
              "name": "path",
              "type": "string",
              "value": "/data/video-work/shorts/inbox/test.mp4"
            }
          ]
        },
        "options": {}
      },
      "id": "07748aef-ba6c-4463-acd7-1eb7410be442",
      "name": "Test Video Path",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        512,
        560
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://ffmpeg-worker:8080/trim",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.trimRequest) }}",
        "options": {
          "timeout": 900000
        }
      },
      "id": "c0d8f787-f9f4-488c-8ba0-b7aebc614141",
      "name": "Cut Clips with ffmpeg-worker",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.3,
      "position": [
        2592,
        336
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "const job = $('Validate Ranges and Plan Clips').first().json;\nconst r = $input.first().json || {};\nconst NL = String.fromCharCode(10);\nconst made = Array.isArray(r.clips) ? r.clips : [];\nconst lines = made.map(function (c, i) {\n  const plan = job.clips[i] || {};\n  return (i + 1) + '. ' + (plan.title || c.name) + ' (' + c.duration_sec + 's, ' + Math.round(c.start) + 's-' + Math.round(c.end) + 's) -> /data/video-work/' + c.path;\n});\nconst message = 'Shorts job ' + job.jobId + ' finished for ' + job.fileName + ': ' + made.length + ' vertical clip(s) cut and saved.' + NL + lines.join(NL);\nreturn [{ json: Object.assign({}, job, { status: 'clips_cut', outputs: made, message: message.slice(0, 1900) }) }];"
      },
      "id": "aabbb1f1-f331-44af-a58c-77915e1f5dc3",
      "name": "Clips Cut Message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2848,
        240
      ]
    },
    {
      "parameters": {
        "jsCode": "const job = $('Validate Ranges and Plan Clips').first().json;\nconst e = $input.first().json || {};\nlet err = e.error ? (e.error.message || JSON.stringify(e.error)) : JSON.stringify(e);\nconst d = e.error && e.error.description ? String(e.error.description) : '';\nif (d) err += ' ' + d;\nreturn [{ json: Object.assign({}, job, { status: 'failed', message: ('Shorts job ' + job.jobId + ' FAILED for ' + job.fileName + ' at the cut step: ' + err).slice(0, 1900) }) }];"
      },
      "id": "bea7cd42-dda8-4cac-8965-1f0c047abefd",
      "name": "Cut Failed Message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2848,
        432
      ]
    }
  ],
  "connections": {
    "Watch Shorts Inbox Folder": {
      "main": [
        [
          {
            "node": "Only Video Files",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Only Video Files": {
      "main": [
        [
          {
            "node": "Read Video",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Video": {
      "main": [
        [
          {
            "node": "Create Job",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Job": {
      "main": [
        [
          {
            "node": "Transcribe with Local Whisper",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Transcribe with Local Whisper": {
      "main": [
        [
          {
            "node": "Format Timestamped Transcript",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Timestamped Transcript": {
      "main": [
        [
          {
            "node": "Find Highlights with Ollama",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find Highlights with Ollama": {
      "main": [
        [
          {
            "node": "Validate Ranges and Plan Clips",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Highlights Valid?": {
      "main": [
        [
          {
            "node": "Cut Clips with ffmpeg-worker",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Discord: Job Failed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run On Test Video": {
      "main": [
        [
          {
            "node": "Test Video Path",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Test Video Path": {
      "main": [
        [
          {
            "node": "Read Video",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Ranges and Plan Clips": {
      "main": [
        [
          {
            "node": "Highlights Valid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Cut Clips with ffmpeg-worker": {
      "main": [
        [
          {
            "node": "Clips Cut Message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Cut Failed Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Clips Cut Message": {
      "main": [
        [
          {
            "node": "Discord: Clips Ready",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Cut Failed Message": {
      "main": [
        [
          {
            "node": "Discord: Job Failed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate"
  },
  "nodeGroups": [
    {
      "id": "c8d15e23-f3ce-43f9-8486-41f9cb506ab6",
      "name": "2 Transcribe with Whisper",
      "nodeNames": [
        "Create Job",
        "Transcribe with Local Whisper",
        "Format Timestamped Transcript"
      ],
      "description": "Creates the job record, sends audio to local Whisper, builds a timestamped transcript"
    },
    {
      "id": "206e282b-e84c-4c23-967c-768929f22b92",
      "name": "3 Pick highlights",
      "nodeNames": [
        "Find Highlights with Ollama",
        "Validate Ranges and Plan Clips"
      ],
      "description": "Local Ollama picks highlight ranges; Code checks them and builds the trim request for ffmpeg-worker"
    },
    {
      "id": "13414bd3-461d-470f-93c6-2766109fdb95",
      "name": "1 Detect new video",
      "nodeNames": [
        "Only Video Files"
      ],
      "description": "Ignores non-video files dropped into the inbox"
    },
    {
      "id": "3077724e-ddd2-4c5b-bd1a-2acbbad617f4",
      "name": "4 Cut the clips",
      "nodeNames": [
        "Cut Clips with ffmpeg-worker"
      ],
      "description": "ffmpeg-worker cuts each highlight into a vertical 1080x1920 clip"
    },
    {
      "id": "95d9dcbc-234b-43d6-a0ce-b04b36bd3242",
      "name": "5 Report clips",
      "nodeNames": [
        "Clips Cut Message",
        "Discord: Clips Ready"
      ],
      "description": "Lists the finished clips in Discord"
    },
    {
      "id": "9ed4ac8b-ac8b-4fca-b2ec-7ca32bc5fec2",
      "name": "Report problems",
      "nodeNames": [
        "Cut Failed Message"
      ],
      "description": "Posts the error to Discord if the highlights or the cut fail"
    }
  ],
  "active": false,
  "tags": []
}
