Skip to content

Instantly share code, notes, and snippets.

@8ullyMaguire
Created June 20, 2026 09:48
Show Gist options
  • Select an option

  • Save 8ullyMaguire/923eb7da24fc273ac12ba88932fa984e to your computer and use it in GitHub Desktop.

Select an option

Save 8ullyMaguire/923eb7da24fc273ac12ba88932fa984e to your computer and use it in GitHub Desktop.
AI Agent "Viral Content Pipeline" Skill

Viral Content Pipeline — Bilingual (EN/ES) Analysis → Multi-Platform Publish

A fully automated pipeline that takes any source (YouTube URL, EPUB, local text/video file), extracts content, produces bilingual (EN+ES) analysis videos with TTS audio and static background renders, then publishes across multiple platforms.

Tech stack: ffmpeg, edge-tts (Neural TTS), ImageMagick, Python, Whisper, atproto (Bluesky), Mastodon API, PeerTube API, PieFed API.


Pipeline Architecture

Source (YT/EPUB/text/video)  
  → Transcript extraction  
  → Script writing (with word count calibration)  
  → TTS audio generation (2x speed)  
  → Background rendering (ImageMagick)  
  → Video composition (ffmpeg)  
  → PeerTube auto-upload  
  → YouTube manual upload (user does this)  
  → Bluesky clip posting  
  → Mastodon clip posting  
  → PieFed full-text post  
  → Telegram post  

Three-Track Content Strategy

Track Purpose Format Notes
Long video (YouTube) Full analysis 13–15 min video Write → audio → video → publish
Clip (Bluesky) Hook & share 2:30–3:00 clip Extract from long analysis
Text-only (no video) Share link + summary Text post only Source → summary → cross-post

Section 1: Word Count Calibration (CRITICAL)

Calibrate BEFORE writing. The pipeline can only TRIM audio, not pad it. A script 20% short produces a video 20% short.

Calibrated WPM rates at 2x speed (TTS):

Type EN at 2x ES at 2x
Long analysis 394 wpm 284 wpm
Bluesky clip 330 wpm 282 wpm

Planning quick reference:

EN long: 14 min → 5,516 words | 13 min → 5,122 words | 15 min → 5,910 words  
ES long: 13 min → 3,692 words  
EN clip: 2.5 min → 825 words | 3.0 min → 990 words  
ES clip: 2.5 min → 705 words  

Workflow:

  1. Calculate target word count from desired duration and calibrated WPM
  2. Write the script to match the target
  3. Verify with wc -w — must be within ±5%
  4. If short, expand by adding full sections (300-500 words each), not sentences
  5. Only proceed after verification passes

Section 2: Source Type Handling

A. YouTube URL

# Get title + author  
curl -s "https://www.youtube.com/oembed?url=https://youtube.com/watch?v=VIDEO_ID&format=json"  

# Fetch transcript  
python3 -c "  
from youtube_transcript_api import YouTubeTranscriptApi  
api = YouTubeTranscriptApi()  
transcript = api.fetch('VIDEO_ID')  
text = ' '.join([t.text for t in transcript])  
open('/tmp/transcript_raw.txt','w').write(text)  
print(f'{len(text.split())} words')  
"  

B. EPUB file

Extract with pandoc:

pandoc "$BOOK" -t plain -o /tmp/book_extracted.txt  

C. Local text/markdown file

Read as-is. Strip markdown formatting before edge-tts (bold, headers, section markers).

D. Local video file (no existing subtitles)

whisper "$SOURCE_PATH" --model tiny --language en --output_dir /tmp/whisper_out  

E. Book / long-form summary (30-60 min video)

For extended content:

  1. Extract EPUB with pandoc
  2. Calibrate word count for the desired duration (e.g., 60 min = ~23,640 words)
  3. Read book structure, then write chapter-by-chapter summary
  4. Verify word count — expand if short
  5. Build audio and compose video (may need manual ffmpeg for very long scripts)

