feat: video creation dashboard with real-time progress tracking

Add /create page with pipeline stage polling, /video/<id> route for
safe file serving, modernized Tailwind/DaisyUI UI, and pytest
regression tests. Consolidate AGENT.md + AGENTS.md into CLAUDE.md.

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

@ -1,392 +0,0 @@
# AGENT.md — Guidance for Agents & AI Working on VideoMakerBot
This document guides **agents, bots, and AI assistants** on how to work effectively with the VideoMakerBot codebase.
---
## Quick Start for Agents
### Core Principle
**VideoMakerBot uses a platform-agnostic factory pattern.** Always respect the abstraction:
- Don't import platform-specific modules (reddit/, threads/) directly
- Always use `platforms/__init__.py` factory functions
- Keep platform-specific logic in `platforms/{platform}/`
### The "Do This" Checklist
1. ✅ Read existing CLAUDE.md for architecture context
2. ✅ Use factory: `from platforms import get_content_object, get_screenshot_fn`
3. ✅ Return standard `content_object` dict from all fetchers
4. ✅ Test both Reddit and Threads modes before declaring completion
5. ✅ Use config fallback chains for cross-platform keys
6. ✅ Document platform-specific logic in docstrings
### The "Don't Do This" List
1. ❌ Import `reddit.subreddit` directly in main.py or generic modules
2. ❌ Hardcode subreddit/platform names in core video pipeline
3. ❌ Add platform-specific selectors outside `platforms/{platform}/`
4. ❌ Assume config keys exist without `.get()` and fallbacks
5. ❌ Modify screenshot_downloader.py for non-Reddit platforms
---
## Understanding the Codebase Structure
### Entry Point
**`main.py`** — Single CLI entry point using platform factory
- Calls `get_content_object(POST_ID)` from factory
- Calls `get_screenshot_fn()` from factory
- Everything else is platform-agnostic
### Platform Layer (`platforms/`)
- **`__init__.py`** — Factory dispatch functions (add new platforms here)
- **`threads/fetcher.py`** — Threads Graph API client (returns standard dict)
- **`threads/screenshot.py`** — Threads.net Playwright screenshotter
### Legacy Platform (`reddit/`)
- **`subreddit.py`** — PRAW API client (returns standard dict)
- No changes needed; called via factory
### Video Pipeline (`video_creation/`)
- **`final_video.py`** — FFmpeg composition (platform-aware output folder only)
- **`screenshot_downloader.py`** — Reddit Playwright screenshotter (not called for Threads)
- **`voices.py`** — TTS orchestration (platform-agnostic)
- **`background.py`** — Video/audio download (platform-agnostic)
### TTS Layer (`TTS/`)
- **`engine_wrapper.py`** — Provider abstraction (handles `post_lang` fallback)
- **`*.py`** — Individual provider implementations (elevenlabs, aws_polly, etc.)
### Config & Utils (`utils/`)
- **`settings.py`** — TOML config loading & validation
- **`videos.py`** — Dedup tracking (`check_done()` + `check_done_by_id()`)
- **`.config.template.toml`** — Config schema with `[settings]`, `[reddit.*]`, `[threads.*]`, `[ai]`
---
## How to Approach Common Tasks
### Adding a New Social Platform (e.g., X/Twitter)
**Steps:**
1. Create `platforms/twitter/fetcher.py`:
```python
def get_twitter_content(POST_ID=None) -> dict:
"""Fetch post + replies, return standard content_object."""
# Implement API fetching logic here
return {
"thread_id": ...,
"thread_category": "twitter", # NEW: generic field for output folder
"thread_title": ...,
"thread_url": ...,
"comments": [...]
}
```
2. Create `platforms/twitter/screenshot.py`:
```python
def get_screenshots_of_twitter_posts(content_object: dict, screenshot_num: int):
"""Use Playwright to screenshot X/Twitter posts."""
# Implement Playwright logic here
```
3. Update `platforms/__init__.py`:
```python
elif platform == "twitter":
from platforms.twitter.fetcher import get_twitter_content
return get_twitter_content(POST_ID)
```
4. Add config section to `utils/.config.template.toml`:
```toml
[twitter.creds]
api_key = { ... }
api_secret = { ... }
[twitter.thread]
post_id = { ... }
```
5. Update `main.py` helper:
```python
elif platform == "twitter":
return config.get("twitter", {}).get("thread", {}).get("post_id", "")
```
6. **Zero changes needed to:** TTS, backgrounds, video composition, utils.
**Verification:**
```bash
# Test Reddit (regression check)
sed -i 's/platform = "twitter"/platform = "reddit"/' config.toml
python3 main.py
# Verify results/{subreddit}/ output
# Test Twitter
sed -i 's/platform = "reddit"/platform = "twitter"/' config.toml
python3 main.py --post-id <twitter-id>
# Verify results/twitter/ output
```
---
### Modifying the Video Pipeline
**Scenario:** You need to change FFmpeg composition or add a new processing step.
**Approach:**
1. Check which data the modified code consumes (`content_object` dict)
2. Verify it works with both Reddit and Threads content structures
3. If platform-specific: move logic to `platforms/{platform}/`
4. If generic: keep in `video_creation/`
5. Test both modes before merging
**Example:** Adding video filters
```python
# In final_video.py (generic, works for all platforms)
def apply_filter(video_clip, filter_type):
# No platform-specific logic here
return video_clip.filter(...)
# Test:
# - Reddit mode produces filtered video
# - Threads mode produces filtered video
```
---
### Fixing a Bug in Config Handling
**Scenario:** `post_lang` is not being applied correctly.
**Debug Path:**
1. Check `utils/settings.py` — how is config loaded?
2. Check `TTS/engine_wrapper.py:182` — uses fallback chain:
```python
lang = (settings.config["settings"].get("post_lang") or
settings.config.get("reddit", {}).get("thread", {}).get("post_lang", ""))
```
3. Check `video_creation/final_video.py:78` — same fallback logic
4. If still broken: verify `utils/.config.template.toml` has the key defined
5. Test both platforms with `post_lang = "es"` in config
---
### Adding Support for a New TTS Provider
**Scenario:** User wants Whisper TTS support.
**Steps:**
1. Create `TTS/whisper_tts.py`:
```python
class WhisperTTS:
def make_voice(self, text):
# Call Whisper API
return audio_bytes
```
2. Update `TTS/engine_wrapper.py:make_voice()`:
```python
elif voice_choice == "whisper":
from TTS.whisper_tts import WhisperTTS
return WhisperTTS().make_voice(text)
```
3. Add config to `utils/.config.template.toml`:
```toml
[settings.tts]
whisper_api_key = { optional = true, ... }
```
4. Test:
```bash
# In config.toml:
voice_choice = "whisper"
# Run: python3 main.py
```
---
## Common Pitfalls & How to Avoid Them
### Pitfall 1: Platform-Specific Code in Generic Modules
**Problem:**
```python
# BAD: In video_creation/final_video.py
subreddit = settings.config["reddit"]["thread"]["subreddit"]
```
**Will break** when platform = "threads" (no reddit.thread.subreddit).
**Solution:**
```python
# GOOD:
platform = settings.config["settings"].get("platform", "reddit")
if platform == "reddit":
category = settings.config["reddit"]["thread"]["subreddit"]
else:
category = reddit_obj.get("thread_category", platform)
```
### Pitfall 2: Hardcoding Selectors in Platform-Agnostic Code
**Problem:**
```python
# BAD: In video_creation/voices.py
element = page.locator("#t1_{comment_id}") # Reddit-only selector!
```
**Will fail** when running Threads mode (different DOM).
**Solution:**
- Keep all Playwright logic in `platforms/{platform}/screenshot.py`
- Never hardcode selectors in generic modules
### Pitfall 3: Forgetting to Test Both Modes
**Problem:** You change `final_video.py`, test with Reddit, declare done.
Threads mode breaks because you didn't test it.
**Solution:**
```bash
# Test both before committing:
sed -i 's/platform = "threads"/platform = "reddit"/' config.toml
python3 main.py
# Check results/{subreddit}/
sed -i 's/platform = "reddit"/platform = "threads"/' config.toml
python3 main.py --post-id <id>
# Check results/threads/
```
### Pitfall 4: Assuming Config Keys Exist
**Problem:**
```python
# BAD:
lang = settings.config["reddit"]["thread"]["post_lang"]
```
**Will crash** if key doesn't exist.
**Solution:**
```python
# GOOD:
lang = (settings.config["settings"].get("post_lang") or
settings.config.get("reddit", {}).get("thread", {}).get("post_lang", ""))
```
---
## Code Review Checklist for Agents
Before marking work complete, verify:
- [ ] **No platform imports in main.py** — Uses factory only
- [ ] **Standard content_object dict** — All fetchers return same shape
- [ ] **Platform-specific logic isolated** — Only in `platforms/{platform}/`
- [ ] **Config fallback chains** — No hardcoded section names in generic code
- [ ] **Both modes tested** — Reddit AND Threads produce correct output
- [ ] **Docstrings updated** — New functions document platform assumptions
- [ ] **Error messages clear** — Include platform name + actionable guidance
- [ ] **Video dedup works** — No duplicate videos created
---
## Understanding Data Flow
### Happy Path: Fetch → TTS → Screenshot → Compose → Output
```
1. main.py:main()
└─→ platforms/__init__.py:get_content_object()
└─→ platforms/threads/fetcher.py:get_threads_content()
└─→ Returns: {thread_id, thread_title, comments, ...}
2. video_creation/voices.py:save_text_to_mp3()
└─→ TTS/engine_wrapper.py:process_text()
└─→ TTS/engine_wrapper.py:make_voice()
└─→ TTS/{provider}.py: {elevenlabs,tiktok,etc}
└─→ Returns: audio_length, comment_count
3. platforms/__init__.py:get_screenshot_fn()
└─→ platforms/threads/screenshot.py:get_screenshots_of_threads_posts()
└─→ Uses Playwright on threads.net
└─→ Saves: assets/temp/{thread_id}/png/{title,comment_0,etc}.png
4. video_creation/background.py
└─→ download_background_video() & download_background_audio()
└─→ Uses yt-dlp to fetch YouTube videos/audio
└─→ Saves to: assets/temp/{thread_id}/{video,audio}
5. video_creation/final_video.py:make_final_video()
└─→ Uses FFmpeg to compose everything
└─→ Reads: audio files, screenshot PNGs, background video
└─→ Writes: results/{thread_category}/{filename}.mp4
6. utils/videos.py:save_data()
└─→ Records video in videos.json for dedup
```
### Config Flow
```
config.toml (user settings)
utils/settings.py:check_toml()
└─→ Validates against .config.template.toml schema
└─→ Returns: settings.config (dict)
Used by:
├─ main.py (platform selection)
├─ platforms/reddit/ (subreddit, etc.)
├─ platforms/threads/ (Graph API token, etc.)
├─ TTS/engine_wrapper.py (post_lang fallback)
├─ video_creation/ (theme, resolution, etc.)
└─ utils/videos.py (dedup behavior)
```
---
## Deployment Notes
### Python Version
- **Minimum:** 3.10
- **Tested:** 3.10, 3.11, 3.12
- **Reason:** F-strings, type hints, modern async patterns
### Critical Dependencies
- **reddit platform:** praw 7.8.1 (requires Reddit OAuth app)
- **threads platform:** requests (for Graph API calls)
- **screenshots:** playwright 1.49.1 (requires browser installation: `playwright install`)
- **video:** moviepy 2.2.1, ffmpeg-python 0.2.0 (requires FFmpeg system binary)
- **tts:** varies per provider (elevenlabs, aws_polly, openai, etc.)
### Versions That Caused Issues
- **yt-dlp==2026.3.17** — Doesn't exist (use 2025.10.14 or latest stable)
- **playwright without browser install** — Will crash on first screenshot
---
## When to Escalate
### Escalate to User if:
- User needs new platform support (only they know requirements)
- Config changes affect backward compatibility
- Performance optimization needed (only user knows acceptable limits)
- Security concern (token handling, credential storage, etc.)
### Safe to Implement as Agent:
- Bug fixes within existing architecture
- Adding new TTS providers
- Extending config options for existing platforms
- Performance optimizations (caching, parallelization)
- New filter/processing features that work platform-agnostically
- Documentation & refactoring
---
## Final Guidance
**Golden Rule:** The factory pattern is your friend. When in doubt, check if your change breaks the abstraction. If it does, rethink it.
**Test Obsessively:** Always run both Reddit and Threads modes. The codebase is designed for multi-platform support, and it's easy to break one platform while fixing another.
**Document Platform Assumptions:** If your code works differently for Reddit vs Threads, say so explicitly in docstrings and comments.
**Ask Yourself:** "Would this work for X/Twitter?" If no, it probably belongs in `platforms/threads/`, not in generic code.
Good luck, and happy contributing! 🎥

