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.
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
| 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 |
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:
- Calculate target word count from desired duration and calibrated WPM
- Write the script to match the target
- Verify with
wc -w— must be within ±5% - If short, expand by adding full sections (300-500 words each), not sentences
- Only proceed after verification passes
# 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')
" Extract with pandoc:
pandoc "$BOOK" -t plain -o /tmp/book_extracted.txt Read as-is. Strip markdown formatting before edge-tts (bold, headers, section markers).
whisper "$SOURCE_PATH" --model tiny --language en --output_dir /tmp/whisper_out For extended content:
- Extract EPUB with pandoc
- Calibrate word count for the desired duration (e.g., 60 min = ~23,640 words)
- Read book structure, then write chapter-by-chapter summary
- Verify word count — expand if short
- Build audio and compose video (may need manual ffmpeg for very long scripts)
| 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 |
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).
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
- 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
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 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.
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
# 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 Three tracks run in sequence:
- PeerTube auto-upload — pipeline uploads rendered videos to your PeerTube instance automatically
- User uploads to YouTube — manual, then fills YouTube URLs into metadata.yaml
- Social posting — always Bluesky FIRST (video clip), then PieFed (full text), Mastodon (clip), Telegram (clip)
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
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 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()— useget_post_thread()on the specific URI for verification - Read post text from
tweet_en.txt/tweet_es.txtfiles, not hardcoded in the script
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.
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(NOTname) - API login uses
username(NOTusername_or_email) - Must sleep 5-8s between EN and ES posts
- Post editing via API is not supported — delete and recreate
- Set
language_id: 2for English,language_id: 4for Spanish tagsfield IS accepted (HTTP 200), but listings may showtags=empty — API display quirk
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
- Split scripts into ≤1,100 words per chunk
en-US-DavisNeuralis broken — useAndrewNeural- 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
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.
build_audio.sh can hang on individual TTS chunks (edge-tts stalls mid-generation). Recovery:
- Find which chunks have
.txtbut no.mp3(or truncated audio) - Manually regenerate missing chunks:
edge-tts --voice en-US-AndrewNeural -f chunk_N.txt --write-media chunk_N.mp3
- Verify ALL chunks present and have proper duration
- 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
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.
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.
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.
When writing upload scripts as heredocs, use quoted delimiters (<< 'EOF') to prevent shell variable expansion of $ characters in passwords.
- 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(), notget_author_feed()(inconsistent) - Auto-like after posting
- NOT Lemmy-compatible — use
/api/alpha/endpoints - Post editing via API is not supported
- Delete via
POST /api/alpha/post/deletewith{"post_id": N, "deleted": True}
# 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)" 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')" ffprobe -v error -show_entries format=duration -of csv=p=0 /tmp/audio_2x.mp3 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')" rm -f /tmp/audio_chunk_*.txt /tmp/audio_*_*.mp3 /tmp/video_bg*.png /tmp/concat_*.txt /tmp/transcript_raw.txt /tmp/script_clean.txt | Platform | Auth Method |
|---|---|
| Bluesky | Handle + App Password |
| PeerTube | Instance username + password |
| PieFed | Username + password |
| Mastodon | OAuth access token |
| YouTube | Manual upload (human) |