Section 3: Script Writing — Long Analysis

Suggested Structure (14 min target)

Section Duration EN words Notes
Introduction 0:00–1:00 320 State the topic and why it matters
Context 1:00–3:00 640 Background, set up the subject
Body — Point 1 3:00–5:30 800–960 First argument with evidence
Body — Point 2 5:30–8:30 960 Second argument, building
Body — Point 3 8:30–11:00 800 Third point or deeper analysis
Counter-argument 11:00–12:30 480 Address rebuttals honestly
Conclusion 12:30–14:00 480 Summarise, call to reflect

ES Script Writing — Write Full, Not Condensed

Don't write a condensed Spanish version expecting to expand it later — it consistently fails. Write a full independent version matching the same section structure from the start. ES sections should be roughly 2/3 the word count of EN (Spanish is more efficient per unit of information).


Section 4: Script Writing — Bluesky Clip (2:30–3:00)

Extract the most striking insight.

Section Duration EN words Tactic
Hook 0:00–0:15 80–120 Bold claim, pattern interrupt
The Insight 0:15–1:30 400 One clear powerful argument
Why It Matters 1:30–2:15 240 Connect to viewer's life
Call to Action 2:15–2:45 160 Full analysis at [YouTube link]

Minimum word counts (at calibrated rates):

  • 1:00 → EN 330 / ES 282 words
  • 2:00 → EN 660 / ES 564 words
  • 2:30 → EN 825 / ES 705 words (target)
  • 3:00 → EN 990 / ES 846 words

Section 5: Audio & Video Production

Voices

  • EN: en-US-AndrewNeural (edge-tts)
  • ES: es-ES-AlvaroNeural (edge-tts)
  • Speed: 2x via ffmpeg atempo filter
  • Split scripts into chunks ≤1,100 words before TTS

Background Style

