Initial implementation of reddit-video-bot
Full pipeline: Reddit sourcing → Groq text optimization → Edge-TTS voice generation → Whisper transcription → FFmpeg video rendering with word-level subtitles. Includes SQLite deduplication and .env-based config. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
98e829c995
4
.env.example
Normal file
4
.env.example
Normal file
@ -0,0 +1,4 @@
|
||||
REDDIT_CLIENT_ID=your_reddit_client_id
|
||||
REDDIT_CLIENT_SECRET=your_reddit_client_secret
|
||||
REDDIT_USER_AGENT=RedditVideoBot/0.1 by /u/YourUsername
|
||||
GROQ_API_KEY=your_groq_api_key
|
||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
output/
|
||||
processed.db
|
||||
assets/background_videos/
|
||||
assets/fonts/
|
||||
*.mp3
|
||||
*.mp4
|
||||
*.srt
|
||||
81
PLAN.md
Normal file
81
PLAN.md
Normal file
@ -0,0 +1,81 @@
|
||||
# Projekt-Plan: Automatisierter Reddit-Story Video Creator
|
||||
|
||||
Dieses Projekt automatisiert die Erstellung von Social-Media-Videos (TikTok, YouTube Shorts, Reels) basierend auf populären Reddit-Beiträgen. Das Ziel ist ein "Low-Cost, High-Efficiency" Workflow.
|
||||
|
||||
## 1. Architektur & Workflow
|
||||
|
||||
1. **Sourcing**: Ein Skript sucht über die Reddit API (`praw`) nach viralen Posts in Subreddits wie `r/AITAH`, `r/relationship_advice` oder `r/confessions`. Bereits verarbeitete Posts werden über eine lokale SQLite-Datenbank übersprungen.
|
||||
2. **Processing (Groq)**: Der Text wird an Groq (Llama 3) gesendet. Die KI bereinigt den Text, entfernt unnötige Kürzel, verbessert den Spannungsbogen und sorgt für einen starken "Hook" in den ersten 3 Sekunden.
|
||||
3. **Voice (Edge-TTS)**: Der optimierte Text wird mit der Microsoft Edge TTS Engine in eine hochwertige, menschlich klingende MP3-Datei umgewandelt (kostenlos).
|
||||
4. **Transkription (Whisper)**: Das Audio wird mit OpenAI Whisper lokal transkribiert, um wortgenaue Zeitstempel für die Untertitel zu erhalten. Standard-Modell: `base` (schnell) — für höhere Qualität auf `medium` wechseln.
|
||||
5. **Visuals & Montage (FFmpeg)**:
|
||||
* Hintergrundvideo (z.B. Minecraft Parkour) wird geladen und bei Bedarf geloopt, sodass es die volle Audio-Länge abdeckt.
|
||||
* Audio wird darübergelegt.
|
||||
* Untertitel werden per `ffmpeg drawtext`-Filter wort-genau und animiert eingeblendet (schneller und stabiler als MoviePy TextClip).
|
||||
6. **Export**: Das fertige Video wird als `.mp4` im Hochformat (9:16) exportiert.
|
||||
|
||||
## 2. Tech-Stack
|
||||
|
||||
- **Sprache**: Python 3.10+
|
||||
- **LLM**: Groq API (Llama 3 70B)
|
||||
- **Voice**: `edge-tts` (Python Bibliothek)
|
||||
- **Transkription**: `openai-whisper` (lokal, Modell: `base` oder `medium`)
|
||||
- **Video-Editing**: `ffmpeg` (direkt via `subprocess`), `moviepy` nur für einfache Clips
|
||||
- **Reddit API**: `praw`
|
||||
- **Secrets**: `python-dotenv` + `.env` Datei
|
||||
- **Deduplication**: `sqlite3` (Standardbibliothek, kein Extra-Install)
|
||||
|
||||
## 3. Verzeichnisstruktur
|
||||
|
||||
```text
|
||||
reddit-video-bot/
|
||||
├── main.py # Hauptsteuerung des Bots
|
||||
├── .env # API-Keys (wird nicht committed)
|
||||
├── .env.example # Vorlage mit Platzhaltern
|
||||
├── src/
|
||||
│ ├── reddit_client.py # Holt Posts von Reddit, prüft Duplikate per SQLite
|
||||
│ ├── processor.py # Groq-Integration & Text-Optimierung
|
||||
│ ├── voice_gen.py # Edge-TTS Integration
|
||||
│ ├── subtitler.py # Whisper-Transkription & Wort-Zeitstempel
|
||||
│ └── video_engine.py # Montage mit FFmpeg (loop, audio, drawtext)
|
||||
├── assets/
|
||||
│ ├── background_videos/ # Speicherort für Hintergrund-Loops
|
||||
│ └── fonts/ # Schriftarten für Untertitel
|
||||
├── output/ # Hier landen die fertigen Videos
|
||||
├── processed.db # SQLite: bereits verarbeitete Post-IDs
|
||||
└── requirements.txt # Abhängigkeiten
|
||||
```
|
||||
|
||||
## 4. Implementierungsschritte
|
||||
|
||||
### Phase 1: Setup & Sourcing
|
||||
- `requirements.txt` und `.env.example` erstellen, Bibliotheken installieren.
|
||||
- `reddit_client.py` implementieren: Authentifizierung, Abruf der Top-Posts der letzten 24h, SQLite-Check auf Duplikate.
|
||||
|
||||
### Phase 2: Logik & Stimme
|
||||
- `processor.py` erstellen: Groq API einbinden und Prompt-Engineering für virale Storys.
|
||||
- `voice_gen.py` erstellen: Funktion zum Speichern von Text als MP3 via `edge-tts`.
|
||||
|
||||
### Phase 3: Transkription & Untertitel
|
||||
- `subtitler.py` erstellen: Whisper Modell laden (`base` als Default), Audio zu Wort-Zeitstempeln konvertieren.
|
||||
- Zeitstempel als strukturierte Liste ausgeben (für FFmpeg `drawtext`).
|
||||
|
||||
### Phase 4: Video-Engine *(höchstes Risiko — früh prototypen)*
|
||||
- `video_engine.py` erstellen:
|
||||
- Zufälligen Clip aus `assets/background_videos/` wählen.
|
||||
- Hintergrundvideo per FFmpeg loopen bis Audio-Länge erreicht ist.
|
||||
- Audio einbetten.
|
||||
- Untertitel wort-genau per `drawtext`-Filter rendern.
|
||||
- Ziel-Format: 9:16, 1080x1920.
|
||||
|
||||
### Phase 5: Automatisierung
|
||||
- `main.py` schreiben: Alle Module verknüpfen, Post-ID nach erfolgreichem Export in SQLite speichern.
|
||||
|
||||
## 5. Kosten-Optimierung
|
||||
- **Groq**: Kostenlos (Free Tier).
|
||||
- **Edge-TTS**: Kostenlos.
|
||||
- **Whisper**: Kostenlos (läuft lokal).
|
||||
- **Reddit API**: Kostenlos (für persönliche Nutzung).
|
||||
- **Visuals**: Einmalig kostenlose Gameplay-Videos von YouTube/Pexels laden.
|
||||
|
||||
**Gesamtkosten pro Video: ~0,00 €**
|
||||
23
config.py
Normal file
23
config.py
Normal file
@ -0,0 +1,23 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Reddit API Credentials
|
||||
REDDIT_CLIENT_ID = os.getenv("REDDIT_CLIENT_ID")
|
||||
REDDIT_CLIENT_SECRET = os.getenv("REDDIT_CLIENT_SECRET")
|
||||
REDDIT_USER_AGENT = os.getenv("REDDIT_USER_AGENT", "RedditVideoBot/0.1 by /u/YourUsername")
|
||||
|
||||
# Groq API Key
|
||||
GROQ_API_KEY = os.getenv("GROQ_API_KEY")
|
||||
|
||||
# Path Settings
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ASSETS_DIR = os.path.join(BASE_DIR, "assets")
|
||||
OUTPUT_DIR = os.path.join(BASE_DIR, "output")
|
||||
BACKGROUND_VIDEOS_DIR = os.path.join(ASSETS_DIR, "background_videos")
|
||||
|
||||
# Video Settings
|
||||
VIDEO_WIDTH = 1080
|
||||
VIDEO_HEIGHT = 1920
|
||||
FONT_PATH = os.path.join(ASSETS_DIR, "fonts", "BoldFont.ttf")
|
||||
44
main.py
Normal file
44
main.py
Normal file
@ -0,0 +1,44 @@
|
||||
import os
|
||||
import sys
|
||||
from src.reddit_client import get_next_post, mark_processed
|
||||
from src.processor import optimize_text
|
||||
from src.voice_gen import generate_audio
|
||||
from src.subtitler import transcribe
|
||||
from src.video_engine import render_video
|
||||
|
||||
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "base")
|
||||
TEMP_AUDIO = "temp_audio.mp3"
|
||||
|
||||
|
||||
def run():
|
||||
print("[main] Fetching next Reddit post...")
|
||||
post = get_next_post()
|
||||
if not post:
|
||||
print("[main] No new viral posts found. Try again later.")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"[main] Found: r/{post['subreddit']} — {post['title'][:60]} (score: {post['score']})")
|
||||
|
||||
print("[main] Optimizing text with Groq...")
|
||||
script = optimize_text(post["title"], post["text"])
|
||||
print(f"[main] Script preview: {script[:120]}...")
|
||||
|
||||
print("[main] Generating voice...")
|
||||
generate_audio(script, TEMP_AUDIO)
|
||||
|
||||
print("[main] Transcribing with Whisper...")
|
||||
words = transcribe(TEMP_AUDIO, model_name=WHISPER_MODEL)
|
||||
print(f"[main] Got {len(words)} word timestamps.")
|
||||
|
||||
print("[main] Rendering video...")
|
||||
output_path = render_video(TEMP_AUDIO, words, post["id"])
|
||||
|
||||
mark_processed(post["id"])
|
||||
print(f"[main] Done! Video saved to: {output_path}")
|
||||
|
||||
if os.path.exists(TEMP_AUDIO):
|
||||
os.remove(TEMP_AUDIO)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@ -0,0 +1,6 @@
|
||||
praw>=7.7.0
|
||||
groq>=0.9.0
|
||||
edge-tts>=6.1.9
|
||||
openai-whisper>=20231117
|
||||
moviepy>=1.0.3
|
||||
python-dotenv>=1.0.0
|
||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
32
src/processor.py
Normal file
32
src/processor.py
Normal file
@ -0,0 +1,32 @@
|
||||
from groq import Groq
|
||||
from config import GROQ_API_KEY
|
||||
|
||||
client = Groq(api_key=GROQ_API_KEY)
|
||||
|
||||
SYSTEM_PROMPT = """You are a viral social media script writer. Your job is to rewrite Reddit stories for TikTok/YouTube Shorts.
|
||||
|
||||
Rules:
|
||||
- Start with a HOOK in the first sentence that grabs attention immediately (use "So I...", "I can't believe...", "This actually happened to me...")
|
||||
- Remove Reddit-specific abbreviations (AITA → "Am I the asshole", NTA, etc.)
|
||||
- Write in a natural, spoken voice — no bullet points, no markdown
|
||||
- Keep sentences short for TTS pacing
|
||||
- Preserve all the drama and emotion
|
||||
- End with a cliffhanger or strong emotional close
|
||||
- Output ONLY the script, no commentary or headings"""
|
||||
|
||||
|
||||
def optimize_text(title: str, text: str) -> str:
|
||||
"""Send a Reddit post to Groq and return a TTS-ready viral script."""
|
||||
user_message = f"Title: {title}\n\nStory:\n{text}"
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="llama3-70b-8192",
|
||||
messages=[
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_message},
|
||||
],
|
||||
temperature=0.8,
|
||||
max_tokens=1024,
|
||||
)
|
||||
|
||||
return response.choices[0].message.content.strip()
|
||||
61
src/reddit_client.py
Normal file
61
src/reddit_client.py
Normal file
@ -0,0 +1,61 @@
|
||||
import sqlite3
|
||||
import praw
|
||||
from config import REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET, REDDIT_USER_AGENT
|
||||
|
||||
DB_PATH = "processed.db"
|
||||
|
||||
SUBREDDITS = ["AITAH", "relationship_advice", "confessions", "tifu", "AmItheAsshole"]
|
||||
MIN_SCORE = 1000
|
||||
MIN_WORDS = 200
|
||||
MAX_WORDS = 1500
|
||||
|
||||
|
||||
def _init_db():
|
||||
con = sqlite3.connect(DB_PATH)
|
||||
con.execute("CREATE TABLE IF NOT EXISTS processed (post_id TEXT PRIMARY KEY)")
|
||||
con.commit()
|
||||
return con
|
||||
|
||||
|
||||
def _is_processed(con, post_id):
|
||||
return con.execute("SELECT 1 FROM processed WHERE post_id=?", (post_id,)).fetchone() is not None
|
||||
|
||||
|
||||
def mark_processed(post_id):
|
||||
con = sqlite3.connect(DB_PATH)
|
||||
con.execute("INSERT OR IGNORE INTO processed VALUES (?)", (post_id,))
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
|
||||
def get_next_post():
|
||||
"""Return the next unprocessed viral Reddit post as a dict, or None."""
|
||||
reddit = praw.Reddit(
|
||||
client_id=REDDIT_CLIENT_ID,
|
||||
client_secret=REDDIT_CLIENT_SECRET,
|
||||
user_agent=REDDIT_USER_AGENT,
|
||||
)
|
||||
con = _init_db()
|
||||
|
||||
for subreddit_name in SUBREDDITS:
|
||||
subreddit = reddit.subreddit(subreddit_name)
|
||||
for post in subreddit.top(time_filter="day", limit=25):
|
||||
if _is_processed(con, post.id):
|
||||
continue
|
||||
if post.score < MIN_SCORE:
|
||||
continue
|
||||
if post.is_self and post.selftext:
|
||||
word_count = len(post.selftext.split())
|
||||
if MIN_WORDS <= word_count <= MAX_WORDS:
|
||||
con.close()
|
||||
return {
|
||||
"id": post.id,
|
||||
"title": post.title,
|
||||
"text": post.selftext,
|
||||
"score": post.score,
|
||||
"subreddit": subreddit_name,
|
||||
"url": f"https://reddit.com{post.permalink}",
|
||||
}
|
||||
|
||||
con.close()
|
||||
return None
|
||||
31
src/subtitler.py
Normal file
31
src/subtitler.py
Normal file
@ -0,0 +1,31 @@
|
||||
import whisper
|
||||
|
||||
_model = None
|
||||
|
||||
|
||||
def _get_model(model_name: str = "base"):
|
||||
global _model
|
||||
if _model is None:
|
||||
print(f"[subtitler] Loading Whisper model '{model_name}'...")
|
||||
_model = whisper.load_model(model_name)
|
||||
return _model
|
||||
|
||||
|
||||
def transcribe(audio_path: str, model_name: str = "base") -> list[dict]:
|
||||
"""
|
||||
Transcribe audio and return a list of word-level segments:
|
||||
[{"word": str, "start": float, "end": float}, ...]
|
||||
"""
|
||||
model = _get_model(model_name)
|
||||
result = model.transcribe(audio_path, word_timestamps=True, language="en")
|
||||
|
||||
words = []
|
||||
for segment in result.get("segments", []):
|
||||
for w in segment.get("words", []):
|
||||
words.append({
|
||||
"word": w["word"].strip(),
|
||||
"start": w["start"],
|
||||
"end": w["end"],
|
||||
})
|
||||
|
||||
return words
|
||||
112
src/video_engine.py
Normal file
112
src/video_engine.py
Normal file
@ -0,0 +1,112 @@
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
import json
|
||||
from config import BACKGROUND_VIDEOS_DIR, OUTPUT_DIR, VIDEO_WIDTH, VIDEO_HEIGHT, FONT_PATH
|
||||
|
||||
|
||||
def _get_audio_duration(audio_path: str) -> float:
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", audio_path],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
streams = json.loads(result.stdout)["streams"]
|
||||
for s in streams:
|
||||
if s.get("codec_type") == "audio":
|
||||
return float(s["duration"])
|
||||
raise ValueError(f"No audio stream found in {audio_path}")
|
||||
|
||||
|
||||
def _get_video_duration(video_path: str) -> float:
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", video_path],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
streams = json.loads(result.stdout)["streams"]
|
||||
for s in streams:
|
||||
if s.get("codec_type") == "video":
|
||||
return float(s["duration"])
|
||||
raise ValueError(f"No video stream found in {video_path}")
|
||||
|
||||
|
||||
def _pick_background() -> str:
|
||||
videos = [
|
||||
f for f in os.listdir(BACKGROUND_VIDEOS_DIR)
|
||||
if f.lower().endswith((".mp4", ".mov", ".mkv"))
|
||||
]
|
||||
if not videos:
|
||||
raise FileNotFoundError(
|
||||
f"No background videos found in {BACKGROUND_VIDEOS_DIR}. "
|
||||
"Add at least one .mp4 file to assets/background_videos/"
|
||||
)
|
||||
return os.path.join(BACKGROUND_VIDEOS_DIR, random.choice(videos))
|
||||
|
||||
|
||||
def _build_drawtext_filter(words: list[dict]) -> str:
|
||||
"""Build an ffmpeg drawtext filter chain for word-by-word subtitle display."""
|
||||
if not os.path.exists(FONT_PATH):
|
||||
font_arg = "fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
||||
else:
|
||||
font_arg = f"fontfile={FONT_PATH}"
|
||||
|
||||
parts = []
|
||||
for w in words:
|
||||
word = w["word"].replace("'", "\\'").replace(":", "\\:").replace(",", "\\,")
|
||||
start = w["start"]
|
||||
end = w["end"]
|
||||
part = (
|
||||
f"drawtext={font_arg}:text='{word}':"
|
||||
f"fontcolor=white:fontsize=80:borderw=4:bordercolor=black:"
|
||||
f"x=(w-text_w)/2:y=(h-text_h)/2:"
|
||||
f"enable='between(t,{start},{end})'"
|
||||
)
|
||||
parts.append(part)
|
||||
|
||||
return ",".join(parts)
|
||||
|
||||
|
||||
def render_video(audio_path: str, words: list[dict], post_id: str) -> str:
|
||||
"""
|
||||
Compose the final video: looped background + audio + word subtitles.
|
||||
Returns the path to the output .mp4.
|
||||
"""
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
output_path = os.path.join(OUTPUT_DIR, f"{post_id}.mp4")
|
||||
|
||||
bg_path = _pick_background()
|
||||
audio_duration = _get_audio_duration(audio_path)
|
||||
video_duration = _get_video_duration(bg_path)
|
||||
|
||||
# Calculate loop count needed to cover audio duration
|
||||
loop_count = int(audio_duration / video_duration) + 2
|
||||
|
||||
drawtext_filter = _build_drawtext_filter(words)
|
||||
|
||||
# Full filter: loop bg, scale/crop to 9:16, overlay subtitles
|
||||
vf = (
|
||||
f"scale={VIDEO_WIDTH}:{VIDEO_HEIGHT}:force_original_aspect_ratio=increase,"
|
||||
f"crop={VIDEO_WIDTH}:{VIDEO_HEIGHT},"
|
||||
f"{drawtext_filter}"
|
||||
)
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-stream_loop", str(loop_count),
|
||||
"-i", bg_path,
|
||||
"-i", audio_path,
|
||||
"-vf", vf,
|
||||
"-t", str(audio_duration),
|
||||
"-map", "0:v:0",
|
||||
"-map", "1:a:0",
|
||||
"-c:v", "libx264",
|
||||
"-preset", "fast",
|
||||
"-crf", "23",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "192k",
|
||||
"-shortest",
|
||||
output_path,
|
||||
]
|
||||
|
||||
print(f"[video_engine] Rendering video → {output_path}")
|
||||
subprocess.run(cmd, check=True)
|
||||
return output_path
|
||||
14
src/voice_gen.py
Normal file
14
src/voice_gen.py
Normal file
@ -0,0 +1,14 @@
|
||||
import asyncio
|
||||
import edge_tts
|
||||
|
||||
VOICE = "en-US-ChristopherNeural"
|
||||
|
||||
|
||||
async def _synthesize(text: str, output_path: str):
|
||||
communicate = edge_tts.Communicate(text, VOICE)
|
||||
await communicate.save(output_path)
|
||||
|
||||
|
||||
def generate_audio(text: str, output_path: str):
|
||||
"""Generate an MP3 from text using Edge TTS and save to output_path."""
|
||||
asyncio.run(_synthesize(text, output_path))
|
||||
Loading…
Reference in New Issue
Block a user