This workflow follows the Chainllm → Google Drive 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 →
{
"name": "Automated Tiktok Videos",
"nodes": [
{
"parameters": {
"command": "=# --- 1. SETUP & TITLE SANITIZATION ---\n# !!! TESTING FLAG !!!\nTEST_DURATION=\"\" \n\nBASE_DIR=\"/tmp_media/PERSONA/Videos\"\nGAMEPLAY_DIR=\"/tmp_media/Gameplay\"\nRAW_TITLE=\"{{ $('Format Groq').item.json.title }}\"\n\n# Clean the title\nCLEAN_TITLE=$(echo \"$RAW_TITLE\" | sed 's/[^a-zA-Z0-9]/_/g' | tr -s '_')\n\n# Fallback\nif [ -z \"$CLEAN_TITLE\" ]; then\n CLEAN_TITLE=\"PERSONA_$(date +%s)\"\nfi\n\n# Conditional Output Name\nif [ -n \"$TEST_DURATION\" ]; then\n OUTPUT_FILE=\"${BASE_DIR}/${CLEAN_TITLE}_TEST.mp4\"\nelse\n OUTPUT_FILE=\"${BASE_DIR}/${CLEAN_TITLE}.mp4\"\nfi\n\n# --- 2. DEFINE INPUTS ---\n\nif [ -f \"${BASE_DIR}/current_background.mp4\" ]; then\n VIDEO_BG=\"${BASE_DIR}/current_background.mp4\"\nelse\n RAND_BG=$(find \"$GAMEPLAY_DIR\" -type f \\( -name \"*.mp4\" -o -name \"*.webm\" -o -name \"*.mkv\" \\) 2>/dev/null | shuf -n 1)\n if [ -n \"$RAND_BG\" ]; then\n echo \"Found new random background: $RAND_BG\" >&2\n VIDEO_BG=\"$RAND_BG\"\n else\n echo \"No gameplay found. Using static default.\" >&2\n VIDEO_BG=\"${BASE_DIR}/background.webm\"\n fi\nfi\n\n# --- UPDATED: USE SINGLE AUDIO FILE ---\nAUDIO_FINAL=\"${BASE_DIR}/final_audio.mp3\" \n\nIMAGES_LIST=\"${BASE_DIR}/images_list.txt\"\nSUBS_FINAL=\"${BASE_DIR}/final_subs.vtt\"\nVFX_MAP=\"${BASE_DIR}/vfx_map.csv\"\n\necho \"Rendering using Background: $VIDEO_BG\" >&2\necho \"Using Audio: $AUDIO_FINAL\" >&2\n\n# --- 3. BUILD VFX FILTER CHAIN ---\nVFX_CHAIN=\"\"\nif [ -f \"$VFX_MAP\" ]; then\n echo \"Processing VFX Map...\" >&2\n while IFS=, read -r tag start_ms end_ms || [ -n \"$tag\" ]; do\n tag=$(echo \"$tag\" | tr -d '[:space:]')\n start_ms=$(echo \"$start_ms\" | tr -d '[:space:]')\n end_ms=$(echo \"$end_ms\" | tr -d '[:space:]')\n\n START_SEC=$(awk \"BEGIN {print $start_ms/1000}\")\n END_SEC=$(awk \"BEGIN {print $end_ms/1000}\")\n\n case \"$tag\" in\n \"RED\") FILTER=\"eq=gamma_r=2:gamma_g=0.6:gamma_b=0.6:saturation=1.3:enable='between(t,$START_SEC,$END_SEC)'\" ;;\n \"GLITCH\") FILTER=\"noise=alls=100:allf=t+u:enable='between(t,$START_SEC,$END_SEC)',rgbashift=rh=10:bv=10:enable='between(t,$START_SEC,$END_SEC)'\" ;;\n \"SHAKE\") FILTER=\"rgbashift=rh=-10:bv=10:gh=0:edge=wrap:enable='between(t,$START_SEC,$END_SEC)'\" ;;\n \"ZOOM\") FILTER=\"vignette=enable='between(t,$START_SEC,$END_SEC)'\" ;;\n *) FILTER=\"\" ;;\n esac\n\n if [ -n \"$FILTER\" ]; then\n if [ -z \"$VFX_CHAIN\" ]; then VFX_CHAIN=\"$FILTER\"; else VFX_CHAIN=\"$VFX_CHAIN, $FILTER\"; fi\n fi\n done < \"$VFX_MAP\"\nelse\n echo \"No VFX Map found. Skipping effects.\" >&2\nfi\n\nif [ -z \"$VFX_CHAIN\" ]; then VFX_CHAIN=\"null\"; fi\n\n# --- 4. SMART ANTI-FLAG LOGIC ---\nBG_DURATION=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 \"$VIDEO_BG\" | awk '{print int($1)}')\nMAX_START=$((BG_DURATION - 90))\n\nif [ \"$MAX_START\" -gt 0 ]; then RANDOM_START=$(shuf -i 0-\"$MAX_START\" -n 1); else RANDOM_START=0; fi\n\nDO_FLIP=$(shuf -i 0-1 -n 1)\nif [ \"$DO_FLIP\" -eq 1 ]; then FLIP_FILTER=\", hflip\"; else FLIP_FILTER=\"\"; fi\n\nrm -f \"$OUTPUT_FILE\"\n\n# --- 5. RENDER VIDEO (OPTIMIZED) ---\n\nif [ -n \"$TEST_DURATION\" ]; then\n TIME_LIMIT=\"-t $TEST_DURATION\"\n echo \"!!! TEST MODE ENABLED !!!\" >&2\nelse\n TIME_LIMIT=\"\"\nfi\n\n# CHANGELOG:\n# 1. Replaced '-f concat ...' with simple '-i \"$AUDIO_FINAL\"'\n# 2. Kept '-r 30' and '-threads 3' for VPS optimization\n\nffmpeg -y -hide_banner -loglevel error \\\n-ss \"$RANDOM_START\" \\\n-stream_loop -1 -i \"$VIDEO_BG\" \\\n-i \"$AUDIO_FINAL\" \\\n-f concat -safe 0 -i \"$IMAGES_LIST\" \\\n-filter_complex \"\n [0:v]scale=-2:1920:flags=fast_bilinear,crop=1080:1920${FLIP_FILTER}[bg]; \\\n [2:v]scale=2000:-2:flags=fast_bilinear[avatar]; \\\n [bg][avatar]overlay=x='(W-w)/2-150':y=H-h[v_raw]; \\\n [v_raw]${VFX_CHAIN}[v_vfx]; \\\n [v_vfx]subtitles='$SUBS_FINAL':force_style='FontName=Komika Axis,FontSize=20,Alignment=2,MarginV=65'[v_out]\n\" \\\n-map \"[v_out]\" -map 1:a \\\n-c:v libx264 -preset ultrafast -tune fastdecode -crf 28 \\\n-r 30 \\\n-threads 3 \\\n-pix_fmt yuv420p \\\n-c:a aac -b:a 128k \\\n-shortest \\\n$TIME_LIMIT \\\n\"$OUTPUT_FILE\"\n\n# --- 6. OUTPUT ---\necho \"$OUTPUT_FILE\""
},
"type": "n8n-nodes-base.executeCommand",
"typeVersion": 1,
"position": [
2080,
800
],
"id": "332bfc8a-5450-4ce1-9527-488d92be4ac9",
"name": "Render Video"
},
{
"parameters": {
"command": "=# 1. SETUP PATHS\nBASE_DIR=\"/tmp_media/PERSONA/Videos\"\nSUBS_FILE=\"${BASE_DIR}/final_subs.vtt\"\n\n# 2. WRITE THE SPLITTER SCRIPT\ncat <<EOF > \"${BASE_DIR}/split_vtt.py\"\nimport re\nimport math\nimport sys\nimport os\n\n# --- CONFIGURATION ---\nFILENAME = \"${SUBS_FILE}\"\nWORDS_PER_BLOCK = 2 \nBUFFER_MS = 0 # Gap between subs to prevent flicker\nTIME_OFFSET_MS = -650 # <--- ADJUST THIS: Negative = Earlier, Positive = Later\n\ndef parse_time(t):\n t = t.replace(',', '.')\n parts = t.strip().split(':')\n if len(parts) == 2:\n h, m, s_ms = 0, int(parts[0]), parts[1]\n elif len(parts) == 3:\n h, m, s_ms = int(parts[0]), int(parts[1]), parts[2]\n else: return 0\n\n if '.' in s_ms: \n s, ms = s_ms.split('.')\n if len(ms) > 3: ms = ms[:3]\n else: \n s, ms = s_ms, 0\n return h * 3600000 + m * 60000 + int(s) * 1000 + int(ms)\n\ndef format_time(ms):\n h, r = divmod(ms, 3600000)\n m, r = divmod(r, 60000)\n s, ms = divmod(r, 1000)\n return f\"{h:02}:{m:02}:{s:02}.{ms:03}\"\n\ndef clean_text(text):\n text = text.upper()\n text = re.sub(r'[^\\w\\s\\']', '', text)\n return text\n\ntry:\n with open(FILENAME, 'r') as f:\n lines = f.readlines()\nexcept FileNotFoundError:\n print(f\"Error: File {FILENAME} not found.\")\n sys.exit(1)\n\noutput = [\"WEBVTT\\n\\n\"]\ni = 0\n\nwhile i < len(lines):\n line = lines[i].strip()\n \n if '-->' in line:\n try:\n times = line.split(' --> ')\n \n # PARSE RAW TIMES\n raw_start = parse_time(times[0])\n raw_end = parse_time(times[1])\n \n # APPLY OFFSET\n start_ms = raw_start + TIME_OFFSET_MS\n end_ms = raw_end + TIME_OFFSET_MS\n \n # Safety Checks\n if start_ms < 0: start_ms = 0\n if end_ms <= start_ms: end_ms = start_ms + 100\n \n duration = end_ms - start_ms\n \n text_line_index = i + 1\n if text_line_index < len(lines):\n raw_text = lines[text_line_index].strip()\n clean_line = clean_text(raw_text)\n words = clean_line.split()\n else:\n words = []\n \n if not words: \n i+=1; continue\n\n # SPLIT LOGIC\n chunk_size = WORDS_PER_BLOCK\n chunks = [words[j:j + chunk_size] for j in range(0, len(words), chunk_size)]\n \n if len(chunks) == 0:\n time_per_chunk = 0\n else:\n time_per_chunk = duration / len(chunks)\n \n curr = start_ms\n for chunk in chunks:\n math_end = curr + time_per_chunk\n visual_end = math_end - BUFFER_MS\n \n if visual_end <= curr: visual_end = math_end - 1\n \n output.append(f\"{format_time(int(curr))} --> {format_time(int(visual_end))}\\n\")\n output.append(\" \".join(chunk) + \"\\n\\n\")\n \n curr = math_end\n \n i += 2\n except Exception as e:\n print(f\"Error parsing line {i}: {e}\")\n i += 1\n else:\n if line and not line.isdigit() and \"WEBVTT\" not in line:\n pass\n i += 1\n\nwith open(FILENAME, 'w') as f:\n f.writelines(output)\n\nprint(f\"Successfully split subtitles in: {FILENAME}\")\nEOF\n\n# 3. RUN THE SCRIPT\npython3 \"${BASE_DIR}/split_vtt.py\"\n\n# 4. FIX PERMISSIONS\nchmod 666 \"${SUBS_FILE}\""
},
"type": "n8n-nodes-base.executeCommand",
"typeVersion": 1,
"position": [
1856,
800
],
"id": "5652e421-c4f1-4406-bfd3-951957c6d346",
"name": "Split Caption"
},
{
"parameters": {
"jsCode": "// --- HELPER FUNCTIONS ---\nfunction unescapeHTML(str) {\n if (!str) return \"\";\n return str\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/"/g, '\"')\n .replace(/&/g, \"&\")\n .replace(/'/g, \"'\")\n .replace(/ /g, \" \");\n}\n\nfunction removeTags(str) {\n if (!str) return \"\";\n return str.replace(/<[^>]*>?/gm, '');\n}\n\n// --- MAIN LOGIC ---\nconst allEntries = [];\nconst subredditItems = $input.all(); // Get ALL items, not just the first one\n\n// Loop through every Subreddit in the list\nfor (const item of subredditItems) {\n const xmlData = item.json.data;\n\n // Safety check: if the RSS feed failed or is empty, skip it\n if (!xmlData || typeof xmlData !== 'string') continue;\n\n const entryRegex = /<entry>([\\s\\S]*?)<\\/entry>/g;\n let match;\n\n while ((match = entryRegex.exec(xmlData)) !== null) {\n const entryBlock = match[1];\n\n const titleMatch = entryBlock.match(/<title>(.*?)<\\/title>/);\n const title = titleMatch ? titleMatch[1] : \"Unknown Title\";\n\n const authorMatch = entryBlock.match(/<name>(.*?)<\\/name>/);\n const author = authorMatch ? authorMatch[1] : \"Unknown Author\";\n\n const linkMatch = entryBlock.match(/<link href=\"(.*?)\"/);\n const link = linkMatch ? linkMatch[1] : \"\";\n\n const contentMatch = entryBlock.match(/<content type=\"html\">([\\s\\S]*?)<\\/content>/);\n let rawContent = contentMatch ? contentMatch[1] : \"\";\n \n let cleanStory = unescapeHTML(rawContent);\n cleanStory = unescapeHTML(cleanStory);\n cleanStory = cleanStory.split(\"submitted by\")[0];\n cleanStory = removeTags(cleanStory).trim();\n\n // FILTER: Keep stories longer than 500 chars\n if (cleanStory.length > 500) {\n allEntries.push({\n json: {\n title: title,\n author: author,\n link: link,\n story_text: cleanStory\n }\n });\n }\n }\n}\n\nreturn allEntries;"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-3232,
672
],
"id": "93dce3a7-8f75-45d7-8939-b20227ae460e",
"name": "Format Story"
},
{
"parameters": {
"url": "={{ $json.url }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "User-Agent",
"value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
-3456,
672
],
"id": "07188ffc-d4ab-4c44-b912-23a9ad2e909e",
"name": "Reddit RSS",
"alwaysOutputData": true
},
{
"parameters": {
"jsCode": "// 1. Get the output from Groq (Basic LLM Chain)\nconst groqResponse = $input.first().json;\n\n// 2. Extract the script text\nlet cleanScript = groqResponse.text;\n\n// 3. CLEANING & FAIL-SAFE\nif (cleanScript) {\n // --- FAIL SAFE: REMOVE AI HEADERS ---\n \n // Remove Markdown Bold (**text**) -> text\n cleanScript = cleanScript.replace(/\\*\\*(.*?)\\*\\*/g, '$1');\n\n // Remove lines like \"PART 1: THE HOOK\" or \"STEP 1\"\n cleanScript = cleanScript.replace(/^(###\\s*)?(PART|STEP)\\s+\\d+:?.*$/gim, \"\");\n\n // Remove lines like \"THE HOOK:\" or \"THE STORY:\"\n cleanScript = cleanScript.replace(/^(###\\s*)?(THE\\s+HOOK|THE\\s+STORY):?.*$/gim, \"\");\n\n // --- STANDARD CLEANING ---\n \n // Replace newlines (\\n) with a space so words don't stick together\n cleanScript = cleanScript.replace(/\\n/g, \" \");\n \n // Remove double spaces created by the deletion of headers\n cleanScript = cleanScript.replace(/\\s+/g, \" \").trim();\n}\n\n// 4. Retrieve the Story Title & TRUNCATE IT\nlet title = \"Horror_Story\"; // Fallback\ntry {\n // We are grabbing the title from the node that selected the story\n title = $(\"Get Selected Story\").first().json.winner.title;\n\n // --- CRITICAL FIX: TRIM TITLE LENGTH ---\n // If title exists and is longer than 50 characters, cut it.\n if (title && title.length > 50) {\n title = title.substring(0, 50);\n }\n\n} catch (e) {\n console.log(\"Warning: Could not find title. Check the node name in the code.\");\n}\n\n// 5. Output the Clean JSON\nreturn {\n json: {\n title: title,\n script: cleanScript\n }\n};"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-288,
752
],
"id": "1e06399a-7576-4553-8283-93d85bbbf896",
"name": "Format Groq"
},
{
"parameters": {
"model": "llama-3.3-70b-versatile",
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatGroq",
"typeVersion": 1,
"position": [
-784,
848
],
"id": "3b4639fc-7744-4808-95f6-3f058623fa82",
"name": "Groq Chat Model",
"alwaysOutputData": false,
"credentials": {
"groqApi": {
"name": "<your credential>"
}
},
"onError": "continueRegularOutput"
},
{
"parameters": {
"promptType": "define",
"text": "=You are [INSERT PERSONA NAME], a [INSERT ROLE] for a [INSERT NICHE] channel.\n**THE GIMMICK:** [DESCRIBE YOUR NARRATOR'S PERSONALITY e.g., Sarcastic Robot, Hyper-Energetic Gamer, Calm Historian].\n\n**YOUR GOAL:** Rewrite the provided content into a 60-90 second script in [INSERT LANGUAGE].\n\n### STEP 1: SELECT THE HOOK (Internal Logic)\nAnalyze the story's tone and choose ONE hook (Do not output the logic, just use the line):\n- OPTION 1: \"[INSERT HOOK LINE 1]\"\n- OPTION 2: \"[INSERT HOOK LINE 2]\"\n- OPTION 3: \"[INSERT HOOK LINE 3]\"\n\n### STEP 2: EDITING & PACING RULES (CRITICAL)\n- **PACING:** [INSERT PACING INSTRUCTIONS e.g., Fast, Slow, Chaotic].\n- **TAG FREQUENCY:** Insert an Emotion, SFX, or VFX tag every [X] words.\n- **TAG LIST (Must match your filenames in media/SFX/):**\n - **Audio:** [SFX:NAME_1], [SFX:NAME_2], [SFX:NAME_3]\n - **Visual:** [VFX:ZOOM], [VFX:SHAKE], [VFX:GLITCH], [VFX:RED]\n\n### STEP 3: VOCABULARY & STYLE\n- \"[Standard Term]\" -> \"[Your Persona's Slang/Term]\"\n- \"[Standard Term]\" -> \"[Your Persona's Slang/Term]\"\n- \"[Standard Term]\" -> \"[Your Persona's Slang/Term]\"\n\n### STEP 4: PERSPECTIVE\n- Narrate in [FIRST/THIRD] person.\n- Refer to the protagonist as: \"[INSERT TERMS e.g., The Hero, The Victim, I]\".\n\n### CRITICAL OUTPUT RULES:\n1. **NO INTROS.** Start immediately with the first tag.\n2. **NO MARKDOWN.** Do not use bold (**text**).\n3. **FORMAT:** [TAG] Text text text [TAG] Text text [TAG] Text.\n\nInput Story Title: {{ $('Get Selected Story').item.json.winner.title }}\nInput Story Text: {{ $('Get Selected Story').item.json.winner.text }}\n\nOutput ONLY the raw script text.",
"batching": {}
},
"type": "@n8n/n8n-nodes-langchain.chainLlm",
"typeVersion": 1.8,
"position": [
-864,
624
],
"id": "ba29f9a2-1f34-4949-95ac-0f30cdbfd79d",
"name": "Generate Script Groq",
"alwaysOutputData": true,
"onError": "continueRegularOutput"
},
{
"parameters": {
"model": "llama-3.1-8b-instant",
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatGroq",
"typeVersion": 1,
"position": [
-2256,
768
],
"id": "b08590ca-9316-40fc-a2a4-187a4149d510",
"name": "Groq Chat Model1",
"alwaysOutputData": false,
"credentials": {
"groqApi": {
"name": "<your credential>"
}
},
"onError": "continueRegularOutput"
},
{
"parameters": {
"promptType": "define",
"text": "=You are a Content Curator for [INSERT YOUR NICHE/TOPIC].\nYour goal is to separate high-quality content from spam/irrelevant posts.\n\nAnalyze the list of items below (each has an ID).\nClassify them based on these STRICT rules:\n\n1. **THE WINNER (Select 1):** The single best story/post based on these criteria:\n - [INSERT CRITERIA 1 - e.g., High engagement potential]\n - [INSERT CRITERIA 2 - e.g., Clear narrative structure]\n - [INSERT CRITERIA 3 - e.g., Avoids walls of text]\n\n2. **THE TRASH (Blacklist):** Content that must be blocked/removed forever.\n - [INSERT EXCLUSION 1 - e.g., Off-topic posts]\n - [INSERT EXCLUSION 2 - e.g., Politics/Spam]\n - [INSERT EXCLUSION 3 - e.g., Low word count]\n\n3. **THE RESERVES (Ignore):** Decent content that didn't win today. Do NOT list these in \"trash\". We keep them for future cycles.\n\n**CRITICAL OUTPUT RULES:**\n- Return ONLY raw JSON. No Markdown (```json).\n- Format:\n{\n \"selected_index\": 3, // The ID number of the winner (Integer) or null\n \"trash_indices\": [0, 4, 7] // List of ID numbers to blacklist (Integers)\n}\n\n**CONTENT LIST:**\n{{ \n$input.all().map((item, index) => \n `[ID: ${index}]\n Title: ${item.json.title}\n Preview: ${item.json.story_text.substring(0, 350)}...`\n).join('\\n\\n----------------\\n\\n') \n}}",
"batching": {}
},
"type": "@n8n/n8n-nodes-langchain.chainLlm",
"typeVersion": 1.8,
"position": [
-2336,
544
],
"id": "32d399eb-57e3-421c-b564-8bbee70e1d13",
"name": "Select Story Groq",
"executeOnce": true,
"onError": "continueRegularOutput"
},
{
"parameters": {
"command": "=# --- 1. SETUP TITLES & DIRECTORIES ---\nRAW_TITLE=\"{{ $('Format Groq').item.json.title }}\"\nCLEAN_TITLE=$(echo \"$RAW_TITLE\" | sed 's/[^a-zA-Z0-9]/_/g' | tr -s '_')\n\n# Define paths\nBASE_DIR=\"/tmp_media/PERSONA/Videos\"\nPERSONA_DIR=\"/tmp_media/PERSONA/Persona\"\nSFX_DIR=\"/tmp_media/SFX\"\nMUSIC_DIR=\"/tmp_media/Music\"\nGAMEPLAY_DIR=\"/tmp_media/Gameplay\"\n\nmkdir -p \"$BASE_DIR\"\nmkdir -p \"$PERSONA_DIR\"\nmkdir -p \"$SFX_DIR\"\nmkdir -p \"$MUSIC_DIR\"\nmkdir -p \"$GAMEPLAY_DIR\"\n\n# --- RANDOM BACKGROUND SELECTION ---\n# Find all video files (mp4, webm, mkv) in the Gameplay folder\n# shuf -n 1 picks one at random\nSELECTED_BG=$(find \"$GAMEPLAY_DIR\" -type f \\( -name \"*.mp4\" -o -name \"*.webm\" -o -name \"*.mkv\" \\) 2>/dev/null | shuf -n 1)\n\n# Fallback if no gameplay found (Safety Check)\nif [ -z \"$SELECTED_BG\" ]; then\n echo \"WARNING: No gameplay found in $GAMEPLAY_DIR. Using default background.\"\n # Ensure a default exists or copy one manually if needed\n if [ -f \"${BASE_DIR}/background.webm\" ]; then\n SELECTED_BG=\"${BASE_DIR}/background.webm\"\n else\n echo \"ERROR: No default background found at ${BASE_DIR}/background.webm\"\n # Create a dummy file just to prevent immediate crash, though render will fail later\n touch \"${BASE_DIR}/background.webm\"\n SELECTED_BG=\"${BASE_DIR}/background.webm\"\n fi\nfi\n\n# Copy selection to a standard path so the Render Node always knows where to look\ncp \"$SELECTED_BG\" \"${BASE_DIR}/current_background.mp4\"\n\n# Save script text\ncat <<EOF > \"${BASE_DIR}/script.txt\"\n{{ $('Format Groq').item.json.script }}\nEOF\n\necho \"Setup Complete. Selected Background: $SELECTED_BG\""
},
"type": "n8n-nodes-base.executeCommand",
"typeVersion": 1,
"position": [
960,
800
],
"id": "4f0f5026-7e3e-4b66-ad19-e4a80cfd7c8a",
"name": "Setup Directories & Variables"
},
{
"parameters": {
"command": "=# --- CONFIGURATION ---\nBASE_DIR=\"/tmp_media/PERSONA/Videos\"\nPERSONA_DIR=\"/tmp_media/PERSONA/Persona\"\nSFX_DIR=\"/tmp_media/SFX\"\nMUSIC_DIR=\"/tmp_media/Music\"\n\n# WRITE THE OPTIMIZED PYTHON SCRIPT\ncat <<EOF > \"${BASE_DIR}/generate_dynamic_video.py\"\nimport re\nimport subprocess\nimport os\nimport sys\nimport random\nimport traceback\nimport concurrent.futures\nimport json\nimport urllib.request\nimport base64\n\n# --- PYTHON CONFIG ---\nPERSONA_DIR = \"${PERSONA_DIR}\"\nSFX_DIR = \"${SFX_DIR}\"\nMUSIC_DIR = \"${MUSIC_DIR}\"\nOUTPUT_DIR = \"${BASE_DIR}\"\n\n# --- KOKORO SETTINGS ---\nKOKORO_URL = \"http://kokoro-tts:8880/v1/audio/speech\"\nKOKORO_VOICE = \"pm_santa(0.8)+am_onyx(0.1)\"\nKOKORO_SPEED = 1.3 # Speed BEFORE pitch shift (make it faster if pitch shift slows it down too much)\nPITCH_SHIFT = \"0.9\" # 1.0 = Normal, 0.9 = Deep, 0.8 = Demon (affects speed too)\n\nSFX_MAX_DURATION = 1.5\nSFX_VOLUME = 0.18\n\n# --- HELPER FUNCTIONS ---\ndef get_duration_ms(filepath):\n try:\n res = subprocess.run([\"ffprobe\", \"-v\", \"error\", \"-show_entries\", \"format=duration\", \"-of\", \"default=noprint_wrappers=1:nokey=1\", filepath], capture_output=True, text=True)\n return int(float(res.stdout.strip()) * 1000)\n except:\n return 500\n\ndef fmt_vtt(ms):\n h, r = divmod(ms, 3600000)\n m, r = divmod(r, 60000)\n s, ms = divmod(r, 1000)\n return f\"{h:02}:{m:02}:{s:02}.{ms:03}\"\n\ndef process_segment(job):\n \"\"\"\n Worker function to generate audio files in parallel.\n \"\"\"\n i = job['id']\n tag = job['tag']\n text = job['text']\n segment_id = f\"seg_{i}\"\n \n results = [] \n\n is_vfx = tag.startswith(\"VFX:\")\n is_sfx = tag.startswith(\"SFX:\")\n \n # 1. HANDLE SFX GENERATION\n if is_sfx:\n sfx_name = tag.split(\":\")[1].strip().lower()\n sfx_file_wav = f\"{OUTPUT_DIR}/{segment_id}_sfx.wav\"\n source_sfx = f\"{SFX_DIR}/{sfx_name}.mp3\"\n \n # Generate/Convert SFX\n if os.path.exists(source_sfx):\n filter_chain = (f\"volume={SFX_VOLUME},areverse,silenceremove=start_periods=1:start_duration=0:start_threshold=-50dB,areverse,afade=t=out:st={SFX_MAX_DURATION-0.2}:d=0.2\")\n subprocess.run([\"ffmpeg\", \"-y\", \"-v\", \"error\", \"-nostats\", \"-i\", source_sfx, \"-af\", filter_chain, \"-t\", str(SFX_MAX_DURATION), \"-ar\", \"44100\", \"-ac\", \"1\", \"-c:a\", \"pcm_s16le\", sfx_file_wav], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n else:\n subprocess.run([\"ffmpeg\", \"-f\", \"lavfi\", \"-i\", \"anullsrc=r=44100:cl=mono\", \"-t\", \"0.2\", \"-c:a\", \"pcm_s16le\", sfx_file_wav, \"-y\", \"-v\", \"error\", \"-nostats\"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n \n results.append({\n 'type': 'audio',\n 'file': sfx_file_wav,\n 'duration': get_duration_ms(sfx_file_wav),\n 'img': job['emotion'] \n })\n\n # 2. HANDLE TTS GENERATION (KOKORO LOCAL)\n has_letters = bool(re.search(r'[a-zA-Z0-9]', text))\n if has_letters:\n temp_mp3 = f\"{OUTPUT_DIR}/{segment_id}_temp.mp3\"\n tts_file_wav = f\"{OUTPUT_DIR}/{segment_id}_tts.wav\"\n\n try:\n # Prepare JSON Payload\n data = {\n \"model\": \"kokoro\",\n \"input\": text,\n \"voice\": KOKORO_VOICE,\n \"speed\": KOKORO_SPEED,\n \"response_format\": \"mp3\"\n }\n \n # Send Request to Local Container\n req = urllib.request.Request(\n KOKORO_URL, \n json.dumps(data).encode(\"utf-8\"), \n {\"Content-Type\": \"application/json\"}\n )\n \n # Write Response to File\n with urllib.request.urlopen(req) as response:\n with open(temp_mp3, \"wb\") as f:\n f.write(response.read())\n\n # Convert to WAV with PITCH SHIFT\n # asetrate changes pitch (and speed), atempo fixes the speed back\n filter_complex = f\"asetrate=24000*{PITCH_SHIFT},atempo=1/{PITCH_SHIFT},aresample=44100\"\n \n subprocess.run([\n \"ffmpeg\", \"-y\", \"-v\", \"error\", \"-nostats\", \n \"-i\", temp_mp3, \n \"-af\", filter_complex, \n \"-ar\", \"44100\", \"-ac\", \"1\", \"-c:a\", \"pcm_s16le\", \n tts_file_wav\n ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n \n if os.path.exists(temp_mp3): os.remove(temp_mp3)\n \n # GENERATE MANUAL VTT\n duration_ms = get_duration_ms(tts_file_wav)\n vtt_start = \"00:00:00.000\"\n vtt_end = fmt_vtt(duration_ms)\n fake_vtt_content = [f\"{vtt_start} --> {vtt_end}\\n\", f\"{text}\\n\"]\n\n results.append({\n 'type': 'tts',\n 'file': tts_file_wav,\n 'duration': duration_ms,\n 'img': job['emotion'],\n 'vtt_lines': fake_vtt_content,\n 'vfx_to_apply': job.get('pending_vfx')\n })\n \n except Exception as e:\n print(f\"Error in TTS {i}: {e}\")\n if \"Connection refused\" in str(e):\n print(\"CRITICAL: Could not connect to Kokoro. Is the docker container running?\")\n\n return {'id': i, 'items': results}\n\n\n# --- MAIN EXECUTION ---\ntry:\n script_path = f\"{OUTPUT_DIR}/script.txt\"\n if not os.path.exists(script_path):\n print(f\"ERROR: Script file not found at {script_path}\")\n sys.exit(1)\n\n with open(script_path, \"r\") as f:\n full_text = f.read()\n\n if not full_text.strip().startswith(\"[\"):\n full_text = \"[NEUTRAL] \" + full_text\n \n # --- MUSIC SELECTION ---\n if os.path.exists(MUSIC_DIR):\n music_files = [f for f in os.listdir(MUSIC_DIR) if f.lower().endswith('.mp3')]\n if music_files:\n bg_music_path = os.path.join(MUSIC_DIR, random.choice(music_files))\n try:\n res = subprocess.run([\"ffprobe\", \"-v\", \"error\", \"-show_entries\", \"format=duration\", \"-of\", \"default=noprint_wrappers=1:nokey=1\", bg_music_path], capture_output=True, text=True)\n dur = float(res.stdout.strip())\n start = random.uniform(0, max(0, dur - 90))\n with open(f\"{OUTPUT_DIR}/music_path.txt\", \"w\") as f: f.write(bg_music_path)\n with open(f\"{OUTPUT_DIR}/music_start.txt\", \"w\") as f: f.write(str(start))\n except: pass\n\n # --- JOB PREPARATION ---\n segments = re.split(r'\\[(NEUTRAL|SCARED|EVIL|SUSPICIOUS|LAUGHING|SFX:\\s*[A-Z_]+|VFX:\\s*[A-Z_]+)\\]', full_text, flags=re.IGNORECASE)\n \n jobs = []\n last_valid_emotion = \"neutral\"\n pending_vfx = None\n\n for i in range(1, len(segments), 2):\n tag = segments[i].upper().strip()\n text = segments[i+1].strip()\n \n is_vfx = tag.startswith(\"VFX:\")\n is_sfx = tag.startswith(\"SFX:\")\n is_emotion = not (is_vfx or is_sfx)\n\n if is_emotion: last_valid_emotion = tag.lower()\n if is_vfx: pending_vfx = tag.split(\":\")[1].strip()\n\n jobs.append({\n 'id': i,\n 'tag': tag,\n 'text': text,\n 'emotion': last_valid_emotion,\n 'pending_vfx': pending_vfx \n })\n \n if pending_vfx and bool(re.search(r'[a-zA-Z0-9]', text)):\n pending_vfx = None\n\n # --- PARALLEL EXECUTION ---\n print(f\"Starting parallel generation with {len(jobs)} segments...\")\n \n processed_segments = []\n with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:\n futures = {executor.submit(process_segment, job): job for job in jobs}\n for future in concurrent.futures.as_completed(futures):\n try:\n res = future.result()\n processed_segments.append(res)\n except Exception as e:\n print(f\"Worker failed: {e}\")\n traceback.print_exc()\n\n processed_segments.sort(key=lambda x: x['id'])\n\n # --- TIMELINE ASSEMBLY ---\n final_audio_files = []\n concat_image_lines = []\n combined_vtt_lines = [\"WEBVTT\\n\\n\"]\n vfx_map_lines = [] \n current_time_ms = 0\n \n for seg in processed_segments:\n for item in seg['items']:\n final_audio_files.append(item['file'])\n \n dur_sec = item['duration'] / 1000.0\n img = f\"{PERSONA_DIR}/{item['img']}.png\"\n if not os.path.exists(img): img = f\"{PERSONA_DIR}/neutral.png\"\n concat_image_lines.append(f\"file '{img}'\")\n concat_image_lines.append(f\"duration {dur_sec}\")\n \n if item.get('vfx_to_apply'):\n vfx_map_lines.append(f\"{item['vfx_to_apply']},{current_time_ms},{current_time_ms + item['duration']}\\n\")\n\n if item.get('vtt_lines'):\n for line in item['vtt_lines']:\n if \"-->\" in line:\n start_str, end_str = line.strip().split(\" --> \")\n def parse_vtt(t):\n parts = t.replace(',',('.')).split(':')\n s_parts = parts[-1].split('.')\n h,m = (int(parts[0]), int(parts[1])) if len(parts)==3 else (0, int(parts[0]))\n s,ms = int(s_parts[0]), int(s_parts[1])\n return h*3600000 + m*60000 + s*1000 + ms\n \n new_start = fmt_vtt(parse_vtt(start_str) + current_time_ms)\n new_end = fmt_vtt(parse_vtt(end_str) + current_time_ms)\n combined_vtt_lines.append(f\"{new_start} --> {new_end}\\n\")\n elif line.strip() and \"WEBVTT\" not in line:\n combined_vtt_lines.append(line)\n\n current_time_ms += item['duration']\n\n # --- FINAL WRITES ---\n silence_file_wav = f\"{OUTPUT_DIR}/silence_end.wav\"\n subprocess.run([\"ffmpeg\", \"-f\", \"lavfi\", \"-i\", \"anullsrc=r=44100:cl=mono\", \"-t\", \"2\", \"-c:a\", \"pcm_s16le\", silence_file_wav, \"-y\", \"-v\", \"error\", \"-nostats\"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n final_audio_files.append(silence_file_wav)\n \n if concat_image_lines:\n concat_image_lines.append(concat_image_lines[-2])\n concat_image_lines.append(\"duration 2\")\n\n with open(f\"{OUTPUT_DIR}/audio_list.txt\", \"w\") as f:\n for af in final_audio_files: f.write(f\"file '{af}'\\n\")\n\n with open(f\"{OUTPUT_DIR}/images_list.txt\", \"w\") as f:\n f.write(\"\\n\".join(concat_image_lines))\n\n with open(f\"{OUTPUT_DIR}/final_subs.vtt\", \"w\") as f:\n f.writelines(combined_vtt_lines)\n\n with open(f\"{OUTPUT_DIR}/vfx_map.csv\", \"w\") as f:\n f.writelines(vfx_map_lines)\n \n print(\"Python Generation Complete.\")\n\nexcept Exception as e:\n print(\"PYTHON CRASHED:\")\n traceback.print_exc()\n sys.exit(1)\nEOF\n\n# EXECUTE PYTHON\npython3 \"${BASE_DIR}/generate_dynamic_video.py\""
},
"type": "n8n-nodes-base.executeCommand",
"typeVersion": 1,
"position": [
1184,
800
],
"id": "9220766b-7b8f-4935-937d-26d36e77be2b",
"name": "Generate Resources"
},
{
"parameters": {
"command": "BASE_DIR=\"/tmp_media/PERSONA/Videos\"\nMUSIC_VOLUME=\"1.0\" # Slightly lowered music to make room for voice\nVOICE_VOLUME=\"4.0\" # 3.0 = 300% volume (Boosts the quiet AI voice)\n\n# 1. CONCATENATE VOICE TRACK\nif [ -f \"${BASE_DIR}/audio_list.txt\" ]; then\n ffmpeg -f concat -safe 0 -i \"${BASE_DIR}/audio_list.txt\" \\\n -c:a pcm_s16le \"${BASE_DIR}/voice_track.wav\" \\\n -y -v error -nostats > /dev/null 2>&1\nelse\n echo \"CRITICAL ERROR: audio_list.txt was not generated.\"\n exit 1\nfi\n\n# 2. CALCULATE EXACT DURATION\nVOICE_DURATION=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 \"${BASE_DIR}/voice_track.wav\")\necho \"Voice Duration: $VOICE_DURATION seconds\"\n\n# 3. MIX BACKGROUND MUSIC WITH BOOSTED VOICE\nif [ -f \"${BASE_DIR}/music_path.txt\" ]; then\n BG_MUSIC=$(cat \"${BASE_DIR}/music_path.txt\")\n START_TIME=0\n if [ -f \"${BASE_DIR}/music_start.txt\" ]; then START_TIME=$(cat \"${BASE_DIR}/music_start.txt\"); fi\n \n echo \"Mixing music with Boosted Voice & Ducking...\"\n \n # EXPLANATION OF CHANGES:\n # [1:a]volume=${VOICE_VOLUME} -> We boost the voice IMMEDIATELY.\n # We then split the boosted voice so it drives the sidechain (ducking) harder too.\n \n ffmpeg -ss \"$START_TIME\" -stream_loop -1 -i \"$BG_MUSIC\" -i \"${BASE_DIR}/voice_track.wav\" \\\n -t \"$VOICE_DURATION\" \\\n -filter_complex \"\n [1:a]volume=${VOICE_VOLUME},asplit=2[sc][voice_out];\n [0:a]volume=${MUSIC_VOLUME}[music];\n [music][sc]sidechaincompress=threshold=0.05:ratio=2:attack=50:release=300[ducked_music];\n [ducked_music][voice_out]amix=inputs=2:duration=shortest:dropout_transition=2[out]\n \" \\\n -map \"[out]\" -c:a libmp3lame -q:a 2 \"${BASE_DIR}/final_audio.mp3\" -y -v error -nostats > /dev/null 2>&1\n\nelse\n echo \"No music found. Using boosted voice only.\"\n # We still apply the volume boost even if there is no music\n ffmpeg -i \"${BASE_DIR}/voice_track.wav\" -af \"volume=${VOICE_VOLUME}\" -c:a libmp3lame -q:a 2 \"${BASE_DIR}/final_audio.mp3\" -y -v error -nostats > /dev/null 2>&1\nfi\n\n# Cleanup\nrm \"${BASE_DIR}/voice_track.wav\""
},
"type": "n8n-nodes-base.executeCommand",
"typeVersion": 1,
"position": [
1408,
800
],
"id": "c2030b13-a9f5-45f9-aff9-f187bebee9b9",
"name": "Audio Mixing"
},
{
"parameters": {
"command": "BASE_DIR=\"/tmp_media/PERSONA/Videos\"\n\n# PERMISSIONS\n[ -f \"${BASE_DIR}/final_audio.mp3\" ] && chmod 666 \"${BASE_DIR}/final_audio.mp3\"\n[ -f \"${BASE_DIR}/final_subs.vtt\" ] && chmod 666 \"${BASE_DIR}/final_subs.vtt\"\n[ -f \"${BASE_DIR}/images_list.txt\" ] && chmod 666 \"${BASE_DIR}/images_list.txt\"\n[ -f \"${BASE_DIR}/vfx_map.csv\" ] && chmod 666 \"${BASE_DIR}/vfx_map.csv\"\n\n# CLEANUP\n# Keep final_audio, final_subs, images_list, vfx_map. Delete the rest.\n[ -f \"${BASE_DIR}/audio_list.txt\" ] && rm \"${BASE_DIR}/audio_list.txt\"\n[ -f \"${BASE_DIR}/voice_track.mp3\" ] && rm \"${BASE_DIR}/voice_track.mp3\"\n[ -f \"${BASE_DIR}/music_start.txt\" ] && rm \"${BASE_DIR}/music_start.txt\"\n[ -f \"${BASE_DIR}/music_path.txt\" ] && rm \"${BASE_DIR}/music_path.txt\"\nrm -f \"${BASE_DIR}\"/seg_*\n\necho \"Audio Generation & Cleanup Complete.\""
},
"type": "n8n-nodes-base.executeCommand",
"typeVersion": 1,
"position": [
1632,
800
],
"id": "8850145d-3d19-4a1d-85cb-52b31c47083f",
"name": "Cleanup & Permissions"
},
{
"parameters": {
"jsCode": "// 1. GRAB THE \"USED\" LIST FROM THE SHELL NODE\nlet usedFileContent = \"\";\n\ntry {\n usedFileContent = $('Read Used Stories').first().json.stdout || \"\";\n} catch (error) {\n usedFileContent = \"\";\n}\n\n// 2. CONVERT TO ARRAY\nconst usedTitles = usedFileContent\n .split('\\n')\n .map(line => line.trim().toLowerCase()) \n .filter(line => line.length > 0); \n\n// 3. FILTER AND LIMIT\n// We chain .slice(0, 10) at the very end\nreturn $(\"Format Story\").all()\n .filter(item => {\n const title = item.json.title;\n \n if (!title) return false;\n\n const normalizedTitle = title.trim().toLowerCase();\n \n // Return true if title is NOT in the used list\n return !usedTitles.includes(normalizedTitle);\n })\n .slice(0, 10); // <--- THIS CUTS THE LIST TO 10 ITEMS"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-2784,
672
],
"id": "9c3998ac-08c6-4d79-9418-909fac35f06d",
"name": "Filter Used Stories",
"alwaysOutputData": true
},
{
"parameters": {
"command": "BASE_DIR=\"/tmp_media/PERSONA\"\nTRACKING_FILE=\"${BASE_DIR}/used_stories.txt\"\n\n# 1. Create the file if it doesn't exist (prevents crashes)\nif [ ! -f \"$TRACKING_FILE\" ]; then\n mkdir -p \"$BASE_DIR\"\n touch \"$TRACKING_FILE\"\nfi\n\n# 2. Output the file content\ncat \"$TRACKING_FILE\""
},
"type": "n8n-nodes-base.executeCommand",
"typeVersion": 1,
"position": [
-3008,
672
],
"id": "b7362178-ec15-4934-a122-b3a6d87bde05",
"name": "Read Used Stories"
},
{
"parameters": {
"command": "=BASE_DIR=\"/tmp_media/PERSONA\"\nTRACKING_FILE=\"${BASE_DIR}/used_stories.txt\"\n\n# Get the title from the workflow context\nTITLE=\"{{ $('Get Selected Story').item.json.winner.title }}\"\n\n# Clean formatting and append to file\necho \"$TITLE\" | sed 's/^[ \\t]*//;s/[ \\t]*$//' >> \"$TRACKING_FILE\""
},
"type": "n8n-nodes-base.executeCommand",
"typeVersion": 1,
"position": [
2304,
800
],
"id": "84ed0ee6-bd80-4c0f-b6b1-341be0031017",
"name": "Save Used Title"
},
{
"parameters": {
"fileSelector": "={{ $('Render Video').item.json.stdout }}",
"options": {}
},
"type": "n8n-nodes-base.readWriteFile",
"typeVersion": 1.1,
"position": [
3424,
800
],
"id": "f438b9e6-8e25-4dc9-a613-7f9f2b7c49d2",
"name": "Read Video File"
},
{
"parameters": {
"resource": "folder",
"name": "={{ $today.toFormat('yyyy-MM-dd') }}",
"driveId": {
"__rl": true,
"value": "17drDNnMuYXFJMZv6WrylRIw2HQ8O5mXr",
"mode": "id"
},
"folderId": {
"__rl": true,
"mode": "list",
"value": "root",
"cachedResultName": "/ (Root folder)"
},
"options": {}
},
"type": "n8n-nodes-base.googleDrive",
"typeVersion": 3,
"position": [
2976,
864
],
"id": "5adda463-282b-4494-b1d6-5faf10c9d888",
"name": "Create Daily Folder",
"credentials": {
"googleDriveOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"resource": "fileFolder",
"queryString": "={{ $today.toFormat('yyyy-MM-dd') }}",
"filter": {
"driveId": {
"mode": "list",
"value": "My Drive"
},
"folderId": {
"__rl": true,
"value": "17drDNnMuYXFJMZv6WrylRIw2HQ8O5mXr",
"mode": "id"
},
"whatToSearch": "folders",
"includeTrashed": false
},
"options": {}
},
"type": "n8n-nodes-base.googleDrive",
"typeVersion": 3,
"position": [
2528,
800
],
"id": "37631df7-276a-489a-ad90-dc9617a497ba",
"name": "Google Drive Search",
"alwaysOutputData": true,
"credentials": {
"googleDriveOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "d2c7a9e1-70f6-4150-9b29-1ecb6caa7ec8",
"leftValue": "={{ $json.id }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
2752,
800
],
"id": "f1cf2e09-06de-4eec-9b42-c265d42be1b4",
"name": "If Folder Exists"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "c32b5392-ee91-4685-bf6a-f29ca4bbb467",
"leftValue": "={{ $json.text }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "empty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
-1984,
544
],
"id": "ff74a50c-859b-4095-9444-5d407c110cea",
"name": "If Rate Limited"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "c32b5392-ee91-4685-bf6a-f29ca4bbb467",
"leftValue": "={{ $json.text }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "empty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
-512,
624
],
"id": "aaf8bf5b-cb4a-4807-9a7a-d990dd09da72",
"name": "If Rate Limited1"
},
{
"parameters": {
"jsCode": "const subreddits = [\n // POPULATE THIS\n];\n\n// N8N will run the next nodes once for EACH url in this list\nreturn subreddits.map(url => ({ json: { url } }));"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-3680,
672
],
"id": "024ad218-be44-47bd-b3f5-9dcbeb2d6198",
"name": "Subreddits"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
3200,
800
],
"id": "e822c2a8-52da-49e3-b80b-226bf83acb7d",
"name": "Folder ID",
"alwaysOutputData": false
},
{
"parameters": {
"amount": 30,
"unit": "minutes"
},
"type": "n8n-nodes-base.wait",
"typeVersion": 1.1,
"position": [
-1312,
944
],
"id": "724ea4c3-d530-4fc9-afc2-09b754ac6da5",
"name": "Wait 30min #2"
},
{
"parameters": {
"command": "# Delete the rendered video and temp assets from the specific Videos folder\nrm -f /tmp_media/PERSONA/Videos/*\n\n# OPTIONAL: Verify disk space (prints to logs)\ndf -h /tmp_media"
},
"type": "n8n-nodes-base.executeCommand",
"typeVersion": 1,
"position": [
4320,
800
],
"id": "92e7dbd9-2887-44f4-a138-b1ab9125230a",
"name": "Cleanup"
},
{
"parameters": {
"model": "llama-3.1-8b-instant",
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatGroq",
"typeVersion": 1,
"position": [
16,
976
],
"id": "6fd5ad31-8b3e-4646-b7e8-ca785fd184e9",
"name": "Groq Chat Model2",
"alwaysOutputData": false,
"credentials": {
"groqApi": {
"name": "<your credential>"
}
},
"onError": "continueRegularOutput"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "c32b5392-ee91-4685-bf6a-f29ca4bbb467",
"leftValue": "={{ $json.text }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "empty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
288,
752
],
"id": "15900a96-9ea1-4cc3-9563-1440654756a4",
"name": "If Rate Limited2"
},
{
"parameters": {
"promptType": "define",
"text": "=You are a Viral Content Strategist for the [INSERT NICHE] niche.\nYour goal is to maximize \"Retention\" and \"Click-Through Rate\" for [INSERT TARGET AUDIENCE].\n\nI have a script for a short video (in [INSERT LANGUAGE]).\nYou must generate a Metadata JSON object containing:\n1. TITLE: A high-alert clickbait title (Max 6 words). Use ALL CAPS and 1 Emoji.\n2. DESCRIPTION: A 2-line hook addressing the audience as \"[INSERT AUDIENCE NAME]\", followed by a block of hashtags.\n\nSTORY SCRIPT:\n\"\"\"\n{{ $json.script }}\n\"\"\"\n\nINSTRUCTIONS:\n- **TITLE LOGIC (STRICT - PICK ONE CATEGORY):**\n - If [INSERT THEME 1] -> Use: \"[INSERT CLICKBAIT TITLE 1]\"\n - If [INSERT THEME 2] -> Use: \"[INSERT CLICKBAIT TITLE 2]\"\n - If [INSERT THEME 3] -> Use: \"[INSERT CLICKBAIT TITLE 3]\"\n - If [INSERT THEME 4] -> Use: \"[INSERT CLICKBAIT TITLE 4]\"\n - **FALLBACK (If unclear)** -> Use: \"[INSERT GENERIC TITLE]\"\n\n- **DESCRIPTION RULES:** - Address the audience as \"[INSERT AUDIENCE NAME e.g., Chat, Squad, Chefs]\".\n - Ask a rhetorical question related to the content.\n\n- **HASHTAGS (STRICT LIMIT: 5):**\n - Mandatory: #[INSERT TAG 1] #[INSERT TAG 2]\n - Pick 3 Variable: #[TAG_A], #[TAG_B], #[TAG_C], #[TAG_D], #[TAG_E].\n\n- **OUTPUT:** Respond ONLY with valid, raw JSON. Do NOT use markdown blocks (```json).\n\nEXAMPLE OUTPUT:\n{\n \"title\": \"[YOUR TITLE HERE] \ud83d\ude31\",\n \"description\": \"[Audience Name], look at this insane detail! Would you try this?\\n\\n#tag1 #tag2 #tag3\"\n}",
"batching": {}
},
"type": "@n8n/n8n-nodes-langchain.chainLlm",
"typeVersion": 1.8,
"position": [
-64,
752
],
"id": "dd1d524c-0037-40a2-99d5-eee29fcbc696",
"name": "Generate Title and Description",
"alwaysOutputData": true,
"onError": "continueRegularOutput"
},
{
"parameters": {
"command": "=# --- SAVE METADATA TO FILE ---\n\n# 1. Get Data from Groq Node\nRAW_TITLE=\"{{ $json.title }}\"\nRAW_DESC=\"{{ $json.description }}\"\nBASE_DIR=\"/tmp_media/PERSONA/Videos\"\n\n# 2. Clean Title for Filename (Remove emojis/spaces)\nCLEAN_TITLE=$(echo \"$RAW_TITLE\" | sed 's/[^a-zA-Z0-9]/_/g' | tr -s '_')\n\n# 3. Create the Text File\n# Example: /tmp_media/PERSONA/Videos/THE_ZOMBIE_TOOTHBRUSH.txt\ncat <<EOF > \"${BASE_DIR}/${CLEAN_TITLE}.txt\"\n$RAW_TITLE\n\n$RAW_DESC\nEOF\n\necho \"${BASE_DIR}/${CLEAN_TITLE}.txt\""
},
"type": "n8n-nodes-base.executeCommand",
"typeVersion": 1,
"position": [
736,
800
],
"id": "182e412f-beee-4297-8a8d-aa873ee9cb58",
"name": "Save Metadata"
},
{
"parameters": {
"fileSelector": "={{ $('Save Metadata').item.json.stdout }}",
"options": {}
},
"type": "n8n-nodes-base.readWriteFile",
"typeVersion": 1.1,
"position": [
3872,
800
],
"id": "97d29834-1c6e-456e-9234-bee3881ef4af",
"name": "Read Metadata"
},
{
"parameters": {
"name": "={{ $('Read Video File').item.json.fileName }}",
"driveId": {
"__rl": true,
"value": "={{ $('Folder ID').first().json.id }}",
"mode": "id"
},
"folderId": {
"__rl": true,
"mode": "list",
"value": "root",
"cachedResultName": "/ (Root folder)"
},
"options": {}
},
"type": "n8n-nodes-base.googleDrive",
"typeVersion": 3,
"position": [
3648,
800
],
"id": "4c4f11f8-2a8f-4db2-809e-0b042372e50b",
"name": "Upload Video File",
"credentials": {
"googleDriveOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"name": "={{ $('Format Groq').item.json.title }}_INFO.txt",
"driveId": {
"__rl": true,
"value": "={{ $('Folder ID').first().json.id }}",
"mode": "id"
},
"folderId": {
"__rl": true,
"mode": "list",
"value": "root",
"cachedResultName": "/ (Root folder)"
},
"options": {}
},
"type": "n8n-nodes-base.googleDrive",
"typeVersion": 3,
"position": [
4096,
800
],
"id": "77f8753a-1882-4a52-8f9b-2927bbd3a1d3",
"name": "Upload Metadata",
"credentials": {
"googleDriveOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "// Get the raw text output from the previous Groq node\n// Note: Adjust 'message.content' if your node outputs to 'text' or just 'content'\nconst rawContent = $input.first().json.text\n\n// 1. CLEANUP: Remove Markdown formatting (```json and ```)\nlet cleanContent = rawContent.replace(/```json/g, \"\").replace(/```/g, \"\").trim();\n\n// 2. PARSE: Turn string into an Object\nlet parsedData;\ntry {\n parsedData = JSON.parse(cleanContent);\n} catch (error) {\n // Fallback if AI completely failed to make JSON\n parsedData = {\n title: \"SCARY STORY \ud83d\udc80\",\n description: \"Watch till the end... #horror #fyp\",\n error: \"JSON Parse Failed\"\n };\n}\n\n// 3. SANITIZE: Ensure Title is filename-safe (Just in case you use it for files)\n// We keep a 'safe_title' version for filenames, and 'raw_title' for TikTok/YouTube\nconst safeTitle = parsedData.title.replace(/[^a-zA-Z0-9]/g, \"_\").replace(/_+/g, \"_\");\n\nreturn {\n json: {\n title: parsedData.title,\n safe_title: safeTitle,\n description: parsedData.description\n }\n};"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
512,
800
],
"id": "a5f4d58c-bec3-4467-aa86-5285b4b8a9c2",
"name": "Format Metadata"
},
{
"parameters": {
"unit": "minutes"
},
"type": "n8n-nodes-base.wait",
"typeVersion": 1.1,
"position": [
4544,
800
],
"id": "88af903a-adbb-4b73-b7c7-67c1eec28d2b",
"name": "Wait 5min"
},
{
"parameters": {
"authentication": "basicAuth",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
-3904,
672
],
"id": "8535787a-30a2-40c2-9466-cf919a086770",
"name": "Webhook",
"credentials": {
"httpBasicAuth": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"url": "http://localhost:5678/webhook/0eb0abd5-932e-477b-b278-7a9770a43900",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
4768,
800
],
"id": "cb4157de-b227-44db-b087-b8ce2d67c21e",
"name": "Trigger the Webhook Again",
"credentials": {
"httpBasicAuth": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "// 1. Parse the AI Response\nlet aiResponse;\ntry {\n // If your LLM outputs a string, parse it. If it's already JSON, just use it.\n const rawText = $input.first().json.text || $input.first().json.content;\n // Clean up any markdown code blocks ```json ... ```\n const cleanJson = rawText.replace(/```json|```/g, '').trim();\n aiResponse = JSON.parse(cleanJson);\n} catch (e) {\n throw new Error(\"AI did not return valid JSON. Check Prompt.\");\n}\n\n// 2. Retrieve the Original List of Stories\n// We need the exact same list that was sent to the LLM to map the IDs back.\nconst allStories = $(\"Filter Used Stories\").all();\n\n// 3. Prepare the \"Trash List\" (Blacklist)\n// The LLM now gives us IDs (e.g., [0, 4, 7]), so we map those back to Titles.\nconst trashIndices = aiResponse.trash_indices || [];\nconst trashTitles = trashIndices\n .map(index => {\n // Safety check: Ensure the index actually exists in our list\n if (allStories[index]) {\n return allStories[index].json.title;\n }\n return null;\n })\n .filter(title => title !== null) // Remove any invalid lookups\n .join('\\n');\n\n// 4. Prepare the \"Winner\"\nconst winnerIndex = aiResponse.selected_index;\nlet winnerTitle = null;\nlet winnerStoryBody = \"\";\n\n// Only proceed if the index is a valid number and exists in our list\nif (typeof winnerIndex === 'number' && allStories[winnerIndex]) {\n const winnerItem = allStories[winnerIndex].json;\n winnerTitle = winnerItem.title;\n winnerStoryBody = winnerItem.story_text;\n}\n\n// 5. Output separate data for n8n to route\nreturn {\n json: {\n winner: {\n title: winnerTitle,\n text: winnerStoryBody,\n // Simple boolean check: do we have a title?\n hasWinner: !!winnerTitle\n },\n blacklist: {\n titles: trashTitles, // This string goes to your \"Write File\" node\n count: trashIndices.length\n }\n }\n};"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1760,
672
],
"id": "2eca6f0e-fc4a-488b-a725-50bfe617480e",
"name": "Get Selected Story"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "925addf1-a111-4711-b11b-0d80b7e3a918",
"leftValue": "={{ $json.title }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
},
{
"id": "24063be2-a156-4cd9-ae45-a56312c9702b",
"leftValue": "={{ $input.all().lenght }}",
"rightValue": 5,
"operator": {
"type": "number",
"operation": "lt"
}
}
],
"combinator": "or"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
-2560,
672
],
"id": "00330319-5971-4bec-99c1-a8d59a21e407",
"name": "If No Stories"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "45596e6f-c4ba-4cec-9503-5238f8f31cab",
"leftValue": "={{ $json.winner.hasWinner }}",
"rightValue": false,
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
-1536,
672
],
"id": "0cae0a69-7e17-41e7-9657-a6d5f1f587c6",
"name": "If No Winner"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "45596e6f-c4ba-4cec-9503-5238f8f31cab",
"leftValue": "={{ $json.blacklist.count }}",
"rightValue": 0,
"operator": {
"type": "number",
"operation": "notEquals"
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
-1312,
624
],
"id": "653169b5-8b47-4f4c-83c7-640b3d5efdb6",
"name": "If Blacklisted"
},
{
"parameters": {
"command": "=BASE_DIR=\"/tmp_media/PERSONA\"\nTRACKING_FILE=\"${BASE_DIR}/used_stories.txt\"\n\n# 1. GET THE BLOCK OF TITLES\n# Note: We use the 'blacklist.titles' variable here.\n# We wrap it in quotes to preserve newlines.\nTITLES_TO_BAN=\"{{ $json.blacklist.titles }}\"\n\n# 2. APPEND TO FILE\n# Only run if there is actually text to save\nif [ ! -z \"$TITLES_TO_BAN\" ]; then\n echo \"$TITLES_TO_BAN\" | sed 's/^[ \\t]*//;s/[ \\t]*$//' >> \"$TRACKING_FILE\"\nfi"
},
"type": "n8n-nodes-base.executeCommand",
"typeVersion": 1,
"position": [
-1088,
512
],
"id": "e3e0bb2f-ceb4-4293-855a-40923b61c815",
"name": "Blacklist"
},
{
"parameters": {
"url": "http://localhost:5678/webhook/0eb0abd5-932e-477b-b278-7a9770a43900",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
736,
448
],
"id": "2e5f25d2-38a7-409e-a06b-56abe459e795",
"name": "Trigger the Webhook Again1",
"credentials": {
"httpBasicAuth": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"url": "http://localhost:5678/webhook/0eb0abd5-932e-477b-b278-7a9770a43900",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
-1088,
944
],
"id": "86b9afcc-4782-4abb-9b9f-f9d9736d2547",
"name": "Trigger the Webhook Again2",
"credentials": {
"httpBasicAuth": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"amount": 12,
"unit": "hours"
},
"type": "n8n-nodes-base.wait",
"typeVersion": 1.1,
"position": [
512,
448
],
"id": "e1bdf614-68a4-4d0b-b274-319ab92ddae0",
"name": "Wait 12hrs"
}
],
"connections": {
"Render Video": {
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.
googleDriveOAuth2ApigroqApihttpBasicAuth
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Automated Tiktok Videos. Uses executeCommand, httpRequest, lmChatGroq, chainLlm. Webhook trigger; 45 nodes.
Source: https://github.com/nathan-m2004/n8n-ai-video-creation/blob/40f6ef2a05d513c5c3f14db11b5e87a9b9deaa73/workflows/main_pipeline.json — 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.
This n8n workflow template automates the entire process of publishing Instagram Reels from content stored in Google Sheets and Google Drive. It's designed for content creators, social media managers,
This workflow is perfect for digital content creators, marketers, and social media managers who regularly create engaging short-form videos featuring inspirational or motivational quotes. While the wo
Code Schedule. Uses reddit, splitInBatches, httpRequest, convertToFile. Scheduled trigger; 29 nodes.
Tiktok. Uses httpRequest, executeCommand, chatTrigger, crypto. Webhook trigger; 24 nodes.
This template is for learners, researchers, students and professionals who want to quickly capture the essence of a YouTube video.