White + Red on Black — black background, one main word/phrase in large white bold text, punchy conclusion in RED (#ff4444).

Long video background:

magick -size 1280x720 xc:black \  
  -font Liberation-Sans-Bold -pointsize 50 -fill white \  
  -gravity north -annotate +0+100 'KEYWORD' \  
  -font Liberation-Sans-Bold -pointsize 36 -fill '#ff4444' \  
  -gravity north -annotate +0+200 'PUNCHY CONCLUSION' \  
  /tmp/video_bg_long.png  

Bluesky clip thumbnail:

magick -size 1280x720 xc:black \  
  -font Liberation-Sans-Bold -pointsize 100 -fill white -stroke '#333333' -strokewidth 2 \  
  -gravity north -annotate +0+80 'KEYWORD' \  
  -font Liberation-Sans-Bold -pointsize 70 -fill '#ff4444' \  
  -gravity north -annotate +0+220 'PUNCHY CONCLUSION' \  
  /tmp/video_bg_clip.png  

Video Composition

ALWAYS use -preset ultrafast -crf 28 for static backgrounds. Quality is identical because every frame is the same PNG — ultrafast only affects compression speed. Set a generous timeout (300s for 14-min videos):

ffmpeg -y -loop 1 -i "bg.png" -i "audio.mp3" \  
  -c:v libx264 -preset ultrafast -crf 28 -c:a aac -b:a 128k \  
  -shortest -pix_fmt yuv420p -movflags +faststart -t 840 \  
  "output.mp4"  

Use -t <seconds> to trim over-long audio to target duration.

Project Directory Structure

projects/<project-name>/  
├── en/  
│   ├── long_analysis.txt    # Full script (EN)  
│   ├── clip_script.txt      # Bluesky clip script (EN)  
│   └── tweet_en.txt         # Bluesky post text (EN)  
├── es/  
│   ├── long_analysis.txt    # Full script (ES)  
│   ├── clip_script.txt      # Bluesky clip script (ES)  
│   └── tweet_es.txt         # Bluesky post text (ES)  
└── metadata.yaml            # Project metadata  

Full Pipeline Run

# 1. Generate backgrounds  
./scripts/generate_bg.sh <project_dir> en long "KEYWORD" "CONCLUSION"  
./scripts/generate_bg.sh <project_dir> en clip "HOOK" "TAGLINE"  

# 2. Build EN long audio → compose video  
./scripts/build_audio.sh <project_dir> en  
./scripts/compose_video.sh <project_dir> en <target_sec> long  

# 3. Build EN clip audio → compose clip  
./scripts/build_clip_audio.sh <project_dir> en  
ffmpeg -y -loop 1 -i <project_dir>/en/video_bg_clip.png \  
  -i /tmp/audio_<project>_en_clip_2x.mp3 \  
  -c:v libx264 -preset ultrafast -crf 28 -c:a aac -b:a 128k \  
  -shortest -pix_fmt yuv420p <project_dir>/en/clip.mp4  

Section 6: Publishing Strategy

Three tracks run in sequence:

  1. PeerTube auto-upload — pipeline uploads rendered videos to your PeerTube instance automatically
  2. User uploads to YouTube — manual, then fills YouTube URLs into metadata.yaml
  3. Social posting — always Bluesky FIRST (video clip), then PieFed (full text), Mastodon (clip), Telegram (clip)

Publishing Order

1. Generate all content (EN + ES)  
2. Auto-upload to PeerTube  
3. User uploads to YouTube manually  
4. User fills YouTube URLs in metadata.yaml  
5. Bluesky EN clip (video + text)  
6. Bluesky ES clip (video + text)  
7. PieFed EN+ES posts (full analysis text)  
8. Mastodon EN+ES clips  
9. Telegram EN+ES clips  

metadata.yaml Format

project_name: "<project-name>"  
source_video_title: "Original video title"  
source_video_url: "https://www.youtube.com/watch?v=SOURCE_ID"  

youtube_url_en: ""   # Fill after manual upload  
youtube_url_es: ""   # Fill after manual upload  

title_en: "Your Title"  
title_es: "Tu Titulo"  

tags_en: [tag1, tag2]  
tags_es: [tag1-es, tag2-es]  

ready_to_publish: false  # Set true when BOTH URLs are filled  

Section 7: Bluesky — Upload Video Clip

Using atproto's send_video():

from atproto import Client  

client = Client()  
client.login(handle, app_password)  

with open(video_path, "rb") as f:  
    video_bytes = f.read()  

result = client.send_video(  
    text=post_text,  
    video=video_bytes,  
    video_alt=alt_text,  
)  
print(f"Video post sent: {result.uri}")  

# Auto-like  
client.like(result.uri, result.cid)  

Key Bluesky details:

  • 300 grapheme limit on post text (check with regex.findall(r'\X', text))
  • Grapheme check must happen AFTER URL substitution
  • Clip should be ≤3:00 and ≤50MB
  • Video posts may not appear in get_author_feed() — use get_post_thread() on the specific URI for verification
  • Read post text from tweet_en.txt/tweet_es.txt files, not hardcoded in the script

Section 8: PeerTube Caption/Subtitle Upload

Generate VTT subtitles from the script text and upload via API:

python3 upload_subtitles.py <script_path> <video_uuid> <language_code> <duration_sec>  

Approach: Split script text into ~30-word segments, calculate proportional timing from total audio duration, generate VTT, upload.

API details: PUT /api/v1/videos/{uuid}/captions/{lang} with multipart field captionfile. Returns HTTP 204 on success.

with open(vtt_path, "rb") as f:  
    files = {"captionfile": ("captions_en.vtt", f, "text/vtt")}  
    r = requests.put(url, files=files, headers=headers, timeout=30)  
# HTTP 204 = success  

Strip markdown headers, section markers [HOOK ...], bold markers **...**, and dividers before generating subtitles.


Section 9: PieFed Posting

PieFed uses /api/alpha/ endpoints with JWT auth (NOT Lemmy-compatible v3 API):

import requests  

def piefed_post(instance, username, password, community_name, title,  
                body, video_url=None, dry_run=False):  
    # Login  
    r = requests.post(f"{instance}/api/alpha/user/login",  
        json={"username": username, "password": password}, timeout=15)  
    token = r.json().get("jwt")  
    headers = {"Authorization": f"Bearer {token}"}  

    # Get community ID  
    r = requests.get(f"{instance}/api/alpha/community",  
        params={"name": community_name}, headers=headers, timeout=15)  
    cid = r.json().get("community_view", {}).get("community", {}).get("id")  

    # Create post — body is the FULL analysis text  
    payload = {  
        "community_id": cid,  
        "title": title,  
        "body": body,  
        "tags": meta.get("tags_en", []),  
        "ai_generated": True,  
        "language_id": 2,  # 2=English, 4=Spanish  
    }  
    if video_url:  
        payload["url"] = video_url  

    if dry_run:  
        print(f"[DRY RUN] Would post: {title} -> {community_name}@{instance}")  
        return None  

    r = requests.post(f"{instance}/api/alpha/post", json=payload,  
        headers=headers, timeout=15)  
    r.raise_for_status()  
    return r.json()  

PieFed quirks:

  • Post title field is title (NOT name)
  • API login uses username (NOT username_or_email)
  • Must sleep 5-8s between EN and ES posts
  • Post editing via API is not supported — delete and recreate
  • Set language_id: 2 for English, language_id: 4 for Spanish
  • tags field IS accepted (HTTP 200), but listings may show tags= empty — API display quirk

Section 10: Mastodon Posting (Video Clip)

Two-step flow with OAuth access token:

headers = {"Authorization": f"Bearer {MASTODON_ACCESS_TOKEN}"}  
instance = "https://your-instance.social"  

# 1. Upload video  
with open("clip.mp4", "rb") as f:  
    r = requests.post(f"{instance}/api/v2/media", headers=headers,  
        files={"file": ("clip.mp4", f, "video/mp4")}, timeout=120)  
media_id = r.json()["id"]  

# 2. Poll until processed  
while True:  
    r = requests.get(f"{instance}/api/v1/media/{media_id}", headers=headers, timeout=15)  
    if r.ok and r.json().get("url"):  
        break  
    time.sleep(5)  

# 3. Post with media  
r = requests.post(f"{instance}/api/v1/statuses", headers=headers, json={  
    "status": text, "visibility": "public", "language": "en",  
    "media_ids": [media_id],  
}, timeout=30)  

Mastodon details:

  • OAuth password grant is disabled on most instances — register an app and use authorization code flow
  • If API returns 422 (too long), truncate to ~350 chars
  • Read post text from clip script, not hardcoded

Section 11: Known Issues & Pitfalls

edge-tts

  • Split scripts into ≤1,100 words per chunk
  • en-US-DavisNeural is broken — use AndrewNeural
  • Spanish voice is slower per-word; calibrate separately
  • Clip scripts must be cleaned before TTS: Strip [HOOK ...], --- dividers, bold markers, and YouTube URL placeholders — edge-tts reads metadata as spoken text

ffmpeg Preset

For static backgrounds, always use -preset ultrafast -crf 28. The older -preset veryfast -crf 23 is too slow for reliable automation. Set timeout=300 for 14-minute videos.

TTS Chunk Hang / Timeout

build_audio.sh can hang on individual TTS chunks (edge-tts stalls mid-generation). Recovery:

  1. Find which chunks have .txt but no .mp3 (or truncated audio)
  2. Manually regenerate missing chunks:
    edge-tts --voice en-US-AndrewNeural -f chunk_N.txt --write-media chunk_N.mp3  
  3. Verify ALL chunks present and have proper duration
  4. Concatenate and speed to 2x:
    for i in $(seq 0 N); do echo "file '/tmp/audio_chunk_${i}.mp3'"; done > /tmp/concat.txt  
    ffmpeg -f concat -safe 0 -i /tmp/concat.txt -c copy /tmp/audio_full.mp3  
    ffmpeg -i /tmp/audio_full.mp3 -filter:a "atempo=2.0" -vn /tmp/audio_2x.mp3  

Line-number Corruption

NEVER read a script with a tool that prepends line numbers (e.g., read_file with LINE|content format) and then write that output back — TTS reads the line numbers aloud. Read raw via cat and verify content integrity.

File Truncation

cat of files over ~50KB is silently truncated by stdout caps. Always verify word count before and after any modification. For scripts over 10k words, write fresh rather than reading + modifying.

Iterative text.replace() Duplication

Expanding scripts via text.replace(old, new) can match sentences that were already part of previously-added expansions, producing duplicate content. Target unique markers (paragraph boundaries, section headers) rather than single sentences. Better: write to calibrated word count in one shot.

PeerTube Password Quoting

When writing upload scripts as heredocs, use quoted delimiters (<< 'EOF') to prevent shell variable expansion of $ characters in passwords.

Bluesky

  • 300 grapheme limit applies to the FINAL rendered text (after URL substitution), not the template
  • Delete session cache file before login if auth fails
  • Verify video posts with get_post_thread(), not get_author_feed() (inconsistent)
  • Auto-like after posting

PieFed

  • NOT Lemmy-compatible — use /api/alpha/ endpoints
  • Post editing via API is not supported
  • Delete via POST /api/alpha/post/delete with {"post_id": N, "deleted": True}

Cross-Platform Deletion

# Bluesky  
feed = client.get_author_feed(client.me.did, limit=50)  
for pv in feed.feed:  
    if '<project-keyword>' in getattr(pv.post.record, 'text', ''):  
        client.delete_post(pv.post.uri)  

# Mastodon  
acct = requests.get(f'{instance}/api/v1/accounts/verify_credentials', headers=headers)  
statuses = requests.get(f'{instance}/api/v1/accounts/{acct.json()["id"]}/statuses',  
    params={'limit':20}, headers=headers)  
for s in statuses.json():  
    if '<keyword>' in s.get('content', ''):  
        requests.delete(f'{instance}/api/v1/statuses[s["id"]]', headers=headers)  

# PieFed  
requests.post(f"{instance}/api/alpha/post/delete",  
    json={"post_id": POST_ID, "deleted": True}, headers=headers, timeout=15)  

# PeerTube  
curl -X DELETE "https://your-peertube-instance.tube/api/v1/videos/{uuid}" \  
  -H "Authorization: Bearer $(cat /tmp/token.txt)"  

Quick Reference

Get YouTube Transcript

python3 -c "from youtube_transcript_api import YouTubeTranscriptApi as Y; api=Y(); t=api.fetch('VIDEO_ID'); open('/tmp/t.txt','w').write(' '.join([s.text for s in t])); print(f'{len(open(\"/tmp/t.txt\").read().split())} words')"  

Verify Audio Duration

ffprobe -v error -show_entries format=duration -of csv=p=0 /tmp/audio_2x.mp3  

Grapheme Check

python3 -c "import regex; t=open('tweet.txt').read(); g=regex.findall(r'\X',t); print(len(g),'OK' if len(g)<=300 else 'OVER')"  

Cleanup Temp Files

rm -f /tmp/audio_chunk_*.txt /tmp/audio_*_*.mp3 /tmp/video_bg*.png /tmp/concat_*.txt /tmp/transcript_raw.txt /tmp/script_clean.txt  

Credentials Reference

Platform Auth Method
Bluesky Handle + App Password
PeerTube Instance username + password
PieFed Username + password
Mastodon OAuth access token
YouTube Manual upload (human)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment