This workflow corresponds to n8n.io template #11584 — we link there as the canonical source.
This workflow follows the Executecommand → Readwritefile recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"id": "q8o0PDISV4bL0nRg",
"meta": {
"templateCredsSetupCompleted": true
},
"name": "clipping",
"tags": [],
"nodes": [
{
"id": "d2344cd1-5b80-4b89-a3f3-80248db92fce",
"name": "When clicking \u2018Execute workflow\u2019",
"type": "n8n-nodes-base.manualTrigger",
"position": [
-832,
448
],
"parameters": {},
"typeVersion": 1
},
{
"id": "6bd570a9-4b12-4e77-b93f-bb6c6705ba55",
"name": "Extract from File",
"type": "n8n-nodes-base.extractFromFile",
"position": [
192,
432
],
"parameters": {
"options": {},
"operation": "text"
},
"typeVersion": 1
},
{
"id": "8e65f7a7-4de8-4695-bcd4-d8cd73136ca1",
"name": "Loop Over Items",
"type": "n8n-nodes-base.splitInBatches",
"position": [
-784,
1472
],
"parameters": {
"options": {}
},
"typeVersion": 3
},
{
"id": "1705113f-0680-428c-924b-0aea7d8e8944",
"name": "On form submission",
"type": "n8n-nodes-base.formTrigger",
"position": [
-832,
112
],
"parameters": {
"options": {},
"formTitle": "Clipping",
"formFields": {
"values": [
{
"fieldLabel": "project name",
"placeholder": "video title",
"requiredField": true
},
{
"fieldLabel": "yt URL",
"requiredField": true
}
]
}
},
"typeVersion": 2.3
},
{
"id": "8743a053-8bfc-4bcb-9ca9-6608ddff395e",
"name": "get the downloaded video location",
"type": "n8n-nodes-base.code",
"position": [
1088,
144
],
"parameters": {
"jsCode": "// Get the big text log from the previous node\nconst stdout = items[0].json.stdout;\n\nlet filePath = null;\n\n// Strategy 1: Look for the \"Merge\" line (best for high quality downloads)\n// Matches: [Merger] Merging formats into \"/data/clips/video.webm\"\nconst mergeMatch = stdout.match(/Merging formats into \"(.*?)\"/);\n\nif (mergeMatch) {\n filePath = mergeMatch[1];\n} else {\n // Strategy 2: If no merge, look for the \"Destination\" line\n // Matches: [download] Destination: /data/clips/video.mp4\n const downloadMatch = stdout.match(/Destination: (.*?)(?:\\n|\\r|$)/);\n if (downloadMatch) {\n filePath = downloadMatch[1];\n }\n}\n\n// Return the clean path so the next node can use it\nreturn {\n json: {\n downloadedFile: filePath\n }\n};"
},
"typeVersion": 2
},
{
"id": "a384e44f-ecaa-4c6a-b53d-e3f69efa782b",
"name": "Loop Over Items1",
"type": "n8n-nodes-base.splitInBatches",
"position": [
-464,
864
],
"parameters": {
"options": {}
},
"typeVersion": 3
},
{
"id": "d190393a-2d10-43c7-8ed3-9504042b2fc2",
"name": "Loop Over Items2",
"type": "n8n-nodes-base.splitInBatches",
"position": [
192,
1360
],
"parameters": {
"options": {}
},
"typeVersion": 3
},
{
"id": "8fa6b348-9a74-4a23-bcae-9af1f5e5064d",
"name": "Aggregate",
"type": "n8n-nodes-base.aggregate",
"position": [
1072,
960
],
"parameters": {
"options": {},
"aggregate": "aggregateAllItemData"
},
"typeVersion": 1
},
{
"id": "de125fd9-72c4-4cdf-8121-46c81daa8e72",
"name": "EDITING",
"type": "n8n-nodes-base.executeWorkflowTrigger",
"position": [
-176,
1360
],
"parameters": {
"workflowInputs": {
"values": [
{
"name": "data",
"type": "array"
}
]
}
},
"typeVersion": 1.1
},
{
"id": "03da8733-c559-4b94-bbce-113c49391e81",
"name": "Split Out",
"type": "n8n-nodes-base.splitOut",
"position": [
-16,
1360
],
"parameters": {
"options": {},
"fieldToSplitOut": "data"
},
"typeVersion": 1
},
{
"id": "ce53e11d-71e9-412a-a133-dbb68c828578",
"name": "Send a message",
"type": "n8n-nodes-base.gmail",
"position": [
1232,
848
],
"parameters": {
"sendTo": "user@example.com",
"message": "clips are ready!",
"options": {},
"subject": "cliping done",
"emailType": "text"
},
"credentials": {
"gmailOAuth2": {
"name": "<your credential>"
}
},
"typeVersion": 2.1
},
{
"id": "9a213ba8-88e1-4737-b9f5-8e2562517095",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1616,
0
],
"parameters": {
"width": 720,
"height": 1120,
"content": "# \ud83c\udfac AI-Powered YouTube Clip Creator\n\nTransform long-form YouTube videos into viral-ready short clips automatically using AI analysis and professional editing.\n\n## What This Workflow Does\n\n1. **Downloads** any YouTube video and its transcript\n2. **Analyzes** the content using Gemini AI to identify 3-5 viral-worthy moments\n3. **Clips** out the best segments automatically\n4. **Edits** each clip with:\n - Smart 9:16 cropping for TikTok/Shorts\n - Precise trimming to optimal length\n - Professional styled subtitles\n5. **Notifies** you via email when clips are ready\n\n## Requirements\n\n\u26a0\ufe0f **Self-hosted n8n only** (requires command line access)\n\n**System Setup Required:**\n- FFmpeg installed ([Setup Guide](https://docs.n8n.io/integrations/community-nodes/installation/gui-install/#install-via-npm))\n- yt-dlp installed ([Installation Guide](https://github.com/yt-dlp/yt-dlp#installation))\n\n**Credentials Needed:**\n- Google Gemini API key ([Get it here](https://ai.google.dev/))\n- Gmail OAuth2 (for notifications)\n\n## How to Use\n\n1. Fill out the form with video title and YouTube URL\n2. Hit submit and wait for the magic\n3. Check your email when clips are ready\n4. Find your edited clips in `/data/clips/`\n\n## Customization Tips\n\n- Adjust clip count in \"filter out top clips\" node (default: top 10)\n- Modify subtitle styling in \"calculate relative subtitle size\" node\n- Change aspect ratio in Gemini prompt (currently 9:16 for vertical)\n- Add your own editing steps in the pipeline\n\n---\n\n\ud83d\udca1 **Pro Tip**: This workflow processes clips sequentially to avoid system overload. Adjust the Wait nodes based on your system's RAM and CPU power."
},
"typeVersion": 1
},
{
"id": "37caaaa7-158e-4c09-9ef3-12e0fa70ecb6",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
-880,
16
],
"parameters": {
"color": 4,
"width": 2416,
"height": 736,
"content": "## Initial Download and identification of clips:- \n### used FFMPEG, Gemini & YT-DLP"
},
"typeVersion": 1
},
{
"id": "88b55c17-b921-4d8a-87c8-6675a46f8f8b",
"name": "video download with yt-dlp",
"type": "n8n-nodes-base.executeCommand",
"position": [
-464,
160
],
"parameters": {
"command": "=yt-dlp -o \"/data/clips/%(title)s.%(ext)s\" \"{{ $json[\"yt URL\"] }}\"\n"
},
"typeVersion": 1
},
{
"id": "5571f1bf-b302-4372-bba6-ba6287ab6b68",
"name": "get transcript from yt-dlp",
"type": "n8n-nodes-base.executeCommand",
"position": [
-448,
432
],
"parameters": {
"command": "=yt-dlp --write-auto-sub --sub-lang \"en.*,live\" --skip-download {{ $json[\"yt URL\"] }} -o \"/data/%(title)s\"\n"
},
"typeVersion": 1
},
{
"id": "34e62147-5f65-417c-98d4-f731c3fddb36",
"name": "extract filepath",
"type": "n8n-nodes-base.code",
"position": [
-208,
432
],
"parameters": {
"jsCode": "// Get the big text log from the previous node\nconst stdout = items[0].json.stdout;\n\nlet filePath = null;\n\n// Strategy 1: Look for the \"Merge\" line (best for high quality downloads)\n// Matches: [Merger] Merging formats into \"/data/clips/video.webm\"\nconst mergeMatch = stdout.match(/Merging formats into \"(.*?)\"/);\n\nif (mergeMatch) {\n filePath = mergeMatch[1];\n} else {\n // Strategy 2: If no merge, look for the \"Destination\" line\n // Matches: [download] Destination: /data/clips/video.mp4\n const downloadMatch = stdout.match(/Destination: (.*?)(?:\\n|\\r|$)/);\n if (downloadMatch) {\n filePath = downloadMatch[1];\n }\n}\n\n// Return the clean path so the next node can use it\nreturn {\n json: {\n downloadedFile: filePath\n }\n};"
},
"typeVersion": 2
},
{
"id": "80e059a0-6145-4deb-a2e0-5aab4ef9b95a",
"name": "read srt from disk",
"type": "n8n-nodes-base.readWriteFile",
"position": [
-16,
432
],
"parameters": {
"options": {},
"fileSelector": "={{ $json.downloadedFile }}"
},
"typeVersion": 1,
"alwaysOutputData": false
},
{
"id": "b769ef5b-c339-4fec-817b-86fac4f94f98",
"name": "formating of data",
"type": "n8n-nodes-base.code",
"position": [
400,
432
],
"parameters": {
"jsCode": "const raw = $json.data;\n\nconst lines = raw.split(/\\r?\\n/);\n\nconst segments = [];\nlet current = null;\n\nfunction cleanText(t) {\n return t\n .replace(/<c>/g, \"\")\n .replace(/<\\/c>/g, \"\")\n .replace(/<\\d+:\\d+:\\d+\\.\\d+>/g, \"\")\n .replace(/\\s+/g, \" \")\n .trim();\n}\n\nconst timestampRegex = /(\\d{2}:\\d{2}:\\d{2}\\.\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2}\\.\\d{3})/;\n\nfor (let line of lines) {\n line = line.trim();\n if (!line) continue;\n\n // If line contains a timestamp line\n const match = line.match(timestampRegex);\n if (match) {\n // Save previous\n if (current && current.text.trim()) {\n segments.push({\n start: current.start,\n end: current.end,\n text: cleanText(current.text)\n });\n }\n\n current = {\n start: match[1],\n end: match[2],\n text: \"\"\n };\n\n continue;\n }\n\n // Text line\n if (current) {\n current.text += \" \" + cleanText(line);\n }\n}\n\n// Push last segment\nif (current && current.text.trim()) {\n segments.push({\n start: current.start,\n end: current.end,\n text: cleanText(current.text)\n });\n}\n\nreturn segments;\n"
},
"typeVersion": 2
},
{
"id": "5d43acbb-a45e-4ceb-8692-a819aa8c58ae",
"name": "some more formating",
"type": "n8n-nodes-base.code",
"position": [
592,
432
],
"parameters": {
"jsCode": "const items = $items();\nconst size = 150; // number of caption segments per chunk\n\nlet chunks = [];\nfor (let i = 0; i < items.length; i += size) {\n const slice = items.slice(i, i + size);\n chunks.push({\n json: {\n chunkIndex: chunks.length,\n captions: slice.map(s => s.json)\n }\n });\n}\n\nreturn chunks;\n"
},
"typeVersion": 2
},
{
"id": "2395ac82-d71b-4964-9839-bf522e29161a",
"name": "viral clips identification",
"type": "@n8n/n8n-nodes-langchain.googleGemini",
"position": [
800,
432
],
"parameters": {
"modelId": {
"__rl": true,
"mode": "list",
"value": "models/gemini-2.5-flash",
"cachedResultName": "models/gemini-2.5-flash"
},
"options": {
"systemMessage": "You are an expert short-form clip editor trained to extract viral moments from transcripts.\n"
},
"messages": {
"values": [
{
"content": "=Task:\n- Identify 3-5 high-quality viral TikTok-style short-form clips\n- Combine segments when needed\n- Use EXACT provided timestamps\n- Return ONLY a JSON array of objects:\n[\n { \"start\": \"00:00:12.200\", \"end\": \"00:00:28.400\", \"hook\": \"....\", \"score\": 0.0 }\n]\n\n\nBelow is a transcript chunk in caption format:\n\n{{ JSON.stringify($json) }}\n"
}
]
},
"jsonOutput": true
},
"credentials": {
"googlePalmApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "d11dc961-a42a-4788-8194-5403ca021f29",
"name": "filter out top clips according to score",
"type": "n8n-nodes-base.code",
"position": [
1104,
432
],
"parameters": {
"jsCode": "const results = [];\nconst items = $items();\n\n// Loop all AI responses\nfor (const item of items) {\n try {\n // The JSON output returned as text string\n const raw = item.json.content.parts[0].text;\n\n // Parse the JSON array inside the string\n const clips = JSON.parse(raw);\n\n // Add clips to results\n results.push(...clips);\n } catch (e) {\n console.log(\"Error parsing:\", e);\n }\n}\n\n// Sort by score (highest first)\nresults.sort((a, b) => b.score - a.score);\n\n// OPTIONAL: keep only top 10 clips\nconst topClips = results.slice(0, 10);\n\nreturn topClips.map(c => ({ json: c }));\n"
},
"typeVersion": 2
},
{
"id": "43311265-2728-4e66-9540-10383dfa1f8e",
"name": "wait for both branches to complete and merge",
"type": "n8n-nodes-base.merge",
"position": [
1360,
272
],
"parameters": {},
"typeVersion": 3.2
},
{
"id": "774311aa-8de9-478b-9bee-7dd5933ce18f",
"name": "seperate actionable data items",
"type": "n8n-nodes-base.code",
"position": [
1360,
544
],
"parameters": {
"jsCode": "// Get all incoming items from Join node\nconst items = $input.all();\n\n// First item = video metadata\nconst videoItem = items[0].json;\nconst videoPath = videoItem.downloadedFile;\n\n// Remaining items = clip candidates from AI\nconst clips = items.slice(1).map(i => i.json);\n\n// Build merged output\nconst merged = clips.map(c => ({\n json: {\n start: c.start,\n end: c.end,\n hook: c.hook,\n score: c.score,\n videoPath: videoPath\n }\n}));\n\nreturn merged;\n"
},
"typeVersion": 2
},
{
"id": "e2d9696e-3278-487b-9a37-4f85cad468b4",
"name": "simple clipping (still in orignal aspect ratio)",
"type": "n8n-nodes-base.executeCommand",
"position": [
-592,
1488
],
"parameters": {
"command": "=ffmpeg -ss {{$json.start}} -to {{$json.end}} \\\n-i \"{{$json.videoPath}}\" \\\n-c:v libx264 -preset fast -crf 22 \\\n-c:a aac -b:a 128k \\\n\"/data/clips/{{ $json.start }}_{{ $json.end }}.mp4\"\n",
"executeOnce": false
},
"typeVersion": 1,
"alwaysOutputData": false
},
{
"id": "9ebb066b-4af9-4140-a488-dfdfc380dd5b",
"name": "extract all clips paths",
"type": "n8n-nodes-base.code",
"position": [
-768,
880
],
"parameters": {
"jsCode": "// Get all items from FFmpeg Execute Command node\nconst items = $input.all();\n\nconst output = [];\n\nfor (const item of items) {\n const stderr = item.json.stderr || \"\";\n\n // Extract path: Output #0, mp4, to '...'\n const match = stderr.match(/to '([^']+)'/);\n\n const outputFile = match ? match[1] : null;\n\n output.push({\n json: {\n outputFile,\n exitCode: item.json.exitCode,\n start: item.json.start,\n end: item.json.end,\n hook: item.json.hook,\n score: item.json.score\n }\n });\n}\n\nreturn output;\n"
},
"typeVersion": 2
},
{
"id": "39442dc5-76a5-4f6f-9e00-7ecfaa399f3b",
"name": "Read clips from disk",
"type": "n8n-nodes-base.readWriteFile",
"position": [
-160,
944
],
"parameters": {
"options": {},
"fileSelector": "={{ $json.outputFile }}"
},
"typeVersion": 1
},
{
"id": "4ceee2b8-7e5d-4434-94fb-11f6d8098a95",
"name": "extract clip file in base64",
"type": "n8n-nodes-base.extractFromFile",
"position": [
64,
944
],
"parameters": {
"options": {},
"operation": "binaryToPropery"
},
"typeVersion": 1
},
{
"id": "d3c796d7-67fe-4a27-b744-0a2f11431d5a",
"name": "convert base64 to actual binary file",
"type": "n8n-nodes-base.convertToFile",
"position": [
304,
944
],
"parameters": {
"options": {},
"operation": "toBinary",
"sourceProperty": "data"
},
"typeVersion": 1.1
},
{
"id": "13aceeda-9bce-4a59-89dd-41302a0241dc",
"name": "Analyze the actual whole video",
"type": "@n8n/n8n-nodes-langchain.googleGemini",
"position": [
544,
944
],
"parameters": {
"text": "=You are a professional vertical video editor.\nYou MUST output ONLY one valid JSON object.\nYou MUST obey the JSON schema EXACTLY.\n\nBefore generating the JSON, you MUST follow these timestamp rules COMBINED:\n\nSTRICT TIMESTAMP RULES (MANDATORY \u2014 DO NOT VIOLATE):\n\nALL timestamps MUST use EXACTLY this pattern:\n\"HH:MM:SS,mmm\"\nExample: \"00:01:05,320\"\n\nYou MUST always include hours, minutes, seconds, and milliseconds.\n\nMilliseconds MUST always be 3 digits.\nValid: 00:00:01,005\nInvalid: 00:00:01,5 or 00:00:01,05 or 00:00:01\n\nYou MUST NEVER use periods instead of commas.\nNEVER use: \"00:01:05.320\"\nONLY use: \"00:01:05,320\"\n\nYou MUST NEVER output timestamps like:\n\n\"00:00:560\"\n\n\"00:01:800\"\n\n\"00:32:010\"\n\n\"00:01\"\n\n\"0:1:2,0\"\n\nEvery subtitle entry MUST match this template EXACTLY:\n\n{\n \"text\": \"example\",\n \"start\": \"00:00:01,000\",\n \"end\": \"00:00:02,000\"\n}\n\n\nYou MUST validate your own timestamps BEFORE outputting the JSON.\nIf any timestamp does NOT match \"HH:MM:SS,mmm\", you MUST FIX IT before output.\n\nFollow this JSON schema:\n\n{\n\"clip_id\": string,\n\"editor_instructions\": {\n\"cropping\": {\n\"required\": boolean,\n\"aspect_ratio\": \"9:16\",\n\"crop_coordinates\": {\n\"x\": number,\n\"y\": number,\n\"width\": number,\n\"height\": number\n},\n\"description\": string\n},\n\"trimming\": {\n\"required\": boolean,\n\"start_time\": string,\n\"end_time\": string,\n\"description\": string\n},\n\"subtitles\": {\n\"required\": boolean,\n\"placement\": \"top\" | \"center\" | \"bottom\" | \"lower_center\",\n\"font_size\": \"small\" | \"medium\" | \"large\",\n\"font_style\": string,\n\"font_color\": string,\n\"text_outline\": {\n\"color\": string,\n\"width\": string\n},\n\"transcript\": [\n{\n\"text\": string,\n\"start\": string,\n\"end\": string\n}\n]\n},\n\"other_changes\": [\n{\n\"type\": string,\n\"description\": string,\n\"start_time\": string,\n\"end_time\": string,\n\"text\": string | null,\n\"position\": string | null,\n\"timestamps\": [string] | null,\n\"level\": string | null\n}\n]\n}\n}\n\nADDITIONAL REQUIREMENTS:\n\nNo explanation. No markdown. Only raw JSON.\n\nCropping must be a valid 9:16 region inside the input frame.\n\nSubtitles must be split into micro-chunks (max 3 words).\n\nCaptions must be bold, large, and high-engagement.\n\nTrimming removes unnecessary segments.\n\nOnly include other_changes when actually needed.\n\nFINAL CHECK BEFORE OUTPUT:\nYou MUST re-check your entire JSON and CONFIRM:\n\n\u2714 Every timestamp matches \"HH:MM:SS,mmm\" exactly\n\u2714 Every timestamp has 3-digit milliseconds\n\u2714 No timestamp contains a period\n\u2714 No timestamp contains missing hours or minutes\n\u2714 start < end for every subtitle\n\u2714 No timestamps drift from the required pattern\n\nIf ANY timestamp fails, FIX it BEFORE outputting the JSON.\n\nOnly then output the final JSON.",
"modelId": {
"__rl": true,
"mode": "list",
"value": "models/gemini-2.5-flash",
"cachedResultName": "models/gemini-2.5-flash"
},
"options": {},
"resource": "video",
"inputType": "binary",
"operation": "analyze"
},
"credentials": {
"googlePalmApi": {
"name": "<your credential>"
}
},
"retryOnFail": false,
"typeVersion": 1
},
{
"id": "e811973a-16c6-4c91-8a01-c41c497bebd9",
"name": "extract all actionable operations",
"type": "n8n-nodes-base.code",
"position": [
752,
944
],
"parameters": {
"jsCode": "// YOUR_AWS_SECRET_KEY_HERE=============\n// VIDEO EDIT PLANNER - n8n JavaScript Node (FINAL v2)\n// YOUR_AWS_SECRET_KEY_HERE=============\n// This node parses Gemini's analysis and creates a \n// sequential FFmpeg task pipeline\n// YOUR_AWS_SECRET_KEY_HERE=============\n\n// YOUR_AWS_SECRET_KEY_HERE=============\n// 1. GET INPUT VIDEO PATH SAFELY\n// YOUR_AWS_SECRET_KEY_HERE=============\nlet inputVideoPath = null;\ntry {\n // Option A: Try getting from current item first (Best for loops)\n if ($('Loop Over Items1') && $('Loop Over Items1').item) {\n inputVideoPath = $('Loop Over Items1').item.json.outputFile;\n }\n // Option B: Fallback to the specific node you referenced in your prompt\n else if ($('Read clips from disk').first().json.fileName) {\n inputVideoPath = $('Read clips from disk').first().json.fileName;\n }\n // Option C: Generic fallback to previous node output\n else if ($input.first().json.outputFile) {\n inputVideoPath = $input.first().json.outputFile;\n }\n} catch (e) {\n // Ignore initial lookup errors, we check validity below\n}\n\n// Fallback if path is totally missing (prevents crash, helps debug)\nif (!inputVideoPath) {\n inputVideoPath = \"/data/clips/placeholder_debug.mp4\"; \n}\n\n// *** CRITICAL FIX: HANDLE COLONS IN FILENAMES ***\n// If path doesn't start with '/' or '.', FFmpeg treats \"00:10...\" as a protocol.\n// We force it to be an absolute path if it looks like just a filename.\nif (!inputVideoPath.startsWith('/') && !inputVideoPath.startsWith('.')) {\n // Assuming your files are in /data/clips/ based on standard n8n docker setups\n inputVideoPath = `/data/clips/${inputVideoPath}`;\n}\n\n// YOUR_AWS_SECRET_KEY_HERE=============\n// 2. PARSE GEMINI RESPONSE\n// YOUR_AWS_SECRET_KEY_HERE=============\nconst geminiRawText = $input.first().json.content?.parts?.[0]?.text;\nlet editorInstructions;\n\ntry {\n if (!geminiRawText) {\n throw new Error(\"Gemini response is empty or structure has changed\");\n }\n\n // Extract the JSON from Gemini's text response using Regex\n const jsonMatch = geminiRawText.match(/```json\\n([\\s\\S]*?)\\n```/);\n \n if (jsonMatch) {\n const parsedData = JSON.parse(jsonMatch[1]);\n editorInstructions = parsedData.editor_instructions;\n } else {\n // Fallback: Try parsing the raw text directly if regex fails\n try {\n const parsedData = JSON.parse(geminiRawText);\n editorInstructions = parsedData.editor_instructions;\n } catch (e) {\n throw new Error(\"Could not extract JSON from Gemini response via Regex or direct parse\");\n }\n }\n} catch (error) {\n throw new Error(`Failed to parse Gemini response: ${error.message}`);\n}\n\n// YOUR_AWS_SECRET_KEY_HERE=============\n// HELPER FUNCTIONS\n// YOUR_AWS_SECRET_KEY_HERE=============\n\n// *** CRITICAL FIX: ROBUST TIME PARSING ***\n// Handles HH:MM:SS (3 parts) AND MM:SS (2 parts) AND SS (1 part)\nfunction timeToSeconds(timeStr) {\n if (!timeStr) return 0;\n \n // Clean string: replace commas with dots, remove whitespace\n const cleanStr = timeStr.toString().replace(',', '.').trim();\n const parts = cleanStr.split(':').map(parseFloat);\n\n if (parts.length === 3) {\n return parts[0] * 3600 + parts[1] * 60 + parts[2];\n } else if (parts.length === 2) {\n return parts[0] * 60 + parts[1];\n } else if (parts.length === 1) {\n return parts[0];\n }\n return 0;\n}\n\n// Generate unique filename for each step\nfunction generateStepFilename(clipId, step, extension = 'mp4') {\n return `/data/clips/${clipId}_${step}.${extension}`;\n}\n\n// Create SRT subtitle file content\nfunction generateSRTContent(transcript) {\n let srtContent = '';\n transcript.forEach((line, index) => {\n // Ensure comma format for SRT timestamp\n const startTime = line.start.replace('.', ',');\n const endTime = line.end.replace('.', ',');\n \n srtContent += `${index + 1}\\n`;\n srtContent += `${startTime} --> ${endTime}\\n`;\n srtContent += `${line.text}\\n\\n`;\n });\n return srtContent;\n}\n\n// YOUR_AWS_SECRET_KEY_HERE=============\n// BUILD TASK PIPELINE\n// YOUR_AWS_SECRET_KEY_HERE=============\n\nconst clipId = $runIndex.toString().padStart(4, \"0\");\nconst tasks = [];\n\nlet currentInput = inputVideoPath;\nlet stepCounter = 1;\n\n// YOUR_AWS_SECRET_KEY_HERE=============\n// STEP 1: TRIM + CROP COMBINED\n// YOUR_AWS_SECRET_KEY_HERE=============\nconst hasTrim = editorInstructions.trimming && editorInstructions.trimming.required;\nconst hasCrop = editorInstructions.cropping && editorInstructions.cropping.required;\n\nif (hasTrim && hasCrop) {\n // COMBINE trim and crop\n const trim = editorInstructions.trimming;\n const crop = editorInstructions.cropping;\n const outputFile = generateStepFilename(clipId, `${stepCounter}_trim_crop`);\n \n const startSeconds = timeToSeconds(trim.start_time);\n const endSeconds = timeToSeconds(trim.end_time);\n\n // Validation\n if (isNaN(startSeconds) || isNaN(endSeconds)) throw new Error(`Invalid time format in Gemini response`);\n \n const duration = endSeconds - startSeconds;\n \n const cropFilter = `crop=${crop.crop_coordinates.width}:${crop.crop_coordinates.height}:${crop.crop_coordinates.x}:${crop.crop_coordinates.y}`;\n \n // Note: -ss placed BEFORE -i for fast seek\n tasks.push({\n step: 'trim_crop',\n stepNumber: stepCounter,\n enabled: true,\n inputFile: currentInput,\n outputFile: outputFile,\n command: `ffmpeg -ss ${startSeconds} -i \"${currentInput}\" -t ${duration} -vf \"${cropFilter}\" -c:v libx264 -preset fast -crf 23 -c:a aac -b:a 128k \"${outputFile}\"`,\n description: 'Trim and crop video in single pass for optimal quality',\n params: {\n start_time: trim.start_time,\n end_time: trim.end_time,\n duration: duration,\n aspect_ratio: crop.aspect_ratio,\n coordinates: crop.crop_coordinates\n }\n });\n \n currentInput = outputFile;\n stepCounter++;\n \n} else if (hasTrim) {\n // Only trim\n const trim = editorInstructions.trimming;\n const outputFile = generateStepFilename(clipId, `${stepCounter}_trimmed`);\n \n const startSeconds = timeToSeconds(trim.start_time);\n const endSeconds = timeToSeconds(trim.end_time);\n\n if (isNaN(startSeconds) || isNaN(endSeconds)) throw new Error(`Invalid time format in Gemini response`);\n\n const duration = endSeconds - startSeconds;\n \n tasks.push({\n step: 'trim',\n stepNumber: stepCounter,\n enabled: true,\n inputFile: currentInput,\n outputFile: outputFile,\n command: `ffmpeg -ss ${startSeconds} -i \"${currentInput}\" -t ${duration} -c:v libx264 -preset fast -crf 23 -c:a aac -b:a 128k \"${outputFile}\"`,\n description: trim.description || 'Trim video to specified time range',\n params: {\n start_time: trim.start_time,\n end_time: trim.end_time,\n duration: duration\n }\n });\n \n currentInput = outputFile;\n stepCounter++;\n \n} else if (hasCrop) {\n // Only crop\n const crop = editorInstructions.cropping;\n const outputFile = generateStepFilename(clipId, `${stepCounter}_cropped`);\n \n const cropFilter = `crop=${crop.crop_coordinates.width}:${crop.crop_coordinates.height}:${crop.crop_coordinates.x}:${crop.crop_coordinates.y}`;\n \n tasks.push({\n step: 'crop',\n stepNumber: stepCounter,\n enabled: true,\n inputFile: currentInput,\n outputFile: outputFile,\n command: `ffmpeg -i \"${currentInput}\" -vf \"${cropFilter}\" -c:v libx264 -preset fast -crf 23 -c:a aac -b:a 128k \"${outputFile}\"`,\n description: crop.description || 'Crop video to specified dimensions',\n params: {\n aspect_ratio: crop.aspect_ratio,\n coordinates: crop.crop_coordinates\n }\n });\n \n currentInput = outputFile;\n stepCounter++;\n \n} else {\n // Neither trim nor crop\n tasks.push({\n step: 'trim_crop',\n stepNumber: stepCounter,\n enabled: false,\n inputFile: null,\n outputFile: null,\n command: null,\n description: 'No trimming or cropping required'\n });\n stepCounter++;\n}\n\n// YOUR_AWS_SECRET_KEY_HERE=============\n// STEP 3: CREATE SRT FILE (separate task)\n// YOUR_AWS_SECRET_KEY_HERE=============\nif (editorInstructions.subtitles && editorInstructions.subtitles.required) {\n const subs = editorInstructions.subtitles;\n const srtFile = generateStepFilename(clipId, 'subtitles', 'srt');\n \n // Generate SRT content\n const srtContent = generateSRTContent(subs.transcript);\n \n tasks.push({\n step: 'create_srt',\n stepNumber: stepCounter,\n enabled: true,\n inputFile: null,\n outputFile: srtFile,\n srtContent: srtContent,\n // Using cat with heredoc to write file safely\n command: `cat > \"${srtFile}\" << 'EOF'\\n${srtContent}EOF`,\n description: 'Create SRT subtitle file',\n params: {\n srt_file: srtFile,\n lines_count: subs.transcript.length\n }\n });\n \n stepCounter++;\n \n // YOUR_AWS_SECRET_KEY_HERE=============\n // STEP 4: BURN SUBTITLES INTO VIDEO\n // YOUR_AWS_SECRET_KEY_HERE=============\n const outputFile = generateStepFilename(clipId, `${stepCounter}_subtitled`);\n \n // YouTube Shorts style: smaller, cleaner captions\n const fontSize = 42; \n const primaryColor = '&H00FFFF&'; // Yellow (BGR)\n const borderColor = '&H000000&'; // Black outline\n const fontName = 'Arial';\n const finalOutlineWidth = 3;\n \n // MarginV controls distance from bottom\n const subtitlesFilter = `subtitles=${srtFile}:force_style='FontName=${fontName},FontSize=${fontSize},PrimaryColour=${primaryColor},OutlineColour=${borderColor},Outline=${finalOutlineWidth},Bold=1,Alignment=2,MarginV=120'`;\n \n tasks.push({\n step: 'subtitles',\n stepNumber: stepCounter,\n enabled: true,\n inputFile: currentInput,\n outputFile: outputFile,\n srtFile: srtFile,\n command: `ffmpeg -i \"${currentInput}\" -vf \"${subtitlesFilter}\" -c:v libx264 -preset fast -crf 23 -c:a copy \"${outputFile}\"`,\n description: 'Burn styled subtitles into video',\n params: {\n placement: subs.placement,\n font_style: subs.font_style,\n font_size: fontSize,\n font_color: subs.font_color,\n transcript_lines: subs.transcript.length\n }\n });\n \n currentInput = outputFile;\n stepCounter++;\n} else {\n // Push disabled tasks to keep pipeline structure\n tasks.push({\n step: 'create_srt',\n stepNumber: stepCounter,\n enabled: false,\n description: 'Subtitles not required'\n });\n stepCounter++;\n \n tasks.push({\n step: 'subtitles',\n stepNumber: stepCounter,\n enabled: false,\n description: 'Subtitles not required'\n });\n stepCounter++;\n}\n\n// YOUR_AWS_SECRET_KEY_HERE=============\n// STEP 5: AUDIO NORMALIZATION (Optional)\n// YOUR_AWS_SECRET_KEY_HERE=============\nconst normalizeOutputFile = generateStepFilename(clipId, `${stepCounter}_normalized`);\ntasks.push({\n step: 'audio_normalize',\n stepNumber: stepCounter,\n enabled: false, // Set to true if you want audio normalization\n inputFile: currentInput,\n outputFile: normalizeOutputFile,\n command: `ffmpeg -i \"${currentInput}\" -af loudnorm -c:v copy \"${normalizeOutputFile}\"`,\n description: 'Audio normalization (disabled by default)'\n});\n\n// If audio normalization is enabled, update currentInput\nif (tasks[tasks.length - 1].enabled) {\n currentInput = normalizeOutputFile;\n}\nstepCounter++;\n\n// YOUR_AWS_SECRET_KEY_HERE=============\n// STEP 6: FINAL OUTPUT\n// YOUR_AWS_SECRET_KEY_HERE=============\nconst finalOutputFile = generateStepFilename(clipId, 'final');\ntasks.push({\n step: 'finalize',\n stepNumber: stepCounter,\n enabled: true,\n inputFile: currentInput,\n outputFile: finalOutputFile,\n command: `cp \"${currentInput}\" \"${finalOutputFile}\"`,\n description: 'Copy final processed video',\n params: {\n finalOutput: finalOutputFile\n }\n});\n\n// YOUR_AWS_SECRET_KEY_HERE=============\n// OUTPUT RESULTS\n// YOUR_AWS_SECRET_KEY_HERE=============\n\nreturn tasks.map(task => ({\n json: {\n ...task,\n clipId: clipId,\n originalInput: inputVideoPath,\n timestamp: new Date().toISOString()\n }\n}));"
},
"typeVersion": 2
},
{
"id": "0f504a64-9e4c-4fc8-b8d0-45bf7eedab13",
"name": "Filterout the not required operations",
"type": "n8n-nodes-base.filter",
"position": [
928,
960
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "7cc81ed2-2a0d-4b05-9368-eac1462f899a",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{ $json.enabled }}",
"rightValue": "="
}
]
}
},
"typeVersion": 2.2
},
{
"id": "ed6c6ff5-4a1e-424f-a59b-342eebd87872",
"name": "Call subworkflow",
"type": "n8n-nodes-base.executeWorkflow",
"position": [
1232,
960
],
"parameters": {
"options": {},
"workflowId": {
"__rl": true,
"mode": "list",
"value": "EgTgnv601VaZosUz",
"cachedResultUrl": "/workflow/EgTgnv601VaZosUz",
"cachedResultName": "editing"
},
"workflowInputs": {
"value": {
"data": "={{ $json.data }}"
},
"schema": [
{
"id": "data",
"type": "array",
"display": true,
"removed": false,
"required": false,
"displayName": "data",
"defaultMatch": false,
"canBeUsedToMatch": true
}
],
"mappingMode": "defineBelow",
"matchingColumns": [
"data"
],
"attemptToConvertTypes": false,
"convertFieldsToString": true
}
},
"typeVersion": 1.3
},
{
"id": "0d49f3cd-8a20-4bf9-aed3-89e5c6dec425",
"name": "if operation is subtitles",
"type": "n8n-nodes-base.if",
"position": [
432,
1360
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "6651a68e-b616-4a24-b847-bc8f14bf915c",
"operator": {
"name": "filter.operator.equals",
"type": "string",
"operation": "equals"
},
"leftValue": "={{ $json.step }}",
"rightValue": "subtitles"
}
]
}
},
"typeVersion": 2.2
},
{
"id": "5b245253-c587-4447-bc5c-bafa31db0859",
"name": "Execute operation on the clip",
"type": "n8n-nodes-base.executeCommand",
"position": [
704,
1488
],
"parameters": {
"command": "={{ $json.command }}",
"executeOnce": false
},
"typeVersion": 1
},
{
"id": "d01f6482-6def-41e0-a772-19d2c3127fbe",
"name": "Wait (according to how powerful your system is and how much ram you have)",
"type": "n8n-nodes-base.wait",
"position": [
912,
1488
],
"parameters": {
"amount": 60
},
"typeVersion": 1.1
},
{
"id": "15905235-a21b-42bd-a5ff-6d1f0889bf15",
"name": "find height & width",
"type": "n8n-nodes-base.executeCommand",
"position": [
704,
1232
],
"parameters": {
"command": "=ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of json \"{{$json[\"inputFile\"]}}\"\n",
"executeOnce": false
},
"typeVersion": 1
},
{
"id": "cd6c45fb-e6f0-458b-9e23-1eeb30171036",
"name": "calculate relative subtitle size",
"type": "n8n-nodes-base.code",
"position": [
912,
1232
],
"parameters": {
"jsCode": "return items.map((item, index) => {\n \n // 1) Get ffprobe output\n const ffprobe = JSON.parse(item.json.stdout);\n const stream = ffprobe.streams[0];\n\n const videoWidth = stream.width;\n const videoHeight = stream.height;\n\n // 2) Get original task data from before ffprobe\n const original = $item(index).$node[\"if operation is subtitles\"].json;\n\n const inputFile = original.inputFile;\n const outputFile = original.outputFile;\n const srtFile = original.srtFile;\n\n if (!inputFile || !outputFile || !srtFile) {\n throw new Error(\"Missing inputFile/outputFile/srtFile from If node.\");\n }\n\n// Math.round(videoWidth * 0.085);\n// Math.round(videoHeight * 0.12);\n// // 3) Dynamic scaling\n const fontSize = 12;\n const marginV = 50;\n\n // 4) Subtitle styling\n const fontName = 'Arial';\n const primaryColor = '&H00FFFF&';\n const borderColor = '&H000000&';\n const outlineWidth = 1;\n const alignment = 2;\n\n const subtitlesFilter =\n `subtitles=${srtFile}:force_style=` +\n `'FontName=${fontName},FontSize=${fontSize},PrimaryColour=${primaryColor},` +\n `OutlineColour=${borderColor},Outline=${outlineWidth},Bold=1,Alignment=${alignment},MarginV=${marginV},WrapStyle=2'`;\n\n // 5) Final ffmpeg command\n const command =\n `ffmpeg -i \"${inputFile}\" -vf \"${subtitlesFilter}\" ` +\n `-c:v libx264 -preset fast -crf 23 -c:a copy \"${outputFile}\"`;\n\n // 6) Output back\n item.json.videoWidth = videoWidth;\n item.json.videoHeight = videoHeight;\n item.json.fontSize = fontSize;\n item.json.marginV = marginV;\n item.json.command = command;\n\n return item;\n});\n"
},
"typeVersion": 2
},
{
"id": "e80d7716-e37f-4093-8226-73a49d34157c",
"name": "burn subtitles",
"type": "n8n-nodes-base.executeCommand",
"position": [
1104,
1232
],
"parameters": {
"command": "={{$json[\"command\"]}}\n"
},
"typeVersion": 1
},
{
"id": "c11721d5-b5c6-44f9-91dc-5a255d42a313",
"name": "Wait",
"type": "n8n-nodes-base.wait",
"position": [
1296,
1232
],
"parameters": {
"amount": 60
},
"typeVersion": 1.1
},
{
"id": "e681e63f-3b6d-4c35-8b5c-b7bc8c8b066c",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
-880,
768
],
"parameters": {
"color": 5,
"width": 2416,
"height": 976,
"content": "## Analysis of each clip and extracting required editing operations"
},
"typeVersion": 1
},
{
"id": "90d148db-9dae-4bb1-b664-460bb40da64b",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
-864,
1408
],
"parameters": {
"color": 3,
"width": 464,
"height": 304,
"content": "## Clipping out"
},
"typeVersion": 1
},
{
"id": "77f8d535-a391-4a85-8fab-6f724a9fd2cb",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
-240,
1200
],
"parameters": {
"color": 3,
"width": 1728,
"height": 512,
"content": "## executing editing commands on the clips\nTake this into a seperate workflow, and configure the call sub-workflow node"
},
"typeVersion": 1
}
],
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "0ea784bc-a040-4004-9ad5-0f069f97fbf6",
"connections": {
"Wait": {
"main": [
[
{
"node": "Loop Over Items2",
"type": "main",
"index": 0
}
]
]
},
"EDITING": {
"main": [
[
{
"node": "Split Out",
"type": "main",
"index": 0
}
]
]
},
"Aggregate": {
"main": [
[
{
"node": "Call subworkflow",
"type": "main",
"index": 0
}
]
]
},
"Split Out": {
"main": [
[
{
"node": "Loop Over Items2",
"type": "main",
"index": 0
}
]
]
},
"burn subtitles": {
"main": [
[
{
"node": "Wait",
"type": "main",
"index": 0
}
]
]
},
"Loop Over Items": {
"main": [
[
{
"node": "extract all clips paths",
"type": "main",
"index": 0
}
],
[
{
"node": "simple clipping (still in orignal aspect ratio)",
"type": "main",
"index": 0
}
]
]
},
"Call subworkflow": {
"main": [
[
{
"node": "Loop Over Items1",
"type": "main",
"index": 0
}
]
]
},
"Loop Over Items1": {
"main": [
[
{
"node": "Send a message",
"type": "main",
"index": 0
}
],
[
{
"node": "Read clips from disk",
"type": "main",
"index": 0
}
]
]
},
"Loop Over Items2": {
"main": [
[],
[
{
"node": "if operation is subtitles",
"type": "main",
"index": 0
}
]
]
},
"extract filepath": {
"main": [
[
{
"node": "read srt from disk",
"type": "main",
"index": 0
}
]
]
},
"Extract from File": {
"main": [
[
{
"node": "formating of data",
"type": "main",
"index": 0
}
]
]
},
"formating of data": {
"main": [
[
{
"node": "some more formating",
"type": "main",
"index": 0
}
]
]
},
"On form submission": {
"main": [
[
{
"node": "video download with yt-dlp",
"type": "main",
"index": 0
},
{
"node": "get transcript from yt-dlp",
"type": "main",
"index": 0
}
]
]
},
"read srt from disk": {
"main": [
[
{
"node": "Extract from File",
"type": "main",
"index": 0
}
]
]
},
"find height & width": {
"main": [
[
{
"node": "calculate relative subtitle size",
"type": "main",
"index": 0
}
]
]
},
"some more formating": {
"main": [
[
{
"node": "viral clips identification",
"type": "main",
"index": 0
}
]
]
},
"Read clips from disk": {
"main": [
[
{
"node": "extract clip file in base64",
"type": "main",
"index": 0
}
]
]
},
"extract all clips paths": {
"main": [
[
{
"node": "Loop Over Items1",
"type": "main",
"index": 0
}
]
]
},
"if operation is subtitles": {
"main": [
[
{
"node": "find height & width",
"type": "main",
"index": 0
}
],
[
{
"node": "Execute operation on the clip",
"type": "main",
"index": 0
}
]
]
},
"get transcript from yt-dlp": {
"main": [
[
{
"node": "extract filepath",
"type": "main",
"index": 0
}
]
]
},
"video download with yt-dlp": {
"main": [
[
{
"node": "get the downloaded video location",
"type": "main",
"index": 0
}
]
]
},
"viral clips identification": {
"main": [
[
{
"node": "filter out top clips according to score",
"type": "main",
"index": 0
}
]
]
},
"extract clip file in base64": {
"main": [
[
{
"node": "convert base64 to actual binary file",
"type": "main",
"index": 0
}
]
]
},
"Execute operation on the clip": {
"main": [
[
{
"node": "Wait (according to how powerful your system is and how much ram you have)",
"type": "main",
"index": 0
}
]
]
},
"Analyze the actual whole video": {
"main": [
[
{
"node": "extract all actionable operations",
"type": "main",
"index": 0
}
]
]
},
"seperate actionable data items": {
"main": [
[
{
"node": "Loop Over Items",
"type": "main",
"index": 0
}
]
]
},
"calculate relative subtitle size": {
"main": [
[
{
"node": "burn subtitles",
"type": "main",
"index": 0
}
]
]
},
"extract all actionable operations": {
"main": [
[
{
"node": "Filterout the not required operations",
"type": "main",
"index": 0
}
]
]
},
"get the downloaded video location": {
"main": [
[
{
"node": "wait for both branches to complete and merge",
"type": "main",
"index": 0
}
]
]
},
"When clicking \u2018Execute workflow\u2019": {
"main": [
[
{
"node": "video download with yt-dlp",
"type": "main",
"index": 0
},
{
"node": "get transcript from yt-dlp",
"type": "main",
"index": 0
}
]
]
},
"convert base64 to actual binary file": {
"main": [
[
{
"node": "Analyze the actual whole video",
"type": "main",
"index": 0
}
]
]
},
"Filterout the not required operations": {
"main": [
[
{
"node": "Aggregate",
"type": "main",
"index": 0
}
]
]
},
"filter out top clips according to score": {
"main": [
[
{
"node": "wait for both branches to complete and merge",
"type": "main",
"index": 1
}
]
]
},
"wait for both branches to complete and merge": {
"main": [
[
{
"node": "seperate actionable data items",
"type": "main",
"index": 0
}
]
]
},
"simple clipping (still in orignal aspect ratio)": {
"main": [
[
{
"node": "Loop Over Items",
"type": "main",
"index": 0
}
]
]
},
"Wait (according to how powerful your system is and how much ram you have)": {
"main": [
[
{
"node": "Loop Over Items2",
"type": "main",
"index": 0
}
]
]
}
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
gmailOAuth2googlePalmApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow transforms hours of manual video editing into an automated AI-powered pipeline. Perfect for anyone looking to repurpose long-form content into viral short-form clips.
Source: https://n8n.io/workflows/11584/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
🤖🧑💻 AI Agent for Top n8n Creators Leaderboard Reporting. Uses httpRequest, lmChatOpenAi, executeWorkflowTrigger, toolWorkflow. Event-driven trigger; 49 nodes.
🤖🧑💻 AI Agent for Top n8n Creators Leaderboard Reporting. Uses httpRequest, lmChatOpenAi, executeWorkflowTrigger, toolWorkflow. Event-driven trigger; 49 nodes.
This n8n workflow is designed to automate the aggregation, processing, and reporting of community statistics related to n8n creators and workflows. Its primary purpose is to generate insightful report
This workflow is perfect for: Agile development teams and project managers who need to quickly set up Jira projects Product managers who want to convert feature ideas into structured user stories and
This workflow automates business intelligence. Submit one URL, and it scrapes the website, uses AI to perform a comprehensive analysis, and generates a professional report in Google Doc and PDF format