@ -1,457 +0,0 @@
# AGENTS.md — VideoMakerBot Development Guide
## Project Overview
**VideoMakerBot** — Automated short-form video creator from social media content.
**Status:** Production-ready, actively maintained (v3.4.0)
**Language:** Python 3.10+
**Platforms:** Reddit (original), Threads (NEW), X/Twitter (planned)
### Core Mission
Transforms social media threads (post + comments/replies) into complete short-form videos with:
- AI-generated speech (7+ TTS providers)
- UI screenshots (Playwright)
- Background video/audio overlays
- FFmpeg composition & output
---
## Architecture at a Glance
```
main.py (CLI)
↓ [platform factory]
├─→ reddit/subreddit.py [PRAW API]
└─→ platforms/threads/fetcher.py [Graph API]
↓ [standard data dict]
├─→ TTS/engine_wrapper.py [7+ providers]
├─→ screenshot_downloader.py (Reddit)
│ or platforms/threads/screenshot.py (Threads)
├─→ video_creation/background.py
└─→ video_creation/final_video.py [FFmpeg]
results/{category}/{video.mp4}
```
### Key Design: Platform Abstraction via Factory Pattern
**Why:** Single codebase supports multiple platforms without tight coupling.
**How:** `platforms/__init__.py` exports:
- `get_content_object(POST_ID=None)` — routes to right fetcher
- `get_screenshot_fn()` — routes to right screenshotter
**Result:** Adding X/Twitter requires only: new module + config section + two `elif` branches.
---
## Data Contract: The "content_object" Dict
All fetchers return this shape (defined in `platforms/__init__.py`):
```python
{
# Unique identifiers
"thread_id": str, # Used for temp folder: assets/temp/{id}/
"thread_category": str, # "reddit", "threads", etc. → output folder
# Content
"thread_title": str, # TTS as title + output filename
"thread_url": str, # Playwright navigates here for screenshot
"is_nsfw": bool, # Content filter flag
# Replies/Comments (mutually exclusive with thread_post)
"comments": [
{
"comment_body": str, # TTS per reply
"comment_url": str, # Playwright navigates here
"comment_id": str, # CSS selector ID or unique identifier
}
],
# OR Story mode:
"thread_post": str | list, # Long-form text (no comments)
}
```
**Why:** Loose coupling—TTS, backgrounds, and video composition don't need platform-specific logic.
---
## File Organization
```
VideoMakerBot/
├── platforms/ # Multi-platform abstraction
│ ├── __init__.py # Factory: get_content_object(), get_screenshot_fn()
│ └── threads/ # Threads (Meta) implementation
│ ├── fetcher.py # Graph API → content_object
│ └── screenshot.py # Playwright Threads screenshotter
├── reddit/ # Reddit implementation (kept as-is)
│ └── subreddit.py # PRAW API → content_object + thread_category
├── video_creation/
│ ├── final_video.py # FFmpeg composition (platform-aware folder naming)
│ ├── screenshot_downloader.py # Playwright Reddit UI capturer
│ ├── voices.py # TTS orchestrator (platform-agnostic)
│ ├── background.py # Video/audio downloader (platform-agnostic)
│ └── data/
│ ├── videos.json # Dedup tracker
│ ├── cookie-dark-mode.json # Reddit theme cookie
│ └── cookie-threads.json # Threads session cookie (auto-created)
├── TTS/ # Text-to-Speech
│ ├── engine_wrapper.py # Provider abstraction + post_lang fallback
│ ├── elevenlabs.py, aws_polly.py, etc. # 7+ provider implementations
├── utils/
│ ├── settings.py # Config loading + validation
│ ├── videos.py # check_done() + check_done_by_id()
│ ├── console.py # Rich terminal output
│ ├── .config.template.toml # Config schema (platform sections)
│ └── ... (id, voice, cleanup, etc.)
├── main.py # CLI entry (platform-routed via factory)
├── GUI.py # Flask web UI (localhost:4000 in host mode, 0.0.0.0 in Docker)
├── requirements.txt # Dependencies
└── AGENTS.md / AGENT.md # This file + agent guidelines
```
---
## Configuration
**File:** `utils/.config.template.toml` (schema) → `config.toml` (user config)
### Platform Selection
```toml
[settings]
platform = "reddit" # or "threads"
post_lang = "es-cr" # Optional: translation language (all platforms)
```
### Reddit Config
```toml
[reddit.creds]
client_id = "..." # OAuth app
client_secret = "..."
username = "..."
password = "..."
2fa = true/false
[reddit.thread]
subreddit = "AskReddit"
post_id = "" # Leave blank for auto-pick
max_comment_length = 500
min_comment_length = 1
min_comments = 20
blocked_words = "..."
```
### Threads Config (NEW)
```toml
[threads.creds]
access_token = "EAABsbCS..." # Meta Graph API token (60-day expiry)
user_id = "12345678901234567"
username = "your_insta" # For Playwright login
password = "your_password"
[threads.thread]
post_id = "" # Leave blank for auto-pick
max_reply_length = 500
min_reply_length = 1
min_replies = 5
blocked_words = "..."
```
### Generic Settings
```toml
[settings]
theme = "dark"
resolution_w = 1080
resolution_h = 1920
storymode = false
times_to_run = 1
[settings.tts]
voice_choice = "tiktok" # or "elevenlabs", "awspolly", "googletranslate", etc.
random_voice = true
silence_duration = 0.3
[settings.background]
background_video = "minecraft"
background_audio = "lofi"
background_audio_volume = 0.15
```
---
## Development Guidelines
### ✅ DO:
1. **Use platform factory in main.py**
```python
from platforms import get_content_object, get_screenshot_fn
reddit_object = get_content_object(POST_ID)
screenshot_fn = get_screenshot_fn()
screenshot_fn(reddit_object, number_of_comments)
```
2. **Return standard content dict** from all fetchers
```python
return {
"thread_id": ...,
"thread_category": ..., # NEW: replaces hardcoded subreddit
"comments": [...]
}
```
3. **Use config fallback chains** for cross-platform keys
```python
lang = (settings.config["settings"].get("post_lang") or
settings.config.get("reddit", {}).get("thread", {}).get("post_lang", ""))
```
4. **Read thread_category from dict** instead of config
```python
# WRONG:
subreddit = settings.config["reddit"]["thread"]["subreddit"]
# RIGHT:
platform = settings.config["settings"].get("platform", "reddit")
if platform == "reddit":
subreddit = settings.config["reddit"]["thread"]["subreddit"]
else:
subreddit = reddit_obj.get("thread_category", platform)
```
5. **Test both platforms** after core pipeline changes
```bash
# Test Reddit (must not regress)
sed -i 's/platform = "threads"/platform = "reddit"/' config.toml
python3 main.py
# Test Threads
sed -i 's/platform = "reddit"/platform = "threads"/' config.toml
python3 main.py --post-id <threads-id>
```
### ❌ DON'T:
1. **Don't import platform modules directly** in main.py/utils
```python
# WRONG: from reddit.subreddit import get_subreddit_threads
# RIGHT: from platforms import get_content_object
```
2. **Don't hardcode platform names** in generic modules
```python
# WRONG in final_video.py:
subreddit = settings.config["reddit"]["thread"]["subreddit"]
# RIGHT:
subreddit = reddit_obj.get("thread_category", "unknown")
```
3. **Don't add platform-specific UI selectors** outside `platforms/{platform}/screenshot.py`
- Reddit selectors stay in `video_creation/screenshot_downloader.py`
- Threads selectors stay in `platforms/threads/screenshot.py`
4. **Don't assume config keys exist** without fallback
```python
# WRONG: lang = settings.config["reddit"]["thread"]["post_lang"]
# RIGHT: lang = settings.config.get("settings", {}).get("post_lang", "")
```
---
## Platform-Specific Knowledge
### Reddit
- **API:** PRAW (Python Reddit API Wrapper)
- **Auth:** OAuth app (client_id, secret) + username/password
- **Screenshot:** Playwright on reddit.com/new.reddit.com
- Login form: `input[name="username"]`, `input[name="password"]`
- Post selector: `[data-test-id="post-content"]`
- Comment selector: `#t1_{comment_id}`
- **NSFW:** `submission.over_18`
- **Output folder:** `results/{subreddit}/`
### Threads
- **API:** Meta Graph API (v18.0+)
- **Auth:** User access token (60-day lifetime) via https://developers.facebook.com/
- **Screenshot:** Playwright on threads.net
- Login form: `input[autocomplete="username"]`, `input[autocomplete="current-password"]`
- Post selector: `article` (universal, more stable than Reddit)
- Cookies saved to: `video_creation/data/cookie-threads.json`
- **NSFW:** API doesn't provide; always False
- **Output folder:** `results/threads/`
### Future: X/Twitter
Create: `platforms/twitter/fetcher.py` + `platforms/twitter/screenshot.py` + config section
Update: `platforms/__init__.py` with `elif platform == "twitter"` branches
---
## Extending the Project
### Adding a New TTS Provider
1. Create `TTS/my_provider.py` with a class implementing the TTS interface
2. Add config keys to `[settings.tts]` in `.config.template.toml`
3. Update `TTS/engine_wrapper.py` to call your provider
4. Test with `settings.config["settings"]["tts"]["voice_choice"] = "my_provider"`
### Adding a New Platform (e.g., X/Twitter)
1. **Create fetcher:** `platforms/twitter/fetcher.py`
- Implement `get_twitter_content(POST_ID=None)` returning standard dict
2. **Create screenshotter:** `platforms/twitter/screenshot.py`
- Implement `get_screenshots_of_twitter_posts(content_object, screenshot_num)`
3. **Update config:** Add `[twitter.creds]` and `[twitter.thread]` sections
4. **Update factory:** Add `elif platform == "twitter"` in `platforms/__init__.py`
5. **Update CLI helper:** Add case to `_get_platform_post_id()` in `main.py`
6. **Test:** Verify Reddit mode still works, test Twitter mode end-to-end
**Zero changes needed to:** TTS, backgrounds, video composition, or utils.
---
## Debugging Tips
### "No matching distribution found for yt-dlp==2026.3.17"
→ yt-dlp uses date versioning (YYYY.M.DD, no leading zeros). Use `2025.10.14` (latest stable).
### "Threads API: Invalid or expired access_token"
→ Meta tokens expire every 60 days. Refresh at https://developers.facebook.com/tools/explorer/
### Playwright timeout on Threads screenshot
→ Login cookies corrupted or expired. Delete `video_creation/data/cookie-threads.json` to force fresh login next run.
### "No eligible Threads posts found"
→ Configure `[threads.thread].min_replies = 5` (or lower). Ensure your Threads account has public posts with replies.
### Video dedup not working
→ Check `video_creation/data/videos.json` is writable. Ensure `check_done_by_id()` is called before fetching content.
---
## Testing Checklist
- [ ] Reddit mode: `platform = "reddit"` produces video to `results/{subreddit}/`
- [ ] Threads mode: `platform = "threads"` produces video to `results/threads/`
- [ ] Video dedup: Running same post_id twice skips second run
- [ ] Translation: `post_lang = "es"` translates filenames
- [ ] TTS providers: Test with different voice_choice values
- [ ] Background selection: Custom background video/audio works
- [ ] Story mode: storymode=true only uses thread_post, not comments
- [ ] Error handling: Invalid credentials show clear messages
---
## Key Files to Know
| File | Purpose |
|------|---------|
| `main.py` | CLI entry; orchestrates pipeline via factory |
| `platforms/__init__.py` | Factory dispatch for multi-platform support |
| `platforms/threads/fetcher.py` | Threads Graph API client |
| `platforms/threads/screenshot.py` | Threads.net Playwright screenshotter |
| `video_creation/final_video.py` | FFmpeg composition; platform-aware output naming |
| `TTS/engine_wrapper.py` | TTS provider abstraction; post_lang fallback |
| `utils/settings.py` | Config loading & validation |
| `utils/videos.py` | Video dedup tracking |
| `utils/.config.template.toml` | Config schema |
| `requirements.txt` | Dependencies |
---
## Useful Commands
```bash
# Install dependencies
pip install -r requirements.txt
# Run CLI
python3 main.py
# Run with specific post
python3 main.py <post_id>
# Run Flask GUI
python3 GUI.py
# Check syntax
python3 -m py_compile main.py platforms/threads/fetcher.py
# Format code
black main.py platforms/ utils/
# Lint
pylint main.py
```
## Docker Workflow
- Use `docker compose build` to build the shared image for both CLI and GUI.
- Use `docker compose up gui` to run the Flask app on port `4000`.
- Use `docker compose run --rm cli` to run the video generator in a container.
- The repo root is bind-mounted in Compose, so `config.toml`, `results/`, `assets/temp/`, `video_creation/data/videos.json`, and `utils/backgrounds.json` should persist across runs.
- The GUI must bind to `0.0.0.0` in Docker; do not switch it back to `localhost` for container use.
---
## When You Get Stuck
1. **"What does this module do?"** → Check imports in `main.py` or docstrings
2. **"How do I add support for platform X?"** → See "Adding a New Platform" section above
3. **"Why is my config not being read?"** → Check `utils/settings.py:check_toml()` and `.config.template.toml` schema
4. **"Why isn't my TTS provider being called?"** → Check `TTS/engine_wrapper.py:make_voice()` and config `voice_choice`
5. **"How do I debug the Playwright screenshot?"** → Uncomment `page.pause()` in screenshot downloader, run headful browser
Good luck! 🚀
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **VideoMakerBot** (802 symbols, 1287 relationships, 32 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/VideoMakerBot/context` | Codebase overview, check index freshness |
| `gitnexus://repo/VideoMakerBot/clusters` | All functional areas |
| `gitnexus://repo/VideoMakerBot/processes` | All execution flows |
| `gitnexus://repo/VideoMakerBot/process/{name}` | Step-by-step execution trace |
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->

@ -5,16 +5,18 @@
**VideoMakerBot** — Automated short-form video creator from social media content.
**Status:** Production-ready, actively maintained (v3.4.0)
**Language:** Python 3.10+
**Language:** Python 3.10 (locked by `Dockerfile`; host venv may use 3.14 for tooling only)
**Runtime:** **Docker only** — all CLI, GUI, and test invocations go through `docker compose`. Do not invoke `python` on the host.
**Platforms:** Reddit (PRAW API), Threads (Graph API + Web Scraping)
### Core Mission
Transforms social media threads (post + comments/replies) into complete short-form videos with:
- AI-generated speech (7+ TTS providers)
- UI screenshots (Playwright)
- UI screenshots (Playwright, headless Chromium pre-installed in image)
- Background video/audio overlays
- FFmpeg composition & output
- FFmpeg composition & output (Linux ffmpeg with full filter set, including `drawtext`)
- Optional YouTube upload
- Modern web UI (Tailwind CSS + DaisyUI + Lucide + vanilla ES6) on `localhost:4000`
---
@ -101,8 +103,21 @@ VideoMakerBot/
│ ├── 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 (localhost:4000)
├── 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
```
@ -229,33 +244,40 @@ Last 1-4: engagement metrics (likes, replies, reposts, quotes)
### ✅ DO:
1. **Use platform factory** — never import platform modules directly
2. **Return standard content_object** from all fetchers
3. **Use clean body text** for TTS — parse out username/timestamp metadata
4. **Default to `googletranslate` TTS on macOS** — pyttsx3 hangs in headless environments
5. **Use `libx264` encoder on macOS**`h264_nvenc` is NVIDIA-only
6. **Test both Threads discovery methods:** `api` and `scrape`
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 the `utils/background_*.json` catalogs persist across container runs
9. **GUI must bind to `0.0.0.0`** in Docker (already 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 the `Content-Disposition` filename, and avoids 404s caused by literal newlines in titles
### ❌ DON'T:
1. **Don't use `<article>` selectors** on Threads.net — the DOM is div-based
2. **Don't hardcode `h264_nvenc`** — use `libx264` for cross-platform compatibility
3. **Don't rely on `drawtext` FFmpeg filter** — not available in Homebrew builds
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 — the 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** — the UI is vanilla ES6 + Tailwind + DaisyUI + Lucide
7. **Don't write to `utils/backgrounds.json`** — it is a legacy empty file. Use `utils/background_videos.json` and `utils/background_audios.json`
---
## macOS-Specific Notes
## Web UI (Flask, served by `gui` service)
- **TTS:** `googletranslate` (gTTS) is the most reliable — free, fast, no API key
- `tiktok` auto-falls back to `pyttsx3` if sessionid missing, but pyttsx3 is very slow
- `pyttsx3` works but takes ~60s to initialize NSSpeechSynthesizer
- **FFmpeg encoder:** MUST use `libx264``h264_nvenc` is NVIDIA GPU only
- **FFmpeg filters:** `drawtext` missing from Homebrew bottle — credit text is disabled
- **yt-dlp:** Keep updated (`pip install --upgrade yt-dlp`) — YouTube changes APIs frequently
- Format selector: `best[height<=1080]` not `bestvideo` (many videos lack video-only streams)
- Upgrade path: `pip install --upgrade yt-dlp`
- **Stack:** Tailwind CSS, DaisyUI, Lucide Icons, vanilla ES6 (no jQuery, no Bootstrap, no ClipboardJS)
- **Routes:**
- `/` — Video Library; cards show source-post link, download, and copy-link buttons
- `/video/<id>` — serves the rendered mp4 by id (lookup via `videos.json`); guards path-traversal and sanitizes the filename for `Content-Disposition`
- `/backgrounds` — Background Manager UI
- `/backgrounds.json` — serves `utils/background_videos.json` (the videos catalog)
- `/background/add`, `/background/delete` — POST endpoints; mutate **both** `utils/background_videos.json` and the `settings.background.background_video.options` array 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:** the `h()` helper in `index.html` escapes `& " < >` for any user-controlled string embedded in attributes — use it for any new dynamic data on the Library page
---
@ -277,64 +299,79 @@ Last 1-4: engagement metrics (likes, replies, reposts, quotes)
| `reddit/subreddit.py` | PRAW Reddit fetcher with auto-2FA |
| `utils/settings.py` | Config loading + interactive validation |
| `utils/videos.py` | Video dedup tracking |
| `utils/.config.template.toml` | Config schema |
| `utils/background_videos.json` | Background video manifest |
| `utils/.config.template.toml` | Config schema (also 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` |
| `GUI.py` | Flask app: `/`, `/video/<id>`, `/backgrounds`, `/settings`, `/create` |
| `Dockerfile` | python:3.10-slim-bookworm + ffmpeg + Playwright Chromium + pytest |
| `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'"
→ On macOS, change to `libx264`. Find-and-replace `h264_nvenc``libx264` in `video_creation/final_video.py`.
### FFmpeg "No such filter: 'drawtext'"
→ Homebrew FFmpeg lacks drawtext. The credit text overlay is automatically skipped.
→ Use `libx264`. Find-and-replace `h264_nvenc``libx264` in `video_creation/final_video.py`. The slim image does not ship with NVIDIA encoders.
### yt-dlp "Requested format is not available"
→ Update yt-dlp: `pip install --upgrade yt-dlp`. Also change format selector from `bestvideo` to `best` in `video_creation/background.py`.
### pyttsx3 hang on macOS
→ NSSpeechSynthesizer needs GUI session. Switch to `voice_choice = "googletranslate"` for headless use.
→ Bump the pinned version in `requirements.txt` and rebuild (`docker compose build`). Also 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>`. Ensure screenshot code uses `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 or load config directly with `toml.load()` + `settings.config = ...`.
`check_toml()` prompts for ALL platform sections regardless of `platform` setting. Either 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. Also check button selector: must use `exact=True` due to multiple "Log in" buttons.
→ Cookies corrupted. Delete `video_creation/data/cookie-threads.json` for fresh login (the file is bind-mounted, so deleting on host clears the container too). Also confirm selectors: button uses `exact=True` due to 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** the legacy `utils/backgrounds.json` (empty `{}`). Verify in `GUI.py:backgrounds_json`.
### `/video/<id>` returns 404
→ The route looks up the entry in `video_creation/data/videos.json` by `id` and resolves the file under `results/<thread_category>/<filename>.mp4`. Confirm both the JSON entry and the file exist; the file may have been pruned.
### JS "Unexpected end of input" on Library page
→ Any user-controlled string interpolated into an HTML attribute must go through the `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 changes alone do NOT need a rebuild because the repo root is bind-mounted to `/app`.
---
## Useful Commands
## Useful Commands (Docker-only)
```bash
# Install dependencies
pip install -r requirements.txt
# Build (or rebuild after Dockerfile / requirements.txt changes)
docker compose build
# Run CLI
python3 main.py
# Run the GUI (foreground)
docker compose up gui
# → http://localhost:4000
# Run bypassing config validator (non-interactive)
python3 -c "
import sys, toml
sys.path.insert(0, '.')
from utils import settings
settings.config = toml.load('config.toml')
from main import main; main()
"
# Run the GUI in the background
docker compose up -d gui
docker compose logs -f gui
docker compose down
# Update yt-dlp (YouTube downloads fix)
pip install --upgrade yt-dlp
# Run the CLI pipeline (one-off, removed on exit)
docker compose run --rm cli
docker compose run --rm cli python main.py <post_id>
# Check syntax
python3 -m py_compile main.py platforms/threads/scraper.py
# Run the test suite
docker compose run --rm test
# Run Flask GUI
python3 GUI.py
# Open a shell in a 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 a running GUI container
docker compose exec gui ls /app/results/threads/
```
> Anything that needs `pip install`, `playwright install`, or `apt-get` belongs in `Dockerfile` followed by `docker compose build` — never run those on the host.

@ -16,7 +16,7 @@ RUN apt-get update \
COPY requirements.txt ./
RUN pip install --upgrade pip \
&& pip install -r requirements.txt \
&& python -m spacy download en_core_web_sm
&& pip install pytest
RUN python -m playwright install --with-deps chromium

166
GUI.py

@ -1,28 +1,35 @@
import os
import webbrowser
from pathlib import Path
import io
import json
import os
import sys
import threading
import webbrowser
from pathlib import Path
# Used "tomlkit" instead of "toml" because it doesn't change formatting on "dump"
import tomlkit
import tomlkit
from flask import (
Flask,
abort,
jsonify,
redirect,
render_template,
request,
send_file,
send_from_directory,
url_for,
)
import utils.gui_utils as gui
from utils.docker_bootstrap import ensure_runtime_state
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}")
import utils.gui_utils as gui
from utils.docker_bootstrap import ensure_runtime_state
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}")
# Configure application
app = Flask(__name__, template_folder="GUI")
@ -99,13 +106,57 @@ 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():
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
@ -114,9 +165,82 @@ 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
}
def _run_pipeline():
"""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"] = []
try:
# Load config
settings.config = toml.load("config.toml")
# Set up progress callback
def on_progress(stage=""):
with pipeline_lock:
pipeline_state["stage"] = stage
pipeline_state["log"].append(stage)
if len(pipeline_state["log"]) > 20:
pipeline_state["log"] = pipeline_state["log"][-20:]
uconsole.set_progress_callback(on_progress)
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 pipeline_state["running"]:
return jsonify({"status": "already_running"})
thread = threading.Thread(target=_run_pipeline, daemon=True)
thread.start()
return jsonify({"status": "started"})
return render_template("create.html", state=pipeline_state)
@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__":
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)
if __name__ == "__main__":
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,235 @@
{% 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-slate-900 min-h-screen 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-2xl font-bold text-white">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-slate-400"></i>
<input type="text"
class="searchFilter input input-bordered w-full pl-10 bg-slate-800 border-slate-700 text-slate-200 focus:border-indigo-500"
placeholder="Search..."
onkeyup="searchFilter()">
</div>
<button onclick="add_modal.showModal()" class="btn btn-indigo bg-indigo-600 hover:bg-indigo-500 border-none text-white">
<i data-lucide="plus" class="w-4 h-4"></i>
<span class="hidden sm:inline">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-slate-500">
<i data-lucide="film" class="w-16 h-16 mb-4 opacity-20"></i>
<p class="text-lg">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-slate-800 border border-white/10">
<h3 class="font-bold text-lg text-white">Delete Background</h3>
<p class="py-4 text-slate-400">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 btn-ghost text-slate-400">Cancel</button>
<button type="submit" class="btn btn-error">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-slate-800 border border-white/10 max-w-lg">
<h3 class="font-bold text-lg text-white 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-slate-300">YouTube URI</span></label>
<div class="join w-full">
<div class="btn join-item no-animation bg-slate-900 border-slate-700 pointer-events-none">
<i data-lucide="youtube" class="w-4 h-4 text-red-500"></i>
</div>
<input name="youtube_uri" type="text" placeholder="https://www.youtube.com/watch?v=..."
class="input input-bordered join-item w-full bg-slate-900 border-slate-700 text-slate-200 focus:border-indigo-500">
</div>
<label class="label h-6"><span id="feedbackYT" class="label-text-alt text-error 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>
<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 class="form-control w-full">
<label class="label"><span class="label-text text-slate-300">Filename</span></label>
<div class="join w-full">
<div class="btn join-item no-animation bg-slate-900 border-slate-700 pointer-events-none">
<i data-lucide="file-video" class="w-4 h-4 text-indigo-500"></i>
</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 input-bordered join-item w-full bg-slate-900 border-slate-700 text-slate-200 focus:border-indigo-500">
</div>
<label class="label h-6"><span id="feedbackFilename" class="label-text-alt text-error 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-slate-300">Credits</span></label>
<div class="join w-full">
<div class="btn join-item no-animation bg-slate-900 border-slate-700 pointer-events-none">
<i data-lucide="user" class="w-4 h-4 text-slate-400"></i>
</div>
<input name="citation" type="text" placeholder="YouTube Channel Name"
class="input input-bordered join-item w-full bg-slate-900 border-slate-700 text-slate-200 focus:border-indigo-500">
</div>
<label class="label"><span class="label-text-alt text-slate-500 italic">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-slate-300 text-xs">Advanced: Position</span></label>
<input name="position" type="text" placeholder="center (optional)"
class="input input-bordered w-full bg-slate-900 border-slate-700 text-slate-200 focus:border-indigo-500 text-sm h-10">
</div>
<div class="modal-action">
<button type="button" onclick="add_modal.close()" class="btn btn-ghost text-slate-400">Cancel</button>
<button type="submit" class="btn btn-indigo bg-indigo-600 hover:bg-indigo-500 border-none text-white">Add Background</button>
</div>
</div>
</form>
</div>
</main>
</dialog>
<script>
var keys = [];
var youtube_urls = [];
// 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]);
let keys = [];
let youtube_urls = [];
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-slate-800 rounded-xl overflow-hidden border border-white/5 hover:border-indigo-500/50 transition-all duration-300 shadow-lg">
<div class="aspect-video w-full bg-black 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-slate-200 font-medium truncate mb-1" title="${key}">${key}</h3>
<p class="text-slate-500 text-xs truncate mb-4">${value[2]}</p>
<div class="flex justify-end">
<button onclick="confirmDelete('${key}')" class="btn btn-square btn-sm btn-ghost hover:bg-red-500/20 hover:text-red-400">
<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").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-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,245 @@
{% extends "layout.html" %}
{% block main %}
<div class="bg-slate-900 min-h-screen py-12">
<div class="container mx-auto px-4 max-w-2xl">
<div class="card bg-slate-800 border border-white/5 shadow-2xl">
<div class="card-body p-8">
<div class="flex items-center gap-4 mb-8">
<div class="bg-indigo-600/20 p-3 rounded-xl">
<i data-lucide="plus-square" class="w-8 h-8 text-indigo-500"></i>
</div>
<div>
<h2 class="card-title text-2xl text-white">Create New Short</h2>
<p class="text-slate-400 text-sm">Start the automated video creation pipeline.</p>
</div>
</div>
<div class="space-y-8">
<!-- Action Button -->
<button id="create-btn" class="btn btn-indigo btn-lg w-full bg-indigo-600 hover:bg-indigo-500 border-none text-white h-16 text-lg"
onclick="startPipeline()" disabled>
<span id="btn-text">Start Generation</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 animate-in fade-in duration-500">
<div class="flex justify-between items-end">
<div class="space-y-1">
<span class="text-xs uppercase tracking-widest text-slate-500 font-bold">Current Stage</span>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-indigo-500 animate-pulse"></div>
<h3 id="stage-text" class="text-indigo-400 font-semibold text-lg capitalize">Preparing...</h3>
</div>
</div>
<span id="pct-text" class="text-2xl font-black text-slate-700 font-mono">0%</span>
</div>
<progress id="progress-bar" class="progress progress-indigo w-full h-3 bg-slate-900" value="0" max="100"></progress>
<div class="grid grid-cols-2 gap-4 pt-4">
<div class="bg-slate-900/50 p-3 rounded-lg border border-white/5 flex items-center gap-3">
<i data-lucide="clock" class="w-4 h-4 text-slate-500"></i>
<div class="flex flex-col">
<span class="text-[10px] uppercase text-slate-500 font-bold">Elapsed</span>
<span id="elapsed-time" class="text-sm font-mono text-slate-300">00:00</span>
</div>
</div>
<div class="bg-slate-900/50 p-3 rounded-lg border border-white/5 flex items-center gap-3">
<i data-lucide="layers" class="w-4 h-4 text-slate-500"></i>
<div class="flex flex-col">
<span class="text-[10px] uppercase text-slate-500 font-bold">Status</span>
<span class="text-sm text-indigo-400">Processing</span>
</div>
</div>
</div>
</div>
<!-- Success Message -->
<div id="done-area" class="hidden animate-in zoom-in duration-300">
<div class="alert bg-emerald-500/10 border-emerald-500/20 text-emerald-400 flex flex-col items-start gap-4 p-6">
<div class="flex items-center gap-3">
<div class="bg-emerald-500 text-slate-900 p-1 rounded-full">
<i data-lucide="check" class="w-4 h-4"></i>
</div>
<span class="font-bold text-lg">Generation Complete!</span>
</div>
<p id="done-msg" class="text-slate-300 text-sm">Your video has been rendered and saved to the library.</p>
<a href="/" class="btn btn-emerald btn-sm bg-emerald-600 hover:bg-emerald-500 border-none text-white px-6">View Video</a>
</div>
</div>
<!-- Error Message -->
<div id="error-area" class="hidden">
<div class="alert bg-red-500/10 border-red-500/20 text-red-400 p-6">
<i data-lucide="alert-triangle" class="w-6 h-6"></i>
<div>
<h3 class="font-bold">Pipeline Failed</h3>
<div id="error-text" class="text-xs mt-2 font-mono bg-black/20 p-3 rounded overflow-x-auto whitespace-pre-wrap"></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-slate-500 font-bold">Execution Logs</h4>
<span class="badge badge-outline border-slate-700 text-slate-500 text-[10px]">Real-time</span>
</div>
<div id="log-list" class="bg-slate-900 rounded-xl p-4 font-mono text-[11px] leading-relaxed text-slate-400 h-48 overflow-y-auto border border-white/5 shadow-inner">
<!-- Logs will appear here -->
</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
};
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;
}
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');
try {
const r = await fetch('/create', { method: 'POST' });
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('/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(l =>
`<div class="py-0.5 border-b border-white/5 last:border-0">${l}</div>`
).join('');
logList.scrollTop = logList.scrollHeight;
}
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);
}
}
window.addEventListener('load', async function() {
lucide.createIcons();
try {
const r = await fetch('/create/status');
const state = await r.json();
const btn = document.getElementById('create-btn');
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(); // Approximate
elapsedTimer = setInterval(updateElapsedTime, 1000);
pollTimer = setInterval(pollStatus, 2000);
} else {
btn.disabled = false;
}
} catch (err) {
console.error("Initial status check failed:", err);
}
});
</script>
{% endblock %}

@ -1,23 +1,96 @@
{% 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-slate-900 min-h-screen py-8">
<div class="container mx-auto px-4">
<!-- Header & Search -->
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-8">
<h1 class="text-2xl font-bold text-white">Video Library</h1>
<!-- Bulk-action bar (visible in select mode only) -->
<div id="bulk-bar" class="hidden items-center gap-2">
<button type="button" onclick="selectAll()"
class="btn btn-ghost text-slate-400 border border-slate-700">
<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 btn-ghost text-slate-400">
Cancel
</button>
<button id="bulk-delete-btn" type="button" onclick="confirmBulkDelete()"
class="btn btn-error" disabled>
<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">
<button type="button" onclick="toggleSelectMode()"
class="btn btn-ghost text-slate-400 border border-slate-700 shrink-0"
style="height: 3rem; min-height: 3rem;">
<i data-lucide="check-square" class="w-4 h-4 mr-1"></i>
Select
</button>
<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-slate-400"></i>
<input type="text"
class="searchFilter input input-bordered w-full pl-10 bg-slate-800 border-slate-700 text-slate-200 focus:border-indigo-500"
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-slate-500">
<i data-lucide="video-off" class="w-16 h-16 mb-4 opacity-20"></i>
<p class="text-lg">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-slate-900 border border-white/10 max-w-2xl p-0 overflow-hidden">
<div class="flex justify-between items-center px-4 py-3 border-b border-white/5">
<h3 id="player_title" class="font-medium text-slate-200 truncate pr-4"></h3>
<button type="button" onclick="closePlayer()" class="btn btn-square btn-sm btn-ghost text-slate-400">
<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-slate-900 border border-white/10">
<div class="flex items-center gap-3 mb-3">
<i data-lucide="triangle-alert" class="w-5 h-5 text-red-400 shrink-0"></i>
<h3 class="font-bold text-lg text-white">Delete Video?</h3>
</div>
<p id="delete-modal-msg" class="text-slate-400 mb-6 text-sm"></p>
<div class="modal-action mt-0">
<form method="dialog">
<button class="btn btn-ghost text-slate-400">Cancel</button>
</form>
<button type="button" class="btn btn-error" 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 +104,287 @@
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');
// Use data-* attributes for arbitrary strings — never embed them in onclick/href
container.innerHTML = data.map(v => {
const title = checkTitle(v.reddit_title, v.filename);
return `
<div class="video-card group bg-slate-800 rounded-xl overflow-hidden border border-white/5 hover:border-indigo-500/50 transition-all duration-300 hover:shadow-2xl hover:shadow-indigo-500/10 relative"
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 checkbox checkbox-primary w-6 h-6 pointer-events-auto" />
</div>
<button type="button"
class="play-btn aspect-video w-full bg-slate-900 flex items-center justify-center relative overflow-hidden cursor-pointer"
data-video-id="${h(v.id)}"
data-video-title="${h(title)}">
<i data-lucide="play-circle" class="w-12 h-12 text-slate-700 group-hover:text-indigo-500 transition-colors"></i>
<div class="absolute top-3 left-3">
<span class="badge badge-sm bg-slate-900/80 border-none text-indigo-400 font-medium backdrop-blur-md">
${h(categoryLabel(v.subreddit))}
</span>
</div>
</button>
<div class="p-4">
<h3 class="text-slate-200 font-medium line-clamp-2 mb-4 h-12" title="${h(title)}">
${h(title)}
</h3>
<div class="flex items-center justify-between gap-2">
<div class="flex gap-1">
<a href="${h(sourceUrl(v.subreddit, v.id))}" target="_blank"
class="btn btn-square btn-sm btn-ghost hover:bg-indigo-500/20 hover:text-indigo-400"
title="View Source">
<i data-lucide="external-link" class="w-4 h-4"></i>
</a>
<a href="/video/${encodeURIComponent(v.id)}?download=1" download
class="btn btn-square btn-sm btn-ghost hover:bg-indigo-500/20 hover:text-indigo-400"
title="Download">
<i data-lucide="download" class="w-4 h-4"></i>
</a>
</div>
<div class="flex gap-1">
<button class="btn btn-square btn-sm btn-ghost hover:bg-slate-700 copy-btn"
data-copy="${h(sourceUrl(v.subreddit, v.id))}"
title="Copy Link">
<i data-lucide="link" class="w-4 h-4"></i>
</button>
<button class="btn btn-square btn-sm btn-ghost hover:bg-red-500/20 hover:text-red-400 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-white/5 flex justify-between items-center">
<span class="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">
${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(`/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-green-500"></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('/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").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 %}

@ -1,155 +1,128 @@
<html lang="en">
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<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>
<!-- Modern Tech Stack -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.4.19/dist/full.min.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script src="https://unpkg.com/lucide@latest"></script>
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
body {
font-family: 'Inter', sans-serif;
background-color: #0f172a; /* Slate 900 */
}
.feedback-invalid {
color: #dc3545;
.glass-nav {
background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
/* Custom scrollbar for a modern look */
::-webkit-scrollbar {
width: 8px;
}
.bi {
vertical-align: -.125em;
fill: currentColor;
::-webkit-scrollbar-track {
background: #1e293b;
}
.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;
::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 10px;
}
#tooltip {
background-color: #333;
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 13px;
::-webkit-scrollbar-thumb:hover {
background: #475569;
}
.tooltip-inner {
max-width: 500px !important;
}
#hard-reload {
cursor: pointer;
color: darkblue;
}
#hard-reload:hover {
color: blue;
/* Dotted path inputs shouldn't show default browser validation UI */
input:invalid {
box-shadow: none;
}
</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 }}
<body class="min-h-screen flex flex-col">
<header class="sticky top-0 z-50 glass-nav">
<div class="container mx-auto px-4">
<div class="navbar px-0">
<div class="flex-1">
<a href="/" class="flex items-center gap-2 group">
<div class="bg-indigo-600 p-2 rounded-lg group-hover:bg-indigo-500 transition-colors">
<i data-lucide="video" class="w-5 h-5 text-white"></i>
</div>
<span class="text-xl font-bold tracking-tight text-white">VideoMakerBot</span>
</a>
</div>
<div class="flex-none gap-2">
<ul class="menu menu-horizontal px-1 gap-1">
<li><a href="/" class="rounded-lg hover:bg-white/10 text-slate-300">Library</a></li>
<li><a href="/backgrounds" class="rounded-lg hover:bg-white/10 text-slate-300">Backgrounds</a></li>
<li><a href="/settings" class="rounded-lg hover:bg-white/10 text-slate-300">Settings</a></li>
</ul>
<a href="/create" class="btn btn-indigo btn-sm ml-2 rounded-lg capitalize border-none bg-indigo-600 hover:bg-indigo-500 text-white">
<i data-lucide="plus" class="w-4 h-4"></i>
Create
</a>
</div>
</div>
</div>
</header>
{% else %}
<div class="alert alert-success mb-0 text-center" role="alert">
{{ message }}
{% 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="alert {{ 'alert-error' if category == 'error' else 'alert-success' }} shadow-lg mb-2">
<i data-lucide="{{ 'alert-circle' if category == 'error' else 'check-circle' }}" class="w-5 h-5"></i>
<span>{{ message }}</span>
</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>
</a>
</div>
{% endif %}
<main class="flex-grow">
{% block main %}{% endblock %}
</main>
<footer class="bg-slate-900 border-t border-white/5 py-12 mt-12">
<div class="container mx-auto px-4">
<div class="flex flex-col md:flex-row justify-between items-center gap-6">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<i data-lucide="video" class="w-5 h-5 text-indigo-500"></i>
<span class="text-lg font-semibold text-white">VideoMakerBot</span>
</div>
<p class="text-slate-400 text-sm">Automated short-form video creator.</p>
</div>
<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>
-->
<div class="flex gap-6 text-sm text-slate-400">
<a href="https://github.com/elebumm/RedditVideoMakerBot" target="_blank" class="hover:text-white transition-colors">GitHub</a>
<a href="#" class="hover:text-white transition-colors" id="hard-reload">Hard Reload</a>
</div>
</div>
</nav>
</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>
<div class="mt-8 pt-8 border-t border-white/5 text-center text-xs text-slate-500">
&copy; 2026 VideoMakerBot. Built for speed and creativity.
</div>
</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,9 +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 👩‍💻
@ -136,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.

@ -25,3 +25,15 @@ services:
volumes:
- ./:/app
shm_size: "1gb"
test:
build:
context: .
image: videomakerbot:latest
command: ["pytest", "tests/", "-v"]
environment:
PYTHONUNBUFFERED: "1"
PYTHONPATH: "/app"
volumes:
- ./:/app
shm_size: "1gb"

@ -0,0 +1,72 @@
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!')

@ -66,4 +66,4 @@
"local",
"center"
]
}
}

@ -9,6 +9,15 @@ 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 print_markdown(text) -> None:
"""Prints a rich info message. Support Markdown syntax."""
@ -22,6 +31,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:

@ -7,32 +7,37 @@ import tomlkit
from flask import flash
# Get validation checks from template
# 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
@ -92,29 +97,30 @@ 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
# 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():
value = check(raw_value, checks[name])
# Value is invalid
if value == "Error":
flash("Some values were incorrect and didn't save!", "error")
else:
# 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 +133,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))
@ -181,7 +188,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 +197,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 +222,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

Loading…
Cancel
Save