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 months 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. **VideoMakerBot** — Automated short-form video creator from social media content.
**Status:** Production-ready, actively maintained (v3.4.0) **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) **Platforms:** Reddit (PRAW API), Threads (Graph API + Web Scraping)
### Core Mission ### Core Mission
Transforms social media threads (post + comments/replies) into complete short-form videos with: Transforms social media threads (post + comments/replies) into complete short-form videos with:
- AI-generated speech (7+ TTS providers) - AI-generated speech (7+ TTS providers)
- UI screenshots (Playwright) - UI screenshots (Playwright, headless Chromium pre-installed in image)
- Background video/audio overlays - Background video/audio overlays
- FFmpeg composition & output - FFmpeg composition & output (Linux ffmpeg with full filter set, including `drawtext`)
- Optional YouTube upload - 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 │ ├── 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) ├── 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 ├── requirements.txt
└── CLAUDE.md └── CLAUDE.md
``` ```
@ -229,33 +244,40 @@ Last 1-4: engagement metrics (likes, replies, reposts, quotes)
### ✅ DO: ### ✅ DO:
1. **Use platform factory** — never import platform modules directly 1. **Run everything through Docker**`docker compose up gui`, `docker compose run --rm cli`, `docker compose run --rm test`
2. **Return standard content_object** from all fetchers 2. **Use platform factory** — never import platform modules directly
3. **Use clean body text** for TTS — parse out username/timestamp metadata 3. **Return standard content_object** from all fetchers
4. **Default to `googletranslate` TTS on macOS** — pyttsx3 hangs in headless environments 4. **Use clean body text** for TTS — parse out username/timestamp metadata
5. **Use `libx264` encoder on macOS**`h264_nvenc` is NVIDIA-only 5. **Default to `googletranslate` TTS** for headless containers — no API key, fast, free
6. **Test both Threads discovery methods:** `api` and `scrape` 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: ### ❌ DON'T:
1. **Don't use `<article>` selectors** on Threads.net — the DOM is div-based 1. **Don't run `python GUI.py` or `python main.py` on the host** — Docker is the only supported path
2. **Don't hardcode `h264_nvenc`** — use `libx264` for cross-platform compatibility 2. **Don't use `<article>` selectors** on Threads.net — the DOM is div-based
3. **Don't rely on `drawtext` FFmpeg filter** — not available in Homebrew builds 3. **Don't hardcode `h264_nvenc`** — use `libx264` for cross-platform compatibility
4. **Don't import platform modules directly** in main.py/utils 4. **Don't import platform modules directly** in main.py/utils
5. **Don't assume config keys exist** without `.get()` fallback 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 - **Stack:** Tailwind CSS, DaisyUI, Lucide Icons, vanilla ES6 (no jQuery, no Bootstrap, no ClipboardJS)
- `tiktok` auto-falls back to `pyttsx3` if sessionid missing, but pyttsx3 is very slow - **Routes:**
- `pyttsx3` works but takes ~60s to initialize NSSpeechSynthesizer - `/` — Video Library; cards show source-post link, download, and copy-link buttons
- **FFmpeg encoder:** MUST use `libx264``h264_nvenc` is NVIDIA GPU only - `/video/<id>` — serves the rendered mp4 by id (lookup via `videos.json`); guards path-traversal and sanitizes the filename for `Content-Disposition`
- **FFmpeg filters:** `drawtext` missing from Homebrew bottle — credit text is disabled - `/backgrounds` — Background Manager UI
- **yt-dlp:** Keep updated (`pip install --upgrade yt-dlp`) — YouTube changes APIs frequently - `/backgrounds.json` — serves `utils/background_videos.json` (the videos catalog)
- Format selector: `best[height<=1080]` not `bestvideo` (many videos lack video-only streams) - `/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`
- Upgrade path: `pip install --upgrade yt-dlp` - `/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 | | `reddit/subreddit.py` | PRAW Reddit fetcher with auto-2FA |
| `utils/settings.py` | Config loading + interactive validation | | `utils/settings.py` | Config loading + interactive validation |
| `utils/videos.py` | Video dedup tracking | | `utils/videos.py` | Video dedup tracking |
| `utils/.config.template.toml` | Config schema | | `utils/.config.template.toml` | Config schema (also drives Settings page validation) |
| `utils/background_videos.json` | Background video manifest | | `utils/background_videos.json` | Background video manifest (served at `/backgrounds.json`) |
| `utils/background_audios.json` | Background audio manifest | | `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 ## Debugging Tips
### FFmpeg "Unknown encoder 'h264_nvenc'" ### FFmpeg "Unknown encoder 'h264_nvenc'"
→ On macOS, change to `libx264`. Find-and-replace `h264_nvenc``libx264` in `video_creation/final_video.py`. → Use `libx264`. Find-and-replace `h264_nvenc``libx264` in `video_creation/final_video.py`. The slim image does not ship with NVIDIA encoders.
### FFmpeg "No such filter: 'drawtext'"
→ Homebrew FFmpeg lacks drawtext. The credit text overlay is automatically skipped.
### yt-dlp "Requested format is not available" ### 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`. → 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.
### pyttsx3 hang on macOS
→ NSSpeechSynthesizer needs GUI session. Switch to `voice_choice = "googletranslate"` for headless use.
### Threads screenshots fail ("Main post article not found") ### 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. → Threads.net uses div cards, not `<article>`. Ensure screenshot code uses `a[href*="/post/"]` → ancestor div approach.
### Config validator EOFError in non-interactive mode ### 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 ### 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 ### No viral posts found
→ Lower `min_engagement` in config. Most Threads feed posts have <100 likes 10000 filters almost everything. → 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 ```bash
# Install dependencies # Build (or rebuild after Dockerfile / requirements.txt changes)
pip install -r requirements.txt docker compose build
# Run CLI # Run the GUI (foreground)
python3 main.py docker compose up gui
# → http://localhost:4000
# Run bypassing config validator (non-interactive) # Run the GUI in the background
python3 -c " docker compose up -d gui
import sys, toml docker compose logs -f gui
sys.path.insert(0, '.') docker compose down
from utils import settings
settings.config = toml.load('config.toml')
from main import main; main()
"
# Update yt-dlp (YouTube downloads fix) # Run the CLI pipeline (one-off, removed on exit)
pip install --upgrade yt-dlp docker compose run --rm cli
docker compose run --rm cli python main.py <post_id>
# Check syntax # Run the test suite
python3 -m py_compile main.py platforms/threads/scraper.py docker compose run --rm test
# Run Flask GUI # Open a shell in a fresh container for ad-hoc commands
python3 GUI.py 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 ./ COPY requirements.txt ./
RUN pip install --upgrade pip \ RUN pip install --upgrade pip \
&& pip install -r requirements.txt \ && pip install -r requirements.txt \
&& python -m spacy download en_core_web_sm && pip install pytest
RUN python -m playwright install --with-deps chromium RUN python -m playwright install --with-deps chromium

166
GUI.py

@ -1,28 +1,35 @@
import os import io
import webbrowser import json
from pathlib import Path 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" # Used "tomlkit" instead of "toml" because it doesn't change formatting on "dump"
import tomlkit import tomlkit
from flask import ( from flask import (
Flask, Flask,
abort,
jsonify,
redirect, redirect,
render_template, render_template,
request, request,
send_file,
send_from_directory, send_from_directory,
url_for, url_for,
) )
import utils.gui_utils as gui import utils.gui_utils as gui
from utils.docker_bootstrap import ensure_runtime_state from utils.docker_bootstrap import ensure_runtime_state
ensure_runtime_state() ensure_runtime_state()
# Set the hostname and port # Set the hostname and port
HOST = os.environ.get("GUI_HOST", "0.0.0.0") HOST = os.environ.get("GUI_HOST", "0.0.0.0")
PORT = int(os.environ.get("GUI_PORT", "4000")) PORT = int(os.environ.get("GUI_PORT", "4000"))
OPEN_BROWSER = os.environ.get("GUI_OPEN_BROWSER", "1").lower() in {"1", "true", "yes", "on"} 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}") BROWSER_URL = os.environ.get("GUI_BROWSER_URL", f"http://localhost:{PORT}")
# Configure application # Configure application
app = Flask(__name__, template_folder="GUI") app = Flask(__name__, template_folder="GUI")
@ -99,13 +106,57 @@ def videos_json():
# Make backgrounds.json accessible # Make backgrounds.json accessible
@app.route("/backgrounds.json") @app.route("/backgrounds.json")
def 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 # Make videos in results folder accessible
@app.route("/results/<path:name>") @app.route("/results/<path:name>")
def results(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 # Make voices samples in voices folder accessible
@ -114,9 +165,82 @@ def voices(name):
return send_from_directory("GUI/voices", name, as_attachment=True) 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 # Run browser and start the app
if __name__ == "__main__": if __name__ == "__main__":
if OPEN_BROWSER: if OPEN_BROWSER:
webbrowser.open(BROWSER_URL, new=2) webbrowser.open(BROWSER_URL, new=2)
print("Website opened in new tab. Refresh if it didn't load.") print("Website opened in new tab. Refresh if it didn't load.")
app.run(host=HOST, port=PORT) app.run(host=HOST, port=PORT)

@ -1,263 +1,235 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block main %} {% block main %}
<!-- Delete Background Modal --> <div class="bg-slate-900 min-h-screen py-8">
<div class="modal fade" id="deleteBtnModal" tabindex="-1" role="dialog" aria-hidden="true"> <div class="container mx-auto px-4">
<div class="modal-dialog modal-dialog-centered" role="document"> <!-- Header & Actions -->
<div class="modal-content"> <div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-8">
<div class="modal-header"> <h1 class="text-2xl font-bold text-white">Background Manager</h1>
<h5 class="modal-title">Delete background</h5> <div class="flex w-full md:w-auto gap-2">
</div> <div class="relative flex-grow md:w-64">
<div class="modal-body"> <i data-lucide="search" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400"></i>
Are you sure you want to delete this background? <input type="text"
</div> class="searchFilter input input-bordered w-full pl-10 bg-slate-800 border-slate-700 text-slate-200 focus:border-indigo-500"
<div class="modal-footer"> placeholder="Search..."
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button> onkeyup="searchFilter()">
<form action="background/delete" method="post"> </div>
<input type="hidden" id="background-key" name="background-key" value=""> <button onclick="add_modal.showModal()" class="btn btn-indigo bg-indigo-600 hover:bg-indigo-500 border-none text-white">
<button type="submit" class="btn btn-danger">Delete</button> <i data-lucide="plus" class="w-4 h-4"></i>
</form> <span class="hidden sm:inline">Add Video</span>
</button>
</div> </div>
</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>
</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 --> <!-- Add Background Modal -->
<div class="modal fade" id="backgroundAddModal" tabindex="-1" role="dialog" aria-hidden="true"> <dialog id="add_modal" class="modal modal-bottom sm:modal-middle">
<div class="modal-dialog modal-dialog-centered" role="document"> <div class="modal-box bg-slate-800 border border-white/10 max-w-lg">
<div class="modal-content"> <h3 class="font-bold text-lg text-white mb-6">Add Background Video</h3>
<div class="modal-header"> <form id="addBgForm" action="background/add" method="post" novalidate class="space-y-4">
<h5 class="modal-title">Add background video</h5> <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>
<div class="modal-body">
<!-- Add video form --> <div class="form-control w-full">
<form id="addBgForm" action="background/add" method="post" novalidate> <label class="label"><span class="label-text text-slate-300">Filename</span></label>
<div class="form-group row"> <div class="join w-full">
<label class="col-4 col-form-label" for="youtube_uri">YouTube URI</label> <div class="btn join-item no-animation bg-slate-900 border-slate-700 pointer-events-none">
<div class="col-8"> <i data-lucide="file-video" class="w-4 h-4 text-indigo-500"></i>
<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> </div>
<div class="form-group row"> <input name="filename" type="text" placeholder="e.g. minecraft-parkour"
<label for="position" class="col-4 col-form-label">Position of screenshots</label> class="input input-bordered join-item w-full bg-slate-900 border-slate-700 text-slate-200 focus:border-indigo-500">
<div class="col-8"> </div>
<div class="input-group"> <label class="label h-6"><span id="feedbackFilename" class="label-text-alt text-error hidden"></span></label>
<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>
</div> </div>
</div>
</div>
</div>
<main> <div class="form-control w-full">
<div class="album py-2 bg-light"> <label class="label"><span class="label-text text-slate-300">Credits</span></label>
<div class="container"> <div class="join w-full">
<div class="btn join-item no-animation bg-slate-900 border-slate-700 pointer-events-none">
<div class="row justify-content-between mt-2"> <i data-lucide="user" class="w-4 h-4 text-slate-400"></i>
<div class="col-12 col-md-3 mb-3"> </div>
<input type="text" class="form-control searchFilter" placeholder="Search backgrounds" <input name="citation" type="text" placeholder="YouTube Channel Name"
onkeyup="searchFilter()"> class="input input-bordered join-item w-full bg-slate-900 border-slate-700 text-slate-200 focus:border-indigo-500">
</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> </div>
<label class="label"><span class="label-text-alt text-slate-500 italic">Name of the video owner.</span></label>
</div> </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>
</div> </form>
</div> </div>
</main> </dialog>
<script> <script>
var keys = []; let keys = [];
var youtube_urls = []; let youtube_urls = [];
// Show background videos async function loadBackgrounds() {
$(document).ready(function () { try {
$.getJSON("backgrounds.json", const response = await fetch("backgrounds.json");
function (data) { const data = await response.json();
delete data["__comment"]; delete data["__comment"];
var background = '';
$.each(data, function (key, value) { const container = document.getElementById('backgrounds');
// Add YT urls and keys (for validation) let html = '';
keys.push(key);
youtube_urls.push(value[0]); 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">'; container.innerHTML = html;
background += '<div class="card shadow-sm">'; lucide.createIcons();
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>'; } catch (error) {
background += '<div class="card-body">'; console.error("Error loading backgrounds:", error);
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>';
});
$('#backgrounds').append(background); function confirmDelete(key) {
}); document.getElementById('background-key').value = key;
}); delete_modal.showModal();
}
// Add background key when deleting function searchFilter() {
$('#deleteBtnModal').on('show.bs.modal', function (event) { const query = document.querySelector(".searchFilter").value.toLowerCase();
var button = $(event.relatedTarget); const cards = document.querySelectorAll(".bg-card");
var key = button.data('background-key'); 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 = () => { document.getElementById('empty-state').classList.toggle('hidden', visibleCount > 0);
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")
}
}
} }
// Validate form const form = document.getElementById('addBgForm');
$("#addBgForm").submit(function (event) { form.addEventListener('submit', (e) => {
$("#addBgForm input").each(function () { let isValid = true;
if (!(validate($(this)))) { form.querySelectorAll('input').forEach(input => {
event.preventDefault(); if (!validate(input)) isValid = false;
event.stopPropagation();
}
}); });
if (!isValid) e.preventDefault();
}); });
$('#addBgForm input[type="text"]').on("keyup", function () { form.querySelectorAll('input').forEach(input => {
validate($(this)); input.addEventListener('keyup', () => validate(input));
}); });
function validate(object) { function validate(input) {
let bool = check(object.prop("name"), object.prop("value")); const name = input.name;
const value = input.value;
// Change class let valid = true;
if (bool) { let message = "";
object.removeClass("is-invalid");
object.addClass("is-valid"); if (name === "youtube_uri") {
} const regex = /(?:\/|%3D|v=|vi=)([0-9A-z-_]{11})(?:[%#?&]|$)/;
else { if (!regex.test(value)) {
object.removeClass("is-valid"); message = "Invalid YouTube URI";
object.addClass("is-invalid"); valid = false;
} } else if (youtube_urls.includes(value)) {
message = "Background already added";
return bool; valid = false;
// 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;
} }
const feedback = document.getElementById('feedbackYT');
feedback.textContent = message;
feedback.classList.toggle('hidden', valid);
}
if (name == "citation") { if (name === "filename") {
if (value.trim()) { if (keys.includes(value)) {
return true; 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 (name === "position") {
if (!(value == "center" || value.length == 0 || value % 1 == 0)) { if (value && value !== "center" && isNaN(parseFloat(value))) {
return false; valid = false;
}
return true;
} }
} }
input.classList.toggle('input-error', !valid);
return valid;
} }
document.addEventListener('DOMContentLoaded', loadBackgrounds);
</script> </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" %} {% extends "layout.html" %}
{% block main %} {% block main %}
<main> <div class="bg-slate-900 min-h-screen py-8">
<div class="album py-2 bg-light"> <div class="container mx-auto px-4">
<div class="container"> <!-- Header & Search -->
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-8">
<div class="row mt-2"> <h1 class="text-2xl font-bold text-white">Video Library</h1>
<div class="col-12 col-md-3 mb-3">
<input type="text" class="form-control searchFilter" placeholder="Search videos" <!-- Bulk-action bar (visible in select mode only) -->
aria-label="Search videos" onkeyup="searchFilter()"> <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>
</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> </div>
<video id="player_video" class="w-full bg-black" controls playsinline></video>
</div> </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> <script>
const intervals = [ const intervals = [
@ -31,152 +104,287 @@
function timeSince(date) { function timeSince(date) {
const seconds = Math.floor((Date.now() / 1000 - 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); const count = Math.floor(seconds / interval.seconds);
return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`; return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`;
} }
$(document).ready(function () { function categoryLabel(subreddit) {
$.getJSON("videos.json", if (!subreddit) return "";
function (data) { if (subreddit === "threads") return "Threads";
data.sort((b, a) => a['time'] - b['time']) return `r/${subreddit}`;
var video = ''; }
$.each(data, function (key, value) {
video += '<div class="col">'; function sourceUrl(subreddit, id) {
video += '<div class="card shadow-sm">'; if (subreddit === "threads") {
//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>'; return `https://www.threads.net/post/${id}`;
}
video += '<div class="card-body">'; return `https://www.reddit.com/r/${subreddit}/comments/${id}/`;
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">'; function checkTitle(reddit_title, filename) {
video += '<a href="https://www.reddit.com/r/' + value.subreddit + '/comments/' + value.id + '/" class="btn btn-sm btn-outline-secondary" target="_blank">View</a>'; const file = filename.slice(0, -4);
video += '<a href="http://localhost:4000/results/' + value.subreddit + '/' + value.filename + '" class="btn btn-sm btn-outline-secondary" download>Download</a>'; return reddit_title === file ? reddit_title : file;
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>'; // Escape arbitrary strings for safe embedding inside HTML attributes
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>'; function h(str) {
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>'; return String(str ?? '')
video += '</div>'; .replace(/&/g, '&amp;')
video += '<small class="text-muted">' + timeSince(value.time) + '</small>'; .replace(/"/g, '&quot;')
video += '</div>'; .replace(/</g, '&lt;')
video += '</div>'; .replace(/>/g, '&gt;');
video += '</div>'; }
video += '</div>';
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 () { // Wire checkboxes to update the bulk-delete counter
$('[data-toggle="tooltip"]').tooltip(); container.querySelectorAll('.card-checkbox').forEach(cb => {
$('[data-toggle="tooltip"]').on('click', function () { cb.addEventListener('change', updateSelectionCount);
$(this).tooltip('hide'); });
});
});
$('#copy').tooltip({ // Re-init icons
trigger: 'click', lucide.createIcons();
placement: 'bottom' } catch (error) {
}); console.error("Error loading videos:", error);
}
}
function setTooltip(btn, message) { // ── Select mode ────────────────────────────────────────────────────────────
$(btn).tooltip('hide') let selectMode = false;
.attr('data-original-title', message) let pendingDeleteIds = [];
.tooltip('show');
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) { function cancelSelectMode() {
$(btn).tooltip('hide') selectMode = false;
.attr('data-original-title', message) document.getElementById('bulk-bar').classList.add('hidden');
.tooltip('show'); 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) { function selectAll() {
setTimeout(function () { const checkboxes = document.querySelectorAll('.card-checkbox');
$(btn).tooltip('hide'); const allChecked = [...checkboxes].every(cb => cb.checked);
}, 1000); checkboxes.forEach(cb => cb.checked = !allChecked);
document.getElementById('select-all-label').textContent = allChecked ? 'Select All' : 'Deselect All';
updateSelectionCount();
} }
function disposeTooltip(btn) { function updateSelectionCount() {
setTimeout(function () { const count = document.querySelectorAll('.card-checkbox:checked').length;
$(btn).tooltip('dispose'); document.getElementById('selection-count').textContent = count;
}, 1500); 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) { // ── Delete confirmation ─────────────────────────────────────────────────
e.clearSelection(); function confirmBulkDelete() {
console.info('Action:', e.action); pendingDeleteIds = getSelectedIds();
console.info('Text:', e.text); if (!pendingDeleteIds.length) return;
console.info('Trigger:', e.trigger); const n = pendingDeleteIds.length;
setTooltip(e.trigger, 'Copied!'); document.getElementById('delete-modal-msg').textContent =
hideTooltip(e.trigger); `Are you sure you want to delete ${n} video${n !== 1 ? 's' : ''}? This cannot be undone.`;
disposeTooltip(e.trigger); document.getElementById('delete_modal').showModal();
}); }
clipboard.on('error', function (e) { function confirmSingleDelete(videoId) {
console.error('Action:', e.action); pendingDeleteIds = [videoId];
console.error('Trigger:', e.trigger); document.getElementById('delete-modal-msg').textContent =
setTooltip(e.trigger, fallbackMessage(e.action)); 'Are you sure you want to delete this video? This cannot be undone.';
hideTooltip(e.trigger); 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) { const ids = [...pendingDeleteIds];
subredditCopy = ""; pendingDeleteIds = [];
} else {
subredditCopy = "r/" + subreddit + "\n\n";
}
const file = filename.slice(0, -4); try {
if (reddit_title == file) { await fetch('/videos/delete', {
titleCopy = reddit_title; method: 'POST',
} else { headers: { 'Content-Type': 'application/json' },
titleCopy = file; body: JSON.stringify({ ids })
});
} catch (err) {
console.error('Delete request failed:', err);
} }
var copyData = ""; // Remove cards from DOM regardless (optimistic UI)
copyData += subredditCopy; ids.forEach(id => {
copyData += titleCopy; const card = document.querySelector(`.video-card[data-video-id="${CSS.escape(id)}"]`);
copyData += "\n\nBackground credit: " + background_credit; if (card) card.remove();
return copyData; });
// 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) { function searchFilter() {
if (subreddit == undefined) { const query = document.querySelector(".searchFilter").value.toLowerCase();
return reddit_title; const cards = document.querySelectorAll(".video-card");
} else { let visibleCount = 0;
return "<a target='_blank' href='https://www.reddit.com/r/" + subreddit + "/comments/" + id + "/'>" + reddit_title + "</a>";
} 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) { function openPlayer(src, title) {
const file = filename.slice(0, -4); const modal = document.getElementById('player_modal');
if (reddit_title == file) { const video = document.getElementById('player_video');
return reddit_title; const titleEl = document.getElementById('player_title');
} else { titleEl.textContent = title || '';
return file; video.src = src;
} modal.showModal();
video.play().catch(() => {});
} }
var searchFilter = () => { function closePlayer() {
const input = document.querySelector(".searchFilter"); const modal = document.getElementById('player_modal');
const cards = document.getElementsByClassName("col"); const video = document.getElementById('player_video');
console.log(cards[1]) video.pause();
let filter = input.value video.removeAttribute('src');
for (let i = 0; i < cards.length; i++) { video.load();
let title = cards[i].querySelector(".card-text"); modal.close();
if (title.innerText.toLowerCase().indexOf(filter.toLowerCase()) > -1) {
cards[i].classList.remove("d-none")
} else {
cards[i].classList.add("d-none")
}
}
} }
document.getElementById('player_modal').addEventListener('close', () => {
const video = document.getElementById('player_video');
video.pause();
video.removeAttribute('src');
video.load();
});
document.addEventListener('DOMContentLoaded', loadVideos);
</script> </script>
{% endblock %} {% endblock %}

@ -1,155 +1,128 @@
<html lang="en"> <!DOCTYPE html>
<html lang="en" data-theme="dark">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="cache-control" content="no-cache" /> <meta http-equiv="cache-control" content="no-cache" />
<title>RedditVideoMakerBot</title> <title>VideoMakerBot</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"> <!-- Modern Tech Stack -->
<link href="https://getbootstrap.com/docs/5.2/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/daisyui@4.4.19/dist/full.min.css" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.9.1/font/bootstrap-icons.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> <style>
.bd-placeholder-img { body {
font-size: 1.125rem; font-family: 'Inter', sans-serif;
text-anchor: middle; background-color: #0f172a; /* Slate 900 */
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
} }
.feedback-invalid { .glass-nav {
color: #dc3545; 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) { /* Custom scrollbar for a modern look */
.bd-placeholder-img-lg { ::-webkit-scrollbar {
font-size: 3.5rem; width: 8px;
}
} }
::-webkit-scrollbar-track {
.bi { background: #1e293b;
vertical-align: -.125em;
fill: currentColor;
} }
::-webkit-scrollbar-thumb {
.nav { background: #334155;
display: flex; border-radius: 10px;
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:hover {
#tooltip { background: #475569;
background-color: #333;
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 13px;
} }
.tooltip-inner { /* Dotted path inputs shouldn't show default browser validation UI */
max-width: 500px !important; input:invalid {
} box-shadow: none;
#hard-reload {
cursor: pointer;
color: darkblue;
}
#hard-reload:hover {
color: blue;
} }
</style> </style>
</head> </head>
<script src="https://code.jquery.com/jquery-3.1.1.js" integrity="sha256-16cdPddA6VdVInumRGo6IbivbERE8p7CQR3HzTBuELA=" <body class="min-h-screen flex flex-col">
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.14.3/dist/umd/popper.min.js" <header class="sticky top-0 z-50 glass-nav">
integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" <div class="container mx-auto px-4">
crossorigin="anonymous"></script> <div class="navbar px-0">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.1.3/dist/js/bootstrap.min.js" <div class="flex-1">
integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" <a href="/" class="flex items-center gap-2 group">
crossorigin="anonymous"></script> <div class="bg-indigo-600 p-2 rounded-lg group-hover:bg-indigo-500 transition-colors">
<script src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.10/clipboard.min.js"></script> <i data-lucide="video" class="w-5 h-5 text-white"></i>
<script src="https://unpkg.com/isotope-layout@3/dist/isotope.pkgd.js"></script> </div>
<span class="text-xl font-bold tracking-tight text-white">VideoMakerBot</span>
<body> </a>
<header> </div>
{% if get_flashed_messages() %} <div class="flex-none gap-2">
{% for category, message in get_flashed_messages(with_categories=true) %} <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>
{% if category == "error" %} <li><a href="/backgrounds" class="rounded-lg hover:bg-white/10 text-slate-300">Backgrounds</a></li>
<div class="alert alert-danger mb-0 text-center" role="alert"> <li><a href="/settings" class="rounded-lg hover:bg-white/10 text-slate-300">Settings</a></li>
{{ message }} </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> </div>
</header>
{% else %} {% if get_flashed_messages() %}
<div class="alert alert-success mb-0 text-center" role="alert"> <div class="container mx-auto px-4 mt-4">
{{ message }} {% 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> </div>
{% endif %}
{% endfor %} {% endfor %}
{% endif %} </div>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark"> {% endif %}
<div class="container">
<a href="/" class="navbar-brand d-flex align-items-center"> <main class="flex-grow">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" stroke="currentColor" {% block main %}{% endblock %}
stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="me-2" </main>
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" /> <footer class="bg-slate-900 border-t border-white/5 py-12 mt-12">
<circle cx="12" cy="13" r="4" /> <div class="container mx-auto px-4">
</svg> <div class="flex flex-col md:flex-row justify-between items-center gap-6">
<strong>RedditVideoMakerBot</strong> <div class="flex flex-col gap-2">
</a> <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"> <div class="flex gap-6 text-sm text-slate-400">
<ul class="navbar-nav mr-auto"> <a href="https://github.com/elebumm/RedditVideoMakerBot" target="_blank" class="hover:text-white transition-colors">GitHub</a>
<li class="nav-item"> <a href="#" class="hover:text-white transition-colors" id="hard-reload">Hard Reload</a>
<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> </div>
</div> </div>
</nav> <div class="mt-8 pt-8 border-t border-white/5 text-center text-xs text-slate-500">
</header> &copy; 2026 VideoMakerBot. Built for speed and creativity.
</div>
{% 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> </div>
</footer> </footer>
<script> <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); window.location.reload(true);
}); });
</script> </script>
</body> </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 ## Requirements
- Python 3.10 - Python 3.10+
- Playwright (this should install automatically in installation) - Playwright (this should install automatically in installation)
- Docker and Docker Compose for the container workflow - Docker and Docker Compose for the container workflow
- FFmpeg (for video composition)
## Installation 👩‍💻 ## 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 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 📈 ## Contributing & Ways to improve 📈
In its current state, this bot does exactly what it needs to do. However, improvements can always be made! 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! 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 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 a background that is picked instead of the Minecraft one.
- [x] Allowing users to choose between any subreddit. - [x] Allowing users to choose between any subreddit.
- [x] Allowing users to change voice. - [x] Allowing users to change voice.
- [x] Checks if a video has already been created - [x] Checks if a video has already been created.
- [x] Light and Dark modes - [x] Light and Dark modes.
- [x] NSFW post filter - [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. Please read our [contributing guidelines](CONTRIBUTING.md) for more detailed information.

@ -25,3 +25,15 @@ services:
volumes: volumes:
- ./:/app - ./:/app
shm_size: "1gb" 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", "local",
"center" "center"
] ]
} }

@ -9,6 +9,15 @@ from rich.text import Text
console = Console() 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: def print_markdown(text) -> None:
"""Prints a rich info message. Support Markdown syntax.""" """Prints a rich info message. Support Markdown syntax."""
@ -22,6 +31,8 @@ def print_step(text) -> None:
panel = Panel(Text(text, justify="left")) panel = Panel(Text(text, justify="left"))
console.print(panel) console.print(panel)
if _progress_callback:
_progress_callback(stage=text)
def print_table(items) -> None: def print_table(items) -> None:

@ -7,32 +7,37 @@ import tomlkit
from flask import flash 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(): def get_checks():
template = toml.load("utils/.config.template.toml") template = toml.load("utils/.config.template.toml")
checks = {} checks = {}
def unpack_checks(obj: dict): def unpack_checks(obj: dict, path):
for key in obj.keys(): for key in obj.keys():
if "optional" in obj[key].keys(): full = f"{path}.{key}" if path else key
checks[key] = obj[key] if isinstance(obj[key], dict) and "optional" in obj[key].keys():
else: checks[full] = obj[key]
unpack_checks(obj[key]) elif isinstance(obj[key], dict):
unpack_checks(obj[key], full)
unpack_checks(template) unpack_checks(template, "")
return checks return checks
# Get current config (from config.toml) as dict # Get current config (from config.toml) as a dict keyed by dotted path.
def get_config(obj: dict, done=None): # 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: if done is None:
done = {} done = {}
for key in obj.keys(): for key in obj.keys():
full = f"{path}.{key}" if path else key
if not isinstance(obj[key], dict): if not isinstance(obj[key], dict):
done[key] = obj[key] done[full] = obj[key]
else: else:
get_config(obj[key], done) get_config(obj[key], done, full)
return done return done
@ -92,29 +97,30 @@ def check(value, checks):
# Modify settings (after the form is submitted) # Modify settings (after the form is submitted)
def modify_settings(data: dict, config_load, checks: dict): def modify_settings(data: dict, config_load, checks: dict):
# Modify config settings # Walk the dotted path and set the value at the precise location.
def modify_config(obj: dict, config_name: str, value: any): # Example: "reddit.creds.username" -> config_load["reddit"]["creds"]["username"]
for key in obj.keys(): def set_by_path(obj: dict, dotted_path: str, value):
if config_name == key: parts = dotted_path.split(".")
obj[key] = value cursor = obj
elif not isinstance(obj[key], dict): for part in parts[:-1]:
continue if part not in cursor or not isinstance(cursor[part], dict):
else: cursor[part] = {}
modify_config(obj[key], config_name, value) cursor = cursor[part]
cursor[parts[-1]] = value
# Remove empty/incorrect key-value pairs
data = {key: value for key, value in data.items() if value and key in checks.keys()} # Filter data to only include keys present in checks
data = {key: value for key, value in data.items() if key in checks.keys()}
# Validate values
for name in data.keys(): # Validate and apply values
value = check(data[name], checks[name]) for name, raw_value in data.items():
value = check(raw_value, checks[name])
# Value is invalid # Value is invalid
if value == "Error": if value == "Error":
flash("Some values were incorrect and didn't save!", "error") flash("Some values were incorrect and didn't save!", "error")
else: else:
# Value is valid # Value is valid
modify_config(config_load, name, value) set_by_path(config_load, name, value)
# Save changes in config.toml # Save changes in config.toml
with Path("config.toml").open("w") as toml_file: 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 # Delete background video
def delete_background(key): def delete_background(key):
# Read backgrounds.json # Read background catalog
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 = json.load(backgrounds)
# Remove background from backgrounds.json if data.pop(key, None) is None:
with open("utils/backgrounds.json", "w", encoding="utf-8") as backgrounds: flash("Couldn't find this background. Try refreshing the page.", "error")
if data.pop(key, None): return
json.dump(data, backgrounds, ensure_ascii=False, indent=4)
else: with open("utils/background_videos.json", "w", encoding="utf-8") as backgrounds:
flash("Couldn't find this background. Try refreshing the page.", "error") json.dump(data, backgrounds, ensure_ascii=False, indent=4)
return
# Remove background video from ".config.template.toml" # Remove background video from ".config.template.toml"
config = tomlkit.loads(Path("utils/.config.template.toml").read_text()) 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: with Path("utils/.config.template.toml").open("w") as toml_file:
toml_file.write(tomlkit.dumps(config)) toml_file.write(tomlkit.dumps(config))
@ -181,7 +188,7 @@ def add_background(youtube_uri, filename, citation, position):
filename = filename.replace(" ", "_") filename = filename.replace(" ", "_")
# Check if the background doesn't already exist # 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) data = json.load(backgrounds)
# Check if key isn't already taken # Check if key isn't already taken
@ -190,21 +197,24 @@ def add_background(youtube_uri, filename, citation, position):
return return
# Check if the YouTube URI isn't already used under different name # 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") flash("Background video with this YouTube URI is already added!", "error")
return return
# Add background video to json file # 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 = json.load(backgrounds)
data[filename] = [youtube_uri, filename + ".mp4", citation, position] data[filename] = [youtube_uri, filename + ".mp4", citation, position]
backgrounds.seek(0) backgrounds.seek(0)
backgrounds.truncate()
json.dump(data, backgrounds, ensure_ascii=False, indent=4) json.dump(data, backgrounds, ensure_ascii=False, indent=4)
# Add background video to ".config.template.toml" # Add background video to ".config.template.toml"
config = tomlkit.loads(Path("utils/.config.template.toml").read_text()) 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: with Path("utils/.config.template.toml").open("w") as toml_file:
toml_file.write(tomlkit.dumps(config)) 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!') flash(f'Added "{citation}-{filename}.mp4" as a new background video!')
return 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