feat: Threads trending scraper — web scraping, engagement filtering, av migration

- Web scraper (platforms/threads/scraper.py) with div-based card parsing
- Multi-source discovery: For You feed + configurable search queries
- Engagement filtering (min_engagement) and post age filter (max_post_age)
- Shared Playwright auth module (platforms/threads/auth.py)
- Migrated ffmpeg-python to av (PyAV) for in-process media probing
- Video composition uses subprocess ffmpeg (av filter graph segfault workaround)
- Updated CLAUDE.md with Threads scraping and macOS-specific notes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
pull/2551/head
Hong Phuc 4 weeks ago
parent 89b1c0d0e5
commit 5e183d8e2c

@ -6,7 +6,7 @@
**Status:** Production-ready, actively maintained (v3.4.0)
**Language:** Python 3.10+
**Platforms:** Reddit (original), Threads (NEW), X/Twitter (planned)
**Platforms:** Reddit (PRAW API), Threads (Graph API + Web Scraping)
### Core Mission
Transforms social media threads (post + comments/replies) into complete short-form videos with:
@ -14,6 +14,7 @@ Transforms social media threads (post + comments/replies) into complete short-fo
- UI screenshots (Playwright)
- Background video/audio overlays
- FFmpeg composition & output
- Optional YouTube upload
---
@ -23,162 +24,120 @@ Transforms social media threads (post + comments/replies) into complete short-fo
main.py (CLI)
↓ [platform factory]
├─→ reddit/subreddit.py [PRAW API]
└─→ platforms/threads/fetcher.py [Graph API]
↓ [standard data dict]
├─→ TTS/engine_wrapper.py [7+ providers]
├─→ screenshot_downloader.py (Reddit)
│ or platforms/threads/screenshot.py (Threads)
├─→ video_creation/background.py
└─→ video_creation/final_video.py [FFmpeg]
results/{category}/{video.mp4}
└─→ platforms/threads/
├─→ fetcher.py [Graph API — your own posts]
├─→ scraper.py [Web scraping — trending For You feed]
└─→ auth.py [Shared Playwright login + cookies]
↓ [standard data dict]
├─→ TTS/engine_wrapper.py [7+ providers, auto-fallback]
├─→ screenshot_downloader.py (Reddit)
│ or platforms/threads/screenshot.py (Threads)
├─→ video_creation/background.py [local or yt-dlp]
├─→ video_creation/youtube_uploader.py [optional auto-upload]
└─→ video_creation/final_video.py [FFmpeg with libx264]
results/{category}/{video.mp4}
```
### Key Design: Platform Abstraction via Factory Pattern
**Why:** Single codebase supports multiple platforms without tight coupling.
**How:** `platforms/__init__.py` exports:
- `get_content_object(POST_ID=None)` — routes to right fetcher
- `get_screenshot_fn()` — routes to right screenshotter
**Result:** Adding X/Twitter requires only: new module + config section + two `elif` branches.
---
## Data Contract: The "content_object" Dict
All fetchers return this shape (defined in `platforms/__init__.py`):
All fetchers return this shape:
```python
{
# Unique identifiers
"thread_id": str, # Used for temp folder: assets/temp/{id}/
"thread_category": str, # "reddit", "threads", etc. → output folder
# Content
"thread_title": str, # TTS as title + output filename
"thread_category": str, # "reddit", "threads" → output folder
"thread_title": str, # TTS + output filename (clean, no metadata)
"thread_url": str, # Playwright navigates here for screenshot
"is_nsfw": bool, # Content filter flag
# Replies/Comments (mutually exclusive with thread_post)
"is_nsfw": bool,
"comments": [
{
"comment_body": str, # TTS per reply
"comment_body": str, # TTS per reply (clean body text)
"comment_url": str, # Playwright navigates here
"comment_id": str, # CSS selector ID or unique identifier
"comment_id": str, # Unique identifier (URL-based for scraper)
}
],
# OR Story mode:
"thread_post": str | list, # Long-form text (no comments)
"thread_post": str | list, # Story mode (no comments)
}
```
**Why:** Loose coupling—TTS, backgrounds, and video composition don't need platform-specific logic.
---
## File Organization
```
VideoMakerBot/
├── platforms/ # Multi-platform abstraction
│ ├── __init__.py # Factory: get_content_object(), get_screenshot_fn()
│ └── threads/ # Threads (Meta) implementation
│ ├── fetcher.py # Graph API → content_object
│ └── screenshot.py # Playwright Threads screenshotter
├── platforms/
│ ├── __init__.py # Factory: get_content_object(), get_screenshot_fn()
│ └── threads/
│ ├── auth.py # Shared Playwright login + cookie management
│ ├── fetcher.py # Graph API → content_object (your own posts)
│ ├── scraper.py # Web scraping → content_object (trending feed)
│ └── screenshot.py # Playwright Threads screenshotter (div-based)
├── reddit/ # Reddit implementation (kept as-is)
│ └── subreddit.py # PRAW API → content_object + thread_category
├── reddit/
│ └── subreddit.py # PRAW API → content_object
├── video_creation/
│ ├── final_video.py # FFmpeg composition (platform-aware folder naming)
│ ├── screenshot_downloader.py # Playwright Reddit UI capturer
│ ├── voices.py # TTS orchestrator (platform-agnostic)
│ ├── background.py # Video/audio downloader (platform-agnostic)
│ └── data/
│ ├── videos.json # Dedup tracker
│ ├── cookie-dark-mode.json # Reddit theme cookie
│ └── cookie-threads.json # Threads session cookie (auto-created)
│ ├── final_video.py # FFmpeg composition (libx264, no drawtext on macOS)
│ ├── background.py # Video/audio downloader (local files or yt-dlp)
│ ├── screenshot_downloader.py # Playwright Reddit UI capturer
│ ├── voices.py # TTS orchestrator
│ └── youtube_uploader.py # YouTube OAuth2 upload (post-render hook)
├── TTS/ # Text-to-Speech
│ ├── engine_wrapper.py # Provider abstraction + post_lang fallback
│ ├── elevenlabs.py, aws_polly.py, etc. # 7+ provider implementations
├── TTS/
│ ├── engine_wrapper.py # Provider abstraction + TikTok→pyttsx3 fallback
│ ├── TikTok.py # TikTok TTS (hardened error handling)
│ └── ... # 7+ provider implementations
├── utils/
│ ├── settings.py # Config loading + validation
│ ├── videos.py # check_done() + check_done_by_id()
│ ├── console.py # Rich terminal output
│ ├── .config.template.toml # Config schema (platform sections)
│ └── ... (id, voice, cleanup, etc.)
│ ├── settings.py # Config loading + interactive validation
│ ├── videos.py # check_done() + check_done_by_id()
│ ├── console.py # Rich terminal output
│ ├── .config.template.toml # Config schema
│ ├── background_videos.json # Background video manifest
│ ├── background_audios.json # Background audio manifest
│ └── ...
├── main.py # CLI entry (platform-routed via factory)
├── GUI.py # Flask web UI (localhost:4000)
├── requirements.txt # Dependencies
└── CLAUDE.md / AGENT.md # This file + agent guidelines
├── main.py # CLI entry (platform-routed via factory)
├── GUI.py # Flask web UI (localhost:4000)
├── requirements.txt
└── CLAUDE.md
```
---
## Configuration
**File:** `utils/.config.template.toml` (schema) → `config.toml` (user config)
### Threads (full config)
### Platform Selection
```toml
[settings]
platform = "reddit" # or "threads"
post_lang = "es-cr" # Optional: translation language (all platforms)
```
### Reddit Config
```toml
[reddit.creds]
client_id = "..." # OAuth app
client_secret = "..."
username = "..."
password = "..."
2fa = true/false
platform = "threads"
[reddit.thread]
subreddit = "AskReddit"
post_id = "" # Leave blank for auto-pick
max_comment_length = 500
min_comment_length = 1
min_comments = 20
blocked_words = "..."
```
[threads]
discovery_method = "scrape" # "api" (Graph API, own posts) or "scrape" (trending feed)
### Threads Config (NEW)
```toml
[threads.creds]
access_token = "EAABsbCS..." # Meta Graph API token (60-day expiry)
user_id = "12345678901234567"
username = "your_insta" # For Playwright login
username = "your_insta" # For Playwright login (always needed)
password = "your_password"
access_token = "" # Only for discovery_method="api"
user_id = "" # Only for discovery_method="api"
[threads.thread]
post_id = "" # Leave blank for auto-pick
post_id = "" # Specific post ID; blank = auto-pick from feed
max_reply_length = 500
min_reply_length = 1
min_replies = 5
blocked_words = "..."
```
### Generic Settings
```toml
[settings]
theme = "dark"
resolution_w = 1080
resolution_h = 1920
storymode = false
times_to_run = 1
min_replies = 5 # Minimum replies for post eligibility
min_engagement = 0 # Minimum likes+reposts for viral filter (0=disabled, 10000=viral)
blocked_words = ""
[settings.tts]
voice_choice = "tiktok" # or "elevenlabs", "awspolly", "googletranslate", etc.
random_voice = true
silence_duration = 0.3
voice_choice = "googletranslate" # Best for macOS: no API key, fast, free
# voice_choice = "tiktok" # Needs tiktok_sessionid; auto-falls back to pyttsx3
# voice_choice = "OpenAI" # Needs openai_api_key
[settings.background]
background_video = "minecraft"
@ -186,167 +145,117 @@ background_audio = "lofi"
background_audio_volume = 0.15
```
---
### Reddit (reference)
## Development Guidelines
```toml
[settings]
platform = "reddit"
### ✅ DO:
[reddit.creds]
client_id = "..."
client_secret = "..."
username = "..."
password = "..."
2fa = false
2fa_secret = "" # TOTP base32 secret for auto-2FA
1. **Use platform factory in main.py**
```python
from platforms import get_content_object, get_screenshot_fn
reddit_object = get_content_object(POST_ID)
screenshot_fn = get_screenshot_fn()
screenshot_fn(reddit_object, number_of_comments)
```
2. **Return standard content dict** from all fetchers
```python
return {
"thread_id": ...,
"thread_category": ..., # NEW: replaces hardcoded subreddit
"comments": [...]
}
```
3. **Use config fallback chains** for cross-platform keys
```python
lang = (settings.config["settings"].get("post_lang") or
settings.config.get("reddit", {}).get("thread", {}).get("post_lang", ""))
```
4. **Read thread_category from dict** instead of config
```python
# WRONG:
subreddit = settings.config["reddit"]["thread"]["subreddit"]
# RIGHT:
platform = settings.config["settings"].get("platform", "reddit")
if platform == "reddit":
subreddit = settings.config["reddit"]["thread"]["subreddit"]
else:
subreddit = reddit_obj.get("thread_category", platform)
```
5. **Test both platforms** after core pipeline changes
```bash
# Test Reddit (must not regress)
sed -i 's/platform = "threads"/platform = "reddit"/' config.toml
python3 main.py
# Test Threads
sed -i 's/platform = "reddit"/platform = "threads"/' config.toml
python3 main.py --post-id <threads-id>
```
[reddit.thread]
subreddit = "AskReddit"
min_comments = 20
```
### ❌ DON'T:
### YouTube upload
1. **Don't import platform modules directly** in main.py/utils
```python
# WRONG: from reddit.subreddit import get_subreddit_threads
# RIGHT: from platforms import get_content_object
```
```toml
[youtube]
enabled = false # Set true to auto-upload after render
privacy = "public" # or "private", "unlisted"
client_secret_path = "" # Path to youtube_client_secret.json
```
2. **Don't hardcode platform names** in generic modules
```python
# WRONG in final_video.py:
subreddit = settings.config["reddit"]["thread"]["subreddit"]
---
# RIGHT:
subreddit = reddit_obj.get("thread_category", "unknown")
```
## Platform-Specific Knowledge
3. **Don't add platform-specific UI selectors** outside `platforms/{platform}/screenshot.py`
- Reddit selectors stay in `video_creation/screenshot_downloader.py`
- Threads selectors stay in `platforms/threads/screenshot.py`
### Threads — Web Scraping (discovery_method = "scrape")
4. **Don't assume config keys exist** without fallback
```python
# WRONG: lang = settings.config["reddit"]["thread"]["post_lang"]
# RIGHT: lang = settings.config.get("settings", {}).get("post_lang", "")
```
**DOM Structure:**
- Threads.net uses **div-based card layout** — NO `<article>` elements anywhere
- Feed posts: `a[href*="/post/"]` links inside `<div>` cards (class contains `x1a2a7pz`)
- Post pages: same structure; main post link appears first, replies follow
- Screenshots: Use `a[href*="/post/"]` → ancestor div card, NOT `page.locator("article")`
---
**Card Text Format (used by `_parse_card_text()`):**
```
Line 0: username
Line 1: timestamp (e.g., "14h", "1d")
Line 2..N: post body text
Last 1-4: engagement metrics (likes, replies, reposts, quotes)
```
## Platform-Specific Knowledge
**Engagement Parsing:**
- Numbers can be plain ("266") or abbreviated ("1K", "2.5M")
- `likes` = first trailing number, `replies` = second, `reposts` = third
- `min_engagement` filters by `likes + reposts` total
- Posts are sorted by engagement descending before selection
### Reddit
- **API:** PRAW (Python Reddit API Wrapper)
- **Auth:** OAuth app (client_id, secret) + username/password
- **Screenshot:** Playwright on reddit.com/new.reddit.com
- Login form: `input[name="username"]`, `input[name="password"]`
- Post selector: `[data-test-id="post-content"]`
- Comment selector: `#t1_{comment_id}`
- **NSFW:** `submission.over_18`
- **Output folder:** `results/{subreddit}/`
### Threads
- **API:** Meta Graph API (v18.0+)
- **Auth:** User access token (60-day lifetime) via https://developers.facebook.com/
- **Screenshot:** Playwright on threads.net
- Login form: `input[autocomplete="username"]`, `input[autocomplete="current-password"]`
- Post selector: `article` (universal, more stable than Reddit)
- Cookies saved to: `video_creation/data/cookie-threads.json`
- **NSFW:** API doesn't provide; always False
- **Output folder:** `results/threads/`
### Future: X/Twitter
Create: `platforms/twitter/fetcher.py` + `platforms/twitter/screenshot.py` + config section
Update: `platforms/__init__.py` with `elif platform == "twitter"` branches
**Login Flow:**
- Threads uses Instagram auth (`threads.net/login`)
- Selectors: `input[autocomplete="username"]`, `input[autocomplete="current-password"]`
- Button: `get_by_role("button", name="Log in", exact=True).first`
- Cookies cached at `video_creation/data/cookie-threads.json`
- Login logic is shared via `platforms/threads/auth.py`
---
**API Limitation:**
- Graph API v1.0 only accesses YOUR OWN posts — no trending/discovery
- Scraping bypasses this entirely — no API token needed
## Extending the Project
### Threads — Graph API (discovery_method = "api")
### Adding a New TTS Provider
1. Create `TTS/my_provider.py` with a class implementing the TTS interface
2. Add config keys to `[settings.tts]` in `.config.template.toml`
3. Update `TTS/engine_wrapper.py` to call your provider
4. Test with `settings.config["settings"]["tts"]["voice_choice"] = "my_provider"`
- Auth: Bearer token, 60-day expiry
- Only accesses authenticated user's own threads + replies
- Use when you have your own content with replies
### Adding a New Platform (e.g., X/Twitter)
1. **Create fetcher:** `platforms/twitter/fetcher.py`
- Implement `get_twitter_content(POST_ID=None)` returning standard dict
2. **Create screenshotter:** `platforms/twitter/screenshot.py`
- Implement `get_screenshots_of_twitter_posts(content_object, screenshot_num)`
3. **Update config:** Add `[twitter.creds]` and `[twitter.thread]` sections
4. **Update factory:** Add `elif platform == "twitter"` in `platforms/__init__.py`
5. **Update CLI helper:** Add case to `_get_platform_post_id()` in `main.py`
6. **Test:** Verify Reddit mode still works, test Twitter mode end-to-end
### Reddit
**Zero changes needed to:** TTS, backgrounds, video composition, or utils.
- **API:** PRAW (Python Reddit API Wrapper)
- **Post discovery:** `subreddit.hot(limit=25)``get_subreddit_undone()` → fallback to `top(day/hour/month/week/year/all)`
- **Screenshot:** Playwright on new.reddit.com
- **2FA:** Auto-TOTP via `pyotp` when `2fa_secret` is configured in config.toml
---
## Debugging Tips
### "No matching distribution found for yt-dlp==2026.3.17"
→ yt-dlp uses date versioning (YYYY.M.DD, no leading zeros). Use `2025.10.14` (latest stable).
## Development Guidelines
### "Threads API: Invalid or expired access_token"
→ Meta tokens expire every 60 days. Refresh at https://developers.facebook.com/tools/explorer/
### ✅ DO:
### Playwright timeout on Threads screenshot
→ Login cookies corrupted or expired. Delete `video_creation/data/cookie-threads.json` to force fresh login next run.
1. **Use platform factory** — never import platform modules directly
2. **Return standard content_object** from all fetchers
3. **Use clean body text** for TTS — parse out username/timestamp metadata
4. **Default to `googletranslate` TTS on macOS** — pyttsx3 hangs in headless environments
5. **Use `libx264` encoder on macOS**`h264_nvenc` is NVIDIA-only
6. **Test both Threads discovery methods:** `api` and `scrape`
### "No eligible Threads posts found"
→ Configure `[threads.thread].min_replies = 5` (or lower). Ensure your Threads account has public posts with replies.
### ❌ DON'T:
### Video dedup not working
→ Check `video_creation/data/videos.json` is writable. Ensure `check_done_by_id()` is called before fetching content.
1. **Don't use `<article>` selectors** on Threads.net — the DOM is div-based
2. **Don't hardcode `h264_nvenc`** — use `libx264` for cross-platform compatibility
3. **Don't rely on `drawtext` FFmpeg filter** — not available in Homebrew builds
4. **Don't import platform modules directly** in main.py/utils
5. **Don't assume config keys exist** without `.get()` fallback
---
## Testing Checklist
## macOS-Specific Notes
- [ ] Reddit mode: `platform = "reddit"` produces video to `results/{subreddit}/`
- [ ] Threads mode: `platform = "threads"` produces video to `results/threads/`
- [ ] Video dedup: Running same post_id twice skips second run
- [ ] Translation: `post_lang = "es"` translates filenames
- [ ] TTS providers: Test with different voice_choice values
- [ ] Background selection: Custom background video/audio works
- [ ] Story mode: storymode=true only uses thread_post, not comments
- [ ] Error handling: Invalid credentials show clear messages
- **TTS:** `googletranslate` (gTTS) is the most reliable — free, fast, no API key
- `tiktok` auto-falls back to `pyttsx3` if sessionid missing, but pyttsx3 is very slow
- `pyttsx3` works but takes ~60s to initialize NSSpeechSynthesizer
- **FFmpeg encoder:** MUST use `libx264``h264_nvenc` is NVIDIA GPU only
- **FFmpeg filters:** `drawtext` missing from Homebrew bottle — credit text is disabled
- **yt-dlp:** Keep updated (`pip install --upgrade yt-dlp`) — YouTube changes APIs frequently
- Format selector: `best[height<=1080]` not `bestvideo` (many videos lack video-only streams)
- Upgrade path: `pip install --upgrade yt-dlp`
---
@ -354,96 +263,78 @@ Update: `platforms/__init__.py` with `elif platform == "twitter"` branches
| File | Purpose |
|------|---------|
| `main.py` | CLI entry; orchestrates pipeline via factory |
| `platforms/__init__.py` | Factory dispatch for multi-platform support |
| `platforms/threads/fetcher.py` | Threads Graph API client |
| `platforms/threads/screenshot.py` | Threads.net Playwright screenshotter |
| `video_creation/final_video.py` | FFmpeg composition; platform-aware output naming |
| `TTS/engine_wrapper.py` | TTS provider abstraction; post_lang fallback |
| `utils/settings.py` | Config loading & validation |
| `main.py` | CLI entry; pipeline orchestration via factory |
| `platforms/__init__.py` | Factory dispatch (platform + discovery_method) |
| `platforms/threads/scraper.py` | **NEW** — Web scraping fetcher with engagement parsing |
| `platforms/threads/auth.py` | **NEW** — Shared Playwright login + cookie management |
| `platforms/threads/fetcher.py` | Graph API client (own posts only) |
| `platforms/threads/screenshot.py` | Div-based Threads screenshotter |
| `video_creation/final_video.py` | FFmpeg composition (libx264, platform-aware output) |
| `video_creation/background.py` | Background downloader (local files + yt-dlp) |
| `video_creation/youtube_uploader.py` | **NEW** — OAuth2 YouTube upload |
| `TTS/engine_wrapper.py` | TTS provider abstraction + TikTok fallback |
| `TTS/TikTok.py` | Hardened TikTok TTS with graceful error handling |
| `reddit/subreddit.py` | PRAW Reddit fetcher with auto-2FA |
| `utils/settings.py` | Config loading + interactive validation |
| `utils/videos.py` | Video dedup tracking |
| `utils/.config.template.toml` | Config schema |
| `requirements.txt` | Dependencies |
| `utils/background_videos.json` | Background video manifest |
| `utils/background_audios.json` | Background audio manifest |
---
## Useful Commands
```bash
# Install dependencies
pip install -r requirements.txt
# Run CLI
python3 main.py
# Run with specific post
python3 main.py <post_id>
# Run Flask GUI
python3 GUI.py
# Check syntax
python3 -m py_compile main.py platforms/threads/fetcher.py
# Format code
black main.py platforms/ utils/
## Debugging Tips
# Lint
pylint main.py
```
### FFmpeg "Unknown encoder 'h264_nvenc'"
→ On macOS, change to `libx264`. Find-and-replace `h264_nvenc``libx264` in `video_creation/final_video.py`.
---
### FFmpeg "No such filter: 'drawtext'"
→ Homebrew FFmpeg lacks drawtext. The credit text overlay is automatically skipped.
## When You Get Stuck
### yt-dlp "Requested format is not available"
→ Update yt-dlp: `pip install --upgrade yt-dlp`. Also change format selector from `bestvideo` to `best` in `video_creation/background.py`.
1. **"What does this module do?"** → Check imports in `main.py` or docstrings
2. **"How do I add support for platform X?"** → See "Adding a New Platform" section above
3. **"Why is my config not being read?"** → Check `utils/settings.py:check_toml()` and `.config.template.toml` schema
4. **"Why isn't my TTS provider being called?"** → Check `TTS/engine_wrapper.py:make_voice()` and config `voice_choice`
5. **"How do I debug the Playwright screenshot?"** → Uncomment `page.pause()` in screenshot downloader, run headful browser
### pyttsx3 hang on macOS
→ NSSpeechSynthesizer needs GUI session. Switch to `voice_choice = "googletranslate"` for headless use.
Good luck! 🚀
### Threads screenshots fail ("Main post article not found")
→ Threads.net uses div cards, not `<article>`. Ensure screenshot code uses `a[href*="/post/"]` → ancestor div approach.
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
### Config validator EOFError in non-interactive mode
`check_toml()` prompts for ALL platform sections regardless of `platform` setting. Fill ALL required fields or load config directly with `toml.load()` + `settings.config = ...`.
This project is indexed by GitNexus as **VideoMakerBot** (802 symbols, 1287 relationships, 32 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
### Playwright timeout on Threads login
→ Cookies corrupted. Delete `video_creation/data/cookie-threads.json` for fresh login. Also check button selector: must use `exact=True` due to multiple "Log in" buttons.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
### No viral posts found
→ Lower `min_engagement` in config. Most Threads feed posts have <100 likes 10000 filters almost everything.
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
---
## Never Do
## Useful Commands
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
```bash
# Install dependencies
pip install -r requirements.txt
## Resources
# Run CLI
python3 main.py
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/VideoMakerBot/context` | Codebase overview, check index freshness |
| `gitnexus://repo/VideoMakerBot/clusters` | All functional areas |
| `gitnexus://repo/VideoMakerBot/processes` | All execution flows |
| `gitnexus://repo/VideoMakerBot/process/{name}` | Step-by-step execution trace |
# Run bypassing config validator (non-interactive)
python3 -c "
import sys, toml
sys.path.insert(0, '.')
from utils import settings
settings.config = toml.load('config.toml')
from main import main; main()
"
## CLI
# Update yt-dlp (YouTube downloads fix)
pip install --upgrade yt-dlp
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
# Check syntax
python3 -m py_compile main.py platforms/threads/scraper.py
<!-- gitnexus:end -->
# Run Flask GUI
python3 GUI.py
```

@ -26,8 +26,13 @@ def get_content_object(POST_ID=None) -> dict:
return get_subreddit_threads(POST_ID)
elif platform == "threads":
from platforms.threads.fetcher import get_threads_content
return get_threads_content(POST_ID)
discovery = settings.config.get("threads", {}).get("discovery_method", "api")
if discovery == "scrape":
from platforms.threads.scraper import get_trending_threads_content
return get_trending_threads_content(POST_ID)
else:
from platforms.threads.fetcher import get_threads_content
return get_threads_content(POST_ID)
else:
raise ValueError(

@ -0,0 +1,95 @@
"""Shared Playwright authentication for Threads.net.
Used by both the screenshotter (screenshot.py) and the web scraper (scraper.py).
"""
import json
from pathlib import Path
from playwright.sync_api import Browser, BrowserContext, Page, ViewportSize
from utils import settings
from utils.console import print_substep
THREADS_LOGIN_URL = "https://www.threads.net/login"
THREADS_COOKIE_FILE = "./video_creation/data/cookie-threads.json"
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
)
def login_to_threads(page: Page, _context: BrowserContext) -> None:
"""Log into threads.net via Instagram credentials and persist session cookies."""
username = settings.config["threads"]["creds"].get("username", "").strip()
password = settings.config["threads"]["creds"].get("password", "").strip()
if not username or not password:
raise RuntimeError(
"Threads login requires credentials. "
"Set threads.creds.username and threads.creds.password in config.toml"
)
print_substep("Logging into Threads (via Instagram)...")
page.goto(THREADS_LOGIN_URL, timeout=0)
page.wait_for_load_state("networkidle")
page.locator('input[autocomplete="username"]').fill(username)
page.locator('input[autocomplete="current-password"]').fill(password)
page.get_by_role("button", name="Log in", exact=True).first.click()
page.wait_for_timeout(6000)
cookies = _context.cookies()
Path(THREADS_COOKIE_FILE).parent.mkdir(parents=True, exist_ok=True)
with open(THREADS_COOKIE_FILE, "w") as f:
json.dump(cookies, f)
print_substep("Logged into Threads and saved session cookies.", style="bold green")
def ensure_authenticated_context(browser: Browser, **kwargs) -> BrowserContext:
"""Create a Playwright browser context with Threads session cookies loaded.
Loads saved cookies from cookie-threads.json. If no valid session exists,
performs a fresh login and persists the cookies.
Keyword arguments override defaults for locale, viewport, device_scale_factor,
color_scheme, and user_agent.
"""
theme = settings.config["settings"]["theme"]
W = int(settings.config["settings"]["resolution_w"])
H = int(settings.config["settings"]["resolution_h"])
dsf = (W // 600) + 1
defaults = {
"locale": "en-US",
"color_scheme": "dark" if theme == "dark" else "light",
"viewport": ViewportSize(width=W, height=H),
"device_scale_factor": dsf,
"user_agent": DEFAULT_USER_AGENT,
}
defaults.update(kwargs)
context = browser.new_context(**defaults)
cookie_path = Path(THREADS_COOKIE_FILE)
if cookie_path.exists():
try:
with open(cookie_path, encoding="utf-8") as f:
saved_cookies = json.load(f)
context.add_cookies(saved_cookies)
print_substep("Loaded saved Threads session cookies.")
except (json.JSONDecodeError, IOError):
print_substep("Saved cookies corrupted. Logging in fresh...")
page = context.new_page()
login_to_threads(page, context)
page.close()
else:
print_substep("No saved cookies found. Logging in...")
page = context.new_page()
login_to_threads(page, context)
page.close()
return context

@ -0,0 +1,587 @@
"""Web scraping-based trending post discovery for Threads.net.
Bypasses the Meta Graph API (which only accesses your own posts) by using Playwright
to scrape threads.net directly the "For You" feed, post pages, and replies.
Returns the standard content_object dict consumed by the rest of the pipeline.
"""
import re
from typing import Optional
from playwright.sync_api import BrowserContext, Locator, sync_playwright
from platforms.threads.auth import ensure_authenticated_context
from utils import settings
from utils.console import print_step, print_substep
from utils.voice import sanitize_text
from utils.videos import check_done_by_id
FEED_URL = "https://www.threads.net"
SCROLL_DELAY_MS = 2000
MAX_FEED_SCROLLS = 36
POST_LINK_SELECTOR = 'a[href*="/post/"]'
CARD_XPATH = 'xpath=ancestor::div[contains(@class, "x1a2a7pz")][1]'
def _post_id_from_url(url: str) -> str:
return url.rstrip("/").split("/")[-1]
def _to_absolute_url(href: str) -> str:
if href.startswith("http"):
return href
return "https://www.threads.net" + href
def _parse_abbreviated_number(s: str) -> int:
"""Parse abbreviated numbers like '1K', '2.5M' into integers."""
s = s.strip().upper().replace(",", "")
if not s:
return 0
multipliers = {"K": 1_000, "M": 1_000_000}
if s[-1] in multipliers:
try:
return int(float(s[:-1]) * multipliers[s[-1]])
except ValueError:
return 0
try:
return int(s)
except ValueError:
return 0
def _parse_card_text(text: str) -> dict:
"""Parse a Threads card's raw text into structured data.
Threads card format:
line 0: username
line 1: timestamp (e.g. "14h", "1d")
lines 2..N: post body text
last 1-4 lines: engagement metrics (likes, replies, reposts, quotes)
Returns dict with keys: username, timestamp, body, likes, replies, reposts
"""
if not text:
return {"username": "", "timestamp": "", "body": "", "likes": 0, "replies": 0, "reposts": 0}
lines = text.strip().split("\n")
if len(lines) < 3:
return {"username": "", "timestamp": "", "body": text, "likes": 0, "replies": 0, "reposts": 0}
username = lines[0].strip()
timestamp = lines[1].strip()
# Find where engagement metrics start (trailing numeric/abbreviated lines)
metric_start = len(lines)
for i in range(len(lines) - 1, 1, -1):
line = lines[i].strip()
if re.match(r'^[\d,.]+[KkMm]?$', line):
metric_start = i
else:
break
# Body is everything between timestamp and metrics
body_lines = lines[2:metric_start]
body = "\n".join(body_lines).strip()
# Parse engagement metrics from the end
metrics = lines[metric_start:]
likes = 0
replies_count = 0
reposts = 0
if len(metrics) >= 1:
likes = _parse_abbreviated_number(metrics[0])
if len(metrics) >= 2:
replies_count = _parse_abbreviated_number(metrics[1])
if len(metrics) >= 3:
reposts = _parse_abbreviated_number(metrics[2])
return {
"username": username,
"timestamp": timestamp,
"body": body,
"likes": likes,
"replies": replies_count,
"reposts": reposts,
}
def _extract_text_from_card(link: Locator) -> str:
"""Walk up from a post link to the card container and extract its raw text."""
try:
card = link.locator(CARD_XPATH)
if card.count():
return card.first.inner_text(timeout=3000).strip()
except Exception:
pass
return ""
# --- Feed scraping ---
def _scrape_feed_posts(context: BrowserContext, max_scrolls: int = MAX_FEED_SCROLLS) -> list[dict]:
"""Navigate to threads.net feed, scroll, extract post metadata with engagement metrics."""
print_step("Scraping Threads trending feed...")
page = context.new_page()
posts: list[dict] = []
seen_ids: set[str] = set()
try:
page.goto(FEED_URL, timeout=0)
page.wait_for_timeout(4000)
last_height = 0
for i in range(max_scrolls):
links = page.locator(POST_LINK_SELECTOR).all()
new_found = 0
for link in links:
href = link.get_attribute("href")
if not href:
continue
post_id = _post_id_from_url(href)
if post_id in seen_ids:
continue
seen_ids.add(post_id)
raw_text = _extract_text_from_card(link)
parsed = _parse_card_text(raw_text)
posts.append({
"url": _to_absolute_url(href),
"text": raw_text,
"body": parsed["body"],
"username": parsed["username"],
"timestamp": parsed["timestamp"],
"likes": parsed["likes"],
"replies_shown": parsed["replies"],
"reposts": parsed["reposts"],
"post_id": post_id,
})
new_found += 1
if new_found > 0:
top = posts[-1]
print_substep(
f"Scroll {i + 1}: +{new_found} posts | top: "
f"{top['likes']:,} 💬{top['replies_shown']} 🔁{top['reposts']} "
f"'{top['body'][:50]}...'",
style="dim",
)
if new_found == 0 and i > 5:
break
page.evaluate("window.scrollBy(0, document.body.scrollHeight)")
page.wait_for_timeout(SCROLL_DELAY_MS)
new_height = page.evaluate("document.body.scrollHeight")
if new_height == last_height:
break
last_height = new_height
finally:
page.close()
print_substep(f"Scraped {len(posts)} posts from feed.", style="bold green")
return posts
def _scrape_search_page(context: BrowserContext, query: str, max_scrolls: int = 5) -> list[dict]:
"""Search Threads for a query and scrape the results.
Uses the same card extraction as the main feed.
"""
print_step(f"Scraping Threads search: '{query}'...")
page = context.new_page()
posts: list[dict] = []
seen_ids: set[str] = set()
search_url = f"https://www.threads.net/search?q={query}&serp_type=tags"
try:
page.goto(search_url, timeout=0)
page.wait_for_timeout(4000)
for i in range(max_scrolls):
links = page.locator(POST_LINK_SELECTOR).all()
new_found = 0
for link in links:
href = link.get_attribute("href")
if not href:
continue
post_id = _post_id_from_url(href)
if post_id in seen_ids:
continue
seen_ids.add(post_id)
raw_text = _extract_text_from_card(link)
parsed = _parse_card_text(raw_text)
posts.append({
"url": _to_absolute_url(href),
"text": raw_text,
"body": parsed["body"],
"username": parsed["username"],
"timestamp": parsed["timestamp"],
"likes": parsed["likes"],
"replies_shown": parsed["replies"],
"reposts": parsed["reposts"],
"post_id": post_id,
})
new_found += 1
if new_found == 0:
break
page.evaluate("window.scrollBy(0, document.body.scrollHeight)")
page.wait_for_timeout(SCROLL_DELAY_MS)
finally:
page.close()
print_substep(f"Search '{query}': {len(posts)} posts.", style="dim")
return posts
# --- Candidate filtering ---
def _parse_timestamp_to_hours(ts: str) -> float | None:
"""Convert a Threads timestamp like '14h', '1d', '3d' to hours.
Returns None if the format is unrecognized.
"""
if not ts:
return None
ts = ts.strip().lower()
if ts.endswith("h"):
try:
return float(ts[:-1])
except ValueError:
return None
elif ts.endswith("d"):
try:
return float(ts[:-1]) * 24
except ValueError:
return None
elif ts.endswith("w"):
try:
return float(ts[:-1]) * 24 * 7
except ValueError:
return None
elif ts.endswith("m") and not ts.endswith("min"):
try:
return float(ts[:-1]) * 24 * 30
except ValueError:
return None
return None
def _age_from_config() -> float | None:
"""Parse max_post_age config value into hours. Returns None if disabled."""
raw = settings.config["threads"]["thread"].get("max_post_age", "")
if not raw:
return None
return _parse_timestamp_to_hours(raw)
def _contains_blocked(text: str, blocked_raw: str) -> bool:
if not blocked_raw:
return False
blocked = [w.strip().lower() for w in blocked_raw.split(",") if w.strip()]
text_lower = text.lower()
return any(word in text_lower for word in blocked)
def _filter_candidates(posts: list[dict]) -> list[dict]:
"""Filter feed posts by engagement, blocked words, and duplicates.
Sorts by total engagement (likes + replies) descending so the most
viral posts are tried first.
"""
t_config = settings.config["threads"]["thread"]
blocked_raw = t_config.get("blocked_words", "")
min_engagement = int(t_config.get("min_engagement", 0))
max_age_hours = _age_from_config()
candidates = []
for post in posts:
if check_done_by_id(post["post_id"]):
continue
if _contains_blocked(post["body"], blocked_raw):
continue
if not post["body"] or len(post["body"].strip()) < 10:
continue
# Age filter
if max_age_hours is not None:
post_hours = _parse_timestamp_to_hours(post.get("timestamp", ""))
if post_hours is not None and post_hours > max_age_hours:
continue
total_engagement = post.get("likes", 0) + post.get("reposts", 0)
if total_engagement < min_engagement:
continue
post["_total_engagement"] = total_engagement
candidates.append(post)
# Sort by engagement descending — most viral first
candidates.sort(key=lambda p: p.get("_total_engagement", 0), reverse=True)
age_str = f", max age ≤{max_age_hours}h" if max_age_hours else ""
if min_engagement > 0:
print_substep(
f"Filtered {len(posts)} posts -> {len(candidates)} viral candidates "
f"(min ♥+🔁 ≥ {min_engagement:,}{age_str})",
style="dim",
)
else:
print_substep(
f"Filtered {len(posts)} posts -> {len(candidates)} candidates"
f"{' (max age ≤' + str(max_age_hours) + 'h)' if max_age_hours else ''}",
style="dim",
)
return candidates
# --- Reply scraping on post pages ---
def _scrape_post_replies(context: BrowserContext, post_url: str, max_replies: int = 100) -> list[dict]:
"""Navigate to a post page, scroll to load replies, extract reply data.
Uses _parse_card_text to separate reply body from metadata (username, timestamp, etc.).
"""
page = context.new_page()
replies: list[dict] = []
seen_ids: set[str] = set()
main_post_id = _post_id_from_url(post_url)
try:
page.goto(post_url, timeout=0)
page.wait_for_timeout(4000)
stable_count = 0
last_count = 0
for _ in range(15):
links = page.locator(POST_LINK_SELECTOR).all()
for link in links:
href = link.get_attribute("href")
if not href:
continue
reply_id = _post_id_from_url(href)
if reply_id == main_post_id:
continue
if reply_id in seen_ids:
continue
seen_ids.add(reply_id)
raw_text = _extract_text_from_card(link)
if not raw_text:
continue
parsed = _parse_card_text(raw_text)
cleaned_body = parsed["body"]
replies.append({
"comment_body": cleaned_body,
"comment_url": _to_absolute_url(href),
"comment_id": reply_id,
})
if len(replies) >= max_replies:
break
if len(replies) >= max_replies:
break
if len(replies) == last_count:
stable_count += 1
if stable_count >= 3:
break
else:
stable_count = 0
last_count = len(replies)
page.evaluate("window.scrollBy(0, document.body.scrollHeight)")
page.wait_for_timeout(1500)
finally:
page.close()
return replies
def _scrape_main_post_text(context: BrowserContext, post_url: str) -> str:
"""Extract and clean the main post text from a post page."""
page = context.new_page()
try:
page.goto(post_url, timeout=0)
page.wait_for_timeout(3000)
links = page.locator(POST_LINK_SELECTOR).all()
for link in links:
href = link.get_attribute("href")
if href and _post_id_from_url(href) == _post_id_from_url(post_url):
raw = _extract_text_from_card(link)
if raw:
parsed = _parse_card_text(raw)
return parsed["body"] or raw
return ""
finally:
page.close()
# --- Content object builder ---
def _build_content_object(post: dict, replies: list[dict]) -> dict:
"""Build the standard content_object from scraped post + replies.
Uses cleaned body text for title and comment bodies.
"""
t_config = settings.config["threads"]["thread"]
max_len = int(t_config["max_reply_length"])
min_len = int(t_config["min_reply_length"])
blocked_raw = t_config.get("blocked_words", "")
storymode = settings.config["settings"].get("storymode", False)
# Use cleaned body text for the title, fall back to raw text
title = post.get("body") or post.get("text") or ""
content: dict = {
"thread_id": post["post_id"],
"thread_title": title[:280],
"thread_url": post["url"],
"is_nsfw": False,
"thread_category": "threads",
"comments": [],
}
if storymode:
content["thread_post"] = title
print_substep("Storymode: using post text as thread_post.", style="dim")
return content
for reply in replies:
body = reply.get("comment_body", "").strip()
if not body:
continue
if _contains_blocked(body, blocked_raw):
continue
if not (min_len <= len(body) <= max_len):
continue
sanitised = sanitize_text(body)
if not sanitised:
continue
content["comments"].append({
"comment_body": body,
"comment_url": reply["comment_url"],
"comment_id": reply["comment_id"],
})
return content
# --- Main entry point ---
def get_trending_threads_content(POST_ID: Optional[str] = None) -> dict:
"""Discover trending Threads posts via web scraping and return a content_object."""
print_step("Discovering trending Threads content via web scraping...")
min_replies = int(settings.config["threads"]["thread"]["min_replies"])
min_engagement = int(settings.config["threads"]["thread"].get("min_engagement", 0))
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
try:
context = ensure_authenticated_context(browser)
if POST_ID:
post_url = f"https://www.threads.net/t/{POST_ID}"
post = {"url": post_url, "post_id": POST_ID, "text": "", "body": ""}
replies = _scrape_post_replies(context, post_url)
content = _build_content_object(post, replies)
if content["comments"] or content.get("thread_post"):
return content
raise RuntimeError(
f"No replies found for post {POST_ID}. "
f"Minimum required: {min_replies}."
)
# Scrape from multiple sources: main feed + trending search queries
posts = _scrape_feed_posts(context)
# Also search for popular topics to find high-engagement content
trending_queries = settings.config["threads"]["thread"].get(
"search_queries", "news,politics,trending"
)
for query in trending_queries.split(","):
query = query.strip()
if query:
try:
search_posts = _scrape_search_page(context, query)
# Merge avoiding duplicates
existing_ids = {p["post_id"] for p in posts}
for sp in search_posts:
if sp["post_id"] not in existing_ids:
posts.append(sp)
except Exception:
pass
if not posts:
raise RuntimeError("No posts found in feed. Try again later.")
candidates = _filter_candidates(posts)
if not candidates:
raise RuntimeError(
f"No eligible posts in feed after filtering. "
f"Try lowering min_engagement (currently {min_engagement:,}) "
f"or min_replies (currently {min_replies})."
)
for i, candidate in enumerate(candidates):
eng = candidate.get("_total_engagement", 0)
print_substep(
f"Trying #{i + 1}: ♥{candidate['likes']:,} "
f"💬{candidate['replies_shown']} "
f"'{candidate['body'][:60]}...'",
style="dim",
)
try:
replies = _scrape_post_replies(context, candidate["url"])
if len(replies) >= min_replies:
if not candidate.get("body") or len(candidate.get("body", "")) < 50:
full_text = _scrape_main_post_text(context, candidate["url"])
if full_text:
candidate["body"] = full_text
content = _build_content_object(candidate, replies)
title_preview = content["thread_title"][:60]
print_substep(
f"Selected: '{title_preview}...' "
f"{candidate['likes']:,} 💬{len(content['comments'])} replies",
style="bold green",
)
return content
print_substep(
f" Only {len(replies)} replies (need {min_replies}). Trying next...",
style="yellow",
)
except Exception as e:
print_substep(f" Failed: {e}. Trying next...", style="yellow")
continue
raise RuntimeError(
f"No eligible posts with {min_replies}+ replies found "
f"after trying {len(candidates)} candidates."
)
finally:
browser.close()

@ -1,62 +1,16 @@
"""Captures screenshots of Threads posts via Playwright."""
import json
import re
from pathlib import Path
from typing import Final
from playwright.sync_api import ViewportSize, sync_playwright
from platforms.threads.auth import ensure_authenticated_context
from utils import settings
from utils.console import print_step, print_substep
THREADS_LOGIN_URL = "https://www.threads.net/login"
THREADS_COOKIE_FILE = "./video_creation/data/cookie-threads.json"
def _login_to_threads(page, context) -> None:
"""
Performs Threads login via Instagram credentials (Threads uses Instagram auth).
Saves session cookies to cookie-threads.json for reuse on future runs.
Args:
page: Playwright page object
context: Playwright browser context
Raises:
RuntimeError: If login credentials are not configured.
"""
username = settings.config["threads"]["creds"].get("username", "").strip()
password = settings.config["threads"]["creds"].get("password", "").strip()
if not username or not password:
raise RuntimeError(
"Threads screenshot login requires credentials. "
"Set threads.creds.username and threads.creds.password in config.toml"
)
print_substep("Logging into Threads (via Instagram)...")
page.goto(THREADS_LOGIN_URL, timeout=0)
page.wait_for_load_state("networkidle")
# Threads login form uses Instagram auth with these selectors
page.locator('input[autocomplete="username"]').fill(username)
page.locator('input[autocomplete="current-password"]').fill(password)
page.get_by_role("button", name="Log in").click()
# Wait for login to complete
page.wait_for_timeout(6000)
# Persist cookies for reuse
cookies = context.cookies()
Path(THREADS_COOKIE_FILE).parent.mkdir(parents=True, exist_ok=True)
with open(THREADS_COOKIE_FILE, "w") as f:
json.dump(cookies, f)
print_substep("Logged into Threads and saved session cookies.", style="bold green")
def get_screenshots_of_threads_posts(content_object: dict, screenshot_num: int) -> None:
"""
Downloads screenshots of Threads posts via Playwright.
@ -89,37 +43,13 @@ def get_screenshots_of_threads_posts(content_object: dict, screenshot_num: int)
with sync_playwright() as p:
print_substep("Launching headless browser...")
browser = p.chromium.launch(headless=True)
context = browser.new_context(
locale="en-US",
context = ensure_authenticated_context(
browser,
color_scheme="dark" if theme == "dark" else "light",
viewport=ViewportSize(width=W, height=H),
device_scale_factor=dsf,
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
),
)
# Try to load saved cookies; if not found or invalid, do a fresh login
cookie_path = Path(THREADS_COOKIE_FILE)
if cookie_path.exists():
try:
with open(cookie_path, encoding="utf-8") as f:
saved_cookies = json.load(f)
context.add_cookies(saved_cookies)
print_substep("Loaded saved Threads session cookies.")
except (json.JSONDecodeError, IOError):
print_substep("Saved cookies corrupted. Logging in fresh...")
page = context.new_page()
_login_to_threads(page, context)
page.close()
else:
print_substep("No saved cookies found. Logging in...")
page = context.new_page()
_login_to_threads(page, context)
page.close()
# Screenshot the main post
page = context.new_page()
page.goto(content_object["thread_url"], timeout=0)
@ -128,13 +58,21 @@ def get_screenshots_of_threads_posts(content_object: dict, screenshot_num: int)
postcontentpath = f"assets/temp/{thread_id}/png/title.png"
try:
# On Threads.net post permalink pages, the main post is the first article element
post_locator = page.locator("article").first
if not post_locator.is_visible():
raise RuntimeError(
"Main post article not found on page. "
"Check if you're logged in correctly or if the post is deleted."
)
# Threads.net uses div-based cards, not <article> elements.
# Find the first post link and screenshot its parent card.
post_link = page.locator('a[href*="/post/"]').first
if post_link.count() and post_link.is_visible():
# Screenshot the card container, or fall back to the link's parent
card = post_link.locator('xpath=ancestor::div[contains(@class, "x1a2a7pz")][1]')
if card.count():
post_locator = card.first
else:
post_locator = post_link
else:
# Fallback: try article (older Threads layout) or full page
post_locator = page.locator("article").first
if not post_locator.count() or not post_locator.is_visible():
post_locator = page.locator("body")
if settings.config["settings"].get("zoom", 1) != 1:
zoom = settings.config["settings"]["zoom"]
@ -163,10 +101,16 @@ def get_screenshots_of_threads_posts(content_object: dict, screenshot_num: int)
page.wait_for_load_state("networkidle")
page.wait_for_timeout(2000)
# Each reply permalink page shows that reply as the first article
reply_locator = page.locator("article").first
if not reply_locator.is_visible():
print_substep(f"Reply {idx} article not found. Skipping...", style="yellow")
# Threads.net uses div-based cards for replies too.
# Find the first post link and screenshot its card container.
reply_link = page.locator('a[href*="/post/"]').first
if reply_link.count() and reply_link.is_visible():
card = reply_link.locator('xpath=ancestor::div[contains(@class, "x1a2a7pz")][1]')
reply_locator = card.first if card.count() else reply_link
else:
reply_locator = page.locator("article").first
if not reply_locator.count() or not reply_locator.is_visible():
print_substep(f"Reply {idx} not found. Skipping...", style="yellow")
continue
if settings.config["settings"].get("zoom", 1) != 1:

@ -17,7 +17,7 @@ unidecode==1.4.0
torch==2.11.0
transformers==4.57.6
# spacy==3.8.7 # Optional: only for advanced text parsing (not yet Python 3.14 compatible)
ffmpeg-python==0.2.0
av>=14.0
elevenlabs==2.44.0
yt-dlp==2025.10.14
google-auth-oauthlib==1.2.1

@ -21,6 +21,9 @@ blocked_words = { optional = true, default = "", type = "str", explanation = "Co
ai_similarity_enabled = {optional = true, option = [true, false], default = false, type = "bool", explanation = "Threads read from Reddit are sorted based on their similarity to the keywords given below"}
ai_similarity_keywords = {optional = true, type="str", example= 'Elon Musk, Twitter, Stocks', explanation = "Every keyword or even sentence, seperated with comma, is used to sort the reddit threads based on similarity"}
[threads]
discovery_method = { optional = true, default = "api", options = ["api", "scrape"], type = "str", explanation = "How to discover Threads content: 'api' uses Graph API (your own posts), 'scrape' uses web scraping (trending ForYou feed). Requires threads.creds.username/password for Playwright login." }
[threads.creds]
access_token = { optional = false, explanation = "Meta Threads long-lived user access token (User token from Graph API, valid for 60 days)", example = "EAABsbCS..." }
user_id = { optional = false, explanation = "Numeric Threads user ID", example = "12345678901234567" }
@ -32,6 +35,9 @@ post_id = { optional = true, default = "", regex = "^((?!://|://)[+a-zA-Z0-9])*$
max_reply_length = { default = 500, optional = false, nmin = 10, nmax = 10000, type = "int", explanation = "Max characters per reply", example = 500, oob_error = "Max reply length should be between 10 and 10000" }
min_reply_length = { default = 1, optional = true, nmin = 0, nmax = 10000, type = "int", explanation = "Min characters per reply", example = 1, oob_error = "Min reply length should be between 0 and 10000" }
min_replies = { default = 5, optional = false, nmin = 1, type = "int", explanation = "Minimum number of replies for a post to be eligible", example = 5, oob_error = "Minimum replies should be at least 1" }
min_engagement = { default = 0, optional = true, nmin = 0, type = "int", explanation = "Minimum total engagement (likes + reposts) to consider a post viral. Set to 0 to disable. Example: 10000 means only posts with 10K+ total likes+reposts.", example = 10000 }
max_post_age = { optional = true, default = "", options = ["", "1h", "6h", "24h", "3d", "7d", "30d"], type = "str", explanation = "Maximum age of posts to consider. Empty = no limit.", example = "7d" }
search_queries = { optional = true, default = "news,politics,trending", type = "str", explanation = "Comma-separated search queries to find trending content on Threads. Combined with main feed results.", example = "news,politics,viral" }
blocked_words = { optional = true, default = "", type = "str", explanation = "Comma-separated list of blocked words/phrases. Posts and replies containing any of these will be skipped.", example = "nsfw, spoiler, politics" }
[youtube]
@ -58,7 +64,7 @@ zoom = { optional = true, default = 1, example = 1.1, explanation = "Sets the br
channel_name = { optional = true, default = "Reddit Tales", example = "Reddit Stories", explanation = "Sets the channel name for the video" }
[settings.background]
background_video = { optional = true, default = "minecraft", example = "rocket-league", options = ["minecraft", "gta", "rocket-league", "motor-gta", "csgo-surf", "cluster-truck", "minecraft-2","multiversus","fall-guys","steep", ""], explanation = "Sets the background for the video based on game name" }
background_video = { optional = true, default = "minecraft", example = "rocket-league", options = ["minecraft", "gta", "rocket-league", "motor-gta", "csgo-surf", "cluster-truck", "minecraft-2","multiversus","fall-guys","steep", "black", ""], explanation = "Sets the background for the video based on game name" }
background_audio = { optional = true, default = "lofi", example = "chill-summer", options = ["lofi","lofi-2","chill-summer",""], explanation = "Sets the background audio for the video" }
background_audio_volume = { optional = true, type = "float", nmin = 0, nmax = 1, default = 0.15, example = 0.05, explanation="Sets the volume of the background audio. If you don't want background audio, set it to 0.", oob_error = "The volume HAS to be between 0 and 1", input_error = "The volume HAS to be a float number between 0 and 1"}
enable_extra_audio = { optional = true, type = "bool", default = false, example = false, explanation="Used if you want to render another video without background audio in a separate folder", input_error = "The value HAS to be true or false"}

@ -14,5 +14,10 @@
"https://www.youtube.com/watch?v=EZE8JagnBI8",
"chill-summer.mp3",
"Mellow Vibes Radio"
],
"silent": [
"",
"silent.mp3",
"local"
]
}

@ -59,5 +59,11 @@
"steep.mp4",
"joel",
"center"
],
"black": [
"",
"black-background.mp4",
"local",
"center"
]
}

@ -86,7 +86,7 @@ def download_background_video(background_config: Tuple[str, str, str, Any]):
print_substep("Downloading the backgrounds videos... please be patient 🙏 ")
print_substep(f"Downloading {filename} from {uri}")
ydl_opts = {
"format": "bestvideo[height<=1080][ext=mp4]",
"format": "best[height<=1080][ext=mp4]/best[height<=1080]",
"outtmpl": f"assets/backgrounds/video/{credit}-{filename}",
"retries": 10,
}

@ -1,15 +1,17 @@
import json
import multiprocessing
import os
import re
import subprocess
import tempfile
import textwrap
import threading
import time
from os.path import exists # Needs to be imported specifically
from os.path import exists
from pathlib import Path
from typing import Dict, Final, Tuple
import ffmpeg
import av
import translators
from PIL import Image, ImageDraw, ImageFont
from rich.console import Console
@ -26,7 +28,27 @@ from utils.videos import save_data
console = Console()
def _probe_duration(path: str) -> float:
"""Get media duration in seconds using PyAV."""
with av.open(path) as container:
stream = container.streams[0]
return float(stream.duration * stream.time_base)
def _run_ffmpeg(args: list[str], description: str = "") -> None:
"""Run ffmpeg subprocess with error handling."""
result = subprocess.run(
["ffmpeg", "-y"] + args,
capture_output=True,
)
if result.returncode != 0:
stderr = result.stderr.decode("utf-8", errors="replace")
raise RuntimeError(f"ffmpeg {description} failed: {stderr[-500:]}")
class ProgressFfmpeg(threading.Thread):
"""Thread that reads ffmpeg progress via a named pipe during encoding."""
def __init__(self, vid_duration_seconds, progress_update_callback):
threading.Thread.__init__(self, name="ProgressFfmpeg")
self.stop_event = threading.Event()
@ -36,24 +58,24 @@ class ProgressFfmpeg(threading.Thread):
def run(self):
while not self.stop_event.is_set():
latest_progress = self.get_latest_ms_progress()
latest_progress = self._get_latest_ms_progress()
if latest_progress is not None:
completed_percent = latest_progress / self.vid_duration_seconds
self.progress_update_callback(completed_percent)
self.progress_update_callback(min(completed_percent, 1.0))
time.sleep(1)
def get_latest_ms_progress(self):
lines = self.output_file.readlines()
def _get_latest_ms_progress(self):
try:
with open(self.output_file.name) as f:
lines = f.readlines()
except (IOError, OSError):
return None
if lines:
for line in lines:
if "out_time_ms" in line:
out_time_ms_str = line.split("=")[1].strip()
if out_time_ms_str.isnumeric():
return float(out_time_ms_str) / 1000000.0
else:
# Handle the case when "N/A" is encountered
return None
val = line.split("=")[1].strip()
if val.isnumeric():
return float(val) / 1000000.0
return None
def stop(self):
@ -79,34 +101,22 @@ def name_normalize(name: str) -> str:
settings.config.get("reddit", {}).get("thread", {}).get("post_lang", ""))
if lang:
print_substep("Translating filename...")
translated_name = translators.translate_text(name, translator="google", to_language=lang)
return translated_name
else:
return name
return translators.translate_text(name, translator="google", to_language=lang)
return name
def prepare_background(reddit_id: str, W: int, H: int) -> str:
"""Crop background video to match target aspect ratio, re-encode without audio."""
input_path = f"assets/temp/{reddit_id}/background.mp4"
output_path = f"assets/temp/{reddit_id}/background_noaudio.mp4"
output = (
ffmpeg.input(f"assets/temp/{reddit_id}/background.mp4")
.filter("crop", f"ih*({W}/{H})", "ih")
.output(
output_path,
an=None,
**{
"c:v": "h264_nvenc",
"b:v": "20M",
"b:a": "192k",
"threads": multiprocessing.cpu_count(),
},
)
.overwrite_output()
)
try:
output.run(quiet=True)
except ffmpeg.Error as e:
print(e.stderr.decode("utf8"))
exit(1)
_run_ffmpeg([
"-i", input_path,
"-vf", f"crop=ih*({W}/{H}):ih,scale={W}:{H}",
"-c:v", "libx264", "-b:v", "20M",
"-an",
"-threads", str(multiprocessing.cpu_count()),
output_path,
], "prepare_background")
return output_path
@ -120,51 +130,38 @@ def get_text_height(draw, text, font, max_width):
def create_fancy_thumbnail(image, text, text_color, padding, wrap=35):
"""
It will take the 1px from the middle of the template and will be resized (stretched) vertically to accommodate the extra height needed for the title.
"""
print_step(f"Creating fancy thumbnail for: {text}")
font_title_size = 47
font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), font_title_size)
image_width, image_height = image.size
# Calculate text height to determine new image height
draw = ImageDraw.Draw(image)
text_height = get_text_height(draw, text, font, wrap)
lines = textwrap.wrap(text, width=wrap)
# This is -50 to reduce the empty space at the bottom of the image,
# change it as per your requirement if needed otherwise leave it.
new_image_height = image_height + text_height + padding * (len(lines) - 1) - 50
# Separate the image into top, middle (1px), and bottom parts
top_part_height = image_height // 2
middle_part_height = 1 # 1px height middle section
middle_part_height = 1
bottom_part_height = image_height - top_part_height - middle_part_height
top_part = image.crop((0, 0, image_width, top_part_height))
middle_part = image.crop((0, top_part_height, image_width, top_part_height + middle_part_height))
bottom_part = image.crop((0, top_part_height + middle_part_height, image_width, image_height))
# Stretch the middle part
new_middle_height = new_image_height - top_part_height - bottom_part_height
middle_part = middle_part.resize((image_width, new_middle_height))
# Create new image with the calculated height
new_image = Image.new("RGBA", (image_width, new_image_height))
# Paste the top, stretched middle, and bottom parts into the new image
new_image.paste(top_part, (0, 0))
new_image.paste(middle_part, (0, top_part_height))
new_image.paste(bottom_part, (0, top_part_height + new_middle_height))
# Draw the title text on the new image
draw = ImageDraw.Draw(new_image)
y = top_part_height + padding
for line in lines:
draw.text((120, y), line, font=font, fill=text_color, align="left")
y += get_text_height(draw, line, font, wrap) + padding
# Draw the username "PlotPulse" at the specific position
username_font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), 30)
draw.text(
(205, 825),
@ -173,28 +170,72 @@ def create_fancy_thumbnail(image, text, text_color, padding, wrap=35):
fill=text_color,
align="left",
)
return new_image
def merge_background_audio(audio: ffmpeg, reddit_id: str):
"""Gather an audio and merge with assets/backgrounds/background.mp3
Args:
audio (ffmpeg): The TTS final audio but without background.
reddit_id (str): The ID of subreddit
"""
def merge_background_audio(tts_audio_path: str, reddit_id: str) -> str:
"""Mix background audio into the TTS audio. Returns path to the mixed file."""
background_audio_volume = settings.config["settings"]["background"]["background_audio_volume"]
if background_audio_volume == 0:
return audio # Return the original audio
else:
# sets volume to config
bg_audio = ffmpeg.input(f"assets/temp/{reddit_id}/background.mp3").filter(
"volume",
background_audio_volume,
return tts_audio_path
output_path = f"assets/temp/{reddit_id}/audio_mixed.mp3"
bg_audio_path = f"assets/temp/{reddit_id}/background.mp3"
_run_ffmpeg([
"-i", tts_audio_path,
"-i", bg_audio_path,
"-filter_complex",
f"[1:a]volume={background_audio_volume}[bga];[0:a][bga]amix=inputs=2:duration=longest",
"-b:a", "192k",
output_path,
], "audio_mix")
return output_path
def _build_audio_concat_list(input_paths: list[str], list_path: str) -> None:
"""Write a ffmpeg concat demuxer file list."""
with open(list_path, "w") as f:
for p in input_paths:
f.write(f"file '{os.path.abspath(p)}'\n")
def _build_overlay_filter_complex(overlay_items: list[dict], W: int, H: int) -> str:
"""Build a ffmpeg filter_complex string for overlaying images on background.
Each overlay item: {path, start_time, duration, opacity, scale_w, scale_h}
"""
parts = []
prev_label = "0:v" # background is the first input
for i, item in enumerate(overlay_items):
ov_label = f"ov{i}"
scaled_label = f"sc{i}"
faded_label = f"fd{i}"
# Scale the overlay image
parts.append(
f"[{i + 1}:v]scale={item['scale_w']}:{item['scale_h']}[{scaled_label}];"
)
# Set opacity
parts.append(
f"[{scaled_label}]colorchannelmixer=aa={item['opacity']}[{faded_label}];"
)
# Merges audio and background_audio
merged_audio = ffmpeg.filter([audio, bg_audio], "amix", duration="longest")
return merged_audio # Return merged audio
# Overlay with timing
enable = f"between(t,{item['start_time']},{item['start_time'] + item['duration']})"
next_label = f"out{i}" if i < len(overlay_items) - 1 else "outv"
parts.append(
f"[{prev_label}][{faded_label}]overlay="
f"x=(main_w-overlay_w)/2:y=(main_h-overlay_h)/2:"
f"enable='{enable}'[{next_label}]"
)
if i < len(overlay_items) - 1:
parts.append(";")
ov_label = ov_label # unused, keeps naming consistent
prev_label = next_label
# Final scale
parts.append(f";[{prev_label}]scale={W}:{H}[final]")
return "".join(parts)
def make_final_video(
@ -203,19 +244,10 @@ def make_final_video(
reddit_obj: dict,
background_config: Dict[str, Tuple],
):
"""Gathers audio clips, gathers all screenshots, stitches them together and saves the final video to assets/temp
Args:
number_of_clips (int): Index to end at when going through the screenshots'
length (int): Length of the video
reddit_obj (dict): The reddit object that contains the posts to read.
background_config (Tuple[str, str, str, Any]): The background config to use.
"""
# settings values
"""Gathers audio clips, stitches screenshots together, encodes final video."""
W: Final[int] = int(settings.config["settings"]["resolution_w"])
H: Final[int] = int(settings.config["settings"]["resolution_h"])
opacity = settings.config["settings"]["opacity"]
reddit_id = extract_id(reddit_obj)
allowOnlyTTSFolder: bool = (
@ -225,141 +257,125 @@ def make_final_video(
print_step("Creating the final video 🎥")
background_clip = ffmpeg.input(prepare_background(reddit_id, W=W, H=H))
# --- Step 1: Prepare background ---
background_path = prepare_background(reddit_id, W=W, H=H)
# Gather all audio clips
audio_clips = list()
if number_of_clips == 0 and settings.config["settings"]["storymode"] == "false":
print(
"No audio clips to gather. Please use a different TTS or post."
) # This is to fix the TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'
# --- Step 2: Concatenate all TTS audio clips ---
audio_clip_paths = []
if number_of_clips == 0 and not settings.config["settings"]["storymode"]:
print("No audio clips to gather. Please use a different TTS or post.")
exit()
if settings.config["settings"]["storymode"]:
if settings.config["settings"]["storymodemethod"] == 0:
audio_clips = [ffmpeg.input(f"assets/temp/{reddit_id}/mp3/title.mp3")]
audio_clips.insert(1, ffmpeg.input(f"assets/temp/{reddit_id}/mp3/postaudio.mp3"))
elif settings.config["settings"]["storymodemethod"] == 1:
audio_clips = [
ffmpeg.input(f"assets/temp/{reddit_id}/mp3/postaudio-{i}.mp3")
for i in track(range(number_of_clips + 1), "Collecting the audio files...")
audio_clip_paths = [
f"assets/temp/{reddit_id}/mp3/title.mp3",
f"assets/temp/{reddit_id}/mp3/postaudio.mp3",
]
audio_clips.insert(0, ffmpeg.input(f"assets/temp/{reddit_id}/mp3/title.mp3"))
else:
audio_clip_paths = [f"assets/temp/{reddit_id}/mp3/title.mp3"]
for i in range(number_of_clips + 1):
audio_clip_paths.append(f"assets/temp/{reddit_id}/mp3/postaudio-{i}.mp3")
else:
audio_clips = [
ffmpeg.input(f"assets/temp/{reddit_id}/mp3/{i}.mp3") for i in range(number_of_clips)
]
audio_clips.insert(0, ffmpeg.input(f"assets/temp/{reddit_id}/mp3/title.mp3"))
audio_clips_durations = [
float(ffmpeg.probe(f"assets/temp/{reddit_id}/mp3/{i}.mp3")["format"]["duration"])
for i in range(number_of_clips)
]
audio_clips_durations.insert(
0,
float(ffmpeg.probe(f"assets/temp/{reddit_id}/mp3/title.mp3")["format"]["duration"]),
)
audio_concat = ffmpeg.concat(*audio_clips, a=1, v=0)
ffmpeg.output(
audio_concat, f"assets/temp/{reddit_id}/audio.mp3", **{"b:a": "192k"}
).overwrite_output().run(quiet=True)
audio_clip_paths = [f"assets/temp/{reddit_id}/mp3/title.mp3"]
for i in range(number_of_clips):
audio_clip_paths.append(f"assets/temp/{reddit_id}/mp3/{i}.mp3")
existing = [p for p in audio_clip_paths if os.path.exists(p)]
concat_audio_path = f"assets/temp/{reddit_id}/audio.mp3"
concat_list_path = concat_audio_path + ".concat.txt"
_build_audio_concat_list(existing, concat_list_path)
_run_ffmpeg([
"-f", "concat", "-safe", "0", "-i", concat_list_path,
"-b:a", "192k", concat_audio_path,
], "audio_concat")
os.unlink(concat_list_path)
# Probe durations
audio_clips_durations = [_probe_duration(p) for p in existing]
# --- Step 3: Mix background audio ---
mixed_audio_path = merge_background_audio(concat_audio_path, reddit_id)
console.log(f"[bold green] Video Will Be: {length} Seconds Long")
# --- Step 4: Build overlay items ---
screenshot_width = int((W * 45) // 100)
audio = ffmpeg.input(f"assets/temp/{reddit_id}/audio.mp3")
final_audio = merge_background_audio(audio, reddit_id)
image_clips = list()
Path(f"assets/temp/{reddit_id}/png").mkdir(parents=True, exist_ok=True)
# Credits to tim (beingbored)
# get the title_template image and draw a text in the middle part of it with the title of the thread
title_template = Image.open("assets/title_template.png")
title = reddit_obj["thread_title"]
title = name_normalize(title)
title_img = create_fancy_thumbnail(title_template, title, "#000000", 5)
title_img.save(f"assets/temp/{reddit_id}/png/title.png")
font_color = "#000000"
padding = 5
# create_fancy_thumbnail(image, text, text_color, padding
title_img = create_fancy_thumbnail(title_template, title, font_color, padding)
overlay_items = []
current_time = 0.0
title_img.save(f"assets/temp/{reddit_id}/png/title.png")
image_clips.insert(
0,
ffmpeg.input(f"assets/temp/{reddit_id}/png/title.png")["v"].filter(
"scale", screenshot_width, -1
),
)
overlay_items.append({
"path": f"assets/temp/{reddit_id}/png/title.png",
"start_time": current_time,
"duration": audio_clips_durations[0],
"opacity": opacity,
"scale_w": screenshot_width,
"scale_h": -1,
})
current_time += audio_clips_durations[0]
current_time = 0
if settings.config["settings"]["storymode"]:
audio_clips_durations = [
float(
ffmpeg.probe(f"assets/temp/{reddit_id}/mp3/postaudio-{i}.mp3")["format"]["duration"]
)
for i in range(number_of_clips)
]
audio_clips_durations.insert(
0,
float(ffmpeg.probe(f"assets/temp/{reddit_id}/mp3/title.mp3")["format"]["duration"]),
)
if settings.config["settings"]["storymodemethod"] == 0:
image_clips.insert(
1,
ffmpeg.input(f"assets/temp/{reddit_id}/png/story_content.png").filter(
"scale", screenshot_width, -1
),
)
background_clip = background_clip.overlay(
image_clips[0],
enable=f"between(t,{current_time},{current_time + audio_clips_durations[0]})",
x="(main_w-overlay_w)/2",
y="(main_h-overlay_h)/2",
)
current_time += audio_clips_durations[0]
story_path = f"assets/temp/{reddit_id}/png/story_content.png"
if os.path.exists(story_path):
overlay_items.append({
"path": story_path,
"start_time": current_time,
"duration": audio_clips_durations[1] if len(audio_clips_durations) > 1 else 5,
"opacity": opacity,
"scale_w": screenshot_width,
"scale_h": -1,
})
elif settings.config["settings"]["storymodemethod"] == 1:
for i in track(range(0, number_of_clips + 1), "Collecting the image files..."):
image_clips.append(
ffmpeg.input(f"assets/temp/{reddit_id}/png/img{i}.png")["v"].filter(
"scale", screenshot_width, -1
)
)
background_clip = background_clip.overlay(
image_clips[i],
enable=f"between(t,{current_time},{current_time + audio_clips_durations[i]})",
x="(main_w-overlay_w)/2",
y="(main_h-overlay_h)/2",
)
current_time += audio_clips_durations[i]
for i in range(number_of_clips + 1):
img_path = f"assets/temp/{reddit_id}/png/img{i}.png"
if not os.path.exists(img_path):
continue
dur_idx = i + 1
if dur_idx >= len(audio_clips_durations):
break
overlay_items.append({
"path": img_path,
"start_time": current_time,
"duration": audio_clips_durations[dur_idx],
"opacity": opacity,
"scale_w": screenshot_width,
"scale_h": -1,
})
current_time += audio_clips_durations[dur_idx]
else:
for i in range(0, number_of_clips + 1):
image_clips.append(
ffmpeg.input(f"assets/temp/{reddit_id}/png/comment_{i}.png")["v"].filter(
"scale", screenshot_width, -1
)
)
image_overlay = image_clips[i].filter("colorchannelmixer", aa=opacity)
assert (
audio_clips_durations is not None
), "Please make a GitHub issue if you see this. Ping @JasonLovesDoggo on GitHub."
background_clip = background_clip.overlay(
image_overlay,
enable=f"between(t,{current_time},{current_time + audio_clips_durations[i]})",
x="(main_w-overlay_w)/2",
y="(main_h-overlay_h)/2",
)
for i in range(number_of_clips + 1):
img_path = f"assets/temp/{reddit_id}/png/comment_{i}.png"
if not os.path.exists(img_path):
continue
if i >= len(audio_clips_durations):
break
overlay_items.append({
"path": img_path,
"start_time": current_time,
"duration": audio_clips_durations[i],
"opacity": opacity,
"scale_w": screenshot_width,
"scale_h": -1,
})
current_time += audio_clips_durations[i]
title = extract_id(reddit_obj, "thread_title")
# --- Step 5: Build filter_complex and render ---
filter_complex = _build_overlay_filter_complex(overlay_items, W, H)
title_clean = extract_id(reddit_obj, "thread_title")
idx = extract_id(reddit_obj)
title_thumb = reddit_obj["thread_title"]
filename = f"{name_normalize(title_clean)[:251]}"
filename = f"{name_normalize(title)[:251]}"
platform = settings.config["settings"].get("platform", "reddit")
if platform == "reddit":
subreddit = settings.config["reddit"]["thread"]["subreddit"]
@ -371,58 +387,36 @@ def make_final_video(
os.makedirs(f"./results/{subreddit}")
if not exists(f"./results/{subreddit}/OnlyTTS") and allowOnlyTTSFolder:
print_substep("The 'OnlyTTS' folder could not be found so it was automatically created.")
os.makedirs(f"./results/{subreddit}/OnlyTTS")
# create a thumbnail for the video
# Thumbnail
settingsbackground = settings.config["settings"]["background"]
if settingsbackground["background_thumbnail"]:
if not exists(f"./results/{subreddit}/thumbnails"):
print_substep(
"The 'results/thumbnails' folder could not be found so it was automatically created."
)
os.makedirs(f"./results/{subreddit}/thumbnails")
# get the first file with the .png extension from assets/backgrounds and use it as a background for the thumbnail
first_image = next(
(file for file in os.listdir("assets/backgrounds") if file.endswith(".png")),
(f for f in os.listdir("assets/backgrounds") if f.endswith(".png")),
None,
)
if first_image is None:
print_substep("No png files found in assets/backgrounds", "red")
else:
if first_image:
font_family = settingsbackground["background_thumbnail_font_family"]
font_size = settingsbackground["background_thumbnail_font_size"]
font_color = settingsbackground["background_thumbnail_font_color"]
thumbnail = Image.open(f"assets/backgrounds/{first_image}")
width, height = thumbnail.size
thumbnailSave = create_thumbnail(
thumbnail,
font_family,
font_size,
font_color,
width,
height,
title_thumb,
thumbnail, font_family, font_size, font_color, width, height, title_thumb,
)
thumbnailSave.save(f"./assets/temp/{reddit_id}/thumbnail.png")
print_substep(f"Thumbnail - Building Thumbnail in assets/temp/{reddit_id}/thumbnail.png")
text = f"Background by {background_config['video'][2]}"
background_clip = ffmpeg.drawtext(
background_clip,
text=text,
x=f"(w-text_w)",
y=f"(h-text_h)",
fontsize=5,
fontcolor="White",
fontfile=os.path.join("fonts", "Roboto-Regular.ttf"),
)
background_clip = background_clip.filter("scale", W, H)
# --- Step 6: Render ---
defaultPath = f"results/{subreddit}"
video_output_path = defaultPath + f"/{filename}"
video_output_path = video_output_path[:251] + ".mp4"
print_step("Rendering the video 🎥")
from tqdm import tqdm
pbar = tqdm(total=100, desc="Progress: ", bar_format="{l_bar}{bar}", unit=" %")
def on_update_example(progress) -> None:
@ -430,68 +424,70 @@ def make_final_video(
old_percentage = pbar.n
pbar.update(status - old_percentage)
defaultPath = f"results/{subreddit}"
# Build ffmpeg command: background + overlay images → filter_complex → video only
ffmpeg_inputs = ["-i", background_path]
for item in overlay_items:
ffmpeg_inputs.extend(["-i", item["path"]])
with ProgressFfmpeg(length, on_update_example) as progress:
path = defaultPath + f"/{filename}"
path = (
path[:251] + ".mp4"
) # Prevent a error by limiting the path length, do not change this.
try:
ffmpeg.output(
background_clip,
final_audio,
path,
f="mp4",
**{
"c:v": "h264_nvenc",
"b:v": "20M",
"b:a": "192k",
"threads": multiprocessing.cpu_count(),
},
).overwrite_output().global_args("-progress", progress.output_file.name).run(
quiet=True,
overwrite_output=True,
capture_stdout=False,
capture_stderr=False,
)
except ffmpeg.Error as e:
print(e.stderr.decode("utf8"))
exit(1)
# First pass: render video with overlays (no audio)
video_only_path = video_output_path + ".video.mp4"
_run_ffmpeg(
ffmpeg_inputs + [
"-filter_complex", filter_complex,
"-map", "[final]",
"-c:v", "libx264", "-b:v", "20M",
"-pix_fmt", "yuv420p",
"-threads", str(multiprocessing.cpu_count()),
"-progress", progress.output_file.name,
video_only_path,
],
"overlay_render"
)
# Second pass: mux video with audio
_run_ffmpeg([
"-i", video_only_path,
"-i", mixed_audio_path,
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k",
"-shortest", "-map", "0:v:0", "-map", "1:a:0",
video_output_path,
], "audio_mux")
os.unlink(video_only_path)
old_percentage = pbar.n
pbar.update(100 - old_percentage)
# OnlyTTS variant
if allowOnlyTTSFolder:
path = defaultPath + f"/OnlyTTS/{filename}"
path = (
path[:251] + ".mp4"
) # Prevent a error by limiting the path length, do not change this.
only_tts_path = defaultPath + f"/OnlyTTS/{filename}"
only_tts_path = only_tts_path[:251] + ".mp4"
only_tts_video = only_tts_path + ".video.mp4"
print_step("Rendering the Only TTS Video 🎥")
with ProgressFfmpeg(length, on_update_example) as progress:
try:
ffmpeg.output(
background_clip,
audio,
path,
f="mp4",
**{
"c:v": "h264_nvenc",
"b:v": "20M",
"b:a": "192k",
"threads": multiprocessing.cpu_count(),
},
).overwrite_output().global_args("-progress", progress.output_file.name).run(
quiet=True,
overwrite_output=True,
capture_stdout=False,
capture_stderr=False,
)
except ffmpeg.Error as e:
print(e.stderr.decode("utf8"))
exit(1)
with ProgressFfmpeg(length, on_update_example) as progress2:
_run_ffmpeg(
ffmpeg_inputs + [
"-filter_complex", filter_complex,
"-map", "[final]",
"-c:v", "libx264", "-b:v", "20M",
"-pix_fmt", "yuv420p",
"-threads", str(multiprocessing.cpu_count()),
"-progress", progress2.output_file.name,
only_tts_video,
],
"only_tts_render"
)
_run_ffmpeg([
"-i", only_tts_video,
"-i", concat_audio_path,
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k",
"-shortest", "-map", "0:v:0", "-map", "1:a:0",
only_tts_path,
], "only_tts_mux")
os.unlink(only_tts_video)
old_percentage = pbar.n
pbar.update(100 - old_percentage)
pbar.close()
save_data(subreddit, filename + ".mp4", title, idx, background_config["video"][2])
save_data(subreddit, filename + ".mp4", title_clean, idx, background_config["video"][2])
print_step("Removing temporary files 🗑")
cleanups = cleanup(reddit_id)
print_substep(f"Removed {cleanups} temporary files 🗑")

Loading…
Cancel
Save