pull/2556/merge
Hong Phuc 23 hours ago committed by GitHub
commit 157b06e9be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,2 +1,20 @@
Dockerfile
results
.git
.github
.omx
.venv
venv
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache
.mypy_cache
.ruff_cache
.DS_Store
config.toml
results
assets/temp
assets/backgrounds
video_creation/data/videos.json
video_creation/data/cookie-threads.json
out

@ -2,4 +2,4 @@ blank_issues_enabled: false
contact_links:
- name: Ask a question
about: Join our discord server to ask questions and discuss with maintainers and contributors.
url: https://discord.gg/swqtb7AsNQ
url: https://discord.gg/swqtb7AsNQ

29
.gitignore vendored

@ -242,7 +242,36 @@ reddit-bot-351418-5560ebc49cac.json
/.idea
*.pyc
video_creation/data/videos.json
video_creation/data/cookie-threads.json
video_creation/data/envvars.txt
utils/backgrounds.json
config.toml
*.exe
.omx
.gitnexus
# Claude Code / Ruflo / AgentDB local state
.claude-flow/
.claude/agents/
.claude/commands/
.claude/helpers/
.claude/settings.json
.claude/skills/
.claude/memory.db
.swarm/
.understand-anything/
.mcp.json
.playwright-mcp/
.code-review/
agentdb.rvf
agentdb.rvf.lock
claude-flow.config.json
ruvector.db
pipeline-ui-*.png
.codex
.agents
.config
.local
.gstack/

@ -1 +1 @@
3.10
3.14.4

@ -0,0 +1 @@
CLAUDE.md

@ -0,0 +1,446 @@
# CLAUDE.md — VideoMakerBot Development Guide
## Project Overview
**VideoMakerBot** — Automated short-form video creator from social media content.
**Status:** Production (v3.4.0)
**Language:** Python 3.14+ (host + Docker)
**Runtime:** **Docker only** — CLI, GUI, test go through `docker compose`. Never `python` on host.
**Platforms:** Reddit (PRAW API), Threads (Graph API + Web Scraping)
### Core Mission
Transforms social media threads (post + comments/replies) into short-form videos:
- AI-generated speech (7+ TTS providers)
- UI screenshots (Playwright, headless Chromium in image)
- Background video/audio overlays
- FFmpeg composition & output (Linux ffmpeg, full filter set + `drawtext`)
- Optional YouTube upload
- Web UI (Tailwind CSS + DaisyUI + Lucide + vanilla ES6) on `localhost:4000`
---
## Architecture at a Glance
```
main.py (CLI)
↓ [platform factory]
├─→ reddit/subreddit.py [PRAW API]
└─→ 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; exports get_output_path()]
results/{category}/{video.mp4}
```
---
## Data Contract: The "content_object" Dict
All fetchers return this shape:
```python
{
"thread_id": str, # Used for temp folder: assets/temp/{id}/
"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,
"comments": [
{
"comment_body": str, # TTS per reply (clean body text)
"comment_url": str, # Playwright navigates here
"comment_id": str, # Unique identifier (URL-based for scraper)
}
],
"thread_post": str | list, # Story mode (no comments)
}
```
---
## File Organization
```
VideoMakerBot/
├── 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/
│ └── subreddit.py # PRAW API → content_object
├── video_creation/
│ ├── 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/
│ ├── engine_wrapper.py # Provider abstraction + TikTok→pyttsx3 fallback
│ ├── TikTok.py # TikTok TTS (hardened error handling)
│ └── ... # 7+ provider implementations
├── utils/
│ ├── 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
│ └── ...
├── GUI/ # Flask templates (Tailwind + DaisyUI + Lucide)
│ ├── layout.html # Base layout (no jQuery, no Bootstrap)
│ ├── index.html # Video Library (3 buttons: source / download / copy link)
│ ├── backgrounds.html # Background Manager (videos catalog)
│ ├── settings.html # Config editor (validated against template)
│ └── create.html # Render progress page
├── tests/
│ └── test_gui_utils.py # pytest regression for add/delete background
├── main.py # CLI entry (platform-routed via factory)
├── GUI.py # Flask web UI; `/video/<id>` serves files with sanitized headers
├── Dockerfile # python:3.10-slim-bookworm + ffmpeg + playwright + pytest
├── docker-compose.yml # Services: gui, cli, test
├── docker-entrypoint.sh # Runs `utils.docker_bootstrap` then exec's the command
├── requirements.txt
└── CLAUDE.md
```
---
## Configuration
### Threads (full config)
```toml
[settings]
platform = "threads"
[threads]
discovery_method = "scrape" # "api" (Graph API, own posts) or "scrape" (trending feed)
[threads.creds]
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 = "" # Specific post ID; blank = auto-pick from feed
max_reply_length = 500
min_reply_length = 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 = "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"
background_audio = "lofi"
background_audio_volume = 0.15
```
### Reddit (reference)
```toml
[settings]
platform = "reddit"
[reddit.creds]
client_id = "..."
client_secret = "..."
username = "..."
password = "..."
2fa = false
2fa_secret = "" # TOTP base32 secret for auto-2FA
[reddit.thread]
subreddit = "AskReddit"
min_comments = 20
```
### YouTube upload
```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
```
---
## Platform-Specific Knowledge
### Threads — Web Scraping (discovery_method = "scrape")
**DOM Structure:**
- Threads.net uses **div-based card layout** — NO `<article>` elements
- 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)
```
**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 sorted by engagement descending before selection
**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`
- After click: `page.wait_for_url("https://www.threads.net/", timeout=15000)` — event-wait, not fixed delay
- Cookies cached at `video_creation/data/cookie-threads.json`
- Login logic shared via `platforms/threads/auth.py`
**API Limitation:**
- Graph API v1.0 only accesses YOUR OWN posts — no trending/discovery
- Scraping bypasses this — no API token needed
### Threads — Graph API (discovery_method = "api")
- Auth: Bearer token, 60-day expiry
- Only accesses authenticated user's own threads + replies
- Use when you have your own content with replies
### Reddit
- **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
---
## Development Guidelines
### ✅ DO:
1. **Run everything through Docker**`docker compose up gui`, `docker compose run --rm cli`, `docker compose run --rm test`
2. **Use platform factory** — never import platform modules directly
3. **Return standard content_object** from all fetchers
4. **Use clean body text** for TTS — parse out username/timestamp metadata
5. **Default to `googletranslate` TTS** for headless containers — no API key, fast, free
6. **Use `libx264` encoder**`h264_nvenc` is NVIDIA-only and not available in the slim image
7. **Test both Threads discovery methods:** `api` and `scrape`
8. **Bind-mount preserves state** — edits to `config.toml`, `results/`, `assets/temp/`, `video_creation/data/`, and `utils/background_*.json` catalogs persist across container runs
9. **GUI must bind to `0.0.0.0`** in Docker (enforced via `GUI_HOST=0.0.0.0` env)
10. **Use `/video/<id>` to serve renders** — the route looks up the file by id in `videos.json`, sanitizes `Content-Disposition` filename, avoids 404s from literal newlines in titles
### ❌ DON'T:
1. **Don't run `python GUI.py` or `python main.py` on the host** — Docker is the only supported path
2. **Don't use `<article>` selectors** on Threads.net — DOM is div-based
3. **Don't hardcode `h264_nvenc`** — use `libx264` for cross-platform compatibility
4. **Don't import platform modules directly** in main.py/utils
5. **Don't assume config keys exist** without `.get()` fallback
6. **Don't reintroduce jQuery, Bootstrap, or ClipboardJS** — UI is vanilla ES6 + Tailwind + DaisyUI + Lucide
7. **Don't write to `utils/backgrounds.json`** — legacy empty file. Use `utils/background_videos.json` and `utils/background_audios.json`
### 🔒 Security (hardened May 2026)
1. **No `eval()`** — use `{"int": int, "float": float, "bool": bool, "str": str}` dict dispatch. `utils/settings.py` has module-level `_TYPE_COERCION`.
2. **No `os.system()`** — use `subprocess.run([...])` with argument lists. No shell interpretation.
3. **No `shell=True`** — removed from all `subprocess.run()` and `Popen()` calls.
4. **No bare `except:`** — catch specific exception types. Bare excepts swallow `KeyboardInterrupt` and `SystemExit`.
5. **Redact secrets before printing**`main.py` error handler deep-copies config and masks all credential fields. `GUI.py` redacts API keys/passwords from settings page data. Sensitive fields show as `********`.
6. **CSRF protection**`GUI.py` `@app.before_request` checks `Origin` header on all mutating requests.
7. **Security headers**`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` on every response.
8. **Flask secret key**`FLASK_SECRET_KEY` env var, fallback `os.urandom(32)` per startup.
9. **Docker non-root** — container runs as `appuser`, not root.
10. **Path traversal**`/video/<id>` uses `Path.resolve().relative_to()` guard; `add_background()` sanitizes citation with `re.sub(r"[./\\\\]", "_", citation)`.
11. **No hardcoded credentials** in source — all secrets from `config.toml` (gitignored). Rotate passwords regularly.
---
## Web UI (Flask, served by `gui` service)
- **Stack:** Tailwind CSS, DaisyUI, Lucide Icons, vanilla ES6 (no jQuery, Bootstrap, ClipboardJS)
- **Routes:**
- `/` — Video Library; cards show source-post link, download, copy-link buttons
- `/video/<id>` — serves rendered mp4 by id (lookup via `videos.json`); path-traversal guard, sanitized `Content-Disposition`
- `/backgrounds` — Background Manager UI
- `/backgrounds.json` — serves `utils/background_videos.json` (videos catalog)
- `/background/add`, `/background/delete` — POST; mutate **both** `utils/background_videos.json` and `settings.background.background_video.options` in `utils/.config.template.toml`
- `/settings` — config editor; loads from `config.toml`, validates against `utils/.config.template.toml`, persists via `utils/gui_utils.modify_settings` (preserves comments/formatting via `tomlkit`)
- **HTML escaping:** `h()` helper in `index.html` escapes `& " < >` for user-controlled strings in attributes
---
## Key Files to Know
| File | Purpose |
|------|---------|
| `main.py` | CLI entry; pipeline orchestration via factory |
| `platforms/__init__.py` | Factory dispatch (platform + discovery_method) |
| `platforms/threads/scraper.py` | Web scraping fetcher with engagement parsing |
| `platforms/threads/auth.py` | 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); exports `get_output_path()` |
| `video_creation/background.py` | Background downloader (local files + yt-dlp); prefers already-downloaded videos |
| `video_creation/youtube_uploader.py` | OAuth2 YouTube upload |
| `TTS/engine_wrapper.py` | TTS provider abstraction + TikTok→pyttsx3 fallback; single-pass ffmpeg concat |
| `TTS/TikTok.py` | Hardened TikTok TTS with graceful error handling |
| `reddit/subreddit.py` | PRAW Reddit fetcher with auto-2FA; retry-depth limit (50) |
| `utils/settings.py` | Config loading + interactive validation; uses `_TYPE_COERCION` dict (no eval) |
| `utils/videos.py` | Video dedup tracking (`check_done`, `check_done_by_id`, `save_data` with truncate) |
| `utils/.config.template.toml` | Config schema (drives Settings page validation) |
| `utils/background_videos.json` | Background video manifest (served at `/backgrounds.json`) |
| `utils/background_audios.json` | Background audio manifest |
| `utils/gui_utils.py` | `add_background`, `delete_background`, `modify_settings`, `get_checks` (no eval) |
| `GUI.py` | Flask app: `/`, `/video/<id>`, `/backgrounds`, `/settings`, `/create`; CSRF + security headers |
| `Dockerfile` | python:3.14-slim-bookworm + ffmpeg + Playwright Chromium + pytest; runs as `appuser` |
| `docker-compose.yml` | Three services: `gui` (port 4000), `cli`, `test` |
| `tests/test_gui_utils.py` | Pytest regression for Background Manager round-trip |
---
## Debugging Tips
### FFmpeg "Unknown encoder 'h264_nvenc'"
→ Use `libx264`. Find-and-replace `h264_nvenc``libx264` in `video_creation/final_video.py`. Slim image doesn't ship NVIDIA encoders.
### yt-dlp "Requested format is not available"
→ Bump pinned version in `requirements.txt` and rebuild (`docker compose build`). Prefer `best[height<=1080]` over `bestvideo` in `video_creation/background.py` — many videos lack video-only streams.
### Threads screenshots fail ("Main post article not found")
→ Threads.net uses div cards, not `<article>`. Use `a[href*="/post/"]` → ancestor div approach.
### Config validator EOFError in non-interactive mode
`check_toml()` prompts for ALL platform sections regardless of `platform` setting. Fill all required fields, edit through `/settings`, or pre-populate `config.toml` before `docker compose run cli`.
### Playwright timeout on Threads login
→ Cookies corrupted. Delete `video_creation/data/cookie-threads.json` for fresh login (file is bind-mounted, host delete clears container too). Confirm selectors: button uses `exact=True` for multiple "Log in" buttons.
### No viral posts found
→ Lower `min_engagement` in config. Most Threads feed posts have <100 likes 10000 filters almost everything.
### Background Manager grid is empty
`/backgrounds.json` must serve `utils/background_videos.json` (split catalog), **not** legacy `utils/backgrounds.json` (empty `{}`). Verify in `GUI.py:backgrounds_json`.
### `/video/<id>` returns 404
→ Route looks up entry in `video_creation/data/videos.json` by `id`, resolves file under `results/<thread_category>/<filename>.mp4`. Confirm both JSON entry and file exist; file may have been pruned.
### JS "Unexpected end of input" on Library page
→ User-controlled strings in HTML attributes must go through `h()` helper in `index.html`. Avoid inline `onclick=` with `${JSON.stringify(...)}`.
### Stale image after editing `requirements.txt` or `Dockerfile`
`docker compose build` to rebuild. Code-only changes don't need rebuild — repo root is bind-mounted to `/app`.
### Python bytecode caching in long-running GUI container
→ GUI caches imported modules in `sys.modules`. After editing pipeline code, restart GUI (`docker compose restart gui`) or trigger pipeline run which calls `importlib.reload()` on pipeline modules.
### Reddit image template appearing in Threads videos
→ Verify `platform` in config.toml is `"threads"` (not `"reddit"`). The `if platform == "reddit"` guard in `final_video.py` blocks Reddit template. Restart GUI container to flush Python bytecode cache.
### Background video download fails (yt-dlp HTTP 403)
`get_background_config()` prefers already-downloaded videos. Set `background_video` in config.toml to a downloaded video name (check `assets/backgrounds/video/`). If empty, randomly picks from downloaded videos first.
### TTS output has wrong number of audio clips
`engine_wrapper.run()` returns `idx + 1` (count, not last index). If getting one fewer clip than expected, check return value consumers — treat as count.
### videos.json corruption (trailing garbage after save)
→ Fixed: `save_data()` calls `raw_vids.truncate()` after `json.dump()`. Delete `video_creation/data/videos.json` if existing file is corrupted.
### Infinite recursion in Reddit post discovery
→ Fixed: `get_subreddit_threads()` has retry-depth limit of 50. If hit, subreddit may have no undone posts — try different subreddit or clear `videos.json`.
---
## Useful Commands (Docker-only)
```bash
# Build (or rebuild after Dockerfile / requirements.txt changes)
docker compose build
# Run the GUI (foreground)
docker compose up gui
# → http://localhost:4000
# Run the GUI in background
docker compose up -d gui
docker compose logs -f gui
docker compose down
# Run CLI pipeline (one-off, removed on exit)
docker compose run --rm cli
docker compose run --rm cli python main.py <post_id>
# Run test suite
docker compose run --rm test
# Shell in fresh container for ad-hoc commands
docker compose run --rm --entrypoint /bin/bash gui
# inside: python -m py_compile main.py platforms/threads/scraper.py
# Tail running GUI container
docker compose exec gui ls /app/results/threads/
```
> Anything needing `pip install`, `playwright install`, or `apt-get` belongs in `Dockerfile` + `docker compose build` — never on host.
---
## Recent Changes (May 2026 Security Hardening)
**eval() removal:** `eval(checks["type"])(value)` replaced with `{"int": int, "float": float, "bool": bool, "str": str}` dict dispatch in `utils/settings.py`, `utils/console.py`, `utils/gui_utils.py`.
**os.system() removal:** `TTS/engine_wrapper.py:split_post` uses `subprocess.run([...])` with argument lists. `utils/posttextparser.py` spacy download uses `subprocess.run([sys.executable, "-m", "spacy", ...])`.
**shell=True removal:** All `subprocess.run(..., shell=True)` and `Popen(..., shell=True)` replaced with argument lists in `main.py` and `utils/ffmpeg_install.py`.
**Credential leak prevention:** `main.py` error handler deep-copies config and redacts all secrets. `GUI.py` masks sensitive keys as `********` in settings page data.
**CSRF + security headers:** `GUI.py` checks `Origin` header on POST/PUT/DELETE. `X-Content-Type-Options`, `X-Frame-Options` headers added.
**Docker hardening:** Container runs as `appuser` (non-root). Digest pinning + pip version comments added.
**Bug fixes (18 total):**
- Config overwrite crash (config=None after empty file write)
- Playwright TimeoutError (wrong exception class caught)
- Lambda closure (loop variable captured by reference)
- Redundant ffmpeg runs (concat now single-pass)
- Audio IndexError on empty TTS output
- Hardcoded NSFW post selector (now generic role-based)
- JSON truncation bug in save_data (missing truncate())
- Infinite recursion in Reddit post discovery (retry limit 50)
- Silent exception swallowing in scraper search
- exit() → sys.exit() in subreddit.py
- Dead macOS branch (os.name == "mac" → sys.platform == "darwin")
- Wrong upstream repo in version check (now configurable + resilient)
- Duplicate path logic (get_output_path() shared between main.py and final_video.py)
- Catastrophic backtracking URL regex (now atomic https?://\S+)
- Fixed 6s login delay (now wait_for_url event-wait)
- 6 bare except: clauses → specific exception types
- Temp file leak in ProgressFfmpeg (cleanup in __exit__)
- Flask secret key hardcoded → env var + urandom fallback

@ -1,12 +1,51 @@
FROM python:3.10.14-slim
FROM python:3.14-slim-bookworm
RUN apt update
RUN apt-get install -y ffmpeg
RUN apt install python3-pip -y
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
XDG_CACHE_HOME=/app/.cache
RUN mkdir /app
ADD . /app
WORKDIR /app
RUN pip install -r requirements.txt
CMD ["python3", "main.py"]
# System deps: ffmpeg for video processing
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ffmpeg \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install PyTorch CPU-only first (saves ~1.5GB vs GPU version)
RUN pip install --no-cache-dir torch==2.11.0 --index-url https://download.pytorch.org/whl/cpu
# Install remaining Python deps
COPY requirements.txt ./
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
# Install Chromium for Playwright (already cached at /ms-playwright)
RUN python -m playwright install --with-deps chromium
# Clean up pip cache and temp files
RUN pip cache purge 2>/dev/null || true \
&& find /usr/local/lib/python3.14 -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true \
&& find /usr/local/lib/python3.14 -name '*.pyc' -delete 2>/dev/null || true \
&& rm -rf /root/.cache
# Copy app code
COPY . .
# Create non-root user
RUN groupadd -r appuser && useradd -r -g appuser -d /app appuser \
&& chown -R appuser:appuser /app /ms-playwright
ENV CLOAKBROWSER_CACHE_DIR=/app/.cache/cloakbrowser
ENV PUBLIC_BASE_PATH=/threads-video-maker
ENV PUBLIC_DEMO_MODE=1
RUN chmod +x /app/docker-entrypoint.sh
USER appuser
ENTRYPOINT ["/bin/sh", "/app/docker-entrypoint.sh"]
CMD ["python", "GUI.py"]

322
GUI.py

@ -1,40 +1,122 @@
import io
import json
import os
import sys
import threading
import time
import webbrowser
from copy import deepcopy
from pathlib import Path
from urllib.parse import urlparse
# Used "tomlkit" instead of "toml" because it doesn't change formatting on "dump"
import tomlkit
from flask import (
Flask,
abort,
jsonify,
redirect,
render_template,
request,
send_file,
send_from_directory,
url_for,
)
from werkzeug.wrappers import Response
import utils.gui_utils as gui
from utils.docker_bootstrap import ensure_runtime_state
from utils.settings import apply_template_defaults
# Set the hostname
HOST = "localhost"
# Set the port number
PORT = 4000
ensure_runtime_state()
# Set the hostname and port
HOST = os.environ.get("GUI_HOST", "0.0.0.0")
PORT = int(os.environ.get("GUI_PORT", "4000"))
OPEN_BROWSER = os.environ.get("GUI_OPEN_BROWSER", "1").lower() in {"1", "true", "yes", "on"}
BROWSER_URL = os.environ.get("GUI_BROWSER_URL", f"http://localhost:{PORT}")
PUBLIC_BASE_PATH = "/" + os.environ.get("PUBLIC_BASE_PATH", "").strip("/")
if PUBLIC_BASE_PATH == "/":
PUBLIC_BASE_PATH = ""
PUBLIC_DEMO_MODE = os.environ.get("PUBLIC_DEMO_MODE", "0").lower() in {"1", "true", "yes", "on"}
# Configure application
app = Flask(__name__, template_folder="GUI")
# Configure secret key only to use 'flash'
app.secret_key = b'_5#y2L"F4Q8z\n\xec]/'
# Configure secret key — env var for production, random per-startup for dev
app.secret_key = os.environ.get("FLASK_SECRET_KEY") or os.urandom(32)
class PrefixMiddleware:
def __init__(self, app, prefix: str):
self.app = app
self.prefix = prefix
def __call__(self, environ, start_response):
if not self.prefix:
return self.app(environ, start_response)
path_info = environ.get("PATH_INFO", "")
if path_info == self.prefix:
response = Response("", status=308, headers={"Location": f"{self.prefix}/"})
return response(environ, start_response)
if path_info.startswith(f"{self.prefix}/"):
environ["SCRIPT_NAME"] = self.prefix
environ["PATH_INFO"] = path_info[len(self.prefix):] or "/"
return self.app(environ, start_response)
app.wsgi_app = PrefixMiddleware(app.wsgi_app, PUBLIC_BASE_PATH)
@app.context_processor
def inject_public_context():
def app_url(path: str) -> str:
normalized = path if path.startswith("/") else f"/{path}"
return f"{request.script_root}{normalized}"
return {
"app_url": app_url,
"public_base_path": request.script_root,
"public_demo_mode": PUBLIC_DEMO_MODE,
}
# Ensure responses aren't cached
# Ensure responses aren't cached + security headers
@app.after_request
def after_request(response):
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Expires"] = 0
response.headers["Pragma"] = "no-cache"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
return response
# Simple CSRF check: require same-origin for all mutating requests
@app.before_request
def csrf_check():
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
origin = request.headers.get("Origin")
if origin:
# Allow same-origin + public proxy origin (e.g. Vercel rewrites)
origin_host = urlparse(origin).hostname
allowed = {
urlparse(request.host_url).hostname,
"localhost",
"127.0.0.1",
*(os.environ.get("PUBLIC_ORIGIN_HOST", "").split(",") if os.environ.get("PUBLIC_ORIGIN_HOST") else []),
}
allowed.discard("") # remove empty string
if origin_host not in allowed:
return jsonify({"error": "CSRF check failed"}), 403
def public_demo_forbidden():
return jsonify({"error": "This action is disabled in public demo mode"}), 403
# Display index.html
@app.route("/")
def index():
@ -48,11 +130,13 @@ def backgrounds():
@app.route("/background/add", methods=["POST"])
def background_add():
if PUBLIC_DEMO_MODE:
return public_demo_forbidden()
# Get form values
youtube_uri = request.form.get("youtube_uri").strip()
filename = request.form.get("filename").strip()
citation = request.form.get("citation").strip()
position = request.form.get("position").strip()
youtube_uri = request.form.get("youtube_uri", "").strip()
filename = request.form.get("filename", "").strip()
citation = request.form.get("citation", "").strip()
position = request.form.get("position", "").strip()
gui.add_background(youtube_uri, filename, citation, position)
@ -61,28 +145,46 @@ def background_add():
@app.route("/background/delete", methods=["POST"])
def background_delete():
if PUBLIC_DEMO_MODE:
return public_demo_forbidden()
key = request.form.get("background-key")
gui.delete_background(key)
return redirect(url_for("backgrounds"))
_SENSITIVE_KEYS = {"password", "client_secret", "access_token", "2fa_secret",
"tiktok_sessionid", "elevenlabs_api_key", "openai_api_key",
"api_url", "api_key"}
def _redact_secrets(data: dict) -> dict:
"""Return a copy with sensitive values masked for safe HTML embedding."""
return {
k: ("********" if any(s in k for s in _SENSITIVE_KEYS) and v else v)
for k, v in data.items()
}
@app.route("/settings", methods=["GET", "POST"])
def settings():
config_load = tomlkit.loads(Path("config.toml").read_text())
config = gui.get_config(config_load)
config = gui.get_config(apply_template_defaults(deepcopy(config_load)))
# Get checks for all values
checks = gui.get_checks()
if request.method == "POST":
if PUBLIC_DEMO_MODE:
return public_demo_forbidden()
# Get data from form as dict
data = request.form.to_dict()
# Change settings
config = gui.modify_settings(data, config_load, checks)
gui.modify_settings(data, config_load, checks)
config = gui.get_config(apply_template_defaults(deepcopy(config_load)))
return render_template("settings.html", file="config.toml", data=config, checks=checks)
return render_template("settings.html", file="config.toml", data=_redact_secrets(config), checks=checks)
# Make videos.json accessible
@ -94,13 +196,59 @@ def videos_json():
# Make backgrounds.json accessible
@app.route("/backgrounds.json")
def backgrounds_json():
return send_from_directory("utils", "backgrounds.json")
return send_from_directory("utils", "background_videos.json")
# Make videos in results folder accessible
@app.route("/results/<path:name>")
def results(name):
return send_from_directory("results", name, as_attachment=True)
as_attachment = request.args.get("download", "0").lower() in {"1", "true", "yes"}
return send_from_directory("results", name, as_attachment=as_attachment)
# Serve a video by its videos.json id (handles filenames with unsafe chars like newlines)
@app.route("/video/<video_id>")
def video_by_id(video_id):
try:
with open("video_creation/data/videos.json", "r", encoding="utf-8") as f:
videos = json.load(f)
except (OSError, json.JSONDecodeError):
abort(404)
entry = next((v for v in videos if v.get("id") == video_id), None)
if not entry:
abort(404)
subreddit = entry.get("subreddit", "")
filename = entry.get("filename", "")
file_path = (Path("results") / subreddit / filename).resolve()
results_root = Path("results").resolve()
# Prevent path traversal: ensure resolved file is inside results/
try:
file_path.relative_to(results_root)
except ValueError:
abort(404)
if not file_path.is_file():
abort(404)
as_attachment = request.args.get("download", "0").lower() in {"1", "true", "yes"}
safe_name = filename.replace("\n", " ").replace("\r", " ").strip() or f"{video_id}.mp4"
return send_file(file_path, as_attachment=as_attachment, download_name=safe_name)
# Delete one or more videos by ID
@app.route("/videos/delete", methods=["POST"])
def video_delete():
if PUBLIC_DEMO_MODE:
return public_demo_forbidden()
data = request.get_json(silent=True) or {}
ids = data.get("ids", [])
if not ids or not isinstance(ids, list):
return jsonify({"error": "No IDs provided"}), 400
deleted = gui.delete_videos(ids)
return jsonify({"deleted": deleted})
# Make voices samples in voices folder accessible
@ -109,8 +257,144 @@ def voices(name):
return send_from_directory("GUI/voices", name, as_attachment=True)
# --- Pipeline state (shared across thread + HTTP) ---
pipeline_lock = threading.Lock()
pipeline_state: dict = {
"running": False,
"stage": "",
"error": None,
"result": None, # {"title": ..., "file": ..., "url": ...}
"log": [], # Last N status messages
"scraper_events": [], # Structured scraper events for visualization
}
def _event_to_summary(event_type, data):
"""Convert a structured scraper event to a human-readable log line."""
data = data or {}
summaries = {
"browser_launch": lambda d: "Launching browser...",
"login": lambda d: d.get("message", "Login event"),
"feed_scroll": lambda d: f"Scrolled: {d.get('new_posts', 0)} new, {d.get('total_posts', 0)} total",
"post_discovered": lambda d: f"Post by {d.get('username', '?')}: {d.get('body', '')[:45]}",
"search_query": lambda d: f"Search '{d.get('query', '?')}': {d.get('posts_found', 0)} posts",
"filter_results": lambda d: f"Filtered {d.get('before', 0)} -> {d.get('after', 0)} candidates",
"visiting_post": lambda d: f"Trying post {d.get('post_id', '')[:8]}...",
"replies_found": lambda d: f"Got {d.get('count', 0)} replies (need {d.get('min_required', '?')})",
"post_selected": lambda d: f"Selected: {d.get('title', '')[:55]}",
"general": lambda d: d.get("message", ""),
}
fn = summaries.get(event_type)
return fn(data) if fn else None
def _run_pipeline(search_queries=None):
"""Run the video creation pipeline in a background thread."""
import toml
from utils import console as uconsole
from utils import settings
with pipeline_lock:
pipeline_state["running"] = True
pipeline_state["stage"] = "configuring"
pipeline_state["error"] = None
pipeline_state["result"] = None
pipeline_state["log"] = []
pipeline_state["scraper_events"] = []
try:
# Load config and merge template defaults for non-interactive GUI runs.
settings.config = settings.apply_template_defaults(toml.load("config.toml"))
# Apply search_queries override if provided from UI
if search_queries:
settings.config.setdefault("threads", {}).setdefault("thread", {})["search_queries"] = search_queries
# Set up progress callback with structured event support
def on_progress(stage=None, event=None, data=None):
with pipeline_lock:
if stage:
pipeline_state["stage"] = stage
pipeline_state["log"].append(stage)
if len(pipeline_state["log"]) > 20:
pipeline_state["log"] = pipeline_state["log"][-20:]
if event:
entry = {"type": event, "data": data or {}, "ts": time.time()}
pipeline_state["scraper_events"].append(entry)
if len(pipeline_state["scraper_events"]) > 100:
pipeline_state["scraper_events"] = pipeline_state["scraper_events"][-100:]
summary = _event_to_summary(event, data)
if summary:
pipeline_state["log"].append(summary)
if len(pipeline_state["log"]) > 20:
pipeline_state["log"] = pipeline_state["log"][-20:]
uconsole.set_progress_callback(on_progress)
# Reload pipeline modules so code edits take effect without restart
import importlib
import video_creation.final_video
import video_creation.background
import video_creation.voices
import TTS.engine_wrapper
import platforms.threads.screenshot
import main
importlib.reload(video_creation.final_video)
importlib.reload(video_creation.background)
importlib.reload(TTS.engine_wrapper)
importlib.reload(video_creation.voices)
importlib.reload(platforms.threads.screenshot)
importlib.reload(main)
from main import main as run_pipeline
run_pipeline()
with pipeline_lock:
pipeline_state["stage"] = "done"
pipeline_state["result"] = {"message": "Video created successfully! Check the home page."}
except Exception as e:
with pipeline_lock:
pipeline_state["stage"] = "error"
pipeline_state["error"] = str(e)[:500].encode("ascii", errors="replace").decode("ascii")
finally:
with pipeline_lock:
pipeline_state["running"] = False
uconsole.set_progress_callback(None)
@app.route("/create", methods=["GET", "POST"])
def create():
if request.method == "POST":
if PUBLIC_DEMO_MODE:
return public_demo_forbidden()
if pipeline_state["running"]:
return jsonify({"status": "already_running"})
data = request.get_json(silent=True) or {}
search_queries = data.get("search_queries") or None
thread = threading.Thread(
target=_run_pipeline,
kwargs={"search_queries": search_queries},
daemon=True,
)
thread.start()
return jsonify({"status": "started"})
# Load current config default for pre-filling the keywords input
cfg = tomlkit.loads(Path("config.toml").read_text())
default_queries = cfg.get("threads", {}).get("thread", {}).get("search_queries", "")
return render_template("create.html", state=pipeline_state, default_search_queries=default_queries)
@app.route("/create/status")
def create_status():
with pipeline_lock:
state_copy = dict(pipeline_state)
return jsonify(state_copy)
# Run browser and start the app
if __name__ == "__main__":
webbrowser.open(f"http://{HOST}:{PORT}", new=2)
print("Website opened in new tab. Refresh if it didn't load.")
app.run(port=PORT)
if OPEN_BROWSER:
webbrowser.open(BROWSER_URL, new=2)
print("Website opened in new tab. Refresh if it didn't load.")
app.run(host=HOST, port=PORT)

@ -1,263 +1,244 @@
{% extends "layout.html" %}
{% block main %}
<!-- Delete Background Modal -->
<div class="modal fade" id="deleteBtnModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete background</h5>
</div>
<div class="modal-body">
Are you sure you want to delete this background?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<form action="background/delete" method="post">
<input type="hidden" id="background-key" name="background-key" value="">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
<div class="bg-[#F2EFEB] min-h-[calc(100vh-6.5rem)] py-8">
<div class="container mx-auto px-4">
<!-- Header & Actions -->
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-8">
<h1 class="text-3xl font-display font-black uppercase tracking-tighter text-[#111111]">Background Manager</h1>
<div class="flex w-full md:w-auto gap-2">
<div class="relative flex-grow md:w-64">
<i data-lucide="search" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#111111]/40"></i>
<input type="text"
class="searchFilter input-neo w-full pl-10"
placeholder="Search..."
onkeyup="searchFilter()">
</div>
<button onclick="add_modal.showModal()" class="btn-primary-neo text-sm" data-demo-disabled>
<i data-lucide="plus" class="w-4 h-4"></i>
<span class="hidden sm:inline ml-1">Add Video</span>
</button>
</div>
</div>
<!-- Background Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6" id="backgrounds">
<!-- Backgrounds will be injected here -->
</div>
<!-- Empty State -->
<div id="empty-state" class="hidden flex flex-col items-center justify-center py-20 text-[#111111]/30">
<i data-lucide="film" class="w-16 h-16 mb-4"></i>
<p class="text-lg font-mono uppercase tracking-wider">No backgrounds found</p>
</div>
</div>
</div>
<!-- Delete Background Modal -->
<dialog id="delete_modal" class="modal modal-bottom sm:modal-middle">
<div class="modal-box bg-white border border-[#111111]">
<h3 class="font-display font-black uppercase tracking-tighter text-lg text-[#111111]">Delete Background</h3>
<p class="py-4 text-[#111111]/60 font-mono text-sm">Are you sure you want to delete this background video? This action cannot be undone.</p>
<div class="modal-action">
<form action="background/delete" method="post" class="flex gap-2">
<input type="hidden" id="background-key" name="background-key" value="">
<button type="button" onclick="delete_modal.close()" class="btn-ghost-neo">Cancel</button>
<button type="submit" class="btn-danger-neo text-sm" data-demo-disabled>Delete</button>
</form>
</div>
</div>
</dialog>
<!-- Add Background Modal -->
<div class="modal fade" id="backgroundAddModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add background video</h5>
<dialog id="add_modal" class="modal modal-bottom sm:modal-middle">
<div class="modal-box bg-white border border-[#111111] max-w-lg">
<h3 class="font-display font-black uppercase tracking-tighter text-lg text-[#111111] mb-6">Add Background Video</h3>
<form id="addBgForm" action="background/add" method="post" novalidate class="space-y-4">
<div class="form-control w-full">
<label class="label"><span class="label-text text-[#111111]/70 font-mono text-xs uppercase tracking-wider">YouTube URI</span></label>
<div class="flex border border-[#111111]">
<div class="flex items-center justify-center px-3 bg-[#111111]/5 border-r border-[#111111] shrink-0">
<i data-lucide="youtube" class="w-4 h-4 text-[#111111]"></i>
</div>
<input name="youtube_uri" type="text" placeholder="https://www.youtube.com/watch?v=..."
class="input-neo border-0 flex-1">
</div>
<label class="label h-6"><span id="feedbackYT" class="label-text-alt text-[#DE6C56] font-mono text-xs hidden"></span></label>
</div>
<div class="modal-body">
<!-- Add video form -->
<form id="addBgForm" action="background/add" method="post" novalidate>
<div class="form-group row">
<label class="col-4 col-form-label" for="youtube_uri">YouTube URI</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-youtube"></i>
</div>
<input name="youtube_uri" placeholder="https://www.youtube.com/watch?v=..." type="text"
class="form-control">
</div>
<span id="feedbackYT" class="form-text feedback-invalid"></span>
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text text-[#111111]/70 font-mono text-xs uppercase tracking-wider">Filename</span></label>
<div class="flex border border-[#111111]">
<div class="flex items-center justify-center px-3 bg-[#111111]/5 border-r border-[#111111] shrink-0">
<i data-lucide="file-video" class="w-4 h-4 text-[#111111]"></i>
</div>
<div class="form-group row">
<label for="filename" class="col-4 col-form-label">Filename</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-file-earmark"></i>
</div>
<input name="filename" placeholder="Example: cool-background" type="text"
class="form-control">
</div>
<span id="feedbackFilename" class="form-text feedback-invalid"></span>
</div>
</div>
<div class="form-group row">
<label for="citation" class="col-4 col-form-label">Credits (owner of the video)</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-person-circle"></i>
</div>
<input name="citation" placeholder="YouTube Channel" type="text" class="form-control">
</div>
<span class="form-text text-muted">Include the channel name of the
owner of the background video you are adding.</span>
</div>
</div>
<div class="form-group row">
<label for="position" class="col-4 col-form-label">Position of screenshots</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-arrows-fullscreen"></i>
</div>
<input name="position" placeholder="Example: center" type="text" class="form-control">
</div>
<span class="form-text text-muted">Advanced option (you can leave it
empty). Valid options are "center" and decimal numbers</span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button name="submit" type="submit" class="btn btn-success">Add background</button>
</form>
<input name="filename" type="text" placeholder="e.g. minecraft-parkour"
class="input-neo border-0 flex-1">
</div>
<label class="label h-6"><span id="feedbackFilename" class="label-text-alt text-[#DE6C56] font-mono text-xs hidden"></span></label>
</div>
</div>
</div>
</div>
<main>
<div class="album py-2 bg-light">
<div class="container">
<div class="row justify-content-between mt-2">
<div class="col-12 col-md-3 mb-3">
<input type="text" class="form-control searchFilter" placeholder="Search backgrounds"
onkeyup="searchFilter()">
</div>
<div class="col-12 col-md-2 mb-3">
<button type="button" class="btn btn-primary form-control" data-toggle="modal"
data-target="#backgroundAddModal">
Add background video
</button>
<div class="form-control w-full">
<label class="label"><span class="label-text text-[#111111]/70 font-mono text-xs uppercase tracking-wider">Credits</span></label>
<div class="flex border border-[#111111]">
<div class="flex items-center justify-center px-3 bg-[#111111]/5 border-r border-[#111111] shrink-0">
<i data-lucide="user" class="w-4 h-4 text-[#111111]"></i>
</div>
<input name="citation" type="text" placeholder="YouTube Channel Name"
class="input-neo border-0 flex-1">
</div>
<label class="label"><span class="label-text-alt text-[#111111]/40 font-mono text-xs">Name of the video owner.</span></label>
</div>
<div class="grid row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3" id="backgrounds">
<div class="form-control w-full">
<label class="label"><span class="label-text text-[#111111]/70 font-mono text-xs uppercase tracking-wider">Advanced: Position</span></label>
<input name="position" type="text" placeholder="center (optional)"
class="input-neo w-full text-sm h-10">
</div>
<div class="modal-action">
<button type="button" onclick="add_modal.close()" class="btn-ghost-neo">Cancel</button>
<button type="submit" class="btn-primary-neo text-sm" data-demo-disabled>Add Background</button>
</div>
</div>
</form>
</div>
</main>
</dialog>
<script>
var keys = [];
var youtube_urls = [];
let keys = [];
let youtube_urls = [];
function h(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// Show background videos
$(document).ready(function () {
$.getJSON("backgrounds.json",
function (data) {
delete data["__comment"];
var background = '';
$.each(data, function (key, value) {
// Add YT urls and keys (for validation)
keys.push(key);
youtube_urls.push(value[0]);
async function loadBackgrounds() {
try {
const response = await fetch("backgrounds.json");
const data = await response.json();
delete data["__comment"];
const container = document.getElementById('backgrounds');
let html = '';
Object.entries(data).forEach(([key, value]) => {
keys.push(key);
youtube_urls.push(value[0]);
const videoId = value[0].includes('?v=') ? value[0].split('?v=')[1] : value[0].split('/').pop();
html += `
<div class="bg-card group bg-white border border-[#111111] hover:border-[#DE6C56] transition-colors duration-200">
<div class="aspect-video w-full bg-black border-b border-[#111111] relative">
<iframe class="w-full h-full"
src="https://www.youtube-nocookie.com/embed/${videoId}"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen></iframe>
</div>
<div class="p-4">
<h3 class="text-[#111111] font-mono text-sm font-medium truncate mb-1" title="${h(key)}">${h(key)}</h3>
<p class="text-[#111111]/40 font-mono text-xs truncate mb-4">${h(value[2])}</p>
${window.PUBLIC_DEMO_MODE ? '' : `<div class="flex justify-end">
<button onclick="confirmDelete('${key}')" class="btn-ghost-neo p-2 hover:text-[#DE6C56]">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>`}
</div>
</div>
`;
});
background += '<div class="col">';
background += '<div class="card shadow-sm">';
background += '<iframe class="bd-placeholder-img card-img-top" width="100%" height="225" src="https://www.youtube-nocookie.com/embed/' + value[0].split("?v=")[1] + '" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>';
background += '<div class="card-body">';
background += '<p class="card-text">' + value[2] + ' • ' + key + '</p>';
background += '<div class="d-flex justify-content-between align-items-center">';
background += '<div class="btn-group">';
background += '<button type="button" class="btn btn-outline-danger" data-toggle="modal" data-target="#deleteBtnModal" data-background-key="' + key + '">Delete</button>';
background += '</div>';
background += '</div>';
background += '</div>';
background += '</div>';
background += '</div>';
});
container.innerHTML = html;
lucide.createIcons();
} catch (error) {
console.error("Error loading backgrounds:", error);
}
}
$('#backgrounds').append(background);
});
});
function confirmDelete(key) {
document.getElementById('background-key').value = key;
delete_modal.showModal();
}
// Add background key when deleting
$('#deleteBtnModal').on('show.bs.modal', function (event) {
var button = $(event.relatedTarget);
var key = button.data('background-key');
function searchFilter() {
const query = document.querySelector(".searchFilter") ? document.querySelector(".searchFilter").value.toLowerCase() : '';
const cards = document.querySelectorAll(".bg-card");
let visibleCount = 0;
$('#background-key').prop('value', key);
});
cards.forEach(card => {
const text = card.textContent.toLowerCase();
const matches = text.includes(query);
card.classList.toggle('hidden', !matches);
if (matches) visibleCount++;
});
var searchFilter = () => {
const input = document.querySelector(".searchFilter");
const cards = document.getElementsByClassName("col");
console.log(cards[1])
let filter = input.value
for (let i = 0; i < cards.length; i++) {
let title = cards[i].querySelector(".card-text");
if (title.innerText.toLowerCase().indexOf(filter.toLowerCase()) > -1) {
cards[i].classList.remove("d-none")
} else {
cards[i].classList.add("d-none")
}
}
document.getElementById('empty-state').classList.toggle('hidden', visibleCount > 0);
}
// Validate form
$("#addBgForm").submit(function (event) {
$("#addBgForm input").each(function () {
if (!(validate($(this)))) {
event.preventDefault();
event.stopPropagation();
}
const form = document.getElementById('addBgForm');
form.addEventListener('submit', (e) => {
let isValid = true;
form.querySelectorAll('input').forEach(input => {
if (!validate(input)) isValid = false;
});
if (!isValid) e.preventDefault();
});
$('#addBgForm input[type="text"]').on("keyup", function () {
validate($(this));
form.querySelectorAll('input').forEach(input => {
input.addEventListener('keyup', () => validate(input));
});
function validate(object) {
let bool = check(object.prop("name"), object.prop("value"));
// Change class
if (bool) {
object.removeClass("is-invalid");
object.addClass("is-valid");
}
else {
object.removeClass("is-valid");
object.addClass("is-invalid");
}
return bool;
// Check values (return true/false)
function check(name, value) {
if (name == "youtube_uri") {
// URI validation
let regex = /(?:\/|%3D|v=|vi=)([0-9A-z-_]{11})(?:[%#?&]|$)/;
if (!(regex.test(value))) {
$("#feedbackYT").html("Invalid URI");
$("#feedbackYT").show();
return false;
}
// Check if this background already exists
if (youtube_urls.includes(value)) {
$("#feedbackYT").html("This background is already added");
$("#feedbackYT").show();
return false;
}
$("#feedbackYT").hide();
return true;
}
if (name == "filename") {
// Check if key is already taken
if (keys.includes(value)) {
$("#feedbackFilename").html("This filename is already taken");
$("#feedbackFilename").show();
return false;
}
let regex = /^([a-zA-Z0-9\s_-]{1,100})$/;
if (!(regex.test(value))) {
return false;
}
return true;
function validate(input) {
const name = input.name;
const value = input.value;
let valid = true;
let message = "";
if (name === "youtube_uri") {
const regex = /(?:\/|%3D|v=|vi=)([0-9A-Za-z_-]{11})(?:[%#?&]|$)/;
if (!regex.test(value)) {
message = "Invalid YouTube URI";
valid = false;
} else if (youtube_urls.includes(value)) {
message = "Background already added";
valid = false;
}
const feedback = document.getElementById('feedbackYT');
feedback.textContent = message;
feedback.classList.toggle('hidden', valid);
}
if (name == "citation") {
if (value.trim()) {
return true;
}
if (name === "filename") {
if (keys.includes(value)) {
message = "Filename already taken";
valid = false;
} else if (!/^([a-zA-Z0-9\s_-]{1,100})$/.test(value)) {
valid = false;
}
const feedback = document.getElementById('feedbackFilename');
feedback.textContent = message;
feedback.classList.toggle('hidden', valid);
}
if (name == "position") {
if (!(value == "center" || value.length == 0 || value % 1 == 0)) {
return false;
}
return true;
if (name === "position") {
if (value && value !== "center" && isNaN(parseFloat(value))) {
valid = false;
}
}
input.classList.toggle('input-error', !valid);
return valid;
}
document.addEventListener('DOMContentLoaded', loadBackgrounds);
</script>
{% endblock %}
{% endblock %}

@ -0,0 +1,571 @@
{% extends "layout.html" %}
{% block main %}
<div class="bg-[#F2EFEB] min-h-[calc(100vh-6.5rem)] py-12">
<div class="container mx-auto px-4">
<div class="grid grid-cols-1 lg:grid-cols-5 gap-6">
<!-- LEFT PANEL: Controls + Progress -->
<div class="lg:col-span-2">
<div class="card-neo">
<div class="p-8">
<div class="flex items-center gap-4 mb-8">
<div class="bg-[#DE6C56]/10 p-3 border border-[#DE6C56]">
<i data-lucide="plus-square" class="w-8 h-8 text-[#DE6C56]"></i>
</div>
<div>
<h2 class="font-display font-black uppercase tracking-tighter text-2xl text-[#111111]">Create New Short</h2>
<p class="text-[#111111]/50 font-mono text-xs mt-1">Start the automated video creation pipeline.</p>
</div>
</div>
<div class="space-y-6">
<!-- Search Keywords Input -->
<div class="form-control w-full">
<label class="label px-0">
<span class="label-text text-[#111111] font-mono text-xs uppercase tracking-wider font-medium">Search Keywords</span>
<span class="label-text-alt text-[#111111]/40 font-mono text-[10px]">Optional</span>
</label>
<div class="flex gap-2">
<input id="keywords-input" type="text"
class="input-neo flex-1"
placeholder="news, politics, trending, viral"
value="{{ default_search_queries }}"
data-demo-disabled>
<button id="clear-keywords" class="btn-ghost-neo"
onclick="document.getElementById('keywords-input').value=''" type="button"
data-demo-disabled>
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<label class="label px-0">
<span class="label-text-alt text-[#111111]/40 font-mono text-xs">Comma-separated topics. Leave empty for config default.</span>
</label>
</div>
<!-- Action Button -->
<button id="create-btn" class="btn-primary-neo w-full h-16 text-lg"
onclick="startPipeline()" disabled>
<span id="btn-text">Initializing...</span>
<span id="btn-spinner" class="loading loading-spinner loading-md hidden"></span>
</button>
<!-- Progress Visualization -->
<div id="progress-area" class="hidden space-y-4">
<div class="flex justify-between items-end">
<div class="space-y-1">
<span class="text-xs uppercase tracking-widest text-[#111111]/40 font-mono font-bold">Current Stage</span>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-[#DE6C56]"></div>
<h3 id="stage-text" class="text-[#DE6C56] font-mono font-bold text-lg uppercase tracking-wider">Preparing...</h3>
</div>
</div>
<span id="pct-text" class="text-2xl font-black text-[#111111]/20 font-mono">0%</span>
</div>
<progress id="progress-bar" class="progress w-full h-3 bg-[#111111]/10 [&::-webkit-progress-value]:bg-[#DE6C56] [&::-moz-progress-bar]:bg-[#DE6C56]" value="0" max="100"></progress>
<div class="grid grid-cols-2 gap-4 pt-4">
<div class="bg-white border border-[#111111] p-3 flex items-center gap-3">
<i data-lucide="clock" class="w-4 h-4 text-[#111111]/40"></i>
<div class="flex flex-col">
<span class="text-[10px] uppercase text-[#111111]/40 font-mono font-bold">Elapsed</span>
<span id="elapsed-time" class="text-sm font-mono text-[#111111]">00:00</span>
</div>
</div>
<div class="bg-white border border-[#111111] p-3 flex items-center gap-3">
<i data-lucide="layers" class="w-4 h-4 text-[#111111]/40"></i>
<div class="flex flex-col">
<span class="text-[10px] uppercase text-[#111111]/40 font-mono font-bold">Status</span>
<span class="text-sm font-mono text-[#DE6C56]">Processing</span>
</div>
</div>
</div>
</div>
<!-- Success Message -->
<div id="done-area" class="hidden">
<div class="border border-[#4ADE80] bg-[#4ADE80]/10 p-6 flex flex-col items-start gap-4">
<div class="flex items-center gap-3">
<div class="bg-[#4ADE80] text-[#111111] p-1">
<i data-lucide="check" class="w-4 h-4"></i>
</div>
<span class="font-display font-black uppercase tracking-tighter text-lg text-[#111111]">Generation Complete!</span>
</div>
<p id="done-msg" class="text-[#111111]/60 font-mono text-sm">Your video has been rendered and saved to the library.</p>
<a href="{{ app_url('/') }}" class="btn-secondary-neo text-sm">View Video</a>
</div>
</div>
<!-- Error Message -->
<div id="error-area" class="hidden">
<div class="border border-[#DE6C56] bg-[#DE6C56]/5 p-6">
<i data-lucide="alert-triangle" class="w-6 h-6 text-[#DE6C56]"></i>
<div class="mt-2">
<h3 class="font-display font-black uppercase tracking-tighter text-[#111111]">Pipeline Failed</h3>
<div id="error-text" class="text-xs mt-2 font-mono bg-[#111111]/5 p-3 overflow-x-auto whitespace-pre-wrap text-[#111111]/70"></div>
</div>
</div>
</div>
<!-- Log Output -->
<div id="log-area" class="hidden space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-xs uppercase tracking-widest text-[#111111]/40 font-mono font-bold">Execution Logs</h4>
<span class="font-mono text-[10px] uppercase border border-[#111111] px-2 py-0.5 text-[#111111]/50">Real-time</span>
</div>
<div id="log-list" class="bg-white border border-[#111111] p-4 font-mono text-[11px] leading-relaxed text-[#111111]/60 h-48 overflow-y-auto">
</div>
</div>
</div>
</div>
</div>
</div>
<!-- RIGHT PANEL: Pipeline Activity Visualization -->
<div class="lg:col-span-3">
<div class="card-neo">
<div class="p-6">
<div class="flex items-center justify-between mb-5">
<div class="flex items-center gap-2">
<i data-lucide="activity" class="w-5 h-5 text-[#DE6C56]"></i>
<h3 class="font-display font-black uppercase tracking-tighter text-lg text-[#111111]">Pipeline Activity</h3>
</div>
<span class="font-mono text-[10px] uppercase border border-[#4ADE80] text-[#4ADE80] px-2 py-0.5">Live</span>
</div>
<!-- Stage Diagram -->
<div id="stage-diagram" class="mb-6">
</div>
<!-- Scraper Event Feed -->
<div id="scraper-feed" class="space-y-2 max-h-[500px] overflow-y-auto pr-1">
</div>
<!-- Empty state -->
<div id="feed-empty" class="text-center py-16 text-[#111111]/30">
<i data-lucide="eye-off" class="w-12 h-12 mx-auto mb-3"></i>
<p class="text-sm font-mono uppercase tracking-wider">Scraper activity will appear here</p>
<p class="text-xs mt-1 font-mono">Start a pipeline to see real-time scraping visualization</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
let pollTimer = null;
let startTime = null;
let elapsedTimer = null;
const stageWeights = {
'configuring': 5,
'discovering': 15,
'scraping': 20,
'fetching': 25,
'saving': 35,
'tts': 45,
'screenshots': 60,
'background': 70,
'chopping': 75,
'creating': 80,
'rendering': 90,
'done': 100,
'error': 0
};
const pipelineStages = [
{ id: 'configuring', label: 'Configuring', icon: 'settings' },
{ id: 'discovering', label: 'Discovering', icon: 'search' },
{ id: 'scraping', label: 'Scraping', icon: 'loader-2' },
{ id: 'fetching', label: 'Fetching', icon: 'download' },
{ id: 'tts', label: 'TTS', icon: 'volume-2' },
{ id: 'screenshots', label: 'Screenshots', icon: 'camera' },
{ id: 'background', label: 'Background', icon: 'image' },
{ id: 'chopping', label: 'Chopping', icon: 'scissors' },
{ id: 'creating', label: 'Creating', icon: 'film' },
{ id: 'rendering', label: 'Rendering', icon: 'sparkles' },
{ id: 'done', label: 'Complete', icon: 'check-circle' },
];
// --- helpers ---
function esc(s) {
if (!s) return '';
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
function fmtMetric(n) {
if (!n && n !== 0) return '0';
n = Number(n);
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return n.toLocaleString();
}
function fmtTime(ts) {
if (!ts) return '';
const seconds = Math.floor((Date.now() / 1000) - ts);
if (seconds < 5) return 'just now';
if (seconds < 60) return seconds + 's ago';
const mins = Math.floor(seconds / 60);
return mins + 'm ago';
}
function updateElapsedTime() {
if (!startTime) return;
const now = new Date();
const diff = Math.floor((now - startTime) / 1000);
const mins = Math.floor(diff / 60).toString().padStart(2, '0');
const secs = (diff % 60).toString().padStart(2, '0');
document.getElementById('elapsed-time').textContent = mins + ':' + secs;
}
function stageProgress(stage) {
let pct = 0;
const s = (stage || '').toLowerCase();
for (let [key, val] of Object.entries(stageWeights)) {
if (s.includes(key)) { pct = val; }
}
return pct;
}
// --- stage diagram ---
function renderStageDiagram(currentStage) {
const container = document.getElementById('stage-diagram');
const s = (currentStage || '').toLowerCase();
let activeIdx = -1;
for (let i = 0; i < pipelineStages.length; i++) {
if (s.includes(pipelineStages[i].id)) {
activeIdx = i;
}
}
const start = Math.max(0, activeIdx - 4);
const end = Math.min(pipelineStages.length - 1, Math.max(activeIdx, start + 7));
const visible = pipelineStages.slice(start, end + 1);
let html = '<div class="flex items-center gap-1 overflow-x-auto py-2">';
visible.forEach((st, idx) => {
const globalIdx = start + idx;
const isDone = globalIdx < activeIdx;
const isActive = globalIdx === activeIdx;
const isPending = globalIdx > activeIdx;
let cls = 'flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-mono font-medium whitespace-nowrap shrink-0 transition-all duration-300 border ';
if (isActive) {
cls += 'bg-[#DE6C56]/10 border-[#DE6C56] text-[#DE6C56]';
} else if (isDone) {
cls += 'bg-[#4ADE80]/10 border-[#4ADE80] text-[#111111]';
} else {
cls += 'bg-white border-[#111111]/20 text-[#111111]/40';
}
html += '<div class="' + cls + '">';
if (isDone) {
html += '<i data-lucide="check" class="w-3 h-3 text-[#4ADE80]"></i>';
} else if (isActive) {
html += '<i data-lucide="loader-2" class="w-3 h-3 text-[#DE6C56] animate-spin"></i>';
} else {
html += '<i data-lucide="' + st.icon + '" class="w-3 h-3"></i>';
}
html += '<span class="uppercase tracking-wider">' + st.label + '</span></div>';
if (idx < visible.length - 1) {
html += '<div class="text-[#111111]/30 shrink-0 mx-0.5">///</div>';
}
});
html += '</div>';
container.innerHTML = html;
lucide.createIcons();
}
// --- scraper event feed ---
function renderScraperEvent(event) {
const { type, data, ts } = event;
const d = data || {};
const templates = {
'post_discovered': () =>
'<div class="bg-white border border-[#111111] p-3 hover:border-[#DE6C56] transition-colors">' +
'<div class="flex items-start gap-3">' +
'<div class="bg-[#DE6C56]/10 p-1.5 border border-[#DE6C56] shrink-0">' +
'<i data-lucide="message-circle" class="w-3.5 h-3.5 text-[#DE6C56]"></i>' +
'</div>' +
'<div class="flex-1 min-w-0">' +
'<div class="flex items-center gap-2 mb-1">' +
'<span class="text-xs font-mono font-bold text-[#111111] truncate uppercase">' + esc(d.username || 'unknown') + '</span>' +
'<span class="text-[10px] text-[#111111]/40 font-mono">' + fmtTime(ts) + '</span>' +
'</div>' +
'<p class="text-[11px] text-[#111111]/60 font-mono leading-relaxed line-clamp-2">' + esc(d.body || '') + '</p>' +
'<div class="flex items-center gap-3 mt-1.5 text-[10px] text-[#111111]/40 font-mono">' +
'<span>&#9829; ' + fmtMetric(d.likes) + '</span>' +
'<span>&#128172; ' + fmtMetric(d.replies) + '</span>' +
(d.reposts ? '<span>&#128259; ' + fmtMetric(d.reposts) + '</span>' : '') +
'</div>' +
'</div>' +
'</div>' +
'</div>',
'feed_scroll': () =>
'<div class="flex items-center gap-2 py-1.5 px-1 font-mono">' +
'<div class="bg-white border border-[#111111] p-1">' +
'<i data-lucide="mouse-pointer-2" class="w-3 h-3 text-[#DE6C56]"></i>' +
'</div>' +
'<span class="text-[11px] text-[#111111]/50">' +
'Scrolled <strong class="text-[#111111]">' + (d.scroll || '?') + '/' + (d.max_scrolls || '?') + '</strong>' +
' &mdash; <strong class="text-[#111111]">' + (d.new_posts || 0) + '</strong> new,' +
' <strong class="text-[#111111]">' + (d.total_posts || 0) + '</strong> total posts' +
'</span>' +
'</div>',
'search_query': () =>
'<div class="flex items-center gap-2 py-1.5 px-1 font-mono">' +
'<div class="bg-white border border-[#111111] p-1">' +
'<i data-lucide="search" class="w-3 h-3 text-[#111111]"></i>' +
'</div>' +
'<span class="text-[11px] text-[#111111]/50">' +
'Searched "<strong class="text-[#111111]">' + esc(d.query || '') + '</strong>"' +
' &mdash; <strong class="text-[#111111]">' + (d.posts_found || 0) + '</strong> posts found' +
'</span>' +
'</div>',
'filter_results': () =>
'<div class="flex items-center gap-2 py-1.5 px-1 font-mono">' +
'<div class="bg-white border border-[#111111] p-1">' +
'<i data-lucide="filter" class="w-3 h-3 text-[#111111]"></i>' +
'</div>' +
'<span class="text-[11px] text-[#111111]/50">' +
'Filtered <strong class="text-[#111111]">' + (d.before || 0) + '</strong> posts &rarr;' +
' <strong class="text-[#111111]">' + (d.after || 0) + '</strong> candidates' +
(d.min_engagement ? ' (min ' + fmtMetric(d.min_engagement) + ' engagement)' : '') +
'</span>' +
'</div>',
'visiting_post': () =>
'<div class="flex items-center gap-2 py-1.5 px-1 font-mono">' +
'<div class="bg-white border border-[#111111] p-1">' +
'<i data-lucide="external-link" class="w-3 h-3 text-[#DE6C56]"></i>' +
'</div>' +
'<span class="text-[11px] text-[#111111]/50">' +
'Examining candidate #' + (d.attempt || '?') + ': "' + esc((d.body || '').substring(0, 40)) + '..."' +
' <span class="text-[#111111]/40 ml-1">&#9829;' + fmtMetric(d.likes) + '</span>' +
'</span>' +
'</div>',
'replies_found': () =>
'<div class="flex items-center gap-2 py-1.5 px-1 font-mono">' +
'<div class="bg-white border border-[#111111] p-1">' +
'<i data-lucide="message-square" class="w-3 h-3 text-[#4ADE80]"></i>' +
'</div>' +
'<span class="text-[11px] text-[#111111]/50">' +
d.count + ' replies found' + (d.min_required ? ' (need ' + d.min_required + ')' : '') +
'</span>' +
'</div>',
'post_selected': () =>
'<div class="bg-[#4ADE80]/10 border border-[#4ADE80] p-3">' +
'<div class="flex items-start gap-3">' +
'<div class="bg-[#4ADE80] p-1.5 shrink-0">' +
'<i data-lucide="check-circle" class="w-3.5 h-3.5 text-[#111111]"></i>' +
'</div>' +
'<div>' +
'<span class="text-xs font-mono font-bold text-[#111111] uppercase">Post Selected!</span>' +
'<p class="text-[11px] text-[#111111]/60 font-mono mt-0.5">' + esc(d.title || '') + '</p>' +
'<div class="flex gap-3 mt-1 text-[10px] text-[#111111]/40 font-mono">' +
'<span>&#9829; ' + fmtMetric(d.likes) + '</span>' +
'<span>&#128172; ' + (d.replies_count || 0) + ' replies</span>' +
'</div>' +
'</div>' +
'</div>' +
'</div>',
'login': () =>
'<div class="flex items-center gap-2 py-1.5 px-1 font-mono">' +
'<div class="bg-white border border-[#111111] p-1">' +
'<i data-lucide="log-in" class="w-3 h-3 text-[#111111]"></i>' +
'</div>' +
'<span class="text-[11px] text-[#111111]/50">' + esc(d.message || '') + '</span>' +
'</div>',
'browser_launch': () =>
'<div class="flex items-center gap-2 py-1.5 px-1 font-mono">' +
'<div class="bg-white border border-[#111111] p-1">' +
'<i data-lucide="globe" class="w-3 h-3 text-[#DE6C56]"></i>' +
'</div>' +
'<span class="text-[11px] text-[#111111]/50">' + esc(d.message || '') + '</span>' +
'</div>',
};
const fn = templates[type];
return fn ? fn() : (
'<div class="flex items-center gap-2 py-1.5 px-1 font-mono">' +
'<span class="text-[11px] text-[#111111]/50">' + esc(d.message || type) + '</span>' +
'</div>'
);
}
function renderScraperFeed(events) {
const container = document.getElementById('scraper-feed');
const empty = document.getElementById('feed-empty');
if (!events || events.length === 0) {
container.innerHTML = '';
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
const recent = events.slice(-50);
container.innerHTML = recent.map(function(e) { return renderScraperEvent(e); }).join('');
container.scrollTop = container.scrollHeight;
lucide.createIcons();
}
// --- pipeline lifecycle ---
async function startPipeline() {
const btn = document.getElementById('create-btn');
const btnText = document.getElementById('btn-text');
const spinner = document.getElementById('btn-spinner');
btn.disabled = true;
spinner.classList.remove('hidden');
btnText.textContent = 'Initializing...';
document.getElementById('progress-area').classList.remove('hidden');
document.getElementById('log-area').classList.remove('hidden');
document.getElementById('done-area').classList.add('hidden');
document.getElementById('error-area').classList.add('hidden');
// Show empty feed state
document.getElementById('scraper-feed').innerHTML = '';
document.getElementById('feed-empty').classList.remove('hidden');
document.getElementById('stage-diagram').innerHTML = '';
// Reset progress
document.getElementById('progress-bar').classList.remove('progress-success', 'progress-error');
const keywords = document.getElementById('keywords-input').value.trim();
try {
const r = await fetch(window.appPath('/create'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ search_queries: keywords || null }),
});
const data = await r.json();
if (data.status === 'started' || data.status === 'already_running') {
btnText.textContent = 'Processing...';
startTime = new Date();
elapsedTimer = setInterval(updateElapsedTime, 1000);
pollTimer = setInterval(pollStatus, 2000);
}
} catch (err) {
console.error("Failed to start pipeline:", err);
btn.disabled = false;
spinner.classList.add('hidden');
btnText.textContent = 'Retry';
}
}
async function pollStatus() {
try {
const r = await fetch(window.appPath('/create/status'));
const state = await r.json();
const stageText = document.getElementById('stage-text');
const progressBar = document.getElementById('progress-bar');
const pctText = document.getElementById('pct-text');
const logList = document.getElementById('log-list');
stageText.textContent = state.stage || 'Running...';
const pct = stageProgress(state.stage || '');
progressBar.value = pct;
pctText.textContent = pct + '%';
if (state.log && state.log.length > 0) {
logList.innerHTML = state.log.map(function(l) {
return '<div class="py-0.5 border-b border-[#111111]/10 last:border-0">' + esc(l) + '</div>';
}).join('');
logList.scrollTop = logList.scrollHeight;
}
// Render visualization panels
renderStageDiagram(state.stage);
if (state.scraper_events) {
renderScraperFeed(state.scraper_events);
}
if (!state.running) {
clearInterval(pollTimer);
clearInterval(elapsedTimer);
pollTimer = null;
document.getElementById('btn-spinner').classList.add('hidden');
if (state.stage === 'done' || state.result) {
progressBar.value = 100;
progressBar.classList.add('progress-success');
document.getElementById('btn-text').textContent = 'Create New';
document.getElementById('done-area').classList.remove('hidden');
if (state.result) {
document.getElementById('done-msg').textContent = state.result.message;
}
} else if (state.error) {
progressBar.classList.add('progress-error');
document.getElementById('btn-text').textContent = 'Retry';
document.getElementById('error-area').classList.remove('hidden');
document.getElementById('error-text').textContent = state.error;
}
document.getElementById('create-btn').disabled = false;
}
} catch (err) {
console.error("Status poll failed:", err);
}
}
// --- init ---
window.addEventListener('load', async function() {
lucide.createIcons();
try {
const r = await fetch(window.appPath('/create/status'));
const state = await r.json();
const btn = document.getElementById('create-btn');
if (window.PUBLIC_DEMO_MODE) {
btn.disabled = true;
document.getElementById('btn-text').textContent = 'Public Demo: Disabled';
} else if (state.running) {
document.getElementById('progress-area').classList.remove('hidden');
document.getElementById('log-area').classList.remove('hidden');
btn.disabled = true;
document.getElementById('btn-spinner').classList.remove('hidden');
document.getElementById('btn-text').textContent = 'Running...';
startTime = new Date();
elapsedTimer = setInterval(updateElapsedTime, 1000);
pollTimer = setInterval(pollStatus, 2000);
renderStageDiagram(state.stage);
if (state.scraper_events) {
renderScraperFeed(state.scraper_events);
}
} else {
btn.disabled = false;
document.getElementById('btn-text').textContent = 'Start Generation';
}
} catch (err) {
console.error("Initial status check failed:", err);
document.getElementById('create-btn').disabled = window.PUBLIC_DEMO_MODE;
document.getElementById('btn-text').textContent = window.PUBLIC_DEMO_MODE ? 'Public Demo: Disabled' : 'Start Generation';
}
});
</script>
{% endblock %}

@ -1,23 +1,97 @@
{% extends "layout.html" %}
{% block main %}
<main>
<div class="album py-2 bg-light">
<div class="container">
<div class="row mt-2">
<div class="col-12 col-md-3 mb-3">
<input type="text" class="form-control searchFilter" placeholder="Search videos"
aria-label="Search videos" onkeyup="searchFilter()">
<div class="bg-[#F2EFEB] min-h-[calc(100vh-6.5rem)] py-8">
<div class="container mx-auto px-4">
<!-- Header & Search -->
<div class="flex flex-col md:flex-row justify-between items-stretch md:items-center gap-4 mb-8">
<h1 class="text-3xl font-display font-black uppercase tracking-tighter text-[#111111]">Video Library</h1>
<!-- Bulk-action bar (visible in select mode only) -->
<div id="bulk-bar" class="hidden w-full md:w-auto items-center justify-between md:justify-end gap-2 border border-[#111111] bg-white p-2">
<button type="button" onclick="selectAll()"
class="btn-ghost-neo h-10">
<i data-lucide="check-square" class="w-4 h-4 mr-1"></i>
<span id="select-all-label">Select All</span>
</button>
<button type="button" onclick="cancelSelectMode()"
class="btn-ghost-neo h-10">
Cancel
</button>
<button id="bulk-delete-btn" type="button" onclick="confirmBulkDelete()"
class="btn-danger-neo text-sm h-10">
<i data-lucide="trash-2" class="w-4 h-4 mr-1"></i>
Delete (<span id="selection-count">0</span>)
</button>
</div>
<!-- Normal toolbar (hidden in select mode) -->
<div id="normal-toolbar" class="flex items-center gap-2 w-full md:w-auto">
{% if not public_demo_mode %}
<button type="button" onclick="toggleSelectMode()"
class="btn-ghost-neo shrink-0" style="height: 3rem; min-height: 3rem;">
<i data-lucide="check-square" class="w-4 h-4 mr-1"></i>
Select
</button>
{% endif %}
<div class="relative w-full md:w-72">
<i data-lucide="search" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#111111]/40"></i>
<input type="text"
class="searchFilter input-neo w-full pl-10"
style="height: 3rem;"
placeholder="Search videos..."
onkeyup="searchFilter()">
</div>
</div>
</div>
<div class="grid row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3" id="videos">
<!-- Video Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6" id="videos">
<!-- Videos will be injected here -->
</div>
</div>
<!-- Empty State -->
<div id="empty-state" class="hidden flex flex-col items-center justify-center py-20 text-[#111111]/30">
<i data-lucide="video-off" class="w-16 h-16 mb-4"></i>
<p class="text-lg font-mono uppercase tracking-wider">No videos found</p>
</div>
</div>
</div>
<!-- Video Player Modal -->
<dialog id="player_modal" class="modal modal-bottom sm:modal-middle">
<div class="modal-box bg-white border border-[#111111] max-w-2xl p-0 overflow-hidden">
<div class="flex justify-between items-center px-4 py-3 border-b border-[#111111]">
<h3 id="player_title" class="font-mono text-sm text-[#111111] truncate pr-4 uppercase"></h3>
<button type="button" onclick="closePlayer()" class="btn-ghost-neo p-1">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<video id="player_video" class="w-full bg-black" controls playsinline></video>
</div>
</main>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- Delete Confirmation Modal -->
<dialog id="delete_modal" class="modal">
<div class="modal-box bg-white border border-[#111111]">
<div class="flex items-center gap-3 mb-3">
<i data-lucide="triangle-alert" class="w-5 h-5 text-[#DE6C56] shrink-0"></i>
<h3 class="font-display font-black uppercase tracking-tighter text-lg text-[#111111]">Delete Video?</h3>
</div>
<p id="delete-modal-msg" class="text-[#111111]/60 mb-6 text-sm font-mono"></p>
<div class="modal-action mt-0">
<form method="dialog">
<button class="btn-ghost-neo">Cancel</button>
</form>
<button type="button" class="btn-danger-neo text-sm" onclick="executeDelete()">
<i data-lucide="trash-2" class="w-4 h-4 mr-1"></i>
Delete
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<script>
const intervals = [
@ -31,152 +105,286 @@
function timeSince(date) {
const seconds = Math.floor((Date.now() / 1000 - date));
const interval = intervals.find(i => i.seconds < seconds);
const interval = intervals.find(i => i.seconds <= seconds) || intervals[intervals.length - 1];
const count = Math.floor(seconds / interval.seconds);
return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`;
}
$(document).ready(function () {
$.getJSON("videos.json",
function (data) {
data.sort((b, a) => a['time'] - b['time'])
var video = '';
$.each(data, function (key, value) {
video += '<div class="col">';
video += '<div class="card shadow-sm">';
//keeping original themed image card for future thumbnail usage video += '<svg class="bd-placeholder-img card-img-top" width="100%" height="225" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Placeholder: Thumbnail" preserveAspectRatio="xMidYMid slice" focusable="false"><title>Placeholder</title><rect width="100%" height="100%" fill="#55595c"/><text x="50%" y="50%" fill="#eceeef" dy=".3em">r/'+value.subreddit+'</text></svg>';
video += '<div class="card-body">';
video += '<p class="card-text">r/' + value.subreddit + ' • ' + checkTitle(value.reddit_title, value.filename) + '</p>';
video += '<div class="d-flex justify-content-between align-items-center">';
video += '<div class="btn-group">';
video += '<a href="https://www.reddit.com/r/' + value.subreddit + '/comments/' + value.id + '/" class="btn btn-sm btn-outline-secondary" target="_blank">View</a>';
video += '<a href="http://localhost:4000/results/' + value.subreddit + '/' + value.filename + '" class="btn btn-sm btn-outline-secondary" download>Download</a>';
video += '</div>';
video += '<div class="btn-group">';
video += '<button type="button" data-toggle="tooltip" id="copy" data-original-title="Copy to clipboard" class="btn btn-sm btn-outline-secondary" data-clipboard-text="' + getCopyData(value.subreddit, value.reddit_title, value.filename, value.background_credit) + '"><i class="bi bi-card-text"></i></button>';
video += '<button type="button" data-toggle="tooltip" id="copy" data-original-title="Copy to clipboard" class="btn btn-sm btn-outline-secondary" data-clipboard-text="' + checkTitle(value.reddit_title, value.filename) + ' #Shorts #reddit"><i class="bi bi-youtube"></i></button>';
video += '<button type="button" data-toggle="tooltip" id="copy" data-original-title="Copy to clipboard" class="btn btn-sm btn-outline-secondary" data-clipboard-text="' + checkTitle(value.reddit_title, value.filename) + ' #reddit"><i class="bi bi-instagram"></i></button>';
video += '</div>';
video += '<small class="text-muted">' + timeSince(value.time) + '</small>';
video += '</div>';
video += '</div>';
video += '</div>';
video += '</div>';
function categoryLabel(subreddit) {
if (!subreddit) return "";
if (subreddit === "threads") return "Threads";
return `r/${subreddit}`;
}
function sourceUrl(subreddit, id) {
if (subreddit === "threads") {
return `https://www.threads.net/post/${id}`;
}
return `https://www.reddit.com/r/${subreddit}/comments/${id}/`;
}
function checkTitle(reddit_title, filename) {
const file = filename.slice(0, -4);
return reddit_title === file ? reddit_title : file;
}
// Escape arbitrary strings for safe embedding inside HTML attributes
function h(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
async function loadVideos() {
try {
const response = await fetch("videos.json");
const data = await response.json();
data.sort((b, a) => a['time'] - b['time']);
const container = document.getElementById('videos');
container.innerHTML = data.map(v => {
const title = checkTitle(v.reddit_title, v.filename);
return `
<div class="video-card group bg-white border border-[#111111] relative hover:border-[#DE6C56] transition-colors duration-200 flex flex-col min-h-[23.25rem]"
data-video-id="${h(v.id)}">
<!-- Checkbox overlay (shown in select mode) -->
<div class="select-overlay hidden absolute top-3 right-3 z-10 pointer-events-none">
<input type="checkbox" class="card-checkbox w-5 h-5 pointer-events-auto accent-[#DE6C56]" />
</div>
<button type="button"
class="play-btn aspect-video w-full bg-[#111111]/5 flex items-center justify-center relative overflow-hidden cursor-pointer border-b border-[#111111]"
data-video-id="${h(v.id)}"
data-video-title="${h(title)}">
<i data-lucide="play-circle" class="w-12 h-12 text-[#111111]/30 group-hover:text-[#DE6C56] transition-colors"></i>
<div class="absolute top-3 left-3">
<span class="inline-block bg-white border border-[#111111] text-[#111111] font-mono text-[10px] uppercase tracking-wider px-2 py-0.5">
${h(categoryLabel(v.subreddit))}
</span>
</div>
</button>
<div class="p-4 flex flex-col flex-1">
<h3 class="text-[#111111] font-mono text-sm font-medium line-clamp-2 break-words mb-4 min-h-10" title="${h(title)}">
${h(title)}
</h3>
<div class="flex items-center justify-between gap-2 mt-auto">
<div class="flex gap-1">
<a href="${h(sourceUrl(v.subreddit, v.id))}" target="_blank"
class="btn-ghost-neo p-2"
title="View Source">
<i data-lucide="external-link" class="w-4 h-4"></i>
</a>
<a href="${window.appPath('/video/' + encodeURIComponent(v.id))}?download=1" download
class="btn-ghost-neo p-2"
title="Download">
<i data-lucide="download" class="w-4 h-4"></i>
</a>
</div>
<div class="flex gap-1">
<button class="btn-ghost-neo p-2 copy-btn"
data-copy="${h(sourceUrl(v.subreddit, v.id))}"
title="Copy Link">
<i data-lucide="link" class="w-4 h-4"></i>
</button>
${window.PUBLIC_DEMO_MODE ? '' : `<button class="btn-ghost-neo p-2 hover:text-[#DE6C56] delete-btn"
data-video-id="${h(v.id)}"
title="Delete">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>`}
</div>
</div>
<div class="mt-4 pt-4 border-t border-[#111111]/20 flex justify-between items-center">
<span class="text-[10px] uppercase tracking-wider text-[#111111]/40 font-mono">
${timeSince(v.time)}
</span>
</div>
</div>
</div>`;
}).join('');
// Wire play buttons — in select mode, toggle checkbox instead of playing
container.querySelectorAll('.play-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (selectMode) {
const card = btn.closest('.video-card');
const cb = card.querySelector('.card-checkbox');
cb.checked = !cb.checked;
updateSelectionCount();
} else {
openPlayer(window.appPath(`/video/${encodeURIComponent(btn.dataset.videoId)}`), btn.dataset.videoTitle);
}
});
});
// Wire copy buttons
container.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', () => {
navigator.clipboard.writeText(btn.dataset.copy).then(() => {
const orig = btn.innerHTML;
btn.innerHTML = '<i data-lucide="check" class="w-4 h-4 text-[#4ADE80]"></i>';
lucide.createIcons();
setTimeout(() => { btn.innerHTML = orig; lucide.createIcons(); }, 2000);
});
});
});
$('#videos').append(video);
// Wire single-delete buttons
container.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', () => confirmSingleDelete(btn.dataset.videoId));
});
});
$(document).ready(function () {
$('[data-toggle="tooltip"]').tooltip();
$('[data-toggle="tooltip"]').on('click', function () {
$(this).tooltip('hide');
});
});
// Wire checkboxes to update the bulk-delete counter
container.querySelectorAll('.card-checkbox').forEach(cb => {
cb.addEventListener('change', updateSelectionCount);
});
$('#copy').tooltip({
trigger: 'click',
placement: 'bottom'
});
// Re-init icons
lucide.createIcons();
} catch (error) {
console.error("Error loading videos:", error);
}
}
function setTooltip(btn, message) {
$(btn).tooltip('hide')
.attr('data-original-title', message)
.tooltip('show');
// ── Select mode ────────────────────────────────────────────────────────────
let selectMode = false;
let pendingDeleteIds = [];
function toggleSelectMode() {
selectMode = true;
document.getElementById('bulk-bar').classList.remove('hidden');
document.getElementById('bulk-bar').classList.add('flex');
document.getElementById('normal-toolbar').classList.add('hidden');
document.querySelectorAll('.select-overlay').forEach(el => el.classList.remove('hidden'));
document.querySelectorAll('.card-checkbox').forEach(cb => cb.checked = false);
updateSelectionCount();
lucide.createIcons();
}
function hoverTooltip(btn, message) {
$(btn).tooltip('hide')
.attr('data-original-title', message)
.tooltip('show');
function cancelSelectMode() {
selectMode = false;
document.getElementById('bulk-bar').classList.add('hidden');
document.getElementById('bulk-bar').classList.remove('flex');
document.getElementById('normal-toolbar').classList.remove('hidden');
document.querySelectorAll('.select-overlay').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('.card-checkbox').forEach(cb => cb.checked = false);
updateSelectionCount();
}
function hideTooltip(btn) {
setTimeout(function () {
$(btn).tooltip('hide');
}, 1000);
function selectAll() {
const checkboxes = document.querySelectorAll('.card-checkbox');
const allChecked = [...checkboxes].every(cb => cb.checked);
checkboxes.forEach(cb => cb.checked = !allChecked);
document.getElementById('select-all-label').textContent = allChecked ? 'Select All' : 'Deselect All';
updateSelectionCount();
}
function disposeTooltip(btn) {
setTimeout(function () {
$(btn).tooltip('dispose');
}, 1500);
function updateSelectionCount() {
const count = document.querySelectorAll('.card-checkbox:checked').length;
document.getElementById('selection-count').textContent = count;
document.getElementById('bulk-delete-btn').disabled = count === 0;
}
var clipboard = new ClipboardJS('#copy');
function getSelectedIds() {
return [...document.querySelectorAll('.card-checkbox:checked')]
.map(cb => cb.closest('.video-card').dataset.videoId);
}
clipboard.on('success', function (e) {
e.clearSelection();
console.info('Action:', e.action);
console.info('Text:', e.text);
console.info('Trigger:', e.trigger);
setTooltip(e.trigger, 'Copied!');
hideTooltip(e.trigger);
disposeTooltip(e.trigger);
});
// ── Delete confirmation ─────────────────────────────────────────────────
function confirmBulkDelete() {
pendingDeleteIds = getSelectedIds();
if (!pendingDeleteIds.length) return;
const n = pendingDeleteIds.length;
document.getElementById('delete-modal-msg').textContent =
`Are you sure you want to delete ${n} video${n !== 1 ? 's' : ''}? This cannot be undone.`;
document.getElementById('delete_modal').showModal();
}
clipboard.on('error', function (e) {
console.error('Action:', e.action);
console.error('Trigger:', e.trigger);
setTooltip(e.trigger, fallbackMessage(e.action));
hideTooltip(e.trigger);
});
function confirmSingleDelete(videoId) {
pendingDeleteIds = [videoId];
document.getElementById('delete-modal-msg').textContent =
'Are you sure you want to delete this video? This cannot be undone.';
document.getElementById('delete_modal').showModal();
}
function getCopyData(subreddit, reddit_title, filename, background_credit) {
async function executeDelete() {
document.getElementById('delete_modal').close();
if (!pendingDeleteIds.length) return;
if (subreddit == undefined) {
subredditCopy = "";
} else {
subredditCopy = "r/" + subreddit + "\n\n";
}
const ids = [...pendingDeleteIds];
pendingDeleteIds = [];
const file = filename.slice(0, -4);
if (reddit_title == file) {
titleCopy = reddit_title;
} else {
titleCopy = file;
try {
await fetch(window.appPath('/videos/delete'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })
});
} catch (err) {
console.error('Delete request failed:', err);
}
var copyData = "";
copyData += subredditCopy;
copyData += titleCopy;
copyData += "\n\nBackground credit: " + background_credit;
return copyData;
// Remove cards from DOM regardless (optimistic UI)
ids.forEach(id => {
const card = document.querySelector(`.video-card[data-video-id="${CSS.escape(id)}"]`);
if (card) card.remove();
});
// Show empty state if nothing remains
const remaining = document.querySelectorAll('.video-card:not(.hidden)').length;
document.getElementById('empty-state').classList.toggle('hidden', remaining > 0);
if (selectMode) cancelSelectMode();
}
function getLink(subreddit, id, reddit_title) {
if (subreddit == undefined) {
return reddit_title;
} else {
return "<a target='_blank' href='https://www.reddit.com/r/" + subreddit + "/comments/" + id + "/'>" + reddit_title + "</a>";
}
function searchFilter() {
const query = document.querySelector(".searchFilter") ? document.querySelector(".searchFilter").value.toLowerCase() : '';
const cards = document.querySelectorAll(".video-card");
let visibleCount = 0;
cards.forEach(card => {
const text = card.textContent.toLowerCase();
const matches = text.includes(query);
card.classList.toggle('hidden', !matches);
if (matches) visibleCount++;
});
document.getElementById('empty-state').classList.toggle('hidden', visibleCount > 0);
}
function checkTitle(reddit_title, filename) {
const file = filename.slice(0, -4);
if (reddit_title == file) {
return reddit_title;
} else {
return file;
}
function openPlayer(src, title) {
const modal = document.getElementById('player_modal');
const video = document.getElementById('player_video');
const titleEl = document.getElementById('player_title');
titleEl.textContent = title || '';
video.src = src;
modal.showModal();
video.play().catch(() => {});
}
var searchFilter = () => {
const input = document.querySelector(".searchFilter");
const cards = document.getElementsByClassName("col");
console.log(cards[1])
let filter = input.value
for (let i = 0; i < cards.length; i++) {
let title = cards[i].querySelector(".card-text");
if (title.innerText.toLowerCase().indexOf(filter.toLowerCase()) > -1) {
cards[i].classList.remove("d-none")
} else {
cards[i].classList.add("d-none")
}
}
function closePlayer() {
const modal = document.getElementById('player_modal');
const video = document.getElementById('player_video');
video.pause();
video.removeAttribute('src');
video.load();
modal.close();
}
document.getElementById('player_modal').addEventListener('close', () => {
const video = document.getElementById('player_video');
video.pause();
video.removeAttribute('src');
video.load();
});
document.addEventListener('DOMContentLoaded', loadVideos);
</script>
{% endblock %}
{% endblock %}

@ -1,155 +1,395 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="cache-control" content="no-cache" />
<title>RedditVideoMakerBot</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.1.3/dist/css/bootstrap.min.css"
integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
<link href="https://getbootstrap.com/docs/5.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.9.1/font/bootstrap-icons.css">
<title>VideoMakerBot</title>
<!-- Tailwind config — must load BEFORE the CDN script -->
<script>
tailwind = window.tailwind || {};
tailwind.config = {
theme: {
extend: {
colors: {
cream: '#F2EFEB',
jet: '#111111',
terracotta: '#DE6C56',
'terracotta-dark': '#C85A46',
terminal: '#4ADE80',
},
fontFamily: {
display: ['"Clash Display"', 'system-ui', 'sans-serif'],
mono: ['"Space Mono"', 'monospace'],
},
}
}
};
</script>
<script src="https://cdn.tailwindcss.com"></script>
<!-- DaisyUI (component library) -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.4.19/dist/full.min.css" rel="stylesheet" type="text/css" />
<!-- Fonts: Clash Display (display/headings via Fontshare) + Space Mono (body/UI via Google Fonts) -->
<link rel="preconnect" href="https://api.fontshare.com">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://api.fontshare.com/v2/css?f[]=clash-display@400,500,600,700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<!-- Lucide icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<script>
window.PUBLIC_BASE_PATH = {{ public_base_path|tojson }};
window.PUBLIC_DEMO_MODE = {{ public_demo_mode|tojson }};
window.appPath = function(path) {
var normalized = path.charAt(0) === '/' ? path : '/' + path;
return window.PUBLIC_BASE_PATH + normalized;
};
document.addEventListener('DOMContentLoaded', function() {
if (!window.PUBLIC_DEMO_MODE) return;
document.querySelectorAll('[data-demo-disabled]').forEach(function(el) {
el.disabled = true;
});
});
</script>
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
/* ── Neo-Brutalist Global Overrides ───────────────────────────── */
/* DaisyUI border-radius kill switch */
:root {
--rounded-box: 0rem;
--rounded-btn: 0rem;
--rounded-badge: 0rem;
--tab-radius: 0rem;
--tooltip-radius: 0rem;
--alert-radius: 0rem;
--input-radius: 0rem;
}
.feedback-invalid {
color: #dc3545;
/* Global reset */
body {
font-family: 'Space Mono', monospace;
background-color: #F2EFEB;
color: #111111;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
/* Kill all box-shadows */
*,
*::before,
*::after {
box-shadow: none !important;
}
.bi {
vertical-align: -.125em;
fill: currentColor;
/* ── Typography ────────────────────────────────────────────────── */
h1, h2, h3 {
font-family: 'Clash Display', system-ui, sans-serif;
font-weight: 900;
text-transform: uppercase;
letter-spacing: -0.025em;
}
.nav {
display: flex;
flex-wrap: nowrap;
padding-bottom: 1rem;
margin-top: -1px;
overflow-x: auto;
text-align: center;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
/* All UI text: monospace */
button, a, input, select, textarea, label, .btn, .badge, nav, footer {
font-family: 'Space Mono', monospace !important;
}
/* ── DaisyUI Component Overrides ───────────────────────────────── */
/* Buttons — kill radius, uppercase */
.btn {
border-radius: 0 !important;
text-transform: uppercase;
letter-spacing: 0.025em;
font-weight: 500;
}
/* Cards */
.card {
border-radius: 0 !important;
}
/* Inputs / Selects / Textareas */
.input, .select, .textarea {
border-radius: 0 !important;
border: 1px solid #111111;
}
/* Badges */
.badge {
border-radius: 0 !important;
}
/* Modals */
.modal-box {
border-radius: 0 !important;
}
/* Menu items */
.menu li > * {
border-radius: 0 !important;
}
/* Toggle / Checkbox — kill radius */
.toggle {
border-radius: 0 !important;
}
/* Alerts */
.alert {
border-radius: 0 !important;
}
/* Dividers — use /// text pattern where plain dividers appear */
.divider:not(.divider-text)::after,
.divider:not(.divider-text)::before {
/* We'll use .divider-text overrides in templates */
}
/* Progress bar */
.progress {
border-radius: 0 !important;
}
/* Join groups */
.join > * {
border-radius: 0 !important;
}
/* ── Custom Scrollbar ──────────────────────────────────────────── */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: #F2EFEB; }
::-webkit-scrollbar-thumb { background: #111111; }
::-webkit-scrollbar-thumb:hover { background: #333333; }
/* Dotted path inputs — suppress default browser validation UI */
input:invalid { box-shadow: none; }
/* ── Navigation ────────────────────────────────────────────────── */
.nav-link {
font-family: 'Space Mono', monospace;
font-size: 0.8125rem;
color: #111111;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.375rem 0.75rem;
transition: background-color 150ms;
}
.nav-link:hover {
background-color: #111111;
color: #F2EFEB;
}
#tooltip {
background-color: #333;
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 13px;
/* ── Buttons ───────────────────────────────────────────────────── */
.btn-primary-neo {
background-color: #DE6C56;
color: #F2EFEB;
border: 1px solid #DE6C56;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: 'Space Mono', monospace;
text-transform: uppercase;
font-weight: 500;
letter-spacing: 0.025em;
padding: 0.5rem 1.25rem;
cursor: pointer;
transition: all 150ms;
border-radius: 0;
}
.btn-primary-neo:hover {
background-color: #111111;
border-color: #111111;
color: #F2EFEB;
}
.tooltip-inner {
max-width: 500px !important;
.btn-secondary-neo {
background-color: transparent;
color: #111111;
border: 2px solid #111111;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: 'Space Mono', monospace;
text-transform: uppercase;
font-weight: 500;
letter-spacing: 0.025em;
padding: 0.5rem 1.25rem;
cursor: pointer;
transition: all 150ms;
border-radius: 0;
}
.btn-secondary-neo:hover {
background-color: #111111;
color: #F2EFEB;
}
#hard-reload {
.btn-ghost-neo {
background: transparent;
color: #111111;
border: 1px solid #111111;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: 'Space Mono', monospace;
text-transform: uppercase;
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
cursor: pointer;
color: darkblue;
border-radius: 0;
transition: all 150ms;
}
.btn-ghost-neo:hover {
background-color: #111111;
color: #F2EFEB;
}
.btn-danger-neo {
background-color: transparent;
color: #DE6C56;
border: 1px solid #DE6C56;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: 'Space Mono', monospace;
text-transform: uppercase;
font-weight: 500;
letter-spacing: 0.025em;
padding: 0.5rem 1.25rem;
cursor: pointer;
transition: all 150ms;
border-radius: 0;
}
.btn-danger-neo:hover {
background-color: #DE6C56;
color: #F2EFEB;
}
.btn-danger-neo:disabled {
opacity: 0.45;
cursor: not-allowed;
background-color: transparent;
color: #DE6C56;
}
/* ── Cards ─────────────────────────────────────────────────────── */
.card-neo {
background-color: #FFFFFF;
border: 1px solid #111111;
border-radius: 0;
}
/* ── Inputs ────────────────────────────────────────────────────── */
.input-neo {
background-color: #FFFFFF;
border: 1px solid #111111;
color: #111111;
font-family: 'Space Mono', monospace;
border-radius: 0;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
}
#hard-reload:hover {
color: blue;
.input-neo::placeholder { color: #999999; }
.input-neo:focus {
outline: none;
border-width: 2px;
}
/* ── Footer Ticker ─────────────────────────────────────────────── */
.footer-ticker {
background-color: #111111;
color: #F2EFEB;
font-family: 'Space Mono', monospace;
font-size: 0.6875rem;
letter-spacing: 0.05em;
overflow: hidden;
white-space: nowrap;
padding: 0.625rem 0;
}
.footer-ticker a {
color: #4ADE80;
text-decoration: none;
}
.footer-ticker a:hover {
color: #F2EFEB;
text-decoration: underline;
}
</style>
</head>
<script src="https://code.jquery.com/jquery-3.1.1.js" integrity="sha256-16cdPddA6VdVInumRGo6IbivbERE8p7CQR3HzTBuELA="
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.14.3/dist/umd/popper.min.js"
integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.1.3/dist/js/bootstrap.min.js"
integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy"
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.10/clipboard.min.js"></script>
<script src="https://unpkg.com/isotope-layout@3/dist/isotope.pkgd.js"></script>
<body>
<header>
{% if get_flashed_messages() %}
{% for category, message in get_flashed_messages(with_categories=true) %}
{% if category == "error" %}
<div class="alert alert-danger mb-0 text-center" role="alert">
{{ message }}
</div>
<body class="min-h-screen flex flex-col bg-[#F2EFEB] text-[#111111]">
{% else %}
<div class="alert alert-success mb-0 text-center" role="alert">
{{ message }}
</div>
{% endif %}
{% endfor %}
{% endif %}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a href="/" class="navbar-brand d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" stroke="currentColor"
stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="me-2"
viewBox="0 0 24 24">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
<strong>RedditVideoMakerBot</strong>
<!-- ── Navigation ─────────────────────────────────────────────────── -->
<header class="sticky top-0 z-50 bg-[#F2EFEB] border-b border-[#111111]">
<div class="container mx-auto px-4">
<div class="flex items-center justify-between h-14">
<!-- Logo -->
<a href="{{ app_url('/') }}" class="flex items-center gap-2 no-underline">
<span class="font-display text-lg text-[#111111] tracking-tighter uppercase">VideoMakerBot</span>
</a>
<div class="collapse navbar-collapse">
<ul class="navbar-nav mr-auto">
<li class="nav-item">
<a class="nav-link" href="backgrounds">Background Manager</a>
</li>
<li class="nav-item">
<a class="nav-link" href="settings">Settings</a>
</li>
</ul>
<!-- Future feature
<ul class="navbar-nav">
<li class="nav-item">
<button class="btn btn-outline-success mr-auto mt-2 mt-lg-0">Create new short</button>
</li>
</ul>
-->
<!-- Nav Items -->
<div class="flex items-center gap-0">
<a href="{{ app_url('/') }}" class="nav-link">Library</a>
<a href="{{ app_url('/backgrounds') }}" class="nav-link">Backgrounds</a>
<a href="{{ app_url('/settings') }}" class="nav-link">Settings</a>
<span class="text-[#111111]/20 mx-1">///</span>
<a href="{{ app_url('/create') }}" class="btn-primary-neo text-sm py-1.5 px-4">
<i data-lucide="plus" class="w-3.5 h-3.5 inline mr-1"></i>
Create
</a>
</div>
</div>
</nav>
</div>
</header>
{% block main %}{% endblock %}
<footer class="text-muted py-5">
<div class="container">
<p class="float-end mb-1">
<a href="#">Back to top</a>
</p>
<p class="mb-1"><a href="https://getbootstrap.com/docs/5.2/examples/album/" target="_blank">Album</a>
Example
Theme by &copy; Bootstrap. <a
href="https://github.com/elebumm/RedditVideoMakerBot/blob/master/README.md#developers-and-maintainers"
target="_blank">Developers and Maintainers</a></p>
<p class="mb-0">If your data is not refreshing, try to hard reload(Ctrl + F5) or click <a id="hard-reload">this</a> and visit your local
<strong>{{ file }}</strong> file.
</p>
<!-- ── Flash Messages ─────────────────────────────────────────────── -->
{% if get_flashed_messages() %}
<div class="container mx-auto px-4 mt-4">
{% for category, message in get_flashed_messages(with_categories=true) %}
<div class="border px-4 py-3 mb-2 font-mono text-sm
{{ 'border-[#DE6C56] text-[#DE6C56] bg-[#DE6C56]/5' if category == 'error' else 'border-[#4ADE80] text-[#111111] bg-[#4ADE80]/10' }}">
<i data-lucide="{{ 'alert-circle' if category == 'error' else 'check-circle' }}" class="w-4 h-4 inline mr-2"></i>
<span>{{ message }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<!-- ── Main Content ───────────────────────────────────────────────── -->
<main class="flex-grow">
{% block main %}{% endblock %}
</main>
<!-- ── Footer Ticker ──────────────────────────────────────────────── -->
<footer class="footer-ticker border-t-2 border-[#111111]">
<div class="container mx-auto px-4 flex items-center gap-3 overflow-x-auto">
<span class="text-[#4ADE80] shrink-0">● ONLINE</span>
<span class="text-[#F2EFEB]/30 shrink-0">///</span>
<span class="shrink-0">VideoMakerBot</span>
<span class="text-[#F2EFEB]/30 shrink-0">///</span>
<span class="shrink-0">v3.4.0</span>
<span class="text-[#F2EFEB]/30 shrink-0">///</span>
<a href="https://github.com/elebumm/RedditVideoMakerBot" target="_blank" class="shrink-0">GITHUB</a>
<span class="text-[#F2EFEB]/30 shrink-0">///</span>
<a href="#" id="hard-reload" class="shrink-0">RELOAD</a>
<span class="text-[#F2EFEB]/30 shrink-0">///</span>
<span class="text-[#F2EFEB]/50 shrink-0">&copy; 2026</span>
</div>
</footer>
<script>
document.getElementById("hard-reload").addEventListener("click", function () {
// Initialize Lucide icons
lucide.createIcons();
document.getElementById("hard-reload").addEventListener("click", function (e) {
e.preventDefault();
window.location.reload(true);
});
</script>
</body>
</html>
</html>

File diff suppressed because it is too large Load Diff

@ -32,8 +32,10 @@ The only original thing being done is the editing and gathering of all materials
## Requirements
- Python 3.10
- Python 3.10+
- Playwright (this should install automatically in installation)
- Docker and Docker Compose for the container workflow
- FFmpeg (for video composition)
## Installation 👩‍💻
@ -66,6 +68,44 @@ The only original thing being done is the editing and gathering of all materials
python -m playwright install-deps
```
## Docker
The repository now includes a shared image plus Compose services for the GUI and CLI.
Build the image:
```sh
docker compose build
```
Start the GUI:
```sh
docker compose up gui
```
Open `http://localhost:4000` in your browser.
Run the CLI pipeline:
```sh
docker compose run --rm cli
```
Run the CLI for a specific post:
```sh
docker compose run --rm cli python main.py <post_id>
```
Stop the GUI and remove the Compose stack:
```sh
docker compose down
```
The repo root is bind-mounted into the container so `config.toml`, `results/`, `assets/temp/`, and the runtime JSON files persist across rebuilds and repeated runs.
---
**EXPERIMENTAL!!!!**
@ -97,21 +137,38 @@ For a more detailed guide about the bot, please refer to the [documentation](htt
https://user-images.githubusercontent.com/66544866/173453972-6526e4e6-c6ef-41c5-ab40-5d275e724e7c.mp4
## Web User Interface 🖥️
VideoMakerBot features a modernized Flask-based web UI for easier management and generation.
- **Technology Stack**: Tailwind CSS, DaisyUI, Lucide Icons, Vanilla ES6 JavaScript.
- **Video Library**: View, download, and copy source links for generated videos.
- **Background Manager**: Add and remove background videos (YouTube-linked) and manage audio tracks.
- **Settings**: Complete configuration of platform credentials (Reddit, Threads), TTS providers, and visual preferences.
To start the UI locally without Docker:
```sh
python GUI.py
```
Visit `http://localhost:4000` to access the dashboard.
## Contributing & Ways to improve 📈
In its current state, this bot does exactly what it needs to do. However, improvements can always be made!
I have tried to simplify the code so anyone can read it and start contributing at any skill level. Don't be shy :) contribute!
- [ ] Creating better documentation and adding a command line interface.
- [x] Creating better documentation and adding a command line interface.
- [x] Allowing the user to choose background music for their videos.
- [x] Allowing users to choose a reddit thread instead of being randomized.
- [x] Allowing users to choose a reddit/threads thread instead of being randomized.
- [x] Allowing users to choose a background that is picked instead of the Minecraft one.
- [x] Allowing users to choose between any subreddit.
- [x] Allowing users to change voice.
- [x] Checks if a video has already been created
- [x] Light and Dark modes
- [x] NSFW post filter
- [x] Checks if a video has already been created.
- [x] Light and Dark modes.
- [x] NSFW post filter.
- [x] Threads platform support.
- [x] Modern Web UI (Tailwind + DaisyUI).
Please read our [contributing guidelines](CONTRIBUTING.md) for more detailed information.

@ -80,10 +80,15 @@ class TikTok:
"""TikTok Text-to-Speech Wrapper"""
def __init__(self):
sessionid = (
settings.config.get("settings", {})
.get("tts", {})
.get("tiktok_sessionid", "")
)
headers = {
"User-Agent": "com.zhiliaoapp.musically/2022600030 (Linux; U; Android 7.1.2; es_ES; SM-G988N; "
"Build/NRD90M;tt-ok/3.12.13.1)",
"Cookie": f"sessionid={settings.config['settings']['tts']['tiktok_sessionid']}",
"Cookie": f"sessionid={sessionid}",
}
self.URI_BASE = "https://api16-normal-c-useast1a.tiktokv.com/media/api/text/speech/invoke/"
@ -94,33 +99,35 @@ class TikTok:
self._session.headers = headers
def run(self, text: str, filepath: str, random_voice: bool = False):
if random_voice:
voice = self.random_voice()
else:
# if tiktok_voice is not set in the config file, then use a random voice
voice = settings.config["settings"]["tts"].get("tiktok_voice", None)
# get the audio from the TikTok API
data = self.get_voices(voice=voice, text=text)
# check if there was an error in the request
status_code = data["status_code"]
if status_code != 0:
raise TikTokTTSException(status_code, data["message"])
# decode data from base64 to binary
try:
raw_voices = data["data"]["v_str"]
except:
print(
"The TikTok TTS returned an invalid response. Please try again later, and report this bug."
)
raise TikTokTTSException(0, "Invalid response")
decoded_voices = base64.b64decode(raw_voices)
# write voices to specified filepath
with open(filepath, "wb") as out:
out.write(decoded_voices)
if random_voice:
voice = self.random_voice()
else:
# if tiktok_voice is not set in the config file, then use a random voice
voice = settings.config["settings"]["tts"].get("tiktok_voice", None)
# get the audio from the TikTok API
data = self.get_voices(voice=voice, text=text)
# check if there was an error in the request
status_code = data["status_code"]
if status_code != 0:
raise TikTokTTSException(status_code, data["message"])
# decode data from base64 to binary
try:
raw_voices = data["data"]["v_str"]
except (KeyError, TypeError):
raise TikTokTTSException(0, "Invalid response: missing v_str field")
decoded_voices = base64.b64decode(raw_voices)
# write voices to specified filepath
with open(filepath, "wb") as out:
out.write(decoded_voices)
except TikTokTTSException:
raise # Re-raise TikTok-specific errors as-is
except Exception as err:
raise TikTokTTSException(0, f"Unexpected error in TikTok TTS: {err}")
def get_voices(self, text: str, voice: Optional[str] = None) -> dict:
"""If voice is not passed, the API will try to use the most fitting voice"""
@ -136,11 +143,17 @@ class TikTok:
# send request
try:
response = self._session.post(self.URI_BASE, params=params)
except ConnectionError:
except requests.RequestException:
time.sleep(random.randrange(1, 7))
response = self._session.post(self.URI_BASE, params=params)
try:
response = self._session.post(self.URI_BASE, params=params)
except requests.RequestException as err:
raise TikTokTTSException(0, f"Network error contacting TikTok API: {err}")
return response.json()
try:
return response.json()
except ValueError as err:
raise TikTokTTSException(0, f"Invalid JSON response from TikTok API: {err}")
@staticmethod
def random_voice() -> str:

@ -1,5 +1,6 @@
import os
import re
import subprocess
from pathlib import Path
from typing import Tuple
@ -14,6 +15,10 @@ from utils import settings
from utils.console import print_step, print_substep
from utils.voice import sanitize_text
# TikTok + Google Translate imports — used for graceful fallback when TikTok TTS fails
from TTS.GTTS import GTTS
from TTS.TikTok import TikTokTTSException
DEFAULT_MAX_LENGTH: int = (
50 # Video length variable, edit this on your own risk. It should work, but it's not supported
)
@ -53,8 +58,8 @@ class TTSEngine:
self,
): # adds periods to the end of paragraphs (where people often forget to put them) so tts doesn't blend sentences
for comment in self.reddit_object["comments"]:
# remove links
regex_urls = r"((http|https)\:\/\/)?[a-zA-Z0-9\.\/\?\:@\-_=#]+\.([a-zA-Z]){2,6}([a-zA-Z0-9\.\&\/\?\:@\-_=#])*"
# remove links — atomic URL pattern (no backtracking)
regex_urls = r"https?://\S+|www\.\S+\.\S+"
comment["comment_body"] = re.sub(regex_urls, " ", comment["comment_body"])
comment["comment_body"] = comment["comment_body"].replace("\n", ". ")
comment["comment_body"] = re.sub(r"\bAI\b", "A.I", comment["comment_body"])
@ -100,60 +105,68 @@ class TTSEngine:
self.call_tts(f"{idx}", process_text(comment["comment_body"]))
print_substep("Saved Text to MP3 files successfully.", style="bold green")
return self.length, idx
return self.length, idx + 1 # count, not last index
def split_post(self, text: str, idx):
split_files = []
pattern = r" *(((.|\n){0," + str(self.tts_module.max_chars) + r"})(\.|.$))"
split_text = [
x.group().strip()
for x in re.finditer(
r" *(((.|\n){0," + str(self.tts_module.max_chars) + "})(\.|.$))", text
)
for x in re.finditer(pattern, text)
]
self.create_silence_mp3()
# Generate all TTS segment files first
for idy, text_cut in enumerate(split_text):
newtext = process_text(text_cut)
# print(f"{idx}-{idy}: {newtext}\n")
if not newtext or newtext.isspace():
print("newtext was blank because sanitized split text resulted in none")
continue
else:
self.call_tts(f"{idx}-{idy}.part", newtext)
with open(f"{self.path}/list.txt", "w") as f:
for idz in range(0, len(split_text)):
f.write("file " + f"'{idx}-{idz}.part.mp3'" + "\n")
split_files.append(str(f"{self.path}/{idx}-{idy}.part.mp3"))
f.write("file " + f"'silence.mp3'" + "\n")
os.system(
"ffmpeg -f concat -y -hide_banner -loglevel panic -safe 0 "
+ "-i "
+ f"{self.path}/list.txt "
+ "-c copy "
+ f"{self.path}/{idx}.mp3"
)
try:
for i in range(0, len(split_files)):
os.unlink(split_files[i])
except FileNotFoundError as e:
print("File not found: " + e.filename)
except OSError:
print("OSError")
self.call_tts(f"{idx}-{idy}.part", newtext)
split_files.append(str(f"{self.path}/{idx}-{idy}.part.mp3"))
# Write concat list referencing only generated files, then run ffmpeg once
list_path = f"{self.path}/list.txt"
with open(list_path, "w") as f:
for idz in range(len(split_files)):
f.write(f"file '{idx}-{idz}.part.mp3'\n")
f.write("file 'silence.mp3'\n")
subprocess.run([
"ffmpeg", "-f", "concat", "-y", "-hide_banner", "-loglevel", "panic",
"-safe", "0", "-i", list_path, "-c", "copy",
f"{self.path}/{idx}.mp3",
], check=False)
for part_path in split_files:
try:
os.unlink(part_path)
except FileNotFoundError:
pass
def call_tts(self, filename: str, text: str):
if settings.config["settings"]["tts"]["voice_choice"] == "googletranslate":
# GTTS does not have the argument 'random_voice'
self.tts_module.run(
text,
filepath=f"{self.path}/{filename}.mp3",
try:
if settings.config["settings"]["tts"]["voice_choice"] == "googletranslate":
# GTTS does not have the argument 'random_voice'
self.tts_module.run(
text,
filepath=f"{self.path}/{filename}.mp3",
)
else:
self.tts_module.run(
text,
filepath=f"{self.path}/{filename}.mp3",
random_voice=settings.config["settings"]["tts"]["random_voice"],
)
except TikTokTTSException as err:
print_substep(
f"TikTok TTS failed ({err}). Falling back to Google Translate TTS for this segment.",
"bold yellow",
)
else:
settings.config["settings"]["tts"]["voice_choice"] = "googletranslate"
self.tts_module = GTTS()
self.tts_module.run(
text,
filepath=f"{self.path}/{filename}.mp3",
random_voice=settings.config["settings"]["tts"]["random_voice"],
)
# try:
# self.length += MP3(f"{self.path}/{filename}.mp3").info.length
@ -164,7 +177,8 @@ class TTSEngine:
self.last_clip_length = clip.duration
self.length += clip.duration
clip.close()
except:
except (OSError, IOError, Exception) as e:
print_substep(f"Could not probe audio duration: {e}", "yellow")
self.length = 0
def create_silence_mp3(self):
@ -179,7 +193,8 @@ class TTSEngine:
def process_text(text: str, clean: bool = True):
lang = settings.config["reddit"]["thread"]["post_lang"]
lang = (settings.config["settings"].get("post_lang") or
settings.config.get("reddit", {}).get("thread", {}).get("post_lang", ""))
new_text = sanitize_text(text) if clean else text
if lang:
print_substep("Translating Text...")

@ -0,0 +1,94 @@
import random
import re
import subprocess
import tempfile
from pathlib import Path
from supertonic import TTS
from utils import settings
# Zero-width and invisible formatting characters rejected by supertonic.
# ZWSP, ZWNJ, ZWJ, LTR, RTL,
# word joiner,  BOM
_ZW_RE = re.compile("[-]")
class SupertonicTTS:
def __init__(self):
self.max_chars = 300
self.tts = TTS(auto_download=True)
def run(self, text, filepath, random_voice: bool = False):
output_path = Path(filepath)
output_path.parent.mkdir(parents=True, exist_ok=True)
# Strip known invisible characters first, then retry on any others
# that supertonic rejects — social media text has unpredictable Unicode.
text = _ZW_RE.sub("", text)
tts_settings = settings.config["settings"].get("tts", {})
voice_style = self._voice_style(tts_settings, random_voice)
synth_kwargs = {
"voice_style": voice_style,
"lang": tts_settings.get("supertonic_lang", "na"),
"total_steps": int(tts_settings.get("supertonic_steps", 8)),
"speed": float(tts_settings.get("supertonic_speed", 1.05)),
"max_chunk_length": self.max_chars,
"verbose": False,
}
for _ in range(3):
try:
wav, _duration = self.tts.synthesize(text, **synth_kwargs)
break
except ValueError as e:
msg = str(e)
if "unsupported character" not in msg:
raise
# Extract and strip the reported characters
chars = re.findall(r"'(.+?)'", msg)
if not chars:
raise
for c in chars:
text = text.replace(c, "")
if not text.strip():
raise ValueError(
"Text is empty after stripping unsupported characters"
) from e
else:
raise RuntimeError(
"Supertonic still rejecting characters after 3 retries"
)
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_wav:
wav_path = Path(temp_wav.name)
try:
self.tts.save_audio(wav, str(wav_path))
subprocess.run(
[
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(wav_path),
str(output_path),
],
check=True,
)
finally:
wav_path.unlink(missing_ok=True)
def randomvoice(self):
return random.choice(list(self.tts.voice_style_names))
def _voice_style(self, tts_settings: dict, random_voice: bool):
custom_voice_path = str(tts_settings.get("supertonic_custom_voice_path", "")).strip()
if custom_voice_path:
return self.tts.get_voice_style_from_path(custom_voice_path)
voice_name = self.randomvoice() if random_voice else tts_settings.get("supertonic_voice", "M1")
return self.tts.get_voice_style(voice_name=str(voice_name))

@ -1,2 +1,3 @@
#!/bin/sh
docker build -t rvmt .
set -eu
docker compose build

@ -0,0 +1,45 @@
services:
gui:
build:
context: .
image: videomakerbot:latest
command: ["python", "GUI.py"]
ports:
- "4000:4000"
environment:
GUI_HOST: "0.0.0.0"
GUI_PORT: "4000"
GUI_OPEN_BROWSER: "0"
GUI_BROWSER_URL: "http://localhost:4000"
XDG_CACHE_HOME: "/app/.cache"
CLOAKBROWSER_CACHE_DIR: "/app/.cache/cloakbrowser"
volumes:
- ./:/app
shm_size: "1gb"
cli:
build:
context: .
image: videomakerbot:latest
command: ["python", "main.py"]
environment:
PYTHONUNBUFFERED: "1"
XDG_CACHE_HOME: "/app/.cache"
CLOAKBROWSER_CACHE_DIR: "/app/.cache/cloakbrowser"
volumes:
- ./:/app
shm_size: "1gb"
test:
build:
context: .
image: videomakerbot:latest
command: ["pytest", "tests/", "-v"]
environment:
PYTHONUNBUFFERED: "1"
PYTHONPATH: "/app"
XDG_CACHE_HOME: "/app/.cache"
CLOAKBROWSER_CACHE_DIR: "/app/.cache/cloakbrowser"
volumes:
- ./:/app
shm_size: "1gb"

@ -0,0 +1,6 @@
#!/bin/sh
set -eu
python -m utils.docker_bootstrap
exec "$@"

@ -0,0 +1,29 @@
app = "hongphuc-threads-video-maker"
primary_region = "sin"
[build]
dockerfile = "Dockerfile"
[env]
GUI_HOST = "0.0.0.0"
GUI_PORT = "4000"
GUI_OPEN_BROWSER = "0"
GUI_BROWSER_URL = "https://hongphuc5497.com/threads-video-maker/"
PUBLIC_BASE_PATH = "/threads-video-maker"
PUBLIC_DEMO_MODE = "1"
PUBLIC_ORIGIN_HOST = "hongphuc5497.com"
XDG_CACHE_HOME = "/app/.cache"
CLOAKBROWSER_CACHE_DIR = "/app/.cache/cloakbrowser"
[http_service]
internal_port = 4000
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 0
processes = ["app"]
[[vm]]
memory = "4gb"
cpu_kind = "shared"
cpus = 2

@ -1,14 +1,13 @@
#!/usr/bin/env python
import math
import os
import subprocess
import sys
from os import name
from pathlib import Path
from subprocess import Popen
from typing import Dict, NoReturn
from typing import Dict, NoReturn, Union
from prawcore import ResponseException
from reddit.subreddit import get_subreddit_threads
from platforms import get_content_object, get_screenshot_fn
from utils import settings
from utils.cleanup import cleanup
from utils.console import print_markdown, print_step, print_substep
@ -21,9 +20,15 @@ from video_creation.background import (
download_background_video,
get_background_config,
)
from video_creation.final_video import make_final_video
from video_creation.screenshot_downloader import get_screenshots_of_reddit_posts
from video_creation.final_video import get_output_path, make_final_video
from video_creation.voices import save_text_to_mp3
from video_creation.youtube_uploader import upload_to_youtube
# Guard prawcore import — only available when Reddit is used
try:
from prawcore import ResponseException as _PrawResponseException
except ImportError:
_PrawResponseException = None
__VERSION__ = "3.4.0"
@ -43,17 +48,33 @@ print_markdown(
checkversion(__VERSION__)
reddit_id: str
reddit_object: Dict[str, str | list]
reddit_object: Dict[str, Union[str, list]]
def _get_platform_post_id(config: dict, platform: str) -> str:
"""Returns the post_id string from config for the active platform."""
if platform == "reddit":
return config.get("reddit", {}).get("thread", {}).get("post_id", "")
elif platform == "threads":
return config.get("threads", {}).get("thread", {}).get("post_id", "")
return ""
def clear_screen() -> None:
if name != "nt" and not os.environ.get("TERM"):
return
subprocess.run(["cls" if name == "nt" else "clear"], shell=(name == "nt"))
def main(POST_ID=None) -> None:
global reddit_id, reddit_object
reddit_object = get_subreddit_threads(POST_ID)
reddit_object = get_content_object(POST_ID)
reddit_id = extract_id(reddit_object)
print_substep(f"Thread ID is {reddit_id}", style="bold blue")
length, number_of_comments = save_text_to_mp3(reddit_object)
length = math.ceil(length)
get_screenshots_of_reddit_posts(reddit_object, number_of_comments)
screenshot_fn = get_screenshot_fn()
screenshot_fn(reddit_object, number_of_comments)
bg_config = {
"video": get_background_config("video"),
"audio": get_background_config("audio"),
@ -63,6 +84,19 @@ def main(POST_ID=None) -> None:
chop_background(bg_config, length, reddit_object)
make_final_video(number_of_comments, length, reddit_object, bg_config)
# -- YouTube upload (if enabled in config) ---------------------------
youtube_config = settings.config.get("youtube", {})
if youtube_config.get("enabled", False):
video_path = get_output_path(reddit_object)
youtube_url = upload_to_youtube(
video_path, reddit_object.get("thread_title", "video"), settings.config
)
if youtube_url:
print_substep(f"YouTube URL: {youtube_url}", "bold green")
else:
print_substep("YouTube upload skipped or failed.", "yellow")
# ---------------------------------------------------------------------
def run_many(times) -> None:
for x in range(1, times + 1):
@ -70,7 +104,7 @@ def run_many(times) -> None:
f'on the {x}{("th", "st", "nd", "rd", "th", "th", "th", "th", "th", "th")[x % 10]} iteration of {times}'
)
main()
Popen("cls" if name == "nt" else "clear", shell=True).wait()
clear_screen()
def shutdown() -> NoReturn:
@ -83,9 +117,9 @@ def shutdown() -> NoReturn:
if __name__ == "__main__":
if sys.version_info.major != 3 or sys.version_info.minor not in [10, 11, 12]:
if sys.version_info.major != 3 or sys.version_info.minor < 10:
print(
"Hey! Congratulations, you've made it so far (which is pretty rare with no Python 3.10). Unfortunately, this program only works on Python 3.10. Please install Python 3.10 and try again."
"This program requires Python 3.10 or later. Please install Python 3.10+ and try again."
)
sys.exit()
ffmpeg_install()
@ -100,37 +134,53 @@ if __name__ == "__main__":
or settings.config["settings"]["tts"]["tiktok_sessionid"] == ""
) and config["settings"]["tts"]["voice_choice"] == "tiktok":
print_substep(
"TikTok voice requires a sessionid! Check our documentation on how to obtain one.",
"bold red",
"TikTok voice requires a sessionid! "
"Falling back to pyttsx3 (offline TTS, no API key needed). "
"Set a valid tiktok_sessionid in your config.toml to use TikTok voices.",
"bold yellow",
)
sys.exit()
config["settings"]["tts"]["voice_choice"] = "pyttsx"
try:
if config["reddit"]["thread"]["post_id"]:
for index, post_id in enumerate(config["reddit"]["thread"]["post_id"].split("+")):
platform = config["settings"].get("platform", "reddit")
post_id_str = _get_platform_post_id(config, platform)
if post_id_str:
for index, post_id in enumerate(post_id_str.split("+")):
index += 1
num_posts = len(post_id_str.split("+"))
print_step(
f'on the {index}{("st" if index % 10 == 1 else ("nd" if index % 10 == 2 else ("rd" if index % 10 == 3 else "th")))} post of {len(config["reddit"]["thread"]["post_id"].split("+"))}'
f'on the {index}{("st" if index % 10 == 1 else ("nd" if index % 10 == 2 else ("rd" if index % 10 == 3 else "th")))} post of {num_posts}'
)
main(post_id)
Popen("cls" if name == "nt" else "clear", shell=True).wait()
clear_screen()
elif config["settings"]["times_to_run"]:
run_many(config["settings"]["times_to_run"])
else:
main()
except KeyboardInterrupt:
shutdown()
except ResponseException:
print_markdown("## Invalid credentials")
print_markdown("Please check your credentials in the config.toml file")
shutdown()
except Exception as err:
config["settings"]["tts"]["tiktok_sessionid"] = "REDACTED"
config["settings"]["tts"]["elevenlabs_api_key"] = "REDACTED"
config["settings"]["tts"]["openai_api_key"] = "REDACTED"
# Handle Reddit-specific credential errors if prawcore is available
if _PrawResponseException and isinstance(err, _PrawResponseException):
print_markdown("## Invalid Reddit credentials")
print_markdown("Please check your credentials in the config.toml file")
shutdown()
# Generic error handling — redact secrets before printing
import copy
safe_config = copy.deepcopy(config)
for key in ("tiktok_sessionid", "elevenlabs_api_key", "openai_api_key",
"client_id", "client_secret", "access_token", "password",
"2fa_secret"):
safe_config.setdefault("settings", {}).setdefault("tts", {})[key] = "REDACTED"
for section in ("reddit", "threads"):
creds = safe_config.get(section, {}).get("creds", {})
for cred_key in ("client_id", "client_secret", "password", "access_token", "2fa_secret"):
if cred_key in creds:
creds[cred_key] = "REDACTED"
print_step(
f"Sorry, something went wrong with this version! Try again, and feel free to report this issue at GitHub or the Discord community.\n"
f"Version: {__VERSION__} \n"
f"Error: {err} \n"
f'Config: {config["settings"]}'
f'Config: {safe_config.get("settings", {})}'
)
raise err
raise

@ -0,0 +1,70 @@
"""Platform abstraction layer for content source selection."""
from utils import settings
def get_content_object(POST_ID=None) -> dict:
"""
Returns a populated content_object dict for the configured platform.
Dispatches to the appropriate platform fetcher based on settings.config["settings"]["platform"].
Args:
POST_ID (str, optional): Specific post ID to fetch. If None, auto-selects a post.
Returns:
dict: Standard content_object with keys:
- thread_id, thread_title, thread_url, is_nsfw, thread_category, comments
- (or thread_post if storymode is enabled)
Raises:
ValueError: If platform is unknown or invalid.
"""
platform = settings.config["settings"].get("platform", "reddit").lower()
if platform == "reddit":
from reddit.subreddit import get_subreddit_threads
return get_subreddit_threads(POST_ID)
elif platform == "threads":
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(
f"Unknown platform: '{platform}'. Valid options: reddit, threads"
)
def get_screenshot_fn(platform: str = None):
"""
Returns the appropriate screenshot function for the given platform.
Args:
platform (str, optional): Platform name. If None, uses the configured platform.
Returns:
callable: Screenshot function that takes (content_object, screenshot_num).
Raises:
ValueError: If platform is unknown or invalid.
"""
if platform is None:
platform = settings.config["settings"].get("platform", "reddit").lower()
if platform == "reddit":
from video_creation.screenshot_downloader import get_screenshots_of_reddit_posts
return get_screenshots_of_reddit_posts
elif platform == "threads":
from platforms.threads.screenshot import get_screenshots_of_threads_posts
return get_screenshots_of_threads_posts
else:
raise ValueError(
f"Unknown platform: '{platform}'. Valid options: reddit, threads"
)

@ -0,0 +1 @@
"""Threads (Meta) platform integration for VideoMakerBot."""

@ -0,0 +1,176 @@
"""Shared Playwright authentication for Threads.
Used by both the screenshotter (screenshot.py) and the web scraper (scraper.py).
"""
import json
from pathlib import Path
from urllib.parse import urlparse
from playwright.sync_api import Browser, BrowserContext, Page, TimeoutError, ViewportSize
from utils import settings
from utils.console import emit_scraper_event, print_substep
THREADS_LOGIN_URL = "https://www.threads.com/login"
THREADS_AUTH_CHECK_URL = "https://www.threads.net/"
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"
)
THREADS_HOSTS = {
"threads.com",
"www.threads.com",
"threads.net",
"www.threads.net",
}
THREADS_NON_AUTH_PATH_PREFIXES = (
"/login",
"/challenge",
"/checkpoint",
"/consent",
"/accountsuspended",
"/suspended",
)
def _is_logged_in_threads_url(url: str) -> bool:
"""Return True once Threads has navigated away from the login page."""
parsed = urlparse(url)
hostname = (parsed.hostname or "").casefold()
path = (parsed.path or "/").lower()
return parsed.scheme == "https" and hostname in THREADS_HOSTS and not any(
path.startswith(prefix) for prefix in THREADS_NON_AUTH_PATH_PREFIXES
)
def _has_login_gate(page: Page) -> bool:
"""Detect Threads login modal overlaying the page."""
gate_selectors = [
'input[autocomplete="username"]',
'input[autocomplete="current-password"]',
'div[role="dialog"] button:has-text("Log in")',
]
for selector in gate_selectors:
try:
if page.locator(selector).first.is_visible():
return True
except Exception:
continue
return False
def login_to_threads(page: Page, _context: BrowserContext) -> None:
"""Log into Threads 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)...")
emit_scraper_event("login", {"message": "Logging into Threads (via Instagram)..."})
page.goto(THREADS_LOGIN_URL, timeout=0)
page.wait_for_load_state("domcontentloaded")
username_input = page.locator('input[autocomplete="username"]')
password_input = page.locator('input[autocomplete="current-password"]')
username_input.fill(username)
password_input.fill(password)
# CloakBrowser can be slightly flaky with the humanized button click even
# when the credentials are correct. Try the visible login button first,
# then fall back to pressing Enter in the password field.
submit_attempts = [
("button click", lambda: page.get_by_role("button", name="Log in", exact=True).first.click()),
("password Enter", lambda: password_input.press("Enter")),
]
last_error = None
for index, (label, submit) in enumerate(submit_attempts):
try:
submit()
# Threads currently redirects to threads.com on successful login,
# but older saved sessions and some flows may still hit threads.net.
page.wait_for_url(_is_logged_in_threads_url, timeout=30000 if index == 0 else 15000)
break
except TimeoutError as error:
last_error = error
if index == len(submit_attempts) - 1:
raise
print_substep(f"Threads login via {label} timed out. Retrying...", style="yellow")
page.wait_for_load_state("domcontentloaded")
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")
emit_scraper_event("login", {"message": "Logged in successfully"})
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.")
emit_scraper_event("login", {"message": "Loaded saved session cookies"})
# Verify loaded cookies are still valid
page = context.new_page()
try:
page.goto(THREADS_AUTH_CHECK_URL, timeout=0)
page.wait_for_load_state("domcontentloaded")
page.wait_for_timeout(1500)
if _has_login_gate(page):
print_substep("Saved Threads cookies expired. Re-logging in...", style="yellow")
context.clear_cookies()
cookie_path.unlink(missing_ok=True)
login_to_threads(page, context)
finally:
page.close()
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,190 @@
"""Fetches content from Meta Threads via the Graph API."""
import requests
from typing import Optional
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
GRAPH_API_BASE = "https://graph.threads.net/v1.0"
def _get_headers() -> dict:
"""Returns HTTP headers with Bearer token for Graph API requests."""
token = settings.config["threads"]["creds"]["access_token"]
if not token:
raise RuntimeError(
"Threads API: access_token is required. "
"Set it in config.toml under [threads.creds]."
)
return {"Authorization": f"Bearer {token}"}
def _api_get(url: str, params: dict = None) -> dict:
"""Makes a GET request to Threads Graph API with error handling."""
try:
resp = requests.get(url, headers=_get_headers(), params=params or {}, timeout=15)
resp.raise_for_status()
return resp.json()
except requests.exceptions.HTTPError as e:
if e.response.status_code == 401:
raise RuntimeError(
"Threads API: Invalid or expired access_token. "
"Tokens are valid for 60 days. Refresh at: "
"https://developers.facebook.com/tools/explorer/"
) from e
if e.response.status_code == 400:
error_msg = e.response.json().get("error", {}).get("message", str(e))
raise RuntimeError(f"Threads API: Bad request — {error_msg}") from e
raise RuntimeError(f"Threads API: HTTP {e.response.status_code}") from e
except requests.exceptions.ConnectionError as e:
raise RuntimeError("Threads API: Cannot connect. Check internet connection.") from e
except requests.exceptions.Timeout as e:
raise RuntimeError("Threads API: Request timed out.") from e
def _fetch_post(post_id: str) -> dict:
"""Fetches a single Threads post by ID."""
url = f"{GRAPH_API_BASE}/{post_id}"
params = {"fields": "id,text,timestamp,permalink,is_quote_post,media_type"}
return _api_get(url, params)
def _fetch_replies(post_id: str, limit: int = 50) -> list:
"""Fetches all replies to a Threads post, handling pagination."""
url = f"{GRAPH_API_BASE}/{post_id}/replies"
params = {
"fields": "id,text,timestamp,username,permalink",
"limit": limit,
}
results = []
while url:
data = _api_get(url, params)
results.extend(data.get("data", []))
# Handle pagination — next URL is provided in paging.next
url = data.get("paging", {}).get("next")
params = {} # Next URL already includes all params
return results
def _pick_best_post() -> tuple:
"""
Fetches recent posts from the user and returns the first one
with enough replies that hasn't been processed yet.
Returns:
tuple: (post_dict, replies_list)
Raises:
RuntimeError: If no eligible posts are found.
"""
user_id = settings.config["threads"]["creds"]["user_id"]
if not user_id:
raise RuntimeError(
"Threads API: user_id is required. "
"Set it in config.toml under [threads.creds]."
)
url = f"{GRAPH_API_BASE}/{user_id}/threads"
params = {"fields": "id,text,timestamp,permalink,media_type", "limit": 25}
data = _api_get(url, params)
posts = data.get("data", [])
min_replies = settings.config["threads"]["thread"]["min_replies"]
for post in posts:
if check_done_by_id(post["id"]):
continue
replies = _fetch_replies(post["id"])
if len(replies) >= min_replies:
return post, replies
raise RuntimeError(
f"No eligible Threads posts found. "
f"Ensure you have posts with at least {min_replies} replies."
)
def get_threads_content(POST_ID: str = None) -> dict:
"""
Fetches Threads content (post + replies) and returns it in the standard content_object format.
Args:
POST_ID (str, optional): Specific post ID to fetch. If None, auto-selects.
Returns:
dict: Standard content_object matching the pipeline contract.
Raises:
RuntimeError: On API errors or if no eligible content found.
"""
print_step("Fetching Threads content...")
# Determine which post to fetch
if POST_ID:
post = _fetch_post(POST_ID)
replies = _fetch_replies(POST_ID)
elif settings.config["threads"]["thread"].get("post_id"):
post_id = settings.config["threads"]["thread"]["post_id"]
post = _fetch_post(post_id)
replies = _fetch_replies(post_id)
else:
post, replies = _pick_best_post()
# Load content filters from config
max_len = settings.config["threads"]["thread"]["max_reply_length"]
min_len = settings.config["threads"]["thread"]["min_reply_length"]
blocked_raw = settings.config["threads"]["thread"].get("blocked_words", "")
blocked = [w.strip().lower() for w in blocked_raw.split(",") if w.strip()]
# Build content object in standard format
content = {
"thread_id": post["id"],
"thread_title": (post.get("text") or "")[:280], # Threads has no separate title
"thread_url": post["permalink"],
"is_nsfw": False, # Threads API doesn't provide NSFW flag
"thread_category": "threads", # Generic field for output folder naming
"comments": [],
}
# Filter and add replies
for reply in replies:
body = reply.get("text", "").strip()
if not body:
continue
# Check blocked words
if any(w in body.lower() for w in blocked):
continue
# Check length constraints
if not (min_len <= len(body) <= max_len):
continue
# Sanitize text
sanitised = sanitize_text(body)
if not sanitised:
continue
content["comments"].append({
"comment_body": body,
"comment_url": reply["permalink"],
"comment_id": reply["id"],
})
# Log summary
title_preview = content["thread_title"][:60]
print_substep(
f"Fetched Threads post '{title_preview}...' "
f"with {len(content['comments'])} replies.",
style="bold green",
)
return content

@ -0,0 +1,644 @@
"""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
from platforms.threads.auth import ensure_authenticated_context
from utils import settings
from utils.browser_backend import launch_browser
from utils.console import emit_scraper_event, 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...")
emit_scraper_event("browser_launch", {"message": "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
emit_scraper_event("post_discovered", {
"username": parsed["username"],
"body": parsed["body"][:100],
"likes": parsed["likes"],
"replies": parsed["replies"],
"reposts": parsed["reposts"],
"post_id": post_id,
})
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",
)
emit_scraper_event("feed_scroll", {
"scroll": i + 1,
"new_posts": new_found,
"total_posts": len(posts),
"max_scrolls": max_scrolls,
})
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}'...")
emit_scraper_event("search_query", {"query": query, "posts_found": 0})
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")
emit_scraper_event("search_query", {"query": query, "posts_found": len(posts)})
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 _is_english_text(text: str) -> bool:
"""Return True if text is predominantly English (Latin alphabet)."""
if not text or not text.strip():
return False
alpha_chars = [c for c in text if c.isalpha()]
if not alpha_chars:
return False
latin = sum(1 for c in alpha_chars if "a" <= c.lower() <= "z")
return latin / len(alpha_chars) >= 0.70
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
if t_config.get("english_only", False) and not _is_english_text(post["body"]):
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)
emit_scraper_event("filter_results", {
"before": len(posts),
"after": len(candidates),
"min_engagement": min_engagement,
"max_age_hours": max_age_hours,
})
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 t_config.get("english_only", False) and not _is_english_text(body):
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 launch_browser(headless=True) as browser:
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 as e:
print_substep(f"Search query failed: {e}", "yellow")
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",
)
emit_scraper_event("visiting_post", {
"post_id": candidate["post_id"],
"url": candidate["url"],
"engagement": eng,
"likes": candidate.get("likes", 0),
"body": candidate.get("body", "")[:60],
"attempt": i + 1,
})
try:
replies = _scrape_post_replies(context, candidate["url"])
emit_scraper_event("replies_found", {
"post_id": candidate["post_id"],
"count": len(replies),
"min_required": min_replies,
})
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",
)
emit_scraper_event("post_selected", {
"title": content["thread_title"][:80],
"post_id": candidate["post_id"],
"likes": candidate["likes"],
"replies_count": len(content["comments"]),
"url": candidate["url"],
})
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."
)

@ -0,0 +1,185 @@
"""Captures screenshots of Threads posts via the configured browser backend."""
import re
from pathlib import Path
from typing import Final
from playwright.sync_api import Page, ViewportSize
from platforms.threads.auth import ensure_authenticated_context
from utils import settings
from utils.browser_backend import launch_browser
from utils.console import print_step, print_substep
def _dismiss_popups(page: Page) -> None:
"""Dismiss common Threads/Instagram overlays that block screenshots."""
dismissals = [
("[role='button']", "Not now"),
("button", "Allow all cookies"),
("[aria-label='Close']", None),
("div[role='dialog'] button", "Not Now"),
]
for selector, text in dismissals:
try:
if text:
el = page.locator(selector, has_text=text).first
else:
el = page.locator(selector).first
if el.count() and el.is_visible():
el.click()
page.wait_for_timeout(500)
print_substep(f"Dismissed popup: {selector}", style="dim")
except Exception:
pass
def get_screenshots_of_threads_posts(content_object: dict, screenshot_num: int) -> None:
"""
Downloads screenshots of Threads posts via Playwright.
Args:
content_object: Standard content dict from platforms/threads/fetcher.py
screenshot_num: Number of reply screenshots to capture
"""
W: Final[int] = int(settings.config["settings"]["resolution_w"])
H: Final[int] = int(settings.config["settings"]["resolution_h"])
storymode: Final[bool] = settings.config["settings"]["storymode"]
print_step("Downloading screenshots of Threads posts...")
thread_id = re.sub(r"[^\w\s-]", "", content_object["thread_id"])
Path(f"assets/temp/{thread_id}/png").mkdir(parents=True, exist_ok=True)
theme = settings.config["settings"]["theme"]
# Device scale factor (higher resolution screenshots)
dsf = (W // 600) + 1
with launch_browser(headless=True) as browser:
print_substep("Launching headless browser...")
context = ensure_authenticated_context(
browser,
color_scheme="dark" if theme == "dark" else "light",
viewport=ViewportSize(width=W, height=H),
device_scale_factor=dsf,
)
# Screenshot the main post
page = context.new_page()
page.goto(content_object["thread_url"], timeout=0)
page.wait_for_load_state("networkidle")
page.wait_for_timeout(3000)
_dismiss_popups(page)
if page.locator('div[role="dialog"] button:has-text("Log in")').first.is_visible():
raise RuntimeError(
"Threads login overlay is blocking the page — saved cookies are stale. "
"Delete video_creation/data/cookie-threads.json and re-run."
)
postcontentpath = f"assets/temp/{thread_id}/png/title.png"
try:
# 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"]
page.evaluate(f"document.body.style.zoom={zoom}")
location = post_locator.bounding_box()
if location:
for k in location:
location[k] = float("{:.2f}".format(location[k] * zoom))
page.screenshot(clip=location, path=postcontentpath)
else:
post_locator.screenshot(path=postcontentpath)
else:
post_locator.screenshot(path=postcontentpath)
print_substep("Main post screenshot captured.", style="bold green")
except Exception as e:
print_substep(f"Failed to screenshot main post: {e}", style="red")
raise
# Screenshots of replies — capture all from the main post page (single navigation)
if not storymode:
num_replies = min(screenshot_num, len(content_object["comments"]))
if num_replies > 0:
# Scroll to load all replies inline on the post page
print_substep("Loading replies on post page...", style="dim")
_dismiss_popups(page)
if page.locator('div[role="dialog"] button:has-text("Log in")').first.is_visible():
raise RuntimeError(
"Threads login overlay is blocking the page — saved cookies are stale. "
"Delete video_creation/data/cookie-threads.json and re-run."
)
last_count = 0
stable_count = 0
for _ in range(20):
page.evaluate("window.scrollBy(0, document.body.scrollHeight)")
page.wait_for_timeout(1000)
current = page.locator('a[href*="/post/"]').count()
if current == last_count:
stable_count += 1
if stable_count >= 3:
break
else:
stable_count = 0
last_count = current
for idx in range(num_replies):
comment = content_object["comments"][idx]
try:
reply_id = comment["comment_id"]
reply_link = page.locator(f'a[href*="/{reply_id}"]').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
# Scroll element into view so bounding_box works
reply_locator.scroll_into_view_if_needed()
page.wait_for_timeout(300)
else:
print_substep(f"Reply {idx} not found on post page. Skipping...", style="yellow")
continue
if settings.config["settings"].get("zoom", 1) != 1:
zoom = settings.config["settings"]["zoom"]
page.evaluate(f"document.body.style.zoom={zoom}")
location = reply_locator.bounding_box()
if location:
for k in location:
location[k] = float("{:.2f}".format(location[k] * zoom))
page.screenshot(
clip=location,
path=f"assets/temp/{thread_id}/png/comment_{idx}.png",
)
else:
reply_locator.screenshot(
path=f"assets/temp/{thread_id}/png/comment_{idx}.png"
)
else:
reply_locator.screenshot(
path=f"assets/temp/{thread_id}/png/comment_{idx}.png"
)
except Exception as e:
print_substep(f"Error capturing reply {idx}: {e}. Skipping...", style="yellow")
continue
print_substep(f"Reply screenshots captured ({num_replies} total).", style="bold green")
print_substep("Threads screenshots downloaded successfully.", style="bold green")

@ -1,4 +1,5 @@
import re
import sys
import praw
from praw.models import MoreComments
@ -22,11 +23,23 @@ def get_subreddit_threads(POST_ID: str):
content = {}
if settings.config["reddit"]["creds"]["2fa"]:
print("\nEnter your two-factor authentication code from your authenticator app.\n")
code = input("> ")
print()
pw = settings.config["reddit"]["creds"]["password"]
passkey = f"{pw}:{code}"
twofa_secret = settings.config["reddit"]["creds"].get("2fa_secret", "")
if twofa_secret:
import pyotp
totp = pyotp.TOTP(twofa_secret)
code = totp.now()
pw = settings.config["reddit"]["creds"]["password"]
passkey = f"{pw}:{code}"
else:
print(
"\nEnter your two-factor authentication code from your authenticator app.\n"
"(To skip this prompt in the future, set 2fa_secret in config.toml)\n"
)
code = input("> ")
print()
pw = settings.config["reddit"]["creds"]["password"]
passkey = f"{pw}:{code}"
else:
passkey = settings.config["reddit"]["creds"]["password"]
username = settings.config["reddit"]["creds"]["username"]
@ -44,8 +57,8 @@ def get_subreddit_threads(POST_ID: str):
except ResponseException as e:
if e.response.status_code == 401:
print("Invalid credentials - please check them in config.toml")
except:
print("Something went wrong...")
except Exception as e:
print(f"Something went wrong: {e}")
# Ask user for subreddit input
print_step("Getting subreddit threads...")
@ -93,11 +106,20 @@ def get_subreddit_threads(POST_ID: str):
submission = get_subreddit_undone(threads, subreddit)
if submission is None:
return get_subreddit_threads(POST_ID) # submission already done. rerun
# submission already done — retry with depth limit to prevent infinite recursion
if not hasattr(get_subreddit_threads, "_retry_depth"):
get_subreddit_threads._retry_depth = 0
get_subreddit_threads._retry_depth += 1
if get_subreddit_threads._retry_depth > 50:
raise RuntimeError("Exceeded retry limit (50) looking for an undone submission")
try:
return get_subreddit_threads(POST_ID)
finally:
get_subreddit_threads._retry_depth -= 1
elif not submission.num_comments and settings.config["settings"]["storymode"] == "false":
print_substep("No comments found. Skipping.")
exit()
sys.exit()
submission = check_done(submission) # double-checking
@ -121,6 +143,7 @@ def get_subreddit_threads(POST_ID: str):
content["thread_title"] = submission.title
content["thread_id"] = submission.id
content["is_nsfw"] = submission.over_18
content["thread_category"] = settings.config["reddit"]["thread"]["subreddit"]
content["comments"] = []
if settings.config["settings"]["storymode"]:
if settings.config["settings"]["storymodemethod"] == 1:

@ -1,21 +1,26 @@
boto3==1.36.8
botocore==1.36.8
boto3==1.43.3
botocore==1.43.3
gTTS==2.5.4
moviepy==2.2.1
playwright==1.49.1
playwright==1.59.0
cloakbrowser==0.3.28
pyotp==2.9.0
praw==7.8.1
requests==2.32.3
rich==13.9.4
requests==2.33.1
rich==15.0.0
toml==0.10.2
translators==5.9.9
pyttsx3==2.98
tomlkit==0.13.2
Flask==3.1.1
clean-text==0.6.0
pyttsx3==2.99
tomlkit==0.14.0
Flask==3.1.3
clean-text==0.7.1
unidecode==1.4.0
spacy==3.8.7
torch==2.7.0
transformers==4.52.4
ffmpeg-python==0.2.0
elevenlabs==1.57.0
yt-dlp==2025.10.22
torch==2.11.0
transformers==4.57.6
supertonic==1.3.1
spacy==3.8.13
av>=14.0
elevenlabs==2.44.0
yt-dlp==2026.3.17
google-auth-oauthlib==1.2.1
google-api-python-client==2.159.0

@ -1,2 +1,3 @@
#!/bin/sh
docker run -v $(pwd)/out/:/app/assets -v $(pwd)/.env:/app/.env -it rvmt
set -eu
docker compose run --rm cli "$@"

@ -0,0 +1,54 @@
import sys
from types import SimpleNamespace
from utils import settings
class FakeBrowser:
def __init__(self):
self.closed = False
def close(self):
self.closed = True
def test_launch_browser_defaults_to_playwright(monkeypatch):
from utils import browser_backend
fake_browser = FakeBrowser()
class FakePlaywrightContext:
def __enter__(self):
return SimpleNamespace(
chromium=SimpleNamespace(launch=lambda headless: fake_browser)
)
def __exit__(self, exc_type, exc, traceback):
return False
settings.config = {"settings": {}}
monkeypatch.setattr(browser_backend, "sync_playwright", lambda: FakePlaywrightContext())
with browser_backend.launch_browser() as browser:
assert browser is fake_browser
assert fake_browser.closed is True
def test_launch_browser_uses_cloakbrowser_when_configured(monkeypatch):
from utils import browser_backend
fake_browser = FakeBrowser()
launch_calls = []
fake_module = SimpleNamespace(
launch=lambda **kwargs: launch_calls.append(kwargs) or fake_browser
)
settings.config = {"settings": {"browser": {"backend": "cloakbrowser"}}}
monkeypatch.setitem(sys.modules, "cloakbrowser", fake_module)
with browser_backend.launch_browser(headless=False) as browser:
assert browser is fake_browser
assert launch_calls == [{"headless": False, "humanize": True}]
assert fake_browser.closed is True

@ -0,0 +1,58 @@
from unittest.mock import MagicMock, patch
import GUI
from GUI import app
def test_background_add_handles_missing_form_fields():
app.testing = False
response = app.test_client().post("/background/add", data={})
assert response.status_code == 302
assert response.headers["Location"].endswith("/backgrounds")
def test_background_add_passes_empty_defaults_for_missing_optional_fields():
app.testing = True
with patch("GUI.gui.add_background") as add_background:
response = app.test_client().post(
"/background/add",
data={"youtube_uri": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"},
)
assert response.status_code == 302
add_background.assert_called_once_with(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"",
"",
"",
)
def test_settings_get_uses_template_defaults_for_partial_config():
app.testing = True
fake_path = MagicMock()
fake_path.read_text.return_value = ""
with patch("GUI.Path", MagicMock(return_value=fake_path)):
response = app.test_client().get("/settings")
body = response.get_data(as_text=True)
assert response.status_code == 200
assert '"settings.tts.voice_choice": "Supertonic"' in body
assert 'name="settings.tts.supertonic_voice"' in body
def test_public_demo_mode_blocks_mutating_routes(monkeypatch):
app.testing = True
monkeypatch.setattr(GUI, "PUBLIC_DEMO_MODE", True)
client = app.test_client()
assert client.post("/background/add", data={}).status_code == 403
assert client.post("/background/delete", data={}).status_code == 403
assert client.post("/settings", data={}).status_code == 403
assert client.post("/videos/delete", json={"ids": ["abc"]}).status_code == 403
assert client.post("/create", json={}).status_code == 403

@ -0,0 +1,131 @@
import json
import os
import pytest
from unittest.mock import patch, MagicMock
from pathlib import Path
from utils import gui_utils
@pytest.fixture
def mock_background_json(tmp_path):
bg_file = tmp_path / "background_videos.json"
initial_data = {
"__comment": "test",
"minecraft": ["https://www.youtube.com/watch?v=n_Dv4JMiwK8", "parkour.mp4", "bbswitzer", "center"]
}
bg_file.write_text(json.dumps(initial_data))
return bg_file
@pytest.fixture
def mock_template_toml(tmp_path):
template_file = tmp_path / ".config.template.toml"
template_content = """
[settings.background]
background_video = { optional = true, default = "minecraft", options = ["minecraft"] }
"""
template_file.write_text(template_content)
return template_file
@patch("utils.gui_utils.flash")
def test_delete_background(mock_flash, mock_background_json, mock_template_toml):
# We need to patch the paths used in gui_utils
with patch("utils.gui_utils.open", MagicMock(side_effect=lambda path, *args, **kwargs: open(mock_background_json if "background_videos.json" in str(path) else path, *args, **kwargs))), \
patch("utils.gui_utils.Path", MagicMock(side_effect=lambda path: Path(mock_template_toml) if ".config.template.toml" in str(path) else Path(path))):
gui_utils.delete_background("minecraft")
# Verify background_videos.json
with open(mock_background_json, "r") as f:
data = json.load(f)
assert "minecraft" not in data
# Verify .config.template.toml
import tomlkit
template_data = tomlkit.loads(mock_template_toml.read_text())
assert "minecraft" not in template_data["settings"]["background"]["background_video"]["options"]
mock_flash.assert_called_with('Successfully removed "minecraft" background!')
@patch("utils.gui_utils.flash")
def test_add_background(mock_flash, mock_background_json, mock_template_toml):
with patch("utils.gui_utils.open", MagicMock(side_effect=lambda path, *args, **kwargs: open(mock_background_json if "background_videos.json" in str(path) else path, *args, **kwargs))), \
patch("utils.gui_utils.Path", MagicMock(side_effect=lambda path: Path(mock_template_toml) if ".config.template.toml" in str(path) else Path(path))):
# Test adding a new background
gui_utils.add_background(
youtube_uri="https://www.youtube.com/watch?v=dQw4w9WgXcQ",
filename="test_new",
citation="Rick",
position="center"
)
# Verify background_videos.json
with open(mock_background_json, "r") as f:
data = json.load(f)
assert "test_new" in data
assert data["test_new"][0] == "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
# Verify .config.template.toml
import tomlkit
template_data = tomlkit.loads(mock_template_toml.read_text())
assert "test_new" in template_data["settings"]["background"]["background_video"]["options"]
mock_flash.assert_called_with('Added "Rick-test_new.mp4" as a new background video!')
@patch("utils.gui_utils.flash")
def test_modify_settings_preserves_masked_secrets(mock_flash):
config_load = {
"reddit": {
"creds": {
"client_secret": "real-secret",
"password": "real-password",
}
}
}
checks = {
"reddit.creds.client_secret": {"optional": False, "type": "str"},
"reddit.creds.password": {"optional": False, "type": "str"},
}
result = gui_utils.modify_settings(
{
"reddit.creds.client_secret": "********",
"reddit.creds.password": "changed-password",
},
config_load,
checks,
)
assert config_load["reddit"]["creds"]["client_secret"] == "real-secret"
assert config_load["reddit"]["creds"]["password"] == "changed-password"
assert result["reddit.creds.client_secret"] == "real-secret"
@patch("utils.gui_utils.flash")
def test_modify_settings_does_not_materialize_missing_default_values(mock_flash, tmp_path):
config_load = {"settings": {"channel_name": "My Channel"}}
checks = {
"settings.channel_name": {"optional": True, "type": "str"},
"settings.tts.voice_choice": {"optional": False, "type": "str", "default": "Supertonic"},
"settings.tts.supertonic_voice": {"optional": True, "type": "str", "default": "M1"},
"threads.creds.username": {"optional": True, "type": "str"},
}
config_path = tmp_path / "config.toml"
with patch("utils.gui_utils.Path", MagicMock(return_value=config_path)):
result = gui_utils.modify_settings(
{
"settings.channel_name": "Updated Channel",
"settings.tts.voice_choice": "Supertonic",
"settings.tts.supertonic_voice": "M1",
"threads.creds.username": "",
},
config_load,
checks,
)
assert config_load["settings"]["channel_name"] == "Updated Channel"
assert "tts" not in config_load["settings"]
assert "threads" not in config_load
assert "settings.tts.voice_choice" not in result
assert "threads.creds.username" not in result

@ -0,0 +1,34 @@
import os
from unittest.mock import patch
import main
def test_clear_screen_skips_clear_without_term(monkeypatch):
monkeypatch.delenv("TERM", raising=False)
monkeypatch.setattr(main, "name", "posix")
with patch("main.subprocess.run") as run:
main.clear_screen()
run.assert_not_called()
def test_clear_screen_runs_when_term_is_set(monkeypatch):
monkeypatch.setenv("TERM", "xterm")
monkeypatch.setattr(main, "name", "posix")
with patch("main.subprocess.run") as run:
main.clear_screen()
run.assert_called_once_with(["clear"], shell=False)
def test_clear_screen_runs_windows_command(monkeypatch):
monkeypatch.delenv("TERM", raising=False)
monkeypatch.setattr(main, "name", "nt")
with patch("main.subprocess.run") as run:
main.clear_screen()
run.assert_called_once_with(["cls"], shell=True)

@ -0,0 +1,43 @@
from utils.settings import apply_template_defaults
def test_apply_template_defaults_fills_missing_values_without_overwriting(tmp_path):
template = tmp_path / "template.toml"
template.write_text(
"""
[settings]
platform = { optional = false, default = "reddit" }
[settings.tts]
no_emojis = { optional = false, default = false, type = "bool" }
[threads.creds]
access_token = { optional = false }
username = { optional = true }
[threads.thread]
min_replies = { optional = false, default = 5, type = "int" }
search_queries = { optional = true, default = "news,politics,trending" }
"""
)
config = {
"settings": {"platform": "threads"},
"threads": {"creds": {"username": "configured-user"}},
}
result = apply_template_defaults(config, str(template))
assert result["settings"]["platform"] == "threads"
assert result["settings"]["tts"]["no_emojis"] is False
assert result["threads"]["thread"]["min_replies"] == 5
assert result["threads"]["thread"]["search_queries"] == "news,politics,trending"
assert result["threads"]["creds"]["username"] == "configured-user"
assert "access_token" not in result["threads"]["creds"]
def test_real_template_defaults_to_supertonic_tts():
result = apply_template_defaults({"settings": {}})
assert result["settings"]["tts"]["voice_choice"] == "Supertonic"
assert result["settings"]["tts"]["supertonic_voice"] == "M1"
assert result["settings"]["tts"]["supertonic_lang"] == "na"

@ -0,0 +1,25 @@
from pathlib import Path
def test_audio_settings_template_supports_supertonic_and_provider_visibility():
template = Path("GUI/settings.html").read_text()
assert 'checks["settings.tts.voice_choice"]["options"]' in template
assert 'data-tts-providers="Supertonic"' in template
assert 'data-tts-providers="elevenlabs,OpenAI,tiktok"' in template
assert 'const voiceChoiceSelect = document.getElementById(\'voiceChoiceSelect\');' in template
assert 'applyTtsVisibility();' in template
def test_threads_settings_template_supports_discovery_specific_credentials():
template = Path("GUI/settings.html").read_text()
assert 'id="threadsDiscoveryMethodSelect"' in template
assert 'data-discovery-methods="api"' in template
assert 'data-discovery-methods="scrape"' in template
assert 'data-preserve-hidden="true"' in template
assert 'name="threads.creds.user_id"' in template
assert 'Screenshots still reuse your saved Threads username/password.' in template
assert "el.disabled = !isThreads || (!matches && !preserveHidden);" in template
assert 'const threadsDiscoveryMethodSelect = document.getElementById(\'threadsDiscoveryMethodSelect\');' in template
assert 'applyThreadsDiscoveryVisibility();' in template

@ -0,0 +1,55 @@
from pathlib import Path
from utils import settings
class FakeSupertonicEngine:
voice_style_names = ["M1", "F1"]
def __init__(self, auto_download=True):
self.auto_download = auto_download
def get_voice_style(self, voice_name):
return {"voice_name": voice_name}
def get_voice_style_from_path(self, path):
return {"path": path}
def synthesize(self, text, **kwargs):
return b"wav-data", 1.0
def save_audio(self, wav, path):
Path(path).write_bytes(wav)
def test_supertonic_tts_converts_wav_to_mp3(monkeypatch, tmp_path):
import TTS.supertonic_tts as supertonic_module
run_calls = []
def fake_run(command, check):
run_calls.append((command, check))
Path(command[-1]).write_bytes(b"mp3-data")
settings.config = {
"settings": {
"tts": {
"supertonic_voice": "M1",
"supertonic_lang": "na",
"supertonic_steps": 8,
"supertonic_speed": 1.05,
"supertonic_custom_voice_path": "",
}
}
}
monkeypatch.setattr(supertonic_module, "TTS", FakeSupertonicEngine)
monkeypatch.setattr(supertonic_module.subprocess, "run", fake_run)
output_path = tmp_path / "voice.mp3"
supertonic_module.SupertonicTTS().run("hello", str(output_path))
assert output_path.read_bytes() == b"mp3-data"
command, check = run_calls[0]
assert command[:5] == ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error"]
assert command[-1] == str(output_path)
assert check is True

@ -0,0 +1,10 @@
from pathlib import Path
def test_backgrounds_template_escapes_catalog_values_before_inner_html():
template = Path("GUI/backgrounds.html").read_text(encoding="utf-8")
assert "function h(str)" in template
assert "title=\"${h(key)}\"" in template
assert "${h(key)}" in template
assert "${h(value[2])}" in template

@ -0,0 +1,59 @@
from unittest.mock import MagicMock, patch
from playwright.sync_api import TimeoutError
from platforms.threads import auth
from platforms.threads.auth import _is_logged_in_threads_url
def test_logged_in_threads_url_accepts_current_threads_com_home():
assert _is_logged_in_threads_url("https://www.threads.com/") is True
assert _is_logged_in_threads_url("https://www.threads.com/?hl=en") is True
def test_logged_in_threads_url_accepts_legacy_threads_net_home():
assert _is_logged_in_threads_url("https://www.threads.net/") is True
def test_logged_in_threads_url_rejects_login_page_and_other_hosts():
assert _is_logged_in_threads_url("https://www.threads.com/login") is False
assert _is_logged_in_threads_url("https://www.threads.net/login") is False
assert _is_logged_in_threads_url("https://example.com/") is False
def test_logged_in_threads_url_rejects_checkpoint_and_challenge_paths():
assert _is_logged_in_threads_url("https://www.threads.com/challenge/") is False
assert _is_logged_in_threads_url("https://www.threads.net/checkpoint/") is False
assert _is_logged_in_threads_url("https://www.threads.com/consent/") is False
def test_logged_in_threads_url_requires_https():
assert _is_logged_in_threads_url("http://www.threads.com/") is False
def test_login_to_threads_retries_with_enter_when_button_click_times_out(tmp_path):
username_input = MagicMock()
password_input = MagicMock()
login_button = MagicMock()
login_button.click.side_effect = TimeoutError("button click timed out")
role_result = MagicMock()
role_result.first = login_button
page = MagicMock()
page.locator.side_effect = lambda selector: username_input if "username" in selector else password_input
page.get_by_role.return_value = role_result
context = MagicMock()
context.cookies.return_value = [{"name": "sessionid", "value": "ok"}]
cookie_path = tmp_path / "cookie-threads.json"
with patch.object(auth.settings, "config", {"threads": {"creds": {"username": "demo", "password": "secret"}}}), \
patch.object(auth, "THREADS_COOKIE_FILE", str(cookie_path)):
auth.login_to_threads(page, context)
username_input.fill.assert_called_once_with("demo")
password_input.fill.assert_called_once_with("secret")
password_input.press.assert_called_once_with("Enter")
assert page.wait_for_url.call_count == 1
assert cookie_path.exists()

@ -0,0 +1,50 @@
from pathlib import Path
from TTS.TikTok import TikTokTTSException
from TTS.engine_wrapper import TTSEngine
from utils import settings
class FailingTikTok:
max_chars = 5000
def run(self, text, filepath, random_voice=False):
raise TikTokTTSException(1, "failed")
class FakeGTTS:
max_chars = 5000
def run(self, text, filepath):
Path(filepath).write_text(text)
class FakeAudioFileClip:
duration = 1.0
def __init__(self, path):
self.path = path
def close(self):
pass
def test_tiktok_fallback_uses_googletranslate_not_pyttsx(monkeypatch, tmp_path):
import TTS.engine_wrapper as engine_wrapper
settings.config = {
"settings": {
"tts": {"voice_choice": "tiktok", "random_voice": False},
}
}
reddit_object = {"thread_id": "abc", "thread_title": "title", "comments": []}
monkeypatch.setattr(engine_wrapper, "GTTS", FakeGTTS)
monkeypatch.setattr(engine_wrapper, "AudioFileClip", FakeAudioFileClip)
engine = TTSEngine(FailingTikTok, reddit_object, path=f"{tmp_path}/")
Path(engine.path).mkdir(parents=True)
engine.call_tts("title", "hello")
assert settings.config["settings"]["tts"]["voice_choice"] == "googletranslate"
assert Path(engine.path, "title.mp3").read_text() == "hello"

@ -4,6 +4,8 @@ client_secret = { optional = false, nmin = 20, nmax = 40, explanation = "The SEC
username = { optional = false, nmin = 3, nmax = 20, explanation = "The username of your reddit account", example = "JasonLovesDoggo", regex = "^[-_0-9a-zA-Z]+$", oob_error = "A username HAS to be between 3 and 20 characters" }
password = { optional = false, nmin = 8, explanation = "The password of your reddit account", example = "fFAGRNJru1FTz70BzhT3Zg", oob_error = "Password too short" }
2fa = { optional = true, type = "bool", options = [true, false, ], default = false, explanation = "Whether you have Reddit 2FA enabled, Valid options are True and False", example = true }
# WARNING: Treat this secret like a password — it allows anyone to generate valid 2FA codes.
2fa_secret = { optional = true, default = "", explanation = "TOTP shared secret (base32). If provided, 2FA codes are generated automatically instead of prompting interactively. SECURITY: store this with the same care as a password.", example = "JBSWY3DPEHPK3PXP" }
[reddit.thread]
@ -20,7 +22,36 @@ 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" }
username = { optional = true, explanation = "Instagram/Threads username for Playwright screenshot login" }
password = { optional = true, explanation = "Instagram/Threads password for Playwright screenshot login" }
[threads.thread]
post_id = { optional = true, default = "", regex = "^((?!://|://)[+a-zA-Z0-9])*$", explanation = "Specific Threads post ID to process. Leave blank for auto-pick.", example = "18044348473548254" }
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" }
english_only = { optional = true, default = false, options = [true, false], type = "bool", explanation = "Only include English-language posts and replies (Latin alphabet heuristic). Set to true for English-only content." }
[youtube]
enabled = { optional = true, type = "bool", default = false, options = [true, false], explanation = "Enable automatic YouTube upload after video creation" }
privacy = { optional = true, default = "public", options = ["public", "private", "unlisted"], explanation = "YouTube video privacy status" }
category = { optional = true, default = "22", explanation = "YouTube category ID (22 = People & Blogs)" }
tags = { optional = true, default = "shorts, reddit", explanation = "Comma-separated tags for the video" }
client_secret_path = { optional = true, default = "", explanation = "Path to youtube_client_secret.json for OAuth2 authentication" }
[settings]
platform = { optional = false, default = "reddit", options = ["reddit", "threads"], explanation = "Which social media platform to pull content from." }
post_lang = { default = "", optional = true, explanation = "The language you would like to translate to. Applies to all platforms.", example = "es-cr", options = ['','af', 'ak', 'am', 'ar', 'as', 'ay', 'az', 'be', 'bg', 'bho', 'bm', 'bn', 'bs', 'ca', 'ceb', 'ckb', 'co', 'cs', 'cy', 'da', 'de', 'doi', 'dv', 'ee', 'el', 'en', 'en-US', 'eo', 'es', 'et', 'eu', 'fa', 'fi', 'fr', 'fy', 'ga', 'gd', 'gl', 'gn', 'gom', 'gu', 'ha', 'haw', 'hi', 'hmn', 'hr', 'ht', 'hu', 'hy', 'id', 'ig', 'ilo', 'is', 'it', 'iw', 'ja', 'jw', 'ka', 'kk', 'km', 'kn', 'ko', 'kri', 'ku', 'ky', 'la', 'lb', 'lg', 'ln', 'lo', 'lt', 'lus', 'lv', 'mai', 'mg', 'mi', 'mk', 'ml', 'mn', 'mni-Mtei', 'mr', 'ms', 'mt', 'my', 'ne', 'nl', 'no', 'nso', 'ny', 'om', 'or', 'pa', 'pl', 'ps', 'pt', 'qu', 'ro', 'ru', 'rw', 'sa', 'sd', 'si', 'sk', 'sl', 'sm', 'sn', 'so', 'sq', 'sr', 'st', 'su', 'sv', 'sw', 'ta', 'te', 'tg', 'th', 'ti', 'tk', 'tl', 'tr', 'ts', 'tt', 'ug', 'uk', 'ur', 'uz', 'vi', 'xh', 'yi', 'yo', 'zh-CN', 'zh-TW', 'zu'] }
allow_nsfw = { optional = false, type = "bool", default = false, example = false, options = [true, false, ], explanation = "Whether to allow NSFW content, True or False" }
theme = { optional = false, default = "dark", example = "light", options = ["dark", "light", "transparent", ], explanation = "Sets the Reddit theme, either LIGHT or DARK. For story mode you can also use a transparent background." }
times_to_run = { optional = false, default = 1, example = 2, explanation = "Used if you want to run multiple times. Set to an int e.g. 4 or 29 or 1", type = "int", nmin = 1, oob_error = "It's very hard to run something less than once." }
@ -34,8 +65,11 @@ resolution_h = { optional = false, default = 1920, example = 2560, explantation
zoom = { optional = true, default = 1, example = 1.1, explanation = "Sets the browser zoom level. Useful if you want the text larger.", type = "float", nmin = 0.1, nmax = 2, oob_error = "The text is really difficult to read at a zoom level higher than 2" }
channel_name = { optional = true, default = "Reddit Tales", example = "Reddit Stories", explanation = "Sets the channel name for the video" }
[settings.browser]
backend = { optional = true, default = "playwright", options = ["playwright", "cloakbrowser"], type = "str", explanation = "Browser backend for Threads scraping/screenshots. Defaults to Playwright; CloakBrowser is opt-in." }
[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"}
@ -45,7 +79,7 @@ background_thumbnail_font_size = { optional = true, type = "int", default = 96,
background_thumbnail_font_color = { optional = true, default = "255,255,255", example = "255,255,255", explanation = "Font color in RGB format for the thumbnail text" }
[settings.tts]
voice_choice = { optional = false, default = "tiktok", options = ["elevenlabs", "streamlabspolly", "tiktok", "googletranslate", "awspolly", "pyttsx", "OpenAI"], example = "tiktok", explanation = "The voice platform used for TTS generation. " }
voice_choice = { optional = false, default = "Supertonic", options = ["elevenlabs", "streamlabspolly", "tiktok", "googletranslate", "awspolly", "pyttsx", "OpenAI", "Supertonic"], example = "Supertonic", explanation = "The voice platform used for TTS generation. " }
random_voice = { optional = false, type = "bool", default = true, example = true, options = [true, false,], explanation = "Randomizes the voice used for each comment" }
elevenlabs_voice_name = { optional = false, default = "Bella", example = "Bella", explanation = "The voice used for elevenlabs", options = ["Adam", "Antoni", "Arnold", "Bella", "Domi", "Elli", "Josh", "Rachel", "Sam", ] }
elevenlabs_api_key = { optional = true, example = "21f13f91f54d741e2ae27d2ab1b99d59", explanation = "Elevenlabs API key" }
@ -61,3 +95,8 @@ openai_api_url = { optional = true, default = "https://api.openai.com/v1/", exam
openai_api_key = { optional = true, example = "sk-abc123def456...", explanation = "Your OpenAI API key for TTS generation" }
openai_voice_name = { optional = false, default = "alloy", example = "alloy", explanation = "The voice used for OpenAI TTS generation", options = ["alloy", "ash", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "af_heart"] }
openai_model = { optional = false, default = "tts-1", example = "tts-1", explanation = "The model variant used for OpenAI TTS generation", options = ["tts-1", "tts-1-hd", "gpt-4o-mini-tts"] }
supertonic_voice = { optional = true, default = "M1", example = "M1", explanation = "The built-in Supertonic voice style.", options = ["M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5"] }
supertonic_lang = { optional = true, default = "na", example = "na", explanation = "Language code passed to Supertonic synthesis." }
supertonic_steps = { optional = true, default = 8, example = 8, type = "int", nmin = 5, nmax = 12, explanation = "Supertonic quality steps. Higher is slower." }
supertonic_speed = { optional = true, default = 1.05, example = 1.05, type = "float", nmin = 0.7, nmax = 2.0, explanation = "Supertonic speech speed." }
supertonic_custom_voice_path = { optional = true, default = "", example = "", explanation = "Optional path to a Supertonic custom voice style JSON." }

@ -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"
]
}
}

@ -0,0 +1,47 @@
"""Optional browser backend adapter for Playwright-compatible browsers."""
from contextlib import contextmanager
from typing import Iterator
from playwright.sync_api import Browser, sync_playwright
from utils import settings
def _configured_backend() -> str:
config = settings.config if isinstance(settings.config, dict) else {}
browser_config = config.get("settings", {}).get("browser", {})
return str(browser_config.get("backend", "playwright")).casefold()
@contextmanager
def launch_browser(headless: bool = True) -> Iterator[Browser]:
"""Launch the configured browser backend.
Defaults to stock Playwright. CloakBrowser is opt-in via
settings.browser.backend = "cloakbrowser" and returns a Playwright-compatible
Browser object, preserving browser.new_context(...) call sites.
"""
backend = _configured_backend()
if backend == "cloakbrowser":
import cloakbrowser
browser = cloakbrowser.launch(headless=headless, humanize=True)
try:
yield browser
finally:
browser.close()
return
if backend != "playwright":
raise ValueError(
f"Unsupported browser backend '{backend}'. Use 'playwright' or 'cloakbrowser'."
)
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=headless)
try:
yield browser
finally:
browser.close()

@ -13,7 +13,7 @@ def cleanup(reddit_id) -> int:
Returns:
int: How many files were deleted
"""
directory = f"../assets/temp/{reddit_id}/"
directory = f"assets/temp/{reddit_id}/"
if exists(directory):
shutil.rmtree(directory)

@ -9,6 +9,29 @@ from rich.text import Text
console = Console()
# Progress callback for GUI integration.
# Set by GUI.py to receive stage-change notifications during pipeline runs.
_progress_callback = None
def set_progress_callback(cb):
global _progress_callback
_progress_callback = cb
def emit_scraper_event(event_type: str, data: dict = None):
"""Emit a structured scraper event for GUI visualization.
Called by platform scrapers to stream real-time scraping activity
to the web UI. If no progress callback is set, this is a no-op.
Event types: browser_launch, login, feed_scroll, post_discovered,
search_query, filter_results, visiting_post,
replies_found, post_selected, general
"""
if _progress_callback:
_progress_callback(event=event_type, data=data or {})
def print_markdown(text) -> None:
"""Prints a rich info message. Support Markdown syntax."""
@ -22,6 +45,8 @@ def print_step(text) -> None:
panel = Panel(Text(text, justify="left"))
console.print(panel)
if _progress_callback:
_progress_callback(stage=text)
def print_table(items) -> None:
@ -102,9 +127,9 @@ def handle_input(
user_input = input("").strip()
if check_type is not False:
try:
isinstance(eval(user_input), check_type) # fixme: remove eval
return check_type(user_input)
except:
check_type(user_input)
return user_input
except (ValueError, TypeError):
console.print(
"[red bold]"
+ err_message

@ -0,0 +1,71 @@
"""Container bootstrap helpers for first-run runtime state."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict
import tomlkit
ROOT = Path(__file__).resolve().parent.parent
def _default_from_template(node: Dict[str, Any]) -> Dict[str, Any]:
defaults: Dict[str, Any] = {}
for key, value in node.items():
if isinstance(value, dict) and "optional" in value:
if "default" in value:
defaults[key] = value["default"]
else:
value_type = value.get("type")
if value_type == "bool":
defaults[key] = False
elif value_type in {"int", "float"}:
defaults[key] = 0
else:
defaults[key] = ""
elif isinstance(value, dict):
defaults[key] = _default_from_template(value)
return defaults
def _ensure_json(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if not path.exists():
path.write_text(content, encoding="utf-8")
def _ensure_config(path: Path) -> None:
if path.exists():
return
template_path = ROOT / "utils/.config.template.toml"
template = tomlkit.loads(template_path.read_text(encoding="utf-8"))
defaults = _default_from_template(template)
path.write_text(tomlkit.dumps(defaults), encoding="utf-8")
def ensure_runtime_state() -> None:
"""Create runtime files and directories expected by the app."""
for relative in (
"assets/temp",
"assets/backgrounds/audio",
"assets/backgrounds/video",
".cache",
"results",
"video_creation/data",
):
(ROOT / relative).mkdir(parents=True, exist_ok=True)
_ensure_config(ROOT / "config.toml")
_ensure_json(ROOT / "video_creation/data/videos.json", "[]\n")
_ensure_json(ROOT / "utils/backgrounds.json", "{}\n")
def main() -> None:
ensure_runtime_state()
if __name__ == "__main__":
main()

@ -1,5 +1,6 @@
import os
import subprocess
import sys
import zipfile
import requests
@ -69,8 +70,7 @@ def ffmpeg_install_windows():
def ffmpeg_install_linux():
try:
subprocess.run(
"sudo apt install ffmpeg",
shell=True,
["sudo", "apt", "install", "-y", "ffmpeg"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
@ -87,8 +87,7 @@ def ffmpeg_install_linux():
def ffmpeg_install_mac():
try:
subprocess.run(
"brew install ffmpeg",
shell=True,
["brew", "install", "ffmpeg"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
@ -124,10 +123,10 @@ def ffmpeg_install():
print("Installing FFmpeg...")
if os.name == "nt":
ffmpeg_install_windows()
elif sys.platform == "darwin":
ffmpeg_install_mac()
elif os.name == "posix":
ffmpeg_install_linux()
elif os.name == "mac":
ffmpeg_install_mac()
else:
print("Your OS is not supported. Please install FFmpeg manually and try again.")
exit()

@ -1,13 +1,14 @@
from typing import Union
from PIL.ImageFont import FreeTypeFont, ImageFont
def getsize(font: ImageFont | FreeTypeFont, text: str):
def getsize(font: Union[ImageFont, FreeTypeFont], text: str):
left, top, right, bottom = font.getbbox(text)
width = right - left
height = bottom - top
return width, height
def getheight(font: ImageFont | FreeTypeFont, text: str):
def getheight(font: Union[ImageFont, FreeTypeFont], text: str):
_, height = getsize(font, text)
return height

@ -7,32 +7,53 @@ import tomlkit
from flask import flash
# Get validation checks from template
MASKED_SECRET_VALUE = "********"
SENSITIVE_SETTING_PARTS = {
"password",
"client_secret",
"access_token",
"2fa_secret",
"tiktok_sessionid",
"elevenlabs_api_key",
"openai_api_key",
}
def is_sensitive_setting(name: str) -> bool:
return any(part in name for part in SENSITIVE_SETTING_PARTS)
# Get validation checks from template, keyed by dotted path
# (e.g. "reddit.creds.username", "threads.creds.username") so that
# leaf-key collisions across platform sections don't clobber each other.
def get_checks():
template = toml.load("utils/.config.template.toml")
checks = {}
def unpack_checks(obj: dict):
def unpack_checks(obj: dict, path):
for key in obj.keys():
if "optional" in obj[key].keys():
checks[key] = obj[key]
else:
unpack_checks(obj[key])
full = f"{path}.{key}" if path else key
if isinstance(obj[key], dict) and "optional" in obj[key].keys():
checks[full] = obj[key]
elif isinstance(obj[key], dict):
unpack_checks(obj[key], full)
unpack_checks(template)
unpack_checks(template, "")
return checks
# Get current config (from config.toml) as dict
def get_config(obj: dict, done=None):
# Get current config (from config.toml) as a dict keyed by dotted path.
# Mirrors the path layout of get_checks() so the GUI can match values to checks.
def get_config(obj: dict, done=None, path=""):
if done is None:
done = {}
for key in obj.keys():
full = f"{path}.{key}" if path else key
if not isinstance(obj[key], dict):
done[key] = obj[key]
done[full] = obj[key]
else:
get_config(obj[key], done)
get_config(obj[key], done, full)
return done
@ -46,8 +67,8 @@ def check(value, checks):
if not incorrect and "type" in checks:
try:
value = eval(checks["type"])(value) # fixme remove eval
except Exception:
value = {"int": int, "float": float, "bool": bool, "str": str}.get(checks["type"], str)(value)
except (ValueError, TypeError):
incorrect = True
if (
@ -92,29 +113,48 @@ def check(value, checks):
# Modify settings (after the form is submitted)
def modify_settings(data: dict, config_load, checks: dict):
# Modify config settings
def modify_config(obj: dict, config_name: str, value: any):
for key in obj.keys():
if config_name == key:
obj[key] = value
elif not isinstance(obj[key], dict):
continue
else:
modify_config(obj[key], config_name, value)
# Remove empty/incorrect key-value pairs
data = {key: value for key, value in data.items() if value and key in checks.keys()}
# Validate values
for name in data.keys():
value = check(data[name], checks[name])
# Walk the dotted path and set the value at the precise location.
# Example: "reddit.creds.username" -> config_load["reddit"]["creds"]["username"]
def set_by_path(obj: dict, dotted_path: str, value):
parts = dotted_path.split(".")
cursor = obj
for part in parts[:-1]:
if part not in cursor or not isinstance(cursor[part], dict):
cursor[part] = {}
cursor = cursor[part]
cursor[parts[-1]] = value
def has_path(obj: dict, dotted_path: str) -> bool:
cursor = obj
for part in dotted_path.split("."):
if not isinstance(cursor, dict) or part not in cursor:
return False
cursor = cursor[part]
return True
# Filter data to only include keys present in checks
data = {key: value for key, value in data.items() if key in checks.keys()}
# Validate and apply values
for name, raw_value in data.items():
existed_before = has_path(config_load, name)
if is_sensitive_setting(name) and raw_value == MASKED_SECRET_VALUE:
continue
value = check(raw_value, checks[name])
# Value is invalid
if value == "Error":
flash("Some values were incorrect and didn't save!", "error")
else:
if not existed_before:
if checks[name].get("default") == value:
continue
if checks[name].get("optional", False) and value == "":
continue
# Value is valid
modify_config(config_load, name, value)
set_by_path(config_load, name, value)
# Save changes in config.toml
with Path("config.toml").open("w") as toml_file:
@ -127,21 +167,22 @@ def modify_settings(data: dict, config_load, checks: dict):
# Delete background video
def delete_background(key):
# Read backgrounds.json
with open("utils/backgrounds.json", "r", encoding="utf-8") as backgrounds:
# Read background catalog
with open("utils/background_videos.json", "r", encoding="utf-8") as backgrounds:
data = json.load(backgrounds)
# Remove background from backgrounds.json
with open("utils/backgrounds.json", "w", encoding="utf-8") as backgrounds:
if data.pop(key, None):
json.dump(data, backgrounds, ensure_ascii=False, indent=4)
else:
flash("Couldn't find this background. Try refreshing the page.", "error")
return
if data.pop(key, None) is None:
flash("Couldn't find this background. Try refreshing the page.", "error")
return
with open("utils/background_videos.json", "w", encoding="utf-8") as backgrounds:
json.dump(data, backgrounds, ensure_ascii=False, indent=4)
# Remove background video from ".config.template.toml"
config = tomlkit.loads(Path("utils/.config.template.toml").read_text())
config["settings"]["background"]["background_choice"]["options"].remove(key)
options = config["settings"]["background"]["background_video"]["options"]
if key in options:
options.remove(key)
with Path("utils/.config.template.toml").open("w") as toml_file:
toml_file.write(tomlkit.dumps(config))
@ -152,7 +193,7 @@ def delete_background(key):
# Add background video
def add_background(youtube_uri, filename, citation, position):
# Validate YouTube URI
regex = re.compile(r"(?:\/|%3D|v=|vi=)([0-9A-z\-_]{11})(?:[%#?&]|$)").search(youtube_uri)
regex = re.compile(r"(?:\/|%3D|v=|vi=)([0-9A-Za-z_-]{11})(?:[%#?&]|$)").search(youtube_uri)
if not regex:
flash("YouTube URI is invalid!", "error")
@ -171,7 +212,8 @@ def add_background(youtube_uri, filename, citation, position):
flash('Position is invalid! It can be "center" or decimal number.', "error")
return
# Sanitize filename
# Sanitize citation to prevent path traversal
citation = re.sub(r"[./\\]", "_", citation)
regex = re.compile(r"^([a-zA-Z0-9\s_-]{1,100})$").match(filename)
if not regex:
@ -181,7 +223,7 @@ def add_background(youtube_uri, filename, citation, position):
filename = filename.replace(" ", "_")
# Check if the background doesn't already exist
with open("utils/backgrounds.json", "r", encoding="utf-8") as backgrounds:
with open("utils/background_videos.json", "r", encoding="utf-8") as backgrounds:
data = json.load(backgrounds)
# Check if key isn't already taken
@ -190,21 +232,24 @@ def add_background(youtube_uri, filename, citation, position):
return
# Check if the YouTube URI isn't already used under different name
if youtube_uri in [data[i][0] for i in list(data.keys())]:
if youtube_uri in [data[i][0] for i in list(data.keys()) if i != "__comment"]:
flash("Background video with this YouTube URI is already added!", "error")
return
# Add background video to json file
with open("utils/backgrounds.json", "r+", encoding="utf-8") as backgrounds:
with open("utils/background_videos.json", "r+", encoding="utf-8") as backgrounds:
data = json.load(backgrounds)
data[filename] = [youtube_uri, filename + ".mp4", citation, position]
backgrounds.seek(0)
backgrounds.truncate()
json.dump(data, backgrounds, ensure_ascii=False, indent=4)
# Add background video to ".config.template.toml"
config = tomlkit.loads(Path("utils/.config.template.toml").read_text())
config["settings"]["background"]["background_choice"]["options"].append(filename)
options = config["settings"]["background"]["background_video"]["options"]
if filename not in options:
options.append(filename)
with Path("utils/.config.template.toml").open("w") as toml_file:
toml_file.write(tomlkit.dumps(config))
@ -212,3 +257,36 @@ def add_background(youtube_uri, filename, citation, position):
flash(f'Added "{citation}-{filename}.mp4" as a new background video!')
return
# Delete videos by ID list — removes entries from videos.json and mp4 files from disk.
# Returns the number of files actually removed from disk.
def delete_videos(ids):
ids = set(ids)
videos_path = Path("video_creation/data/videos.json")
results_root = Path("results").resolve()
with videos_path.open("r", encoding="utf-8") as f:
videos = json.load(f)
to_delete = {v["id"]: v for v in videos if v.get("id") in ids}
remaining = [v for v in videos if v.get("id") not in ids]
deleted = 0
for entry in to_delete.values():
subreddit = entry.get("subreddit", "")
filename = entry.get("filename", "")
if subreddit and filename:
try:
file_path = (results_root / subreddit / filename).resolve()
file_path.relative_to(results_root) # path-traversal guard
if file_path.exists():
file_path.unlink()
deleted += 1
except (ValueError, OSError):
pass
with videos_path.open("w", encoding="utf-8") as f:
json.dump(remaining, f, ensure_ascii=False, indent=4)
return deleted

@ -1,28 +1,47 @@
import os
import re
import subprocess
import sys
import time
from typing import List
import spacy
try:
import spacy
SPACY_AVAILABLE = True
except ImportError:
SPACY_AVAILABLE = False
from utils.console import print_step
from utils.voice import sanitize_text
def _fallback_sentence_split(text: str) -> List[str]:
"""Fallback sentence splitter when spacy is not available."""
sentences = re.split(r'[.!?]+', text)
return [s.strip() for s in sentences if s.strip()]
# working good
def posttextparser(obj, *, tried: bool = False) -> List[str]:
text: str = re.sub("\n", " ", obj)
if not SPACY_AVAILABLE:
return _fallback_sentence_split(text)
try:
nlp = spacy.load("en_core_web_sm")
except OSError as e:
if not tried:
os.system("python -m spacy download en_core_web_sm")
subprocess.run(
[sys.executable, "-m", "spacy", "download", "en_core_web_sm"],
check=False,
)
time.sleep(5)
return posttextparser(obj, tried=True)
print_step(
"The spacy model can't load. You need to install it with the command \npython -m spacy download en_core_web_sm "
"The spacy model can't load. Falling back to regex-based sentence splitting. Install with: python -m spacy download en_core_web_sm"
)
raise e
return _fallback_sentence_split(text)
doc = nlp(text)

@ -10,6 +10,32 @@ from utils.console import handle_input
console = Console()
config = dict # autocomplete
_TYPE_COERCION = {"int": int, "float": float, "bool": bool, "str": str}
def apply_template_defaults(config_data: Dict, template_file="utils/.config.template.toml") -> Dict:
"""Fill missing config values from template defaults without prompting.
The CLI uses check_toml(), which can prompt interactively and then writes a
fully-populated config.toml. The GUI cannot prompt inside its background
thread, so it needs a non-interactive way to make partial configs usable.
Values already present in config_data are never overwritten.
"""
template = toml.load(template_file)
def merge_defaults(target: dict, schema: dict) -> None:
for key, value in schema.items():
if isinstance(value, dict) and "optional" in value:
if key not in target and "default" in value:
target[key] = value["default"]
elif isinstance(value, dict):
section = target.setdefault(key, {})
if isinstance(section, dict):
merge_defaults(section, value)
merge_defaults(config_data, template)
return config_data
def crawl(obj: dict, func=lambda x, y: print(x, y, end="\n"), path=None):
if path is None: # path Default argument value is mutable
@ -30,8 +56,8 @@ def check(value, checks, name):
incorrect = True
if not incorrect and "type" in checks:
try:
value = eval(checks["type"])(value) # fixme remove eval
except:
value = _TYPE_COERCION.get(checks["type"], str)(value)
except (ValueError, TypeError):
incorrect = True
if (
@ -78,7 +104,7 @@ def check(value, checks, name):
+ str(name)
+ "[#F7768E bold]=",
extra_info=get_check_value("explanation", ""),
check_type=eval(get_check_value("type", "False")), # fixme remove eval
check_type=_TYPE_COERCION.get(get_check_value("type", ""), False),
default=get_check_value("default", NotImplemented),
match=get_check_value("regex", ""),
err_message=get_check_value("input_error", "Incorrect input"),
@ -129,7 +155,8 @@ Overwrite it?(y/n)"""
try:
with open(config_file, "w") as f:
f.write("")
except:
config = {}
except (OSError, IOError):
console.print(
f"[red bold]Failed to overwrite {config_file}. Giving up.\nSuggestion: check {config_file} permissions for the user."
)
@ -143,7 +170,7 @@ Creating it now."""
with open(config_file, "x") as f:
f.write("")
config = {}
except:
except (OSError, IOError):
console.print(
f"[red bold]Failed to write to {config_file}. Giving up.\nSuggestion: check the folder's permissions for the user."
)

@ -2,20 +2,33 @@ import requests
from utils.console import print_step
# Set to the correct GitHub "owner/repo" for this fork, or leave empty to skip check.
_UPSTREAM_REPO = ""
def checkversion(__VERSION__: str):
response = requests.get(
"https://api.github.com/repos/elebumm/RedditVideoMakerBot/releases/latest"
)
latestversion = response.json()["tag_name"]
if not _UPSTREAM_REPO:
return
try:
response = requests.get(
f"https://api.github.com/repos/{_UPSTREAM_REPO}/releases/latest",
timeout=10,
)
response.raise_for_status()
latestversion = response.json()["tag_name"]
except (requests.RequestException, KeyError, ValueError):
return # Network or API error — skip version check silently
if __VERSION__ == latestversion:
print_step(f"You are using the newest version ({__VERSION__}) of the bot")
return True
elif __VERSION__ < latestversion:
print_step(
f"You are using an older version ({__VERSION__}) of the bot. Download the newest version ({latestversion}) from https://github.com/elebumm/RedditVideoMakerBot/releases/latest"
f"You are using an older version ({__VERSION__}) of the bot. "
f"Download the newest version ({latestversion}) from "
f"https://github.com/{_UPSTREAM_REPO}/releases/latest"
)
else:
print_step(
f"Welcome to the test version ({__VERSION__}) of the bot. Thanks for testing and feel free to report any bugs you find."
f"Welcome to the test version ({__VERSION__}) of the bot. "
f"Thanks for testing and feel free to report any bugs you find."
)

@ -1,15 +1,17 @@
import json
import time
from praw.models import Submission
from typing import TYPE_CHECKING
from utils import settings
from utils.console import print_step
if TYPE_CHECKING:
from praw.models import Submission
def check_done(
redditobj: Submission,
) -> Submission:
redditobj: "Submission",
) -> "Submission":
# don't set this to be run anyplace that isn't subreddit.py bc of inspect stack
"""Checks if the chosen post has already been generated
@ -58,3 +60,20 @@ def save_data(subreddit: str, filename: str, reddit_title: str, reddit_id: str,
done_vids.append(payload)
raw_vids.seek(0)
json.dump(done_vids, raw_vids, ensure_ascii=False, indent=4)
raw_vids.truncate()
def check_done_by_id(post_id: str) -> bool:
"""Returns True if a video for this post_id has already been generated.
Platform-agnostic version of check_done, used by non-Reddit platforms.
Args:
post_id (str): The unique post ID from any platform
Returns:
bool: True if video already exists, False otherwise
"""
with open("./video_creation/data/videos.json", "r", encoding="utf-8") as f:
done_videos = json.load(f)
return any(video["id"] == str(post_id) for video in done_videos)

@ -1,18 +1,26 @@
import json
import random
import re
import subprocess
from pathlib import Path
from random import randrange
from typing import Any, Dict, Tuple
import av
import yt_dlp
from moviepy import AudioFileClip, VideoFileClip
from moviepy.video.io.ffmpeg_tools import ffmpeg_extract_subclip
from moviepy import AudioFileClip
from utils import settings
from utils.console import print_step, print_substep
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 load_background_options():
_background_options = {}
# Load background videos
@ -31,7 +39,7 @@ def load_background_options():
pos = _background_options["video"][name][3]
if pos != "center":
_background_options["video"][name][3] = lambda t: ("center", pos + t)
_background_options["video"][name][3] = lambda t, p=pos: ("center", p + t)
return _background_options
@ -66,9 +74,25 @@ def get_background_config(mode: str):
choice = None
# Handle default / not supported background using default option.
# Default : pick random from supported background.
# Default : pick random from already-downloaded backgrounds if available,
# otherwise pick from all supported backgrounds.
if not choice or choice not in background_options[mode]:
choice = random.choice(list(background_options[mode].keys()))
if mode == "video":
available = [
k for k, v in background_options[mode].items()
if Path(f"assets/backgrounds/video/{v[2]}-{v[1]}").is_file()
]
else:
available = [
k for k, v in background_options[mode].items()
if Path(f"assets/backgrounds/audio/{v[2]}-{v[1]}").is_file()
]
if available:
choice = random.choice(available)
print_substep(f"Picked random {mode} from downloaded: {choice}")
else:
choice = random.choice(list(background_options[mode].keys()))
print_substep(f"No downloaded {mode}s found. Picked: {choice} (will download)")
return background_options[mode][choice]
@ -86,7 +110,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,
}
@ -144,24 +168,19 @@ def chop_background(background_config: Dict[str, Tuple], video_length: int, redd
print_step("Finding a spot in the backgrounds video to chop...✂️")
video_choice = f"{background_config['video'][2]}-{background_config['video'][1]}"
background_video = VideoFileClip(f"assets/backgrounds/video/{video_choice}")
src = f"assets/backgrounds/video/{video_choice}"
out = f"assets/temp/{thread_id}/background.mp4"
start_time_video, end_time_video = get_start_and_end_times(
video_length, background_video.duration
video_length, _probe_duration(src)
)
# Extract video subclip
try:
with VideoFileClip(f"assets/backgrounds/video/{video_choice}") as video:
new = video.subclipped(start_time_video, end_time_video)
new.write_videofile(f"assets/temp/{thread_id}/background.mp4")
except (OSError, IOError): # ffmpeg issue see #348
print_substep("FFMPEG issue. Trying again...")
ffmpeg_extract_subclip(
f"assets/backgrounds/video/{video_choice}",
start_time_video,
end_time_video,
outputfile=f"assets/temp/{thread_id}/background.mp4",
)
# ffmpeg stream-copy (fast) instead of moviepy re-encode
result = subprocess.run([
"ffmpeg", "-y", "-ss", str(start_time_video), "-to", str(end_time_video),
"-i", src, "-c", "copy", "-avoid_negative_ts", "make_zero", out,
], capture_output=True)
if result.returncode != 0:
stderr = result.stderr.decode("utf-8", errors="replace")
raise RuntimeError(f"ffmpeg background extraction failed: {stderr[-500:]}")
print_substep("Background video chopped successfully!", style="bold green")
return background_config["video"][2]

@ -1,15 +1,16 @@
import multiprocessing
import json
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 +27,43 @@ from utils.videos import save_data
console = Console()
def get_output_path(reddit_obj: dict) -> str:
"""Compute the output mp4 path from a content object. Shared with main.py."""
title_raw = reddit_obj.get("thread_title", "video")
filename = f"{name_normalize(title_raw)[:251]}"
platform = settings.config["settings"].get("platform", "reddit")
if platform == "reddit":
subreddit = (
settings.config.get("reddit", {})
.get("thread", {})
.get("subreddit", "unknown")
)
else:
subreddit = reddit_obj.get("thread_category", platform)
return f"results/{subreddit}/{filename}.mp4"
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 +73,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):
@ -65,6 +102,10 @@ class ProgressFfmpeg(threading.Thread):
def __exit__(self, *args, **kwargs):
self.stop()
try:
os.unlink(self.output_file.name)
except OSError:
pass
def name_normalize(name: str) -> str:
@ -75,38 +116,12 @@ def name_normalize(name: str) -> str:
name = re.sub(r"(\w+)\s?\/\s?(\w+)", r"\1 or \2", name)
name = re.sub(r"\/", r"", name)
lang = settings.config["reddit"]["thread"]["post_lang"]
lang = (settings.config["settings"].get("post_lang") or
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
def prepare_background(reddit_id: str, W: int, H: int) -> str:
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)
return output_path
return translators.translate_text(name, translator="google", to_language=lang)
return name
def get_text_height(draw, text, font, max_width):
@ -119,51 +134,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),
@ -172,28 +174,73 @@ 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.
Prepends crop+scale on [0:v] so raw background.mp4 can be used directly
(no separate prepare_background encode pass needed).
Each overlay item: {path, start_time, duration, opacity, scale_w, scale_h}
"""
parts = []
# Crop background to target aspect ratio and scale — merged from prepare_background
parts.append(f"[0:v]crop=ih*({W}/{H}):ih,scale={W}:{H}[bg];")
prev_label = "bg"
for i, item in enumerate(overlay_items):
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 "final"
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(";")
prev_label = next_label
return "".join(parts)
def make_final_video(
@ -202,19 +249,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 = (
@ -224,200 +262,171 @@ def make_final_video(
print_step("Creating the final video 🎥")
background_clip = ffmpeg.input(prepare_background(reddit_id, W=W, H=H))
# --- Step 1: Background path (crop+scale merged into overlay filter) ---
background_path = f"assets/temp/{reddit_id}/background.mp4"
# 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):
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
if not existing:
raise RuntimeError("No audio clips generated — all TTS segments failed to produce output")
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")
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()
# --- Step 4: Build overlay items ---
platform = settings.config["settings"].get("platform", "reddit")
screenshot_width = int(W * (0.82 if platform == "threads" else 0.45))
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)
font_color = "#000000"
padding = 5
# create_fancy_thumbnail(image, text, text_color, padding
title_img = create_fancy_thumbnail(title_template, title, font_color, padding)
# Use actual screenshot for non-Reddit platforms (Threads etc.), Reddit template for Reddit
title_img_path = f"assets/temp/{reddit_id}/png/title.png"
if platform == "reddit":
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(title_img_path)
overlay_items = []
current_time = 0.0
overlay_items.append({
"path": title_img_path,
"start_time": current_time,
"duration": audio_clips_durations[0],
"opacity": opacity,
"scale_w": screenshot_width,
"scale_h": -2,
})
current_time += audio_clips_durations[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
),
)
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": -2,
})
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):
dur_idx = i + 1
if dur_idx >= len(audio_clips_durations):
break
img_path = f"assets/temp/{reddit_id}/png/img{i}.png"
if os.path.exists(img_path):
overlay_items.append({
"path": img_path,
"start_time": current_time,
"duration": audio_clips_durations[dur_idx],
"opacity": opacity,
"scale_w": screenshot_width,
"scale_h": -2,
})
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",
)
current_time += audio_clips_durations[i]
title = extract_id(reddit_obj, "thread_title")
for i in range(number_of_clips):
dur_idx = i + 1 # audio_clips_durations[0] is title, [1..N] are comments
if dur_idx >= len(audio_clips_durations):
break
img_path = f"assets/temp/{reddit_id}/png/comment_{i}.png"
if os.path.exists(img_path):
overlay_items.append({
"path": img_path,
"start_time": current_time,
"duration": audio_clips_durations[dur_idx],
"opacity": opacity,
"scale_w": screenshot_width,
"scale_h": -2,
})
current_time += audio_clips_durations[dur_idx]
# --- 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]}"
subreddit = settings.config["reddit"]["thread"]["subreddit"]
platform = settings.config["settings"].get("platform", "reddit")
if platform == "reddit":
subreddit = settings.config["reddit"]["thread"]["subreddit"]
else:
subreddit = reddit_obj.get("thread_category", platform)
if not exists(f"./results/{subreddit}"):
print_substep("The 'results' folder could not be found so it was automatically created.")
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:
@ -425,68 +434,68 @@ 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", "-preset", "veryfast", "-crf", "23",
"-pix_fmt", "yuv420p",
"-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", "-preset", "veryfast", "-crf", "23",
"-pix_fmt", "yuv420p",
"-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 🗑")

@ -4,7 +4,7 @@ from pathlib import Path
from typing import Dict, Final
import translators
from playwright.sync_api import ViewportSize, sync_playwright
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError, ViewportSize, sync_playwright
from rich.progress import track
from utils import settings
@ -133,16 +133,12 @@ def get_screenshots_of_reddit_posts(reddit_object: dict, screenshot_num: int):
page.wait_for_load_state()
page.wait_for_timeout(5000)
if page.locator(
"#t3_12hmbug > div > div._3xX726aBn29LDbsDtzr_6E._1Ap4F5maDtT1E1YuCiaO0r.D3IL3FD0RFy_mkKLPwL4 > div > div > button"
).is_visible():
# This means the post is NSFW and requires to click the proceed button.
print_substep("Post is NSFW. You are spicy...")
page.locator(
"#t3_12hmbug > div > div._3xX726aBn29LDbsDtzr_6E._1Ap4F5maDtT1E1YuCiaO0r.D3IL3FD0RFy_mkKLPwL4 > div > div > button"
).click()
page.wait_for_load_state() # Wait for page to fully load
# Dismiss NSFW warning overlay if present (generic: finds button in NSFW overlay by role)
nsfw_button = page.get_by_role("button", name="yes", exact=False).first
if nsfw_button.is_visible():
print_substep("Post is NSFW. Attempting to proceed...")
nsfw_button.click()
page.wait_for_load_state()
# translate code
if page.locator(
@ -252,7 +248,7 @@ def get_screenshots_of_reddit_posts(reddit_object: dict, screenshot_num: int):
page.locator(f"#t1_{comment['comment_id']}").screenshot(
path=f"assets/temp/{reddit_id}/png/comment_{idx}.png"
)
except TimeoutError:
except PlaywrightTimeoutError:
del reddit_object["comments"]
screenshot_num += 1
print("TimeoutError: Skipping screenshot...")

@ -9,6 +9,7 @@ from TTS.GTTS import GTTS
from TTS.openai_tts import OpenAITTS
from TTS.pyttsx import pyttsx
from TTS.streamlabs_polly import StreamlabsPolly
from TTS.supertonic_tts import SupertonicTTS
from TTS.TikTok import TikTok
from utils import settings
from utils.console import print_step, print_table
@ -23,6 +24,7 @@ TTSProviders = {
"pyttsx": pyttsx,
"ElevenLabs": elevenlabs,
"OpenAI": OpenAITTS,
"Supertonic": SupertonicTTS,
}

@ -0,0 +1,180 @@
"""
YouTube Uploader OAuth2-authenticated upload to YouTube.
Imports the upload logic pattern from vendor/FullyAutomatedRedditVideoMakerBot/uploaders/youtubeUpload.py
but is a standalone reimplementation that:
- Reads config from the [youtube] section of config.toml
- Lets the user point to their youtube_client_secret.json via config
- Caches OAuth2 tokens to video_creation/data/YTtoken.json
- Derives title, description, tags, privacy, category from config
- Handles missing dependencies and missing secret files gracefully
"""
import os
import sys
from utils.console import print_markdown, print_step, print_substep
SCOPES = ["https://www.googleapis.com/auth/youtube.upload"]
TOKEN_FILE = os.path.join("video_creation", "data", "YTtoken.json")
def _get_authenticated_service(client_secret_path):
"""
Authenticate with YouTube via OAuth2.
Returns a googleapiclient.discovery.Resource (youtube v3) or None on failure.
"""
# Lazy imports so missing dependencies don't crash the pipeline
try:
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
import google.auth.transport.requests
except ImportError:
print_substep(
"YouTube upload requires google-auth-oauthlib and google-api-python-client.\n"
"Install them with: pip install google-auth-oauthlib google-api-python-client",
"bold red",
)
return None
# Validate client secret file exists
if not client_secret_path or not os.path.isfile(client_secret_path):
print_substep(
f"YouTube client secret not found at: '{client_secret_path}'.\n"
"Set youtube.client_secret_path in config.toml to the path of your "
"youtube_client_secret.json file (downloaded from Google Cloud Console).",
"bold red",
)
return None
credentials = None
# Load previously cached token if available
if os.path.isfile(TOKEN_FILE):
try:
with open(TOKEN_FILE, "r") as f:
credentials = Credentials.from_authorized_user_file(TOKEN_FILE, SCOPES)
except Exception:
credentials = None
# Refresh expired token or start fresh OAuth flow
if not credentials or not credentials.valid:
if credentials and credentials.expired and credentials.refresh_token:
try:
credentials.refresh(google.auth.transport.requests.Request())
except Exception:
credentials = None
if not credentials:
try:
print_substep(
"Opening browser for YouTube OAuth2 authorization...",
"blue",
)
flow = InstalledAppFlow.from_client_secrets_file(
client_secret_path, SCOPES
)
credentials = flow.run_local_server(port=0)
except Exception as e:
print_substep(f"YouTube OAuth2 authentication failed: {e}", "bold red")
return None
# Cache credentials for future runs
os.makedirs(os.path.dirname(TOKEN_FILE), exist_ok=True)
with open(TOKEN_FILE, "w") as f:
f.write(credentials.to_json())
print_substep("YouTube credentials cached to video_creation/data/YTtoken.json", "green")
return build("youtube", "v3", credentials=credentials)
def upload_to_youtube(video_path, video_title, config):
"""
Upload a video to YouTube using settings from the [youtube] config section.
The function is safe to call even when youtube is disabled it will
return None immediately with a log message.
Args:
video_path: Absolute or relative path to the .mp4 video file.
video_title: Display title for the YouTube video (typically the
thread title from the content object).
config: Full application configuration dict (settings.config).
Returns:
str YouTube URL (https://youtu.be/VIDEO_ID) on success, or
None if the upload is disabled, skipped, or failed.
"""
youtube_config = config.get("youtube", {})
enabled = youtube_config.get("enabled", False)
if not enabled:
print_substep(
"YouTube upload skipped (youtube.enabled = false in config.toml).",
"yellow",
)
return None
if not os.path.isfile(video_path):
print_substep(f"Video file not found: {video_path}", "bold red")
return None
client_secret_path = youtube_config.get("client_secret_path", "")
print_step("Uploading video to YouTube...")
youtube = _get_authenticated_service(client_secret_path)
if youtube is None:
return None
# Build upload metadata from config (with sensible defaults)
tags_str = youtube_config.get("tags", "shorts, reddit")
tags = [t.strip() for t in tags_str.split(",") if t.strip()]
privacy = youtube_config.get("privacy", "public")
category = youtube_config.get("category", "22")
description = youtube_config.get(
"description",
f"{video_title}\n\n#shorts #short #reddit",
)
try:
from googleapiclient.http import MediaFileUpload
body = {
"snippet": {
"title": video_title,
"description": description,
"tags": tags,
"categoryId": category,
},
"status": {
"privacyStatus": privacy,
"madeForKids": False,
},
}
media = MediaFileUpload(video_path, chunksize=-1, resumable=True)
request = youtube.videos().insert(
part="snippet,status",
body=body,
media_body=media,
)
response = None
while response is None:
status, response = request.next_chunk()
if status:
print_substep(
f"Uploading... {int(status.progress() * 100)}% complete."
)
video_url = f"https://youtu.be/{response['id']}"
print_markdown(f"## Video uploaded successfully: {video_url}")
return video_url
except Exception as e:
print_substep(f"YouTube upload failed: {e}", "bold red")
return None
Loading…
Cancel